Compare commits

...
50 Commits
Author SHA1 Message Date
jpmschweitzerandClaude Opus 4.6 a22bc6ce46 chore(meta): release v0.1.31
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 10:23:56 +02:00
jpmschweitzerandClaude Opus 4.6 ea57a9da70 fix(db): auto-unassign done tickets on sprint close
sprint stop now clears assigned_to on all done tickets in the closing
sprint. Agents don't clean up after themselves — this was causing
5 bookkeeping warnings every sprint.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 10:20:31 +02:00
jpmschweitzer bee93ddbb7 Merge remote-tracking branch 'origin/sprint-31/client' 2026-04-05 10:17:15 +02:00
jpmschweitzerandClaude Opus 4.6 4eb54a6f7a fix(client): resolve all gdlint warnings — zero warnings policy
Fix 354 gdlint warnings across 65 files: 194 class-definitions-order
(reorder declarations), 138 max-line-length (split long lines),
22 code issues (unused args, no-else-return, naming). Update .gdlintrc
to exclude addons/ and raise max-public-methods for test files.
No logic changes — declaration order, whitespace, and naming only.

Ticket: #783

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 09:59:56 +02:00
jpmschweitzer 5941ff791f Merge remote-tracking branch 'origin/sprint-31/server'
# Conflicts:
#	CHANGELOG.md
2026-04-05 09:59:46 +02:00
jpmschweitzerandClaude Opus 4.6 260eefcdd1 refactor(client): decompose main.gd and game_state.gd god objects
Extract SnapshotHandler (static snapshot parsing), SnapshotConsumers
(non-dialogue consumers + audio handlers), and DialogueCoordinator
(dialogue consumers + signal handlers) as class_name scripts.

main.gd: 28KB → 13KB. game_state.gd: 20KB → 8.5KB.
Autoload parse-order safety maintained via load() inline pattern.

Ticket: #775

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 09:59:34 +02:00
jpmschweitzerandClaude Opus 4.6 8eb9e0a383 feat(ui): star map click-through with wiki/GTTR popup and edge visibility
Show system profile popup on select: name, star type, hop distance,
corridor, GTTR excerpt, body count, population, adjacent systems.
Edge rendering changed to show no edges by default, only selected
system's gate lines on click. Star map data extended with wiki fields
for all 301 systems.

Ticket: #780

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 09:59:18 +02:00
jpmschweitzerandClaude Opus 4.6 65bcbd0aa7 fix(meta): move changelog entry to root CHANGELOG.md
Remove erroneous server/CHANGELOG.md and add the atlas split entry
to the project root CHANGELOG.md where it belongs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 09:56:53 +02:00
jpmschweitzer 784bcbf034 Merge remote-tracking branch 'origin/sprint-31/copy' 2026-04-05 09:54:47 +02:00
jpmschweitzer 742d51a3c4 Merge remote-tracking branch 'origin/sprint-31/ci' 2026-04-05 09:54:31 +02:00
jpmschweitzerandClaude Opus 4.6 84c3314924 fix(skills): sprint-start team branch must not run pr-review
Team worktree windows stay on their sprint branch. PR reviews run from
main. Updated step 9b to explicitly stop the team lead from spawning
reviewers — push the PR and wait for feedback instead.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 09:47:50 +02:00
jpmschweitzerandClaude Opus 4.6 f60ae48769 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 09:40:32 +02:00
jpmschweitzerandClaude Opus 4.6 fe788a0ae3 fix(simulation): rustfmt long argument lists in atlas dispatch
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 09:40:28 +02:00
jpmschweitzerandClaude Opus 4.6 74d6aee0c1 chore(ci): add ruff to pre-push hook and Makefile
Add Python/ruff block to .config/hooks/pre-push (runs on tooling/
changes). Add lint-python and setup-venv Makefile targets, wire
both into make lint and make setup respectively.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 09:40:10 +02:00
jpmschweitzerandClaude Opus 4.6 2300d9659d fix(tooling): remove unused imports flagged by ruff
Drop unused hashlib, math, and os imports from assign-astro-ids,
generate-star-map, and test_quaternius_raw to pass ruff clean.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 09:40:02 +02:00
jpmschweitzerandClaude Opus 4.6 980201fb49 chore(db): wire ensure_venv() into connector scripts
Add venv auto-activation to audio_batch, audio_connector,
image_connector, qdrant_connector, and trellis_connector.
Scripts re-exec into .venv/bin/python when invoked outside
the venv, with a friendly error if .venv is missing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 09:39:55 +02:00
jpmschweitzerandClaude Opus 4.6 1640c880da fix(simulation): suppress clippy too_many_arguments on cmd_add_body
The refactor from &Commands to individual parameters exposed the
13-argument signature to clippy. Allow attribute is appropriate
here — the parameters map 1:1 to DB columns.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 09:39:52 +02:00
jpmschweitzerandClaude Opus 4.6 d86ebada8d refactor(db): extract shared DB module from duplicated patterns
Create tooling/db/common.py with resolve_db_path(), load_config(),
get_connection(), and ensure_venv(). Update ticket, sprint,
sqlite_connector.py, and decisions_sync.py to import from common
instead of duplicating. Fixes pre-existing NameError in
decisions_sync.py (missing import os). Add pyproject.toml with
ruff config (ruff==0.15.9, CVE-clean) and dev dependencies.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 09:39:46 +02:00
jpmschweitzerandClaude Opus 4.6 7f23185035 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 09:38:15 +02:00
jpmschweitzerandClaude Opus 4.6 fb5c8854e8 refactor(simulation): split atlas.rs subcommands into modules
Extract 2119-line monolithic atlas.rs into 8 focused modules under
src/bin/atlas/: main.rs (thin dispatch), common.rs (shared types and
DB helpers), show.rs, mutate.rs, stats.rs, systems.rs, author.rs,
and sync_wiki.rs. Commands enum stays in main.rs; each match arm
delegates to module::cmd_fn(&conn, args). No behavior change.

Closes #776.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 09:37:04 +02:00
jpmschweitzerandClaude Opus 4.6 9135f1c656 data(wiki): update west corridor Adams ratings for Lichtung and Haltefenn (#766)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 09:36:31 +02:00
jpmschweitzerandClaude Opus 4.6 a97e2267f0 data(wiki): per-body GTTR entries for 25 moons and stations (#782)
Hub systems at hops 0-4 with pop >10K now have individual GTTR entries.
Covers Gateway, Sirius, ACB, Cygni B, Bastion, Nova Roma, Fremantle,
Kampala Gate, Aurelius, Puerto Rivera, and Okafor Station. Each follows
the established drifter's-voice format with Adams rating and practical
information sections.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 09:36:16 +02:00
jpmschweitzerandClaude Opus 4.6 807e8b74a6 data(wiki): cultural diversity sweep — 4 additional systems enriched (#766)
Schuilhoek (GJ-68): data-wealth commercial dynasty subversion.
Helderoog (GJ-111): genetic protocol precision subversion.
Helle Wacht (GJ-598): philosophical colony governance cost.
Altgrund (GJ-189): institutional archive refugee integration.
Also updates Adams ratings in gttr-deep.md for all enriched systems.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 09:35:56 +02:00
jpmschweitzerandClaude Opus 4.6 898b9c747f data(wiki): enrich Nova Estrada and Entremeio GTTRs to batch standard (#774)
Nova Estrada: added founding agreement time-bomb, thirty-year cost, and
infrastructure audition sections. Entremeio: added transit policy, lake
settlement, and Assembly defiance sections. Both now match Kettenschmied
quality bar.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 09:35:47 +02:00
jpmschweitzerandClaude Sonnet 4.6 2ee095306b docs(wiki): cultural stereotyping sweep — 6 entries enriched (ticket #766)
Subversions added or made explicit across 3 most-imbalanced groups:

Afrikaans/Dutch:
- Breëvlei (GJ-661B): xenobiology research community, pre-settlement
  marsh stakes of unknown origin drive community identity
- Bestevaer (GJ-103): Dutch maritime mystery culture; Diepzeecommissie
  secretly building capacity to investigate restricted deep zone

German:
- Lichtung (GJ-356A): full rewrite → artists' colony, F-type light
  environment, no industrial or agricultural purpose
- Haltefenn (GJ-1248): Moorlesen craft tradition (peat-layer reading as
  art) now primary economic identity over peat fuel extraction

Portuguese:
- Espinho (GJ-1075): science/research subversion made explicit on page
- Nascente (GJ-2097): Irmandade reframed as commercial dynasty via 400
  years of freight transit intelligence

Also creates docs/audits/cultural-stereotyping-audit.md with full audit
findings, ratios, recommendations, and follow-up scope for Sprint 32+.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 09:28:56 +02:00
jpmschweitzerandClaude Sonnet 4.6 b21b28df61 docs(decisions): D-168 Iserlohn (GJ-532c) IP evaluation — retained
Real German city origin (Sauerland wire-drawing centre) documented.
Context diverges fundamentally from LoGH fortress. Altena recorded
as preferred alternative. Ticket #773.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 09:28:34 +02:00
jpmschweitzerandClaude Opus 4.6 b3ae632530 fix(skills): auto-run /sprint-start in team tabs
Pass "claude /sprint-start" as the initial tmux command so each team
tab loads its briefing and spawns agents automatically — no manual
step needed after tabs open.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 08:53:40 +02:00
jpmschweitzerandClaude Opus 4.6 4249a79aa6 fix(skills): open sprint tabs in current window, no duplicate main tab
start-sprint.sh now uses --tab for all teams (opens in active Ptyxis
window) instead of --new-window. No separate main tab — the caller
is already on main.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 08:51:46 +02:00
jpmschweitzerandClaude Opus 4.6 4dd866c9ad feat(skills): integrate start-sprint into sprint-start skill
Move start-sprint.sh into .claude/skills/sprint-start/scripts/ (was
standalone at parent directory level). Sprint-start Case B now calls
the script automatically after activating a sprint — creates ephemeral
worktrees and opens Ptyxis tabs for each active team.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 08:47:49 +02:00
jpmschweitzerandClaude Opus 4.6 4628458ab0 chore(process): add zero-warnings policy to pr-push and pr-review
- pr-push step 1b: lint check before pushing (gdlint, clippy, ruff)
- pr-review step 0c: check warning count before spawning reviewers
- Added .gdlintrc with max-line-length: 120
- Created ticket #783 for Sprint 31: clean up existing 253 warnings
- Updated #780 with edge visibility rule (show edges only for selected system)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 08:39:50 +02:00
jpmschweitzerandClaude Opus 4.6 469114cb0e fix(ui): wire M keybind for star map toggle, clean up info panel
- Add _unhandled_key_input to main.gd: M toggles star_map.toggle_visible()
- Remove topology line from info panel (internal data, not player-facing)
- Cast aperture_count to int (was showing decimal point)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 08:33:07 +02:00
jpmschweitzerandClaude Opus 4.6 822cf34bbc chore(meta): plan Sprint 31: Bedrock
Phase 1 wiki completion (cultural sweep, GTTR depth, star map popups)
+ infrastructure refactors (atlas.rs, main.gd, Python tooling).

8 tickets: copy (4), client (2), server (1), ci (1).
Cascade dependencies locked: Phase 1→2→3→4→5→6.
#693 cancelled (superseded by #704 compositor).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 08:17:28 +02:00
jpmschweitzerandClaude Opus 4.6 dd75203d11 refactor(process): replace persistent team worktrees with ephemeral sprint branches
Persistent worktrees (server, client, copy, audio, visual, ci, planning,
maintenance) caused agents crossing boundaries, stuck agents leaving
uncommitted work, and index.lock collisions. Replaced with ephemeral
sprint branches (sprint-{N}/{team}) and worktrees created on demand.

Changes:
- New start-sprint script replaces start-session (dynamic tabs per active team)
- Sprint teardown integrated into sprint-start skill (A1c step)
- SR_DB_PATH env var for database access from any directory
- CLAUDE.md team boundaries rewritten (scope-based, not directory-based)
- Agent Rule 0 updated to team scope dirs instead of worktree isolation
- PR review uses git show instead of cross-directory reads
- Briefing template updated for sprint-{N}/{team} branch naming
- Deleted worktree-update skill (obsolete)
- Removed WORKTREE_TEAM env var and cross-directory Read permissions
- All 8 persistent worktrees removed

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 00:33:02 +02:00
jpmschweitzerandClaude Opus 4.6 f94d4c7a2c chore(meta): release v0.1.30
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 00:20:23 +02:00
jpmschweitzer 68afd94a57 Merge remote-tracking branch 'origin/client'
# Conflicts:
#	CHANGELOG.md
2026-04-05 00:17:52 +02:00
jpmschweitzerandClaude Opus 4.6 c94c5d7acb fix(client): address PR #109 review — 6 warnings + 5 suggestions
Star map (W1-W3, S3):
- _process visibility guard + dirty flag (no redraw when hidden/unchanged)
- _system_hash masked to 31-bit positive range
- Extracted _find_nearest_system() shared helper

game_state.gd (W4):
- Inline load() in apply_snapshot() replaces per-tick overhead; safe at
  runtime because script is already in resource cache

Data pipeline (W5-W6):
- Script-relative path resolution via __file__
- --check mode + make check-star-map staleness target

Minor (S1-S2, S5):
- Removed redundant bone_idx assignment
- Simplified double-negative test assertion
- Documented autoload parse-order convention in CLAUDE.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 00:12:48 +02:00
jpmschweitzerandClaude Opus 4.6 16b7fe0bb5 fix(simulation): address PR #108 review — LEFT JOIN, JSON output, atmosphere default
Review fixes:
- corridor-status: INNER JOIN → LEFT JOIN so systems without gate
  records are included in counts instead of silently excluded
- corridor-status: output as JSON (serde_json) matching all other
  atlas commands, instead of plain-text ASCII table
- generate_body_matrix: emit atmosphere "standard" instead of
  "breathable" to match committed-system conventions
- Doc header: add corridor-status usage example
- CHANGELOG: note stale habitable_planet_count in pre-fix systems

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 23:50:11 +02:00
jpmschweitzerandClaude Opus 4.6 0d5323f66c fix(client): resolve GDScript autoload parse-order errors
Autoload scripts are parsed before regular scripts, so class_name
types (CharacterVisualDescriptor, TestHarness, YamlParser) are not
available at parse time. Replace type annotations with untyped vars
and use load() for in-body class references. Fixes all 14 headless
parse errors (9 pre-existing + 5 from Sprint 30 changes).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 23:40:28 +02:00
jpmschweitzerandClaude Opus 4.6 c758bcd5d6 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 23:35:12 +02:00
jpmschweitzerandClaude Opus 4.6 d876923485 test(client): add Sprint 30 acceptance tests
27 tests covering all 5 sprint tickets: descriptor persistence (#718),
overhead anchor (#712), hair highlight swatch (#719), manifest JSON
completeness (#720), and star map scene/HUD integration (#674).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 23:34:48 +02:00
jpmschweitzerandClaude Opus 4.6 aed513eb71 feat(ui): star map insert module — concentric hop-ring view (#674)
New StarMapRenderer: 301 systems in concentric hop-rings from player
location, sector-colored, click-to-select with info panel, pan/zoom.
Integrated into HUD (hidden by default), insert state propagation
wired in main.gd. Data enriched from systems.db + star-map.json via
tooling/generate-star-map-data.py regeneration script.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 23:34:36 +02:00
jpmschweitzerandClaude Opus 4.6 bc168c861a feat(client): hair highlight swatch + manifest JSON for exports (#719, #720)
#719: Add display-only hair highlight swatch (Option B) — auto-derived
from primary tint, non-interactive (MOUSE_FILTER_IGNORE).

#720: Remove DirAccess.open() fallback scanning (breaks in PCK exports).
Fully populate manifest.json (11 body types, 14 hair, 4 heads, 4
eyebrows, 8 clothing). Add tooling/generate-character-manifest script
and make manifest target for regeneration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 23:34:23 +02:00
jpmschweitzerandClaude Opus 4.6 4c1e0e9c85 feat(client): wire CharacterVisualDescriptor into startup IPC (#718)
encode_startup_message() now accepts optional CharacterVisualDescriptor
as third param, serialized via to_dict(). sim_bridge passes the
descriptor from GameState on new game start. apply_snapshot() restores
descriptor from server snapshot on save/load cycle.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 23:34:11 +02:00
jpmschweitzerandClaude Opus 4.6 e8a053080c feat(client): add BoneAttachment3D overhead anchor above Head bone (#712)
Infrastructure for floating UI elements (status indicators, thought
bubbles, alert markers). Marker3D at Vector3(0, 0.3, 0) offset from
Head bone, exposed via get_overhead_anchor() public API.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 23:34:00 +02:00
jpmschweitzerandClaude Opus 4.6 ba059ab101 chore(rules): add ticket CLI usage rule
Documents positional argument syntax and key rules for the ticket
CLI to prevent --title flag misuse and sqlite3 crashes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 23:33:50 +02:00
jpmschweitzerandClaude Opus 4.6 6763c30d43 chore(skills): add git-state verification warning to pr-push skill
Sprint 30 showed that grepping source files does not distinguish
committed code from uncommitted agent work. Added mandatory
git status + git diff --stat check from repo root before accepting
"already done" claims, and a warning about CWD path resolution.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 23:11:54 +02:00
jpmschweitzerandClaude Opus 4.6 ef15d0afa3 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 23:09:21 +02:00
jpmschweitzerandClaude Opus 4.6 8a98cfa7fc fix(simulation): Sprint 30 server — atmosphere filter fix + corridor-status cmd
#762: Update habitable_planet_count filter to accept both "breathable"
and "standard" atmosphere values via matches! macro. Previously only
matched "breathable", causing all committed systems to report 0
habitable planets.

#744: Add corridor-status subcommand to atlas CLI. Shows remaining
unfinished systems grouped by geographic_sector and hop_distance,
with plain text table output and totals.

Cargo.lock updated for v0.1.29.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 23:08:59 +02:00
jpmschweitzerandClaude Opus 4.6 d6b98925ed fix(wiki): address PR #107 review — catalog.md sync, math fix, schema fix
- catalog.md: sync star types for GJ 875 (K), GJ 1111 (M), GJ 541 (K),
  GJ 601A (F), GJ 19 (G), GJ 42 (K); add Dois Sóis name for GJ 707
- GJ-1111: fix calibration note total ~1.46M → ~1.67M, station display
  1M → 1.2M to match proposal
- GJ42 proposal: moon GJ42e-2 biome_summary ice → frozen (schema fix)
- GJ3943 proposal: station notes k-m → K5V+M3V
- GJ-3943 wiki: "five corridor communities" → "four" (Eerste Wacht
  joined later)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 13:13:31 +02:00
jpmschweitzerandClaude Opus 4.6 f8b0b17252 fix(wiki): Sprint 30 — wiki quality fixes, atlas regressions, Brandpunt authoring
High priority:
- #763: Rewrite GJ1111/GJ54.1 prose for population rebalance (380K/320K
  planet + 1M/860K station = mid-tier systems, not megahubs)

Medium priority:
- #772: Author Brandpunt (GJ 3943) — Afrikaans agricultural research
  station, subverts pastoral/frontier stereotype
- #753: Fix Mabuhay (GJ 1073) — GJ 482A is Dagat, not unsurveyed
- #757: Fix Pereira's Rest population 800 → 42K
- #758: Fix Langemark schema values (breathable→standard, rivers-lakes, west_reach)
- #759: Fix Solheim topology disconnect
- #764: Fix GJ1111 star_type unusual → M
- #767: Fix GJ707 star_type/spectral_class swap
- #769: Fix GJ-147 etymology passagem → Entremeio
- #770: Fix GJ-875 header G-type → K-type

Low priority:
- #754: Update stale Calibration Notes (GJ1128, GJ136, GJ625)
- #755: Fix GJ432A Punjabi-Canadian → Punjabi heritage
- #756: Fix GJ541 header G-type → K-type (K2IIIp)
- #760: Add F2III context to GJ601A notes
- #761: Fix GJ4056 spectral_class g → M3V
- #765: Add calibration notes for intentional small populations
- #768: Reconcile GJ189 wave_2 at hop 8-11
- #771: Fix GJ-7547/GJ-6711 spectral class truncation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 22:47:36 +02:00
200 changed files with 13446 additions and 3652 deletions
+39
View File
@@ -0,0 +1,39 @@
# Ticket CLI
**Use `tooling/db/ticket`** for all ticket operations. Never use `sqlite3` directly (crashes in Claude Code).
## Positional arguments — not flags
`ticket create` uses **positional** arguments for `type` and `title`. There is no `--title` flag.
```bash
# CORRECT — type and title are positional
tooling/db/ticket create story "My ticket title" --description "Details here" --team server --priority low
# WRONG — --title does not exist, gets absorbed into the title string
tooling/db/ticket create story --title "My ticket title" --description "Details here"
# Creates a ticket titled: "--title My ticket title"
```
## Full usage
```bash
# Create
tooling/db/ticket create <type> <title> [--parent N] [--priority P] [--decision D] [--team T] [--description TEXT]
# type: initiative | epic | story | task | bug
# priority: critical | high | medium | low
# Read
tooling/db/ticket show <id>
tooling/db/ticket list [--sprint N] [--team T] [--status S]
# Update
tooling/db/ticket assign <id> <agent>
```
## Key rules
- **Type and title are positional** — everything else is a flag
- **Quote the title** — always wrap in double quotes to handle spaces
- **Never use `sqlite3` CLI** — it crashes (std::bad_alloc). Use `tooling/db/sqlite-query` or `tooling/db/sqlite-exec` for raw SQL
- **Verify after create** — run `tooling/db/ticket show <id>` to confirm the title is clean
+4 -2
View File
@@ -1,7 +1,8 @@
{
"autoMemoryDirectory": "/home/jeroenschweitzer/Projects/settled-reach/.memory",
"env": {
"CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1"
"CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1",
"SR_DB_PATH": "/var/home/jeroenschweitzer/Projects/settled-reach/settledreach.db"
},
"teammateMode": "in-process",
"permissions": {
@@ -63,9 +64,10 @@
"Bash(list *)",
"Bash(tree *)",
"Bash(sed -n *)",
"Bash(.claude/skills/sprint-start/scripts/start-sprint.sh *)",
"Bash(.claude/skills/sprint-start/scripts/sprint-teardown.sh *)",
"Skill(git-commit)",
"Skill(worktree-update)",
"Skill(sprint-start)",
"Skill(sprint-plan)",
"Skill(pr-push)",
+43 -1
View File
@@ -34,7 +34,35 @@ git branch --show-current
If on `main`, stop: "You're on main. Switch to a team branch first."
### 1b. Runtime smoke test (MANDATORY)
### 1b. Zero warnings policy (MANDATORY)
Before pushing, verify the branch has **zero lint warnings**. Any warning
must be either fixed or suppressed with a commented justification.
**For client/visual branches:**
```bash
gdlint client/scripts/ client/ui/ 2>&1
```
If warnings remain, fix them before pushing. For warnings that cannot be
fixed (e.g. intentional long lines in data literals), add a `# gdlint:
ignore` comment with a reason.
**For server branches:**
```bash
cargo clippy -- -D warnings 2>&1
```
**For CI/tooling branches:**
```bash
ruff check tooling/ 2>&1
```
The goal is zero warnings in the pre-push output. Advisory warnings that
the pre-push hook reports as "(advisory, not blocking)" should still be
zero — they are advisory only because we haven't enforced them yet.
### 1c. Runtime smoke test (MANDATORY)
Before pushing, verify the game actually runs. This is non-negotiable —
Sprint 28 proved that code review without runtime testing misses critical
@@ -66,8 +94,22 @@ works on screen before invoking `/pr-push`. If they haven't, ask:
```bash
git status
git diff --stat
```
**Run both commands from the repo root** (`git rev-parse --show-toplevel`).
Running from a subdirectory can cause paths to not resolve, hiding real
changes — Sprint 30 proved this when `git diff HEAD -- server/src/bin/atlas.rs`
returned 0 lines from the wrong CWD, masking uncommitted agent work.
**CRITICAL: Do not trust "already done" claims without checking git state.**
If agents report that work was "already implemented in a prior commit," verify
by checking `git status` and `git diff --stat` first. Grepping source files
only proves the code exists on disk — it does NOT prove the code is committed.
Uncommitted working-tree changes look identical to committed code when you
read files. Only `git status` distinguishes "already shipped" from "just
written by a teammate."
If there are uncommitted changes (staged or unstaged), run the **commit skill**
first. Use the `/git-commit` skill to group changes into logical commits with
proper conventional commit messages. Wait for commit to complete before
+28 -31
View File
@@ -16,16 +16,15 @@ on the branch type. All reviewers must approve for a clean review.
## Workflow
### 0. Branch guard — MUST be run by a Claude instance in the `main` worktree
### 0. Branch guard — MUST be run from the `main` branch
```bash
git branch --show-current
```
If the current branch is **not `main`**, stop immediately and tell the user:
"PR reviews must be run by a Claude instance in the `main` worktree."
Do NOT proceed with the review. Do NOT work around this by reading files
from another worktree — the review agent itself must be running in main.
"PR reviews must be run from the `main` branch."
Do NOT proceed with the review.
Stop and wait for the user to invoke `/pr-review` from main.
### 0b. Verify runtime smoke test was performed
@@ -48,6 +47,19 @@ godot --headless --path client --quit 2>&1 | grep -i "SCRIPT ERROR"
If script errors appear in the branch diff files, flag them immediately
before spawning reviewers — no point reviewing code that doesn't parse.
### 0c. Zero warnings check
The project enforces a **zero warnings policy**. Before spawning reviewers,
check if the branch introduces lint warnings:
- **client/visual:** `gdlint client/scripts/ client/ui/` should report 0 issues
- **server:** `cargo clippy -- -D warnings` should be clean
- **ci/tooling:** `ruff check tooling/` should be clean
If warnings exist, note the count in the review output. Reviewers should
flag any **new** warnings introduced by the branch as `warning` severity.
Pre-existing warnings are not PR blockers but should be tracked for cleanup.
### 1. Determine the branch to review
If the user provided a branch name as argument, use it. Otherwise list open
@@ -99,39 +111,24 @@ Three-dot diff with pathspec exclusions is unreliable. Instead, either:
For large diffs (>1000 lines of source), provide **source files** rather than
raw diff to reviewers — cleaner context, better reviews.
**IMPORTANT — use team directory paths for ALL agents.** Each team branch
is checked out in its own directory at:
**Reviewer agents read source files via `git show`.** Sprint branches
use the naming pattern `sprint-{N}/{team}`. To read a file from the
branch being reviewed:
```
/var/mnt/data/projects/settled-reach/<branch>/
```bash
git show origin/<branch>:<path>
```
For example, the `copy` team directory is at:
```
/var/mnt/data/projects/settled-reach/copy/content/dialogue/...
For example:
```bash
git show origin/sprint-31/server:server/src/bin/atlas.rs
```
**All reviewer agents** (regardless of Bash access) should read source files
from the team directory using the Read tool. This is more reliable than
`git show origin/<branch>:<path>` because:
- All agents have Read access (no Bash dependency)
- Files are always the actual branch checkout (no stale cache)
- No risk of accidentally reading from main's working directory
If the sprint branch has an active worktree (under `.sprint/`), reviewers
can also use the Read tool with the worktree path. But `git show` is
the reliable default — it works whether or not a worktree exists.
When constructing reviewer prompts, tell agents to read files from the
team directory. Example instruction for agents:
```
Read the changed files from the team directory. The branch is checked
out at: /var/mnt/data/projects/settled-reach/<branch>/
For example, to read `content/dialogue/the-terminal/kael-davan.yaml`,
use: /var/mnt/data/projects/settled-reach/<branch>/content/dialogue/the-terminal/kael-davan.yaml
```
Also tell agents to read relevant `decisions/*.md` files from the same
directory (they're identical to main, but using the team directory path
keeps agents grounded in the correct location).
Also tell agents to read relevant `decisions/*.md` files for context.
### 4. Spawn reviewers in parallel
@@ -2,17 +2,12 @@
Use `model: sonnet` for all reviewers — sufficient for review, saves cost.
**All reviewer agents read from team directories.** Each team branch is
checked out in its own directory at:
`/var/mnt/data/projects/settled-reach/<branch>/`
Tell every reviewer agent to read source files from the team directory
using the Read tool. Include the directory path and a list of changed
files in every prompt. Do NOT rely on `git show` or paste file contents —
agents can read directly from the directory.
Note: cross-directory reading is only permitted for review agents spawned
from the `main` team. Team agents must stay within their own directory.
**Reviewer agents read source files via `git show` or from sprint
worktrees.** Sprint branches use `sprint-{N}/{team}` naming. Include
the branch name and a list of changed files in every prompt. The
default approach is `git show origin/<branch>:<path>`. If an active
worktree exists under `.sprint/`, agents can also use the Read tool
with the worktree path.
## Code reviews (`server`, `client`, `ci`)
+3 -6
View File
@@ -154,12 +154,9 @@ Create `docs/sprints/sprint-N/` and write one file per team.
Read the template at `references/briefing-template.md` in this skill directory
for the exact file structure.
**IMPORTANT — relative paths only:** Each team works in its own directory
containing the full repo (`server/`, `client/`, `docs/`, etc.). All file
paths in briefings must be relative to the working directory. Example:
`server/src/bridge/types.rs`, not `/absolute/path/to/server/src/...` or
paths that navigate outside (`../sibling-dir/...`).
Agents must stay within their team's working directory.
**Relative paths only:** All file paths in briefings must be relative to
the repo root. Example: `server/src/bridge/types.rs`, not absolute paths.
Each sprint branch (`sprint-{N}/{team}`) contains the full repo.
Key requirements per file:
- **server.md**: Carry-overs, new tickets, dependency chain, key decisions, notes
@@ -9,7 +9,7 @@ Each team gets one briefing file at `docs/sprints/sprint-N/<team>.md`.
**Goal:** <One-sentence sprint goal, shared across all teams>
**Branch:** `<team>`
**Branch:** `sprint-{N}/<team>`
**Agents:** <Agent names and roles>
## Carry-over from Sprint N-1
@@ -57,7 +57,7 @@ One bullet per ticket with:
When ready to submit, create a PR with `tea` CLI. **All flags are required** to avoid TTY prompts (see CLAUDE.md "Gitea access" section):
\```bash
tea pr create --repo jpmschweitzer/settled-reach --login schweitz --title "feat(<scope>): description" --description "body" --base main --head <branch>
tea pr create --repo jpmschweitzer/settled-reach --login schweitz --title "feat(<scope>): description" --description "body" --base main --head sprint-{N}/<team>
\```
```
@@ -73,7 +73,8 @@ The `joint.md` file additionally includes:
| Team | Branch | Agents | Scope |
|------|--------|--------|-------|
| server | `server` | Dudley (simulation), Oscar (networking) | Rust/bevy_ecs simulation |
| client | `client` | Stig (UI), Oscar (networking) | Godot client rendering |
| server | `sprint-{N}/server` | Dudley (simulation), Oscar (networking) | server/, Rust/bevy_ecs simulation |
| client | `sprint-{N}/client` | Stig (UI), Oscar (networking) | client/, Godot rendering |
| copy | `sprint-{N}/copy` | Mellanie, Paula, Miri | wiki/, docs/atlas/, content/ |
| joint | both | All implementation agents | Integration, proofs, cross-team schema |
| content | (none) | Mellanie, Paula, Miri, Araminta | Content authoring, no code branch |
+46 -12
View File
@@ -25,8 +25,8 @@ The current branch IS the team. Read it with:
git branch --show-current
```
Valid team branches: `server`, `client`, `copy`, `audio`, `visual`, `ci`,
`planning`.
Sprint branches follow the pattern `sprint-{N}/{team}` (e.g. `sprint-31/server`).
Valid team names: `server`, `client`, `copy`, `audio`, `visual`, `ci`, `planning`.
If on `main`, follow the **Main branch workflow** below instead of
the team branch workflow (steps 28).
@@ -130,6 +130,18 @@ If the user raises items that should be tracked, create Q-NNN entries
or backlog tickets on the spot. If process changes are agreed, update
the relevant skill files or CLAUDE.md immediately — don't defer them.
#### A1c. Clean up sprint worktrees
Remove ephemeral worktrees for the closed sprint. Run the teardown script:
```bash
.claude/skills/sprint-start/scripts/sprint-teardown.sh {N}
```
This removes all worktrees under `.sprint/sprint-{N}/` and prunes git
metadata. Safe to skip if the sprint didn't use ephemeral worktrees
(e.g. legacy persistent worktree setup).
#### A2. Bump the version
The project version scheme is `v0.1.{sprint_number}`. After closing
@@ -197,10 +209,20 @@ If everything looks ready, activate the sprint:
tooling/db/sprint start
```
Then report:
Then open team terminal tabs automatically:
```bash
.claude/skills/sprint-start/scripts/start-sprint.sh
```
This creates ephemeral worktrees under `.sprint/sprint-{N}/{team}/` for
each team with open tickets, and opens Ptyxis windows with tmux + Claude
auto-starting in each tab. The user will have one tab per active team.
Report:
- Sprint activated (name, ticket count per team)
- Remind the user to switch to a team branch and run `/sprint-start`
there (or `cd` into the relevant worktree)
- Worktrees created and tabs opened
- Each team tab runs `/sprint-start` to load briefing and spawn agents
---
@@ -215,6 +237,9 @@ to plan the next sprint.
### 2. Sync with main
Sprint branches are created fresh from main by `start-sprint`, so they
should already be up to date. If main has moved since branch creation:
```bash
git fetch --all
git merge origin/main --no-edit
@@ -329,14 +354,14 @@ Task(
team_name: "sprint-{N}-{team}",
name: "{name_lowercase}",
prompt: "You are on the {team} team for Sprint {N}.
Branch: `{team}`
Branch: `sprint-{N}/{team}`
RULES (NON-NEGOTIABLE):
0. TEAM BOUNDARY: Your team is `{team}` ($WORKTREE_TEAM). Stay
within the current working directory. Do NOT navigate to
parent or sibling directories. Do NOT follow .git pointers
to other directories.
0. TEAM SCOPE: Your team is `{team}` on branch `sprint-{N}/{team}`.
You may modify files in: {team_scope_dirs}
You may read (but not modify): docs/, decisions/, wiki/, .claude/
Do NOT modify files belonging to other teams.
1. GIT: Do NOT run any git commands (commit, push, pull, merge,
checkout, branch, stash, tag, etc.). All git operations are
@@ -487,12 +512,21 @@ When all tasks are complete (TaskList shows all completed):
Run `/git-commit` to commit all changes, then `/pr-push` to create or
update the PR. Do NOT shut down agents — the team stays alive for review.
#### 9b. Review
#### 9b. Wait for review
Run `/pr-review` to spawn temporary reviewers. Wait for results.
Do NOT run `/pr-review` from the team window — PR reviews run from
the `main` branch (a separate window/session). The team window stays
on its sprint branch.
After pushing and creating the PR, report the PR number to the user
and stop. Wait for review feedback to arrive (the user or the main
session will relay it, or it will appear as Gitea PR comments).
#### 9c. Handle review outcome
When review feedback arrives (from the user, main session, or PR
comments):
**If CHANGES_REQUESTED:**
1. Parse the review comment table (from the Gitea PR comment or the
+34
View File
@@ -0,0 +1,34 @@
#!/bin/bash
# Clean up ephemeral worktrees for a closed sprint.
# Usage: sprint-teardown.sh <sprint-number>
#
# Removes all worktrees under .sprint/sprint-{N}/ and prunes git metadata.
# Safe to run multiple times — skips already-removed worktrees.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)"
SPRINT_BASE="$(dirname "$REPO_ROOT")/.sprint"
SPRINT=${1:?Usage: sprint-teardown.sh <sprint-number>}
SPRINT_DIR="$SPRINT_BASE/sprint-${SPRINT}"
if [ ! -d "$SPRINT_DIR" ]; then
echo "No worktrees found for sprint-${SPRINT} (directory $SPRINT_DIR does not exist)."
exit 0
fi
echo "Cleaning sprint-${SPRINT} worktrees..."
for wt in "$SPRINT_DIR"/*/; do
[ -d "$wt" ] || continue
team="$(basename "$wt")"
echo " Removing: $team"
git -C "$REPO_ROOT" worktree remove "$wt" --force 2>/dev/null || echo " (already removed or dirty)"
done
rmdir "$SPRINT_DIR" 2>/dev/null || true
git -C "$REPO_ROOT" worktree prune
echo "Done. Sprint-${SPRINT} worktrees cleaned."
+65
View File
@@ -0,0 +1,65 @@
#!/bin/bash
# Opens Ptyxis tabs for the active sprint teams in the CURRENT window.
# Auto-starts Claude Code in each tab via tmux.
#
# Usage: start-sprint.sh [sprint-number]
# If omitted, auto-detects the active sprint from the database.
#
# Assumes the caller is already on main in the current terminal.
# Adds one tab per active team — no duplicate main tab.
#
# Worktrees are created under .sprint/ (ephemeral, cleaned after sprint close).
# Can be called from any directory — resolves paths from the script location.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# Script lives at .claude/skills/sprint-start/scripts/ — repo root is 4 levels up
REPO_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)"
# Parent of repo root is where .sprint/ and the DB live
PARENT="$(dirname "$REPO_ROOT")"
# Resolve sprint number — argument or active sprint from DB
if [ -n "${1:-}" ]; then
SPRINT="$1"
else
SPRINT=$(cd "$REPO_ROOT" && tooling/db/sqlite-query "SELECT id FROM sprints WHERE status='active'" 2>/dev/null | python3 -c "import sys,json; print(json.load(sys.stdin)['rows'][0]['id'])" 2>/dev/null || true)
if [ -z "$SPRINT" ]; then
echo "error: no active sprint found. Pass a sprint number or activate a sprint first." >&2
exit 1
fi
fi
# Query teams with open tickets
TEAMS=$(cd "$REPO_ROOT" && tooling/db/sqlite-query "SELECT DISTINCT team FROM tickets WHERE sprint_id=$SPRINT AND status NOT IN ('done','cancelled')" 2>/dev/null | python3 -c "import sys,json; [print(r['team']) for r in json.load(sys.stdin)['rows']]" 2>/dev/null || true)
if [ -z "$TEAMS" ]; then
echo "Sprint $SPRINT has no open tickets. Opening main only."
fi
echo "Sprint $SPRINT — teams: ${TEAMS:-none}"
# ── Open team tabs in the CURRENT window ────────────────────────────
# No separate main tab — the caller is already on main.
# All team tabs open as --tab in the active Ptyxis window.
for team in $TEAMS; do
BRANCH="sprint-${SPRINT}/${team}"
WDIR="$PARENT/.sprint/sprint-${SPRINT}/${team}"
# Create worktree if it doesn't exist
if [ ! -d "$WDIR" ]; then
echo "Creating worktree: $WDIR (branch: $BRANCH)"
mkdir -p "$(dirname "$WDIR")"
# Create branch from main if it doesn't exist remotely
if git -C "$REPO_ROOT" rev-parse --verify "origin/$BRANCH" >/dev/null 2>&1; then
git -C "$REPO_ROOT" worktree add "$WDIR" "$BRANCH"
else
git -C "$REPO_ROOT" worktree add -b "$BRANCH" "$WDIR" HEAD
fi
fi
ptyxis --tab -d "$WDIR" -x 'tmux new-session \; send-keys "claude /sprint-start" Enter'
sleep 0.3
done
echo "Session ready. Main + ${TEAMS:-(no teams)}"
-125
View File
@@ -1,125 +0,0 @@
---
name: worktree-update
description: >
Sync worktree branches with main. Use when the user says "update worktrees",
"sync branches", "merge main", "worktree update", or invokes /worktree-update.
When on main: shows which worktree branches are ahead and lets the user pick
which to merge into main (flags branches with open PRs). When on a non-main
branch: merges main into the current branch. All operations are non-destructive.
user-invocable: true
allowed-tools: Bash, Read, AskUserQuestion
---
# Worktree Update Skill
Sync worktree branches safely. Direction depends on the current branch.
## Safety Rules (NON-NEGOTIABLE)
- **Never force-push, reset --hard, rebase, or delete branches.**
- **Never use `--no-verify` or skip hooks.**
- **Always use `--no-edit` on merges** to avoid interactive editor prompts.
- **Stop on merge conflicts** — report them and let the user decide. Never
auto-resolve or abort a conflicted merge without asking.
- **Fetch before comparing** — always `git fetch --all` first so commit
comparisons are accurate.
- **Dry-run first on main** — show the user exactly what will happen before
merging anything into main.
## Workflow
### 1. Detect current branch
```bash
git branch --show-current
```
Branch determines the mode: `main` → outbound sync, anything else → inbound sync.
### 2a. On `main` — merge worktree branches into main
#### Fetch and compare
```bash
git fetch --all
```
Discover all worktree branches (excluding `main` itself):
```bash
git worktree list | grep -v '\[main\]' | sed 's/.*\[//;s/\]//'
```
For each worktree branch, check if it has commits ahead of main:
```bash
git rev-list --count main..origin/<branch>
```
Skip branches with 0 commits ahead. For branches that ARE ahead, collect:
- Branch name
- Number of commits ahead
- One-line log of those commits: `git log --oneline main..<branch>`
#### Check for open PRs
```bash
tea pr list --login schweitz --repo jpmschweitzer/settled-reach --state open --output simple
```
Cross-reference open PR head branches with the ahead-of-main branches.
#### Present results
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 `/pr-review` instead)
- Commit count and summary
Use `AskUserQuestion` to let the user pick which branches to merge.
Exclude PR-flagged branches from the default options (but allow the user to
override via "Other").
#### Merge selected branches
For each selected branch, one at a time:
```bash
git merge <branch> --no-edit
```
If a merge conflicts, **stop immediately**. Report the conflict and do NOT
continue to the next branch. The user must resolve before proceeding.
After all merges, show the final state with `git log --oneline -N` (where N
covers the new commits).
#### Backup the shared database
After successful merges on main, snapshot the database for git tracking:
```bash
make db-backup
```
This copies the shared `settledreach.db` (in the parent directory) to
`docs/backups/settledreach.db.backup`. Stage and commit it with the merge
if the file changed.
### 2b. Not on `main` — merge main into current branch
```bash
git fetch --all
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
After a successful merge, push the branch:
```bash
git push origin <current-branch>
```
+16
View File
@@ -14,10 +14,12 @@ REMOTE_REF="origin/$BRANCH"
if git rev-parse --verify "$REMOTE_REF" >/dev/null 2>&1; then
CLIENT_CHANGED=$(git diff --name-only "$REMOTE_REF"..HEAD -- client/ 2>/dev/null | wc -l)
SERVER_CHANGED=$(git diff --name-only "$REMOTE_REF"..HEAD -- server/ 2>/dev/null | wc -l)
TOOLING_CHANGED=$(git diff --name-only "$REMOTE_REF"..HEAD -- tooling/ pyproject.toml 2>/dev/null | wc -l)
else
# New branch or no remote ref — fall through to directory checks
CLIENT_CHANGED=1
SERVER_CHANGED=1
TOOLING_CHANGED=1
fi
# --- GDScript parse check (headless Godot) ---
@@ -97,6 +99,20 @@ else
echo "pre-push: WARNING — cargo not found or server/ missing, skipping Rust lint"
fi
# --- Python lint (ruff) ---
if [ "$TOOLING_CHANGED" -eq 0 ]; then
echo "pre-push: no tooling/ changes — skipping Python lint"
elif command -v ruff >/dev/null 2>&1 && [ -d "$REPO_ROOT/tooling" ]; then
echo "pre-push: checking Python (ruff)..."
if ! (cd "$REPO_ROOT" && ruff check tooling/ 2>&1); then
ERRORS=$((ERRORS + 1))
else
echo "pre-push: ruff — OK"
fi
else
echo "pre-push: skipping Python lint (ruff not found — install with: pip install 'ruff>=0.9')"
fi
if [ "$ERRORS" -gt 0 ]; then
echo ""
echo "pre-push: $ERRORS check(s) failed. Push aborted."
+48
View File
@@ -0,0 +1,48 @@
class-definitions-order:
- tools
- classnames
- extends
- docstrings
- signals
- enums
- consts
- staticvars
- exports
- pubvars
- prvvars
- onreadypubvars
- onreadyprvvars
- others
class-load-variable-name: (([A-Z][a-z0-9]*)+|_?[a-z][a-z0-9]*(_[a-z0-9]+)*)
class-name: ([A-Z][a-z0-9]*)+
class-variable-name: _?[a-z][a-z0-9]*(_[a-z0-9]+)*
comparison-with-itself: null
constant-name: _?[A-Z][A-Z0-9]*(_[A-Z0-9]+)*
disable: []
duplicated-load: null
enum-element-name: '[A-Z][A-Z0-9]*(_[A-Z0-9]+)*'
enum-name: ([A-Z][a-z0-9]*)+
excluded_directories: !!set
.git: null
addons: null
expression-not-assigned: null
function-argument-name: _?[a-z][a-z0-9]*(_[a-z0-9]+)*
function-arguments-number: 10
function-name: (_on_([A-Z][a-z0-9]*)+(_[a-z0-9]+)*|_?[a-z][a-z0-9]*(_[a-z0-9]+)*)
function-preload-variable-name: ([A-Z][a-z0-9]*)+
function-variable-name: '[a-z][a-z0-9]*(_[a-z0-9]+)*'
load-constant-name: (([A-Z][a-z0-9]*)+|_?[A-Z][A-Z0-9]*(_[A-Z0-9]+)*)
loop-variable-name: _?[a-z][a-z0-9]*(_[a-z0-9]+)*
max-file-lines: 1000
max-line-length: 120
max-public-methods: 200
max-returns: 6
mixed-tabs-and-spaces: null
no-elif-return: null
no-else-return: null
signal-name: '[a-z][a-z0-9]*(_[a-z0-9]+)*'
sub-class-name: _?([A-Z][a-z0-9]*)+
tab-characters: 1
trailing-whitespace: null
unnecessary-pass: null
unused-argument: null
+45
View File
@@ -6,6 +6,51 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
## [Unreleased]
## [v0.1.31] — 2026-04-05
### Added
- Star map click-through popup with GTTR excerpt, system profile, and adjacent systems (#780)
- Edge-only-on-selected visibility rule for star map — default shows no edges, selected system shows adjacents only (#780)
- Cultural diversity sweep — 10 systems enriched, Afrikaans/Dutch subversions, D-168 Iserlohn IP evaluation (#766, #773)
- Per-body GTTR entries for 25 moons and stations at hub systems (#782)
- Shared Python module `tooling/db/common.py` — centralizes DB path resolution, config loading, and WAL-mode connection setup (#777)
- `ensure_venv()` auto-activation for Python tooling scripts with third-party dependencies (#777)
- `make lint-python` target running ruff on `tooling/` (#777)
- Python/ruff lint block in pre-push hook (#777)
- Zero-warnings policy in pr-push and pr-review skills; `.gdlintrc` with max-line-length 120
### Changed
- Decomposed main.gd (28KB → 13.4KB) into SnapshotConsumers, DialogueCoordinator, SnapshotHandler (#775)
- Split monolithic `atlas.rs` (2119 lines) into 8 focused modules under `src/bin/atlas/` (#776)
- Replaced persistent team worktrees with ephemeral sprint branches (`sprint-{N}/{team}`)
- `start-sprint.sh` integrated into sprint-start skill — auto-creates worktrees and opens Ptyxis tabs
### Fixed
- All 354 gdlint warnings resolved — zero warnings across all linters (#783)
- Nova Estrada and Entremeio wiki entries enriched to batch standard (#774)
- Auto-unassign done tickets on sprint close (`tooling/db/sprint stop`)
- Pre-existing `NameError` in `decisions_sync.py` resolved via shared module extraction (#777)
- Unused imports removed from `assign-astro-ids.py`, `generate-star-map.py`, `test_quaternius_raw.py` (#777)
## [v0.1.30] — 2026-04-05
### Added
- `corridor-status` subcommand for atlas CLI — shows remaining unfinished systems grouped by geographic sector and hop distance (#744)
- Star map insert module — concentric hop-ring view of 301 systems, sector-colored, click-to-select with info panel, pan/zoom (#674)
- BoneAttachment3D overhead anchor above Head bone for future floating UI elements (#712)
- CharacterVisualDescriptor wired into startup IPC and snapshot restore for save/load persistence (#718)
- Display-only hair highlight swatch (auto-derived from primary tint) in character creation (#719)
- Asset manifest fully populated (11 body types, 14 hair, 4 heads, 4 eyebrows, 8 clothing) with regeneration script (#720)
- Sprint 30 acceptance test suite (27 tests across all 5 tickets)
### Fixed
- `habitable_planet_count` filter now accepts both "breathable" and "standard" atmosphere values — previously all committed systems reported 0 habitable planets (#762). Systems committed before this fix may have stale `habitable_planet_count = 0`; re-commit to update.
- `corridor-status` uses LEFT JOIN so systems without gate records are included in counts
- `generate_body_matrix` now emits `atmosphere: "standard"` (was "breathable") to match committed-system conventions
- DirAccess asset scanning replaced with manifest JSON — fixes character creation in exported PCK builds (#720)
- Star map set_insert_active() no longer auto-shows the modal panel (#674)
- Star map insert state propagation wired into main.gd (#674)
## [v0.1.29] — 2026-04-03
### Added
+18 -9
View File
@@ -44,18 +44,17 @@ Development follows a strict cascade. Each phase has a concrete deliverable. **D
### Team boundaries
**Your team identity is `$WORKTREE_TEAM`.** All work must stay within the current working directory.
**Your team is determined by your sprint branch** (e.g. `sprint-31/server` → server team).
- All file paths are relative to the current working directory (e.g. `server/src/bridge/types.rs`).
- **Do NOT navigate to parent or sibling directories** (`../`, `../client/`, etc.) unless explicitly instructed. Do NOT use absolute paths to reach other team directories.
- **Do NOT write auto-memory files for other teams.** If `$WORKTREE_TEAM` is `server`, do not write to memory paths containing `client`, `main`, etc.
- For context: each team has its own directory via git worktrees, sharing a parent directory (`settled-reach/`). The `.git` file points to a shared git directory — do not follow it to determine your working root.
- **Exception — stale git lock files:** If a `git` command fails with `index.lock: File exists`, you may remove the lock file for **your own team only** (e.g. `main/.git/worktrees/$WORKTREE_TEAM/index.lock`). Never touch lock files belonging to other teams.
- **Never chain git commands** in a single Bash call (e.g. `git add ... && git commit ...`). The shared `.git` directory means concurrent index access from the same terminal creates `index.lock` collisions. Always run `git add` and `git commit` as **separate sequential Bash calls**.
- All file paths are relative to the repo root (e.g. `server/src/bridge/types.rs`).
- **Stay within your team's scope.** Server team modifies `server/`. Client team modifies `client/`. Copy team modifies `wiki/`, `docs/atlas/`, `content/`. Shared directories (`docs/`, `decisions/`) are readable by all teams.
- **Do NOT modify files outside your team scope** unless the ticket explicitly requires it.
- **Never chain git commands** in a single Bash call (e.g. `git add ... && git commit ...`). Always run `git add` and `git commit` as **separate sequential Bash calls**.
- **Stale git lock files:** If a `git` command fails with `index.lock: File exists`, you may remove the lock file at `.git/index.lock` (or `.git/worktrees/<name>/index.lock` if in a worktree).
### Database
The ticketing database (`settledreach.db`) lives in the **parent directory** shared across all worktrees — it is not tracked in git. A backup is committed to `docs/backups/settledreach.db.backup` via main only.
The ticketing database (`settledreach.db`) is accessed via `SR_DB_PATH` env var (set in `.claude/settings.json`). A backup is committed to `docs/backups/settledreach.db.backup` via main only.
### Before starting work
1. Read your sprint briefing at `docs/sprints/sprint-N/{team}.md` for current tasks
@@ -84,10 +83,20 @@ The ticketing database (`settledreach.db`) lives in the **parent directory** sha
- Three test tiers: (1) Live server — highest fidelity, (2) MessagePack replay via `Protocol.decode_snapshot()` — for unreachable rooms, (3) TestHarness mock — for UI-only tests where fog data doesn't matter.
- `make fixtures-gauntlet` regenerates real server snapshot fixtures from the Gauntlet world.
### GDScript conventions
**Autoload parse-order rule:** Autoload scripts (`client/scripts/autoloads/`) compile before global `class_name` scripts are registered. Referencing a `class_name` type directly in an autoload causes a parse-time "not declared" error. Pattern:
- Declare fields untyped: `var my_field = null` (comment the intended type)
- Do **not** reference `class_name` types at the top level or in `_ready()` of autoloads
- In method bodies called at runtime (e.g. `apply_snapshot`), use `load()` inline — by then the script is cached and `load()` returns the cached resource without reloading: `var CVD := load("res://scripts/rendering/character_visual_descriptor.gd")`
- Do **not** cache the `load()` result in `_ready()``_ready()` fires during autoload init, before the target script is in the resource cache, causing an actual file reload that breaks self-references in scripts using their own `class_name`
`game_state.gd` (`character_visual_descriptor` field) and `sim_bridge.gd` (`harness` field) follow this pattern.
### File conventions
- Decisions: domain files in `decisions/` (see `decisions/README.md` for index)
- Decision IDs: `D-NNN` (confirmed), `Q-NNN` (open questions), `R-NNN` (rejected)
- **Claim IDs before writing:** `tooling/db/decision claim D <domain> "title"` — prevents ID collisions across worktrees
- **Claim IDs before writing:** `tooling/db/decision claim D <domain> "title"` — prevents ID collisions across parallel branches
- Diagrams: `.d2` source + `.png` renders in `docs/diagrams/{category}/`. Create or update diagrams via `/d2-diagram` when D-records are added or modified.
- Discussion rounds: numbered sequentially, archived to `docs/discussions/` when complete
- Briefings: one per agent, updated after decision-producing rounds
+28 -7
View File
@@ -1,17 +1,18 @@
GODOT := $(shell command -v godot4 2>/dev/null || command -v godot 2>/dev/null)
.PHONY: help setup build check-protocol client server game stop test lint ci ci-client ci-server clean \
.PHONY: help setup build check-protocol client server game stop test lint lint-python setup-venv ci ci-client ci-server clean \
decisions-sync decisions-coverage decisions-active decisions-orphan \
db-backup db-install validate-content check-fact-ids setup-hooks \
audit atlas-verify \
pre-pr pre-pr-lint pre-pr-build pre-pr-test pre-pr-validate pre-pr-fixtures \
pre-pr-server pre-pr-client pre-pr-content \
fixtures-client fixtures-gauntlet golden-diff golden-update \
checklist-validate checklist-generate \
checklist-validate checklist-generate check-star-map \
build-sr-voice run-sr-voice test-voice-mock test-voice-real \
perf-baseline debug-schedule \
test-ipc-fixtures test-ipc-protocol test-ipc-integration test-ipc-benchmark \
screenshot visual-movie test-visual visual-update
screenshot visual-movie test-visual visual-update \
manifest
# --- Configuration ---
@@ -21,7 +22,8 @@ GODOT_VERSION ?= 4.6
help:
@echo "The Settled Reach — Development Commands"
@echo ""
@echo " make setup Install dev dependencies (Rust, Godot, tooling)"
@echo " make setup Install dev dependencies (Rust, Godot, tooling, venv)"
@echo " make setup-venv Create .venv and install Python tooling deps"
@echo " make build Build client and server"
@echo " make game Build and run the full game (server + client)"
@echo " make stop Stop any running server instance"
@@ -32,7 +34,8 @@ help:
@echo " make test-ipc-protocol Layer 2: mock IPC protocol tests"
@echo " make test-ipc-integration Layer 3: real subprocess round-trip"
@echo " make test-ipc-benchmark IPC latency benchmark (blocked: #555/#556)"
@echo " make lint Run all linters"
@echo " make lint Run all linters (server, client, python)"
@echo " make lint-python Run ruff on tooling/"
@echo " make ci Run full CI pipeline locally"
@echo " make ci-client Run client CI checks"
@echo " make ci-server Run server CI checks"
@@ -62,6 +65,7 @@ help:
@echo " make test-visual Run visual golden regression tests"
@echo " make visual-update Regenerate visual goldens and stage for commit"
@echo ""
@echo " make manifest Regenerate assets/characters/manifest.json from asset dirs (#720)"
@echo " make pre-pr Run all pre-PR checks (lint, build, test, validate, fixtures)"
@echo " make pre-pr-server Server-scoped pre-PR (lint, build, test, fixtures)"
@echo " make pre-pr-client Client-scoped pre-PR (lint, build, test)"
@@ -80,7 +84,7 @@ help:
# --- Setup ---
setup: setup-rust setup-godot setup-tooling setup-hooks decisions-sync
setup: setup-rust setup-godot setup-tooling setup-venv setup-hooks decisions-sync
@echo "Dev environment ready."
setup-rust:
@@ -101,6 +105,11 @@ setup-hooks:
@git config core.hooksPath .config/hooks
@echo "Git hooks path set to .config/hooks"
setup-venv:
@python3 -m venv .venv
@.venv/bin/pip install -e ".[dev]" --quiet
@echo "Venv ready at .venv — activate with: source .venv/bin/activate"
# --- Build ---
check-protocol:
@@ -220,7 +229,10 @@ clean-imports:
# --- Lint ---
lint: lint-server lint-client
lint: lint-server lint-client lint-python
lint-python:
ruff check tooling/
lint-server:
cd server && cargo clippy -- -D warnings
@@ -345,6 +357,9 @@ checklist-validate:
checklist-generate:
@tooling/validate-checklist
check-star-map:
@python3 tooling/generate-star-map-data.py --check
perf-baseline:
@tooling/perf-baseline
@@ -399,6 +414,12 @@ test-voice-real:
cd server && cargo test --test voice_pipeline -- --nocapture
@echo "Results: .tmp/voice-test/results.txt"
# --- Asset manifest ---
manifest:
@tooling/generate-character-manifest
@echo "Manifest regenerated — commit client/assets/characters/manifest.json if changed."
# --- Clean ---
clean:
+70 -8
View File
@@ -1,13 +1,75 @@
{
"body_types": ["average_m", "average_f", "muscular_m", "muscular_f", "teen_m", "teen_f"],
"heads": [],
"hair": ["bob", "buns", "buzzed", "long", "ponytail", "bald"],
"facial_hair": ["beard", "moustache", "mutton_chops"],
"eyebrows": [],
"body_types": [
"average_f",
"average_m",
"child",
"heavy_f",
"heavy_m",
"muscular_f",
"muscular_m",
"teen_f",
"teen_m",
"thin_f",
"thin_m"
],
"heads": [
"head_001",
"head_002",
"head_003",
"head_004"
],
"hair": [
"bald",
"balding",
"bob",
"buns",
"buzzed",
"buzzed_female",
"dreads",
"long",
"long_dreads",
"mohawk",
"ponytail",
"ponytail_f",
"simple_parted",
"slick_back"
],
"facial_hair": [
"beard",
"moustache",
"mutton_chops"
],
"eyebrows": [
"female",
"regular",
"teen",
"thick"
],
"clothing": {
"peasant_tunic": {"slot": "torso"},
"peasant_pants": {"slot": "legs"},
"peasant_shoes": {"slot": "feet"}
"boots_work": {
"slot": "feet"
},
"coveralls_basic": {
"slot": "torso"
},
"jacket_utility": {
"slot": "torso"
},
"pants_cargo": {
"slot": "legs"
},
"peasant_pants": {
"slot": "legs"
},
"peasant_shoes": {
"slot": "feet"
},
"peasant_tunic": {
"slot": "torso"
},
"shirt_henley": {
"slot": "torso"
}
},
"accessories": []
}
File diff suppressed because it is too large Load Diff
+18 -19
View File
@@ -5,6 +5,8 @@ extends Node
## No-op fallback when audio assets absent (D-038).
## Spatial audio positioning for close-range sounds (D-018).
signal dip_changed(profile: String)
# --- 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.
@@ -63,6 +65,22 @@ const ZONE_ASSETS: Dictionary = {
"corridor": "amb_corridor_layer",
}
const PREFS_PATH := "user://audio_prefs.cfg"
# --- 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://assets/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",
}
# Asset registry: filename stem (e.g. "amb_station_base") → AudioStream
var _registry: Dictionary = {}
@@ -83,10 +101,6 @@ var _ambient_players: Dictionary = {}
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()
@@ -200,21 +214,6 @@ 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://assets/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.
+10 -11
View File
@@ -8,7 +8,8 @@ extends Node
# Used by fog shader to distinguish visual treatment per tile.
# Test assertions reference these: assert_that(byte).is_equal(FogState.VIS_FORWARD)
const VIS_HIDDEN: int = 0 # Not in LOS — fully fogged
const VIS_PERIPHERAL: int = 180 # DEPRECATED: peripheral sector removed in Sprint 22 (#569). Retained — tests still reference it.
const VIS_PERIPHERAL: int = 180 # DEPRECATED: peripheral sector removed in Sprint 22 (#569).
# Retained — tests still reference it.
const VIS_FORWARD: int = 255 # In LOS, forward sector — clear vision
const EXP_UNEXPLORED: int = 0 # Never seen — total darkness
@@ -35,6 +36,14 @@ var map_bounds: Rect2i = Rect2i(0, 0, 1, 1)
var visibility_texture: ImageTexture
var exploration_texture: ImageTexture
var zone_tint_texture: ImageTexture
## Debug flag — when true, fog.gdshader renders raw exploration texture
## as colored overlay (green=visible, blue=explored, red=unexplored).
## Toggle via FogState.debug_exploration = true in the console.
var debug_exploration: bool = false
## Deterministic shader time for visual test captures.
## When >= 0, fog_shader.gd uses this instead of Time.get_ticks_msec().
## Set before settle frames so noise phase is reproducible across runs.
var override_time: float = -1.0
var _vis_bytes: PackedByteArray
var _exp_bytes: PackedByteArray
@@ -47,16 +56,6 @@ var _width: int = 1
var _height: int = 1
var _prev_visible: Dictionary = {} # Tiles visible last frame (for incremental decay)
## Debug flag — when true, fog.gdshader renders raw exploration texture
## as colored overlay (green=visible, blue=explored, red=unexplored).
## Toggle via FogState.debug_exploration = true in the console.
var debug_exploration: bool = false
## Deterministic shader time for visual test captures.
## When >= 0, fog_shader.gd uses this instead of Time.get_ticks_msec().
## Set before settle frames so noise phase is reproducible across runs.
var override_time: float = -1.0
func _ready() -> void:
_resize(Rect2i(0, 0, 64, 64))
+11 -265
View File
@@ -19,7 +19,8 @@ var player_position: Vector2 = Vector2.ZERO
var visible_entities: Array = []
var visible_tiles: Array = []
var visible_positions: Dictionary = {} # Vector2i -> true, for fast fog lookups (normal LOS tiles)
var boundary_positions: Dictionary = {} # Vector2i -> true, BoundaryWall margin tiles (#585) — visible in fog but not explored
var boundary_positions: Dictionary = {} # Vector2i -> true, BoundaryWall margin tiles (#585)
# — visible in fog but not explored
# v2 fields (D-015, D-031)
var game_time: Dictionary = {} # {day, time_of_day, day_phase, tick_rate} or empty
@@ -50,7 +51,8 @@ var player_stance: String = "Walk" # Sprint/Walk/Careful/Crouch
var player_inventory: Array = [] # [{item_id, name, slot}]
# v7 fields (#435, D-061/D-062)
var current_dialogue: Variant = null # {npc_name, npc_entity_id, speech, options: [{text, response_id, priority}]} or null
var current_dialogue: Variant = null # {npc_name, npc_entity_id, speech, options: [{text, response_id, priority}]}
# or null
# D-064: true while dialogue box is visible or fading out (300ms).
# InputMapper suppresses movement when this is true.
@@ -100,7 +102,8 @@ var character_archetype: String = "detective"
# #705: Character visual descriptor — set by character_creation.gd on confirmation.
# Passed to EntityRenderer for the player entity's CharacterVisual on game start.
# Null when no custom appearance has been selected (fallback: default descriptor).
var character_visual_descriptor: CharacterVisualDescriptor = null
# Type is CharacterVisualDescriptor — untyped to avoid autoload parse-order issue.
var character_visual_descriptor = null
# #646: AI-Enhanced Dialogue enabled state (D-138).
# Runtime toggle — true means the LLM re-voicing pipeline should run (server-side).
@@ -151,8 +154,7 @@ var close_sound_events: Array = []
# Fallback: client-side accumulation (deprecated, remove when server populates field).
# ListeningFocus boost activates at 30+ ticks (main.gd manages the dip).
var stationary_ticks: int = 0
# DEPRECATED: Only used by client-side accumulation fallback. Remove with fallback.
var _prev_player_position: Vector2 = Vector2(-1e9, -1e9) # sentinel: no previous position
# DEPRECATED: _prev_player_position moved to SnapshotHandler (client-side accumulation fallback).
# D-073 (#529): Server-authoritative zone_id from the player's current tile.
# D-020: Read directly from snapshot "zone_id" field.
@@ -161,263 +163,7 @@ var _prev_player_position: Vector2 = Vector2(-1e9, -1e9) # sentinel: no previou
var current_zone_id: String = ""
func apply_snapshot(snapshot: Dictionary) -> void:
current_snapshot = snapshot
if snapshot.has("tick"):
current_tick = snapshot.tick
if snapshot.has("entities"):
visible_entities = snapshot.entities
# Derive player position from the entity with kind.variant == "Player"
var found_player := false
for entity in visible_entities:
if entity.has("kind") and entity.kind is Dictionary and entity.kind.get("variant") == "Player":
player_position = Vector2(entity.x, entity.y)
if entity.has("entity_id"):
player_entity_id = entity.entity_id
found_player = true
break
if not found_player and visible_entities.size() > 0:
push_warning("GameState: no Player entity found in %d entities" % [
visible_entities.size()])
# D-020/D-071 (#530): Server-authoritative stationary_ticks for ListeningFocus boost.
# Prefer server-sent value; fall back to client-side accumulation until server populates.
if snapshot.has("stationary_ticks") and snapshot.stationary_ticks is int:
# D-020: direct field assignment from server-authoritative snapshot.
stationary_ticks = snapshot.stationary_ticks
else:
# DEPRECATED fallback — client-side accumulation. Remove when server sends
# "stationary_ticks" in ObserverSnapshot (D-020 violation: derives behavior-
# driving state on the client). Server tracks this in ListeningFocus component.
if player_position == _prev_player_position:
stationary_ticks += 1
else:
stationary_ticks = 0
_prev_player_position = player_position
# Tiles for rendering: test mode sends "tiles", live server sends tile data in "visible_tiles"
if snapshot.has("tiles"):
visible_tiles = snapshot.tiles
elif snapshot.has("visible_tiles") and snapshot.visible_tiles is Array and snapshot.visible_tiles.size() > 0:
# Live server: visible_tiles now includes type from tile_kind field
var has_type := false
if snapshot.visible_tiles.size() > 0 and snapshot.visible_tiles[0] is Dictionary:
has_type = snapshot.visible_tiles[0].has("type")
if has_type:
visible_tiles = snapshot.visible_tiles
if snapshot.has("visible_positions"):
visible_positions.clear()
for pos in snapshot.visible_positions:
visible_positions[Vector2i(pos.x, pos.y)] = true
# v2: game_time (D-031)
if snapshot.has("game_time") and snapshot.game_time is Dictionary:
game_time = snapshot.game_time
# v2: player_facing (D-015)
if snapshot.has("player_facing") and snapshot.player_facing is String:
player_facing = snapshot.player_facing
# v4: nearby_interactions (#404/#405)
if snapshot.has("nearby_interactions") and snapshot.nearby_interactions is Array:
nearby_interactions = snapshot.nearby_interactions
else:
nearby_interactions = []
# v5: current_monologue (#414)
if snapshot.has("current_monologue") and snapshot.current_monologue is Dictionary:
current_monologue = snapshot.current_monologue
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
# v6: player_inventory (#449, D-065)
if snapshot.has("player_inventory") and snapshot.player_inventory is Array:
player_inventory = snapshot.player_inventory
else:
player_inventory = []
# v7: current_dialogue (#434, D-061)
if snapshot.has("current_dialogue") and snapshot.current_dialogue is Dictionary:
current_dialogue = snapshot.current_dialogue
else:
current_dialogue = null
# v7: pending_recognitions (#431, D-059/D-060)
if snapshot.has("pending_recognitions") and snapshot.pending_recognitions is Array:
pending_recognitions = snapshot.pending_recognitions
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
else:
gauntlet_mode = false
if snapshot.has("room_id") and snapshot.room_id is String:
room_id = snapshot.room_id
else:
room_id = null
# OQ-07 (#522): insert_active — defaults true (v0.1 always has insert).
# Server may send false for characters without an insert in future sprints.
if snapshot.has("insert_active") and snapshot.insert_active is bool:
insert_active = snapshot.insert_active
else:
insert_active = true
# #507: rng_seed — server sends current RNG seed for replay determinism.
# Field: "rng_seed" (u64 as integer). Null if server does not include it.
if snapshot.has("rng_seed"):
rng_seed = snapshot.rng_seed
else:
rng_seed = null
# 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 = []
# v10: discovered_pois (#151, D-013) — server sends POIs discovered by the player.
# Accepts "discovered_pois" or "poi_list" key — both map to the same client field.
# Only update if the field is present — absence means "no change since last tick".
if snapshot.has("discovered_pois") and snapshot.discovered_pois is Array:
discovered_pois = snapshot.discovered_pois
elif snapshot.has("poi_list") and snapshot.poi_list is Array:
discovered_pois = snapshot.poi_list
# v14: examine_result (#174, #242) — character-filtered observation from Examine verb.
if snapshot.has("examine_result") and snapshot.examine_result is Dictionary:
current_examine_result = snapshot.examine_result
else:
current_examine_result = null
# v15: save_result (#554, D-085) — one-shot save/load confirmation from server.
if snapshot.has("save_result") and snapshot.save_result is Dictionary:
save_result = snapshot.save_result
else:
save_result = null
# v18: debug_response (#580) — debug console command result.
if snapshot.has("debug_response") and snapshot.debug_response is Dictionary:
debug_response = snapshot.debug_response
else:
debug_response = null
# v20: settings_response (#627, D-138) — one-shot settings ack/dump from server.
# "full" kind → iterate settings array and hydrate matching fields.
if snapshot.has("settings_response") and snapshot.settings_response is Dictionary:
settings_response = snapshot.settings_response
var sr: Dictionary = snapshot.settings_response
if sr.get("kind") == "full":
var sr_settings: Variant = sr.get("settings")
if sr_settings is Array:
for entry in sr_settings:
if not entry is Dictionary:
continue
if entry.get("key") == "ai_dialogue.enabled":
var val: Variant = entry.get("value")
if val != null:
ai_enhanced_dialogue_enabled = _extract_bool_setting("ai_dialogue.enabled", val)
else:
settings_response = null
# v14: player_knowledge (#264, D-041) — partial KG dump for journal panel.
# Only update when field is present (null means no change, server sends when KG changes).
if snapshot.has("player_knowledge") and snapshot.player_knowledge is Dictionary:
player_knowledge = snapshot.player_knowledge
# D-020/D-073 (#529): Server-authoritative zone_id for zone ambient crossfade.
# Prefer server-sent top-level value; fall back to client-side tile lookup until
# server populates top-level "zone_id" in ObserverSnapshot.
if snapshot.has("zone_id") and snapshot.zone_id is String:
# D-020: direct field assignment from server-authoritative snapshot.
current_zone_id = snapshot.zone_id
else:
# DEPRECATED fallback — client-side tile lookup. Remove when server sends
# top-level "zone_id" in ObserverSnapshot (D-020 violation: derives zone
# identity on the client via tile iteration). Server sends zone_id per
# VisibleTile but not as a top-level snapshot field.
var _tile_by_coord: Dictionary = {}
for vtile in visible_tiles:
if vtile is Dictionary and vtile.has("x") and vtile.has("y"):
_tile_by_coord[Vector2i(vtile.x, vtile.y)] = vtile
var player_pos_key := Vector2i(int(player_position.x), int(player_position.y))
var player_tile = _tile_by_coord.get(player_pos_key, null)
current_zone_id = player_tile.get("zone_id", "") if player_tile else ""
# v2: visible_tiles with visibility sectors
# Derives visible_positions when not explicitly provided (real server mode).
# #585: BoundaryWall tiles go to boundary_positions — rendered in fog but not marked explored.
if snapshot.has("visible_tiles") and snapshot.visible_tiles is Array and snapshot.visible_tiles.size() > 0:
visibility_sectors.clear()
var has_explicit_positions := snapshot.has("visible_positions")
if not has_explicit_positions:
visible_positions.clear()
boundary_positions.clear()
for vtile in snapshot.visible_tiles:
if not vtile is Dictionary or not vtile.has("x") or not vtile.has("y"):
continue
var pos := Vector2i(vtile.x, vtile.y)
var vis_sector: String = vtile.get("visibility", "")
if vtile.has("visibility"):
visibility_sectors[pos] = vis_sector
# #585: BoundaryWall tiles are margin tiles visible through fog but not persistently
# explored — they don't update the player's exploration memory when they leave LOS.
if vis_sector == "BoundaryWall":
boundary_positions[pos] = true
elif not has_explicit_positions:
visible_positions[pos] = true
# -- Helpers ------------------------------------------------------------------
## Extract a bool from a tagged-union {"Bool": true} or plain bool value.
## Handles both serde encoding styles; emits push_warning on unrecognised format.
static func _extract_bool_setting(key: String, val: Variant) -> bool:
if val is bool:
return val
if val is Dictionary and val.has("Bool"):
return bool(val["Bool"])
push_warning("GameState: unexpected type for setting '%s': %s" % [key, str(val)])
return false
# Autoload parse-order: class_name types are not registered when autoloads compile.
# load() returns the cached resource after the first call — essentially free per-tick.
var SH := load("res://scripts/snapshot_handler.gd")
SH.apply(snapshot)
+8 -8
View File
@@ -30,14 +30,6 @@ enum Action {
DELETE_SETTING, # #646: delete a setting by key from server SQLite (struct variant)
}
var input_queue: Array[Dictionary] = []
# D-054: Client-side facing angle (radians). 0=East, -PI/2=North, PI/2=South.
# Updated every frame from mouse position. EntityRenderer reads this for indicator.
var facing_angle: float = -PI / 2.0 # Default: North
var facing_octant: String = "North" # Derived from facing_angle
var _last_sent_octant: String = "North" # Track to avoid redundant sends
# Minimum milliseconds between movement commands, per stance.
# Tuned so Walk feels like walking, Sprint feels fast but readable.
const MOVE_INTERVAL_MS := {
@@ -46,6 +38,14 @@ const MOVE_INTERVAL_MS := {
"Careful": 600, # ~1.7/sec — deliberate, scanning
"Crouch": 800, # 1.25/sec — creeping
}
var input_queue: Array[Dictionary] = []
# D-054: Client-side facing angle (radians). 0=East, -PI/2=North, PI/2=South.
# Updated every frame from mouse position. EntityRenderer reads this for indicator.
var facing_angle: float = -PI / 2.0 # Default: North
var facing_octant: String = "North" # Derived from facing_angle
var _last_sent_octant: String = "North" # Track to avoid redundant sends
var _last_move_msec: int = 0
+12 -12
View File
@@ -19,6 +19,9 @@ extends Node
# -- Power profile ------------------------------------------------------------
## Emitted when the detected power profile changes.
signal power_profile_changed(old_profile: int, new_profile: int)
## High-level power classification. POWER_SAVER reserved for future OS API.
enum PowerProfile {
FULL = 0, # Plugged in (charged, charging, or no battery) — no restrictions
@@ -26,18 +29,6 @@ enum PowerProfile {
POWER_SAVER = 2 # System-level power-saver mode (future: no cross-platform API yet)
}
## Emitted when the detected power profile changes.
signal power_profile_changed(old_profile: int, new_profile: int)
## Current power profile. Updated by the 30-second poll timer.
var power_profile: PowerProfile = PowerProfile.FULL
## Raw OS power_state integer from the last poll. 0 = unknown, 1 = on battery, etc.
var raw_power_state: int = 0
## Battery charge percentage (0100). -1 if not available or not on battery.
var battery_percent: int = -1
## How often (seconds) to re-poll OS for power state changes.
const POWER_POLL_INTERVAL := 30.0
@@ -48,6 +39,15 @@ const _POWER_STATE_NO_BATTERY := 2
const _POWER_STATE_CHARGING := 3
const _POWER_STATE_CHARGED := 4
## Current power profile. Updated by the 30-second poll timer.
var power_profile: PowerProfile = PowerProfile.FULL
## Raw OS power_state integer from the last poll. 0 = unknown, 1 = on battery, etc.
var raw_power_state: int = 0
## Battery charge percentage (0100). -1 if not available or not on battery.
var battery_percent: int = -1
# -- Memory -------------------------------------------------------------------
+43 -39
View File
@@ -1,52 +1,37 @@
extends Node
# Connection states
enum ConnectionState { DISCONNECTED, CONNECTING, HANDSHAKING, CONNECTED, ERROR }
var state: ConnectionState = ConnectionState.DISCONNECTED
var test_mode: bool = OS.get_environment("SR_LIVE") != "1" # SR_LIVE=1 connects to real server
var harness: TestHarness = null # Test simulation (D-020: game logic lives outside production client)
var _last_snapshot: Variant = null # Most recent decoded snapshot (consumed by poll_snapshot)
var _outbound_buffer: Array[Dictionary] = [] # Raw inputs awaiting batch encode + transport
# Transport layer (non-test mode)
var _bridge: LocalBridge = null
var _server: ServerProcess = null
var server_port: int = 9876 # Default matches server's default bind address
var server_path: String = "" # Path to server binary — set before connect_to_sim()
# Connection retry state — handles server startup delay (Critical fix #1)
const MAX_CONNECT_RETRIES: int = 20 # ~2 seconds at 60fps with 100ms delay
const CONNECT_RETRY_INTERVAL: float = 0.1 # Seconds between retry attempts
var _connect_retries: int = 0
var _retry_timer: float = 0.0
# Handshake state (#556)
const HANDSHAKE_TIMEOUT_USEC: int = 5_000_000 # 5 seconds
var _handshake_start_usec: int = 0
# Signals
signal connection_state_changed(old_state: ConnectionState, new_state: ConnectionState)
signal snapshot_received(snapshot: Dictionary)
signal handshake_complete(protocol_version: int)
signal handshake_failed(reason: String)
func _ready() -> void:
if test_mode:
harness = TestHarness.new()
print("SimBridge: Running in test mode (dynamic snapshot)")
# Connection states
enum ConnectionState { DISCONNECTED, CONNECTING, HANDSHAKING, CONNECTED, ERROR }
# Connection retry state — handles server startup delay (Critical fix #1)
const MAX_CONNECT_RETRIES: int = 20 # ~2 seconds at 60fps with 100ms delay
const CONNECT_RETRY_INTERVAL: float = 0.1 # Seconds between retry attempts
# -- Test mode proxy API (backward compat for 13+ test files) ------------------
# Handshake state (#556)
const HANDSHAKE_TIMEOUT_USEC: int = 5_000_000 # 5 seconds
func reset_test_state() -> void:
if harness: harness.reset()
var state: ConnectionState = ConnectionState.DISCONNECTED
var test_mode: bool = OS.get_environment("SR_LIVE") != "1" # SR_LIVE=1 connects to real server
# Type is TestHarness — untyped to avoid autoload parse-order issue.
var harness = null # Test simulation (D-020: game logic lives outside production client)
var server_port: int = 9876 # Default matches server's default bind address
var server_path: String = "" # Path to server binary — set before connect_to_sim()
func _test_snapshot() -> Dictionary:
return harness.snapshot()
var _last_snapshot: Variant = null # Most recent decoded snapshot (consumed by poll_snapshot)
var _outbound_buffer: Array[Dictionary] = [] # Raw inputs awaiting batch encode + transport
func _test_has_los(from: Vector2i, to: Vector2i) -> bool:
return harness.has_los(from, to)
# Transport layer (non-test mode)
var _bridge: LocalBridge = null
var _server: ServerProcess = null
var _connect_retries: int = 0
var _retry_timer: float = 0.0
var _handshake_start_usec: int = 0
var _test_tick: int:
get: return harness.tick if harness else 0
@@ -82,6 +67,24 @@ var _test_input_queue: Array:
get: return harness.input_queue if harness else []
func _ready() -> void:
if test_mode:
harness = load("res://scripts/protocol/test_harness.gd").new()
print("SimBridge: Running in test mode (dynamic snapshot)")
# -- Test mode proxy API (backward compat for 13+ test files) ------------------
func reset_test_state() -> void:
if harness: harness.reset()
func _test_snapshot() -> Dictionary:
return harness.snapshot()
func _test_has_los(from: Vector2i, to: Vector2i) -> bool:
return harness.has_los(from, to)
# -- Connection lifecycle ------------------------------------------------------
# Change connection state and emit signal
@@ -143,7 +146,7 @@ func _try_connect() -> void:
_bridge = null
# Poll transport layer every frame (non-test mode only)
func _process(delta: float) -> void:
func _process(delta: float) -> void: # gdlint:disable=max-returns
if test_mode:
return
@@ -231,9 +234,10 @@ func _process(delta: float) -> void:
_set_state(ConnectionState.ERROR)
return
# Send startup message with world_seed (#175, D-010/D-029).
# Send startup message with world_seed and character appearance (#175, D-010/D-029, #718).
# Server blocks waiting for this before entering the tick loop.
var startup_bytes := Protocol.encode_startup_message(GameState.world_seed, GameState.character_archetype)
var startup_bytes := Protocol.encode_startup_message(
GameState.world_seed, GameState.character_archetype, GameState.character_visual_descriptor)
if startup_bytes.size() > 0:
var send_err := _bridge.send_message(startup_bytes)
if send_err != OK:
+1 -1
View File
@@ -51,4 +51,4 @@ func reload() -> void:
## Returns flat Dictionary with dotted keys: { "section.sub.key": "value" }.
## Delegates to YamlParser.parse_flat() (#560).
static func _parse_yaml(text: String) -> Dictionary:
return YamlParser.parse_flat(text)
return load("res://scripts/util/yaml_parser.gd").parse_flat(text)
@@ -135,12 +135,13 @@ func reset() -> void:
static func _warn_empty_ids(conditions: Array, path: String) -> void:
for i in conditions.size():
if conditions[i].get("id", "").is_empty():
push_warning("ChecklistEvaluator: condition at index %d in %s has empty id — will be excluded from results" % [i, path])
push_warning(
"ChecklistEvaluator: condition at index %d in %s has empty id — will be excluded from results" % [i, path])
# -- Condition evaluation ------------------------------------------------------
func _evaluate_condition(cond: Dictionary) -> bool:
func _evaluate_condition(cond: Dictionary) -> bool: # gdlint:disable=max-returns
match cond.get("condition_type", ""):
"player_near":
return _eval_player_near(cond)
+23 -23
View File
@@ -59,24 +59,6 @@ const ENTITY_COLOR_HOSTILE: Color = Color("#d45d5d") # Hostile/Dangerous —
const ENTITY_COLOR_OBJECT: Color = Color("#8b8ba0") # Static objects — muted grey
const ENTITY_COLOR_PLAYER: Color = Color("#e0e8ff") # Player character (detective)
# D-033 color lookup by relationship string (#521)
static func color_for_relationship(relationship: String) -> Color:
match relationship:
"Friendly": return ENTITY_COLOR_FRIENDLY
"PersonOfInterest": return ENTITY_COLOR_POI
"Hostile": return ENTITY_COLOR_HOSTILE
"Unknown": return ENTITY_COLOR_UNKNOWN
_: return ENTITY_COLOR_UNKNOWN
# D-033 color lookup by entity data — uses relationship for NPCs (#521)
static func color_for_entity_kind(entity_data: Dictionary) -> Color:
var kind_variant: String = entity_data.get("kind", {}).get("variant", "")
match kind_variant:
"Player": return ENTITY_COLOR_PLAYER
"Object", "Terrain": return ENTITY_COLOR_OBJECT
"Npc": return color_for_relationship(entity_data.get("relationship", "Unknown"))
_: return ENTITY_COLOR_OBJECT
# D-048/D-056: Insert-styled UI color palette
# Used by dialogue box, interaction list, radial menu, and other diegetic insert UI.
const INSERT_COLOR_TEXT: Color = Color("#c8d0e0") # Default insert text — white-blue
@@ -96,11 +78,6 @@ const FACING_INDICATOR_OFFSET: float = 14.0
# two columns of text comfortably, leaves world game visible alongside.
const DIALOGUE_MAX_WIDTH: int = 1200
# D-031: Format game-minutes (0..1439) as station local time string "HH:MM".
static func format_game_time(time_of_day: int) -> String:
var clamped: int = clampi(time_of_day, 0, 1439)
return "%02d:%02d" % [clamped / 60, clamped % 60]
# Default camera zoom — used as fallback when get_camera_2d() returns null
const CAMERA_DEFAULT_ZOOM: Vector2 = Vector2(2.0, 2.0)
@@ -115,3 +92,26 @@ const IMPLANT_TEXT_DIM: Color = Color("#9EBFC4") # Dimmed variant — second
const IMPLANT_PULSE_MIN: float = 0.85 # Alpha pulse floor
const IMPLANT_PULSE_MAX: float = 1.0 # Alpha pulse ceiling
const IMPLANT_PULSE_PERIOD: float = 2.5 # Seconds per pulse cycle
# D-033 color lookup by relationship string (#521)
static func color_for_relationship(relationship: String) -> Color:
match relationship:
"Friendly": return ENTITY_COLOR_FRIENDLY
"PersonOfInterest": return ENTITY_COLOR_POI
"Hostile": return ENTITY_COLOR_HOSTILE
"Unknown": return ENTITY_COLOR_UNKNOWN
_: return ENTITY_COLOR_UNKNOWN
# D-033 color lookup by entity data — uses relationship for NPCs (#521)
static func color_for_entity_kind(entity_data: Dictionary) -> Color:
var kind_variant: String = entity_data.get("kind", {}).get("variant", "")
match kind_variant:
"Player": return ENTITY_COLOR_PLAYER
"Object", "Terrain": return ENTITY_COLOR_OBJECT
"Npc": return color_for_relationship(entity_data.get("relationship", "Unknown"))
_: return ENTITY_COLOR_OBJECT
# D-031: Format game-minutes (0..1439) as station local time string "HH:MM".
static func format_game_time(time_of_day: int) -> String:
var clamped: int = clampi(time_of_day, 0, 1439)
return "%02d:%02d" % [clamped / 60, clamped % 60]
+166
View File
@@ -0,0 +1,166 @@
class_name DialogueCoordinator
## Dialogue snapshot consumers and signal handlers extracted from main.gd (#775).
##
## Owns NPC identity tracking state (_last_dialogue_npc_id/name) shared between
## consuming dialogue snapshots and handling dialogue_box signals.
## Registered with SnapshotEventRouter; reads from GameState directly.
var dialogue_box: Node = null
var monologue_display: Node = null
var journal_panel: Node = null
# Shared mutable reference — GDScript arrays are reference types.
# main.gd and this coordinator both append to the same array.
var _pending_record_inputs: Array = []
var _last_dialogue_npc_id: int = -1 # D-064: NPC entity_id for WalkAway input
var _last_dialogue_npc_name: String = "" # #535: NPC name for dialogue_response attribution
var _last_dialogue_tick: int = -1
var _last_confrontation_tick: int = -1
func init(refs: Dictionary, pending_record_inputs: Array) -> DialogueCoordinator:
dialogue_box = refs.get("dialogue_box")
monologue_display = refs.get("monologue_display")
journal_panel = refs.get("journal_panel")
_pending_record_inputs = pending_record_inputs
return self
## Connect dialogue_box signals. Call from main.gd._ready() after init().
func connect_signals() -> void:
if not dialogue_box:
return
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)
# D-020 (#558): Decoupled signals — coordinator routes state changes.
dialogue_box.dialogue_state_changed.connect(on_dialogue_state_changed)
dialogue_box.audio_dip_requested.connect(on_audio_dip_requested)
dialogue_box.audio_dip_cleared.connect(on_audio_dip_cleared)
# -- Snapshot consumers (registered with SnapshotEventRouter) -----------------
# Consume-once per tick with ID tracking: show dialogue, then clear.
func consume_dialogue() -> void:
if GameState.current_dialogue == null or not dialogue_box:
return
if GameState.current_tick == _last_dialogue_tick:
return
if dialogue_box.is_dialogue_active():
GameState.current_dialogue = null
return
_last_dialogue_tick = GameState.current_tick
# #264: Close journal when dialogue opens
if journal_panel and journal_panel.has_method("close"):
journal_panel.close()
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", ""),
dlg.get("options", []),
_last_dialogue_npc_id
)
GameState.current_dialogue = null
# #535: Consume overheard NPC-NPC conversation events (D-078).
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.
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
var speaker_entity_id: int = dr.get("speaker_entity_id", _last_dialogue_npc_id)
var speaker_color_index: int = dr.get("speaker_color_index", -1)
var speaker_name: String = dr.get("speaker_name", _last_dialogue_npc_name)
dialogue_box.update_entity_display(speaker_entity_id, speaker_name, speaker_color_index)
dialogue_box.append_dialogue_response(speaker_name, dr.get("text", ""), speaker_entity_id)
GameState.dialogue_response = null
# -- Signal handlers ----------------------------------------------------------
# D-061: Handle dialogue option selection -> send to server
func on_dialogue_option_selected(response_id: String, _text: String) -> void:
SimBridge.send_input({
"action": InputMapper.Action.INTERACT,
"timestamp_msec": Time.get_ticks_msec(),
"action_data": {
"target_entity_id": null,
"verb": "DialogueResponse",
"response_id": response_id,
},
})
# D-063: Confrontation beat monologue -> show on monologue display (layer 7)
func on_confrontation_monologue(text: String, duration: float) -> void:
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
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
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
func on_dialogue_dismissed() -> void:
SimBridge.send_input({
"action": InputMapper.Action.INTERACT,
"timestamp_msec": Time.get_ticks_msec(),
"action_data": {
"target_entity_id": _last_dialogue_npc_id if _last_dialogue_npc_id >= 0 else null,
"verb": "WalkAway",
},
})
# D-020 (#558): Coordinator handles dialogue state changes.
func on_dialogue_state_changed(active: bool) -> void:
GameState.dialogue_active = active
# D-020 (#558): Route audio dip requests.
func on_audio_dip_requested(profile: String) -> void:
AudioManager.apply_dip(profile)
# D-020 (#558): Route audio dip clear.
func on_audio_dip_cleared() -> void:
AudioManager.clear_dip()
+69 -381
View File
@@ -1,5 +1,15 @@
extends Node2D
const TELEPORT_DISTANCE_THRESHOLD: float = 5.0
var _camera_anchored: bool = false
var _flash_rect: ColorRect = null # #502/#501: ephemeral screen flash overlay
var _teleport_in_progress: bool = false # #501/#117: forces camera snap on next frame
var _pending_record_inputs: Array = [] # #507: accumulates server-bound inputs across frames
var _router: SnapshotEventRouter # #559: callable-based snapshot dispatch
var _consumers: SnapshotConsumers # #775: non-dialogue snapshot consumers
var _dialogue: DialogueCoordinator # #775: dialogue consumers + signal handlers
@onready var world_renderer = $World
@onready var fog_entities = $World/FogEntities # D-059/D-060: cognitive delay fog visualization
@onready var camera = $Camera2D
@@ -24,38 +34,19 @@ extends Node2D
@onready var loading_screen = $ModalLayer/LoadingScreen # #257: blocking overlay during load
@onready var debug_console = $ModalLayer/DebugConsole # #581: tilde debug console
@onready var news_ticker = $UILayer/NewsTicker # #592: scrolling headline bar (D-049 z-7)
@onready var star_map = $UILayer/HUD/StarMap # #674: star map insert module (hop-ring view)
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_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 _known_triangle_ids: Dictionary = {} # #590: triangle_ids that have already fired the activation chime
var _flash_rect: ColorRect = null # #502/#501: ephemeral screen flash overlay (shared: teleport preempts amber)
var _teleport_in_progress: bool = false # #501/#117: forces camera snap (not lerp) on next _process frame
var _pending_record_inputs: Array = [] # #507: accumulates server-bound inputs across frames; flushed into record_tick() on snapshot arrival
var _current_zone: String = "" # D-073 (#529): zone tracking for ambient crossfades
var _router: SnapshotEventRouter # #559: callable-based snapshot dispatch
const LISTENING_FOCUS_TICKS: int = 30 # D-071: stationary ticks before ListeningFocus boost activates
func _ready() -> void:
print("The Settled Reach — client initialized")
# #117: Manual lerp approach — disable Godot's built-in Camera2D smoothing.
# We lerp camera.global_position directly in _process() using CAMERA_SMOOTHING_SPEED,
# matching entity_renderer.gd's exponential smoothing pattern. Built-in smoothing
# would conflict because we'd be setting global_position to the target every frame.
camera.position_smoothing_enabled = false
# Connect to simulation (test mode sets CONNECTED immediately)
SimBridge.connect_to_sim()
# #257: If returning from main menu "Load Game" selection, defer dispatch until connected.
# In test mode, connect_to_sim() sets CONNECTED synchronously — dispatch fires immediately.
# In live mode, state is CONNECTING — signal handler dispatches once connected.
# #257: Deferred load dispatch
if not GameState.pending_load_path.is_empty():
if loading_screen:
loading_screen.show_loading()
@@ -65,46 +56,52 @@ func _ready() -> void:
SimBridge.connection_state_changed.connect(_on_sim_connected_for_load)
# Camera anchor: snap to player position before the first frame renders.
# In test mode poll_snapshot() returns synchronously — position is set
# immediately. In live mode the snapshot isn't available yet — _process
# handles it via the lerp block in _process().
var first_snapshot: Variant = SimBridge.poll_snapshot()
if first_snapshot != null:
GameState.apply_snapshot(first_snapshot)
camera.global_position = GameState.player_position * Constants.TILE_SIZE
_camera_anchored = true
# D-061: Connect dialogue box signals
if dialogue_box:
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)
# D-020 (#558): Decoupled signals — coordinator routes state changes.
dialogue_box.dialogue_state_changed.connect(_on_dialogue_state_changed)
dialogue_box.audio_dip_requested.connect(_on_audio_dip_requested)
dialogue_box.audio_dip_cleared.connect(_on_audio_dip_cleared)
# #775: Initialize extracted components
_consumers = SnapshotConsumers.new().init({
"monologue_display": monologue_display,
"dialogue_box": dialogue_box,
"examine_display": examine_display,
"loading_screen": loading_screen,
"debug_console": debug_console,
"cursor_renderer": cursor_renderer,
"interaction_list": interaction_list,
"interaction_prompt": interaction_prompt,
"minimap": minimap,
"star_map": star_map,
}, _screen_flash)
_dialogue = DialogueCoordinator.new().init({
"dialogue_box": dialogue_box,
"monologue_display": monologue_display,
"journal_panel": journal_panel,
}, _pending_record_inputs)
_dialogue.connect_signals()
# #496: Print gauntlet session summary on disconnect
if gauntlet_hud:
SimBridge.connection_state_changed.connect(_on_connection_state_changed)
# #559: Register snapshot dispatch handlers — replaces inline dispatch in _process().
# #559: Register snapshot dispatch handlers
_router = SnapshotEventRouter.new()
# Always-run: child nodes that update from GameState on every snapshot tick.
if world_renderer:
_router.register_always(world_renderer.update_from_state)
_router.register_always(_propagate_insert_state)
_router.register_always(_update_interaction_list)
_router.register_always(_consumers.propagate_insert_state)
_router.register_always(_consumers.update_interaction_list)
if inventory_grid:
_router.register_always(inventory_grid.update_from_state)
if stance_indicator:
_router.register_always(stance_indicator.update_from_state)
if fog_entities:
_router.register_always(fog_entities.update_from_state)
_router.register_always(_play_recognition_chimes)
_router.register_always(_handle_triangle_crisis_events)
_router.register_always(_consumers.play_recognition_chimes)
_router.register_always(_consumers.handle_triangle_crisis_events)
if gauntlet_hud:
_router.register_always(gauntlet_hud.update_from_state)
if checklist_overlay:
@@ -117,27 +114,34 @@ func _ready() -> void:
_router.register_always(journal_panel.update_from_state)
if debug_overlay:
_router.register_always(debug_overlay.update_from_state)
_router.register_always(_play_close_sound_events)
_router.register_always(_update_zone)
_router.register_always(_update_listening_focus)
_router.register_always(_consume_examine_result)
_router.register_always(_consumers.play_close_sound_events)
_router.register_always(_consumers.update_zone)
_router.register_always(_consumers.update_listening_focus)
_router.register_always(_consumers.consume_examine_result)
# Keyed: consume methods guarded by specific snapshot fields.
_router.register("current_monologue", _consume_monologue)
_router.register("current_dialogue", _consume_dialogue)
_router.register("conversation_events", _consume_conversation_events)
_router.register("conversation_ended", _consume_conversation_ended)
_router.register("dialogue_response", _consume_dialogue_response)
_router.register("save_result", _consume_save_result)
_router.register("debug_response", _consume_debug_response)
_router.register("current_monologue", _consumers.consume_monologue)
_router.register("current_dialogue", _dialogue.consume_dialogue)
_router.register("conversation_events", _dialogue.consume_conversation_events)
_router.register("conversation_ended", _dialogue.consume_conversation_ended)
_router.register("dialogue_response", _dialogue.consume_dialogue_response)
_router.register("save_result", _consumers.consume_save_result)
_router.register("debug_response", _consumers.consume_debug_response)
# #581: Wire settings_dialog debug console toggle → debug_console.set_enabled
# #581: Wire settings_dialog debug console toggle
if settings_dialog and debug_console:
settings_dialog.debug_console_toggled.connect(debug_console.set_enabled)
# #581 D-088: Wire debug console pause/unpause — sim must not advance during debug input
# #581 D-088: Wire debug console pause/unpause
if debug_console:
debug_console.pause_requested.connect(_on_dialogue_pause_requested)
debug_console.unpause_requested.connect(_on_dialogue_unpause_requested)
debug_console.pause_requested.connect(_dialogue.on_dialogue_pause_requested)
debug_console.unpause_requested.connect(_dialogue.on_dialogue_unpause_requested)
func _unhandled_key_input(event: InputEvent) -> void:
if event.is_pressed() and not event.is_echo():
if event is InputEventKey and event.keycode == KEY_M:
if star_map:
star_map.toggle_visible()
func _process(delta: float) -> void:
@@ -152,20 +156,15 @@ func _process(delta: float) -> void:
_teleport_transition()
# Late anchor: live mode — first snapshot arrives during _process.
# Smoothing is already OFF (disabled in _ready), so setting
# global_position takes effect immediately with no lerp.
if not _camera_anchored:
camera.global_position = GameState.player_position * Constants.TILE_SIZE
_camera_anchored = true
# #559: Dispatch snapshot to registered handlers (router pattern).
# Always-run handlers update child nodes; keyed handlers fire for present fields.
_router.dispatch(snapshot)
# Track camera to player (D-015: locked, fixed-north).
# #117: Manual exponential smoothing — same pattern as EntityRenderer.LERP_SPEED.
# Teleport (flag set by _teleport_transition): snap immediately, resume lerp next frame.
# Init: camera already snapped in _ready() or late-anchor path above.
# #117: Manual exponential smoothing.
if _camera_anchored:
var target := GameState.player_position * Constants.TILE_SIZE
if _teleport_in_progress:
@@ -176,11 +175,9 @@ func _process(delta: float) -> void:
camera.global_position = camera.global_position.lerp(target, weight)
# 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
# #495: F12 WRONG button — client-only
if input.action == InputMapper.Action.BUG_REPORT:
if bug_report_dialog and not bug_report_dialog.is_active():
bug_report_dialog.start_capture()
@@ -189,7 +186,7 @@ func _process(delta: float) -> void:
if input.action == InputMapper.Action.OPEN_JOURNAL:
_toggle_journal()
continue
# #257: LOAD_GAME — send first, then show loading screen (avoids stuck overlay if send fails)
# #257: LOAD_GAME — send first, then show loading screen
if input.action == InputMapper.Action.LOAD_GAME:
var err := SimBridge.send_input(input)
_pending_record_inputs.append(input)
@@ -216,7 +213,6 @@ func _process(delta: float) -> void:
if target_id < 0 and interaction_prompt:
target_id = interaction_prompt.get_interaction_target()
verb = interaction_prompt.get_selected_verb()
# Always send struct form for Interact (#415) — server expects named fields
if target_id >= 0:
input["action_data"] = {
"target_entity_id": target_id,
@@ -230,9 +226,7 @@ func _process(delta: float) -> void:
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.
# #507: Record tick data to ring buffer
if snapshot != null and bug_report_dialog and bug_report_dialog.has_method("record_tick"):
bug_report_dialog.record_tick(
GameState.current_tick,
@@ -242,294 +236,13 @@ func _process(delta: float) -> void:
_pending_record_inputs.clear()
# OQ-07 (#522): Propagate insert state to all z-layer-6 display nodes.
# Cursor shape still fires (D-056 option a) — only verb labels suppressed.
func _propagate_insert_state() -> void:
var insert_state := GameState.insert_active
if cursor_renderer:
cursor_renderer.set_insert_active(insert_state)
if interaction_list:
interaction_list.set_insert_active(insert_state)
if interaction_prompt:
interaction_prompt.set_insert_active(insert_state)
if minimap:
minimap.set_insert_active(insert_state)
# D-057: Update interaction list from game state.
# Suppress during dialogue — player is in conversation, verb list is noise.
func _update_interaction_list() -> void:
if not interaction_list:
return
if dialogue_box and dialogue_box.is_dialogue_active():
if interaction_list.is_showing():
interaction_list.hide_list()
else:
interaction_list.update_from_state()
# D-018 #125: Play close-range sound events — fired once per snapshot tick.
# Each event is passed to AudioManager.play_sound_event() for 2D positional playback
# on the WorldSFX bus. Events with no registered asset are silently skipped (D-038).
# 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).
# IDs persist for the session — one chime per entity, no re-trigger on
# fog oscillation or server re-send. Cleared on room change (teleport).
func _play_recognition_chimes() -> void:
for rec in GameState.pending_recognitions:
if not rec is Dictionary or not rec.has("entity_id"):
continue
var eid: int = rec.entity_id
if not _known_recognition_ids.has(eid):
_known_recognition_ids[eid] = true
AudioManager.play(AudioManager.CHIME_RECOGNITION)
# #590 D-072/D-089: Triangle activation consumer — fires sfx_monologue_chime_urgent once
# per triangle_id. The tell_state on the activated NPC and subsequent proximity monologue
# lines are the visible consequence (D-039 wow moment #2 "The Character's Eye").
# No overlay is shown — the chime is the only client-side reaction (D-039 intent).
func _handle_triangle_crisis_events() -> void:
var events: Array = GameState.current_snapshot.get("triangle_crisis_events", [])
for ev in events:
if not ev is Dictionary or not ev.has("triangle_id"):
continue
var tid: int = ev.triangle_id
if not _known_triangle_ids.has(tid):
_known_triangle_ids[tid] = true
AudioManager.play(AudioManager.CHIME_ACTIVATION, AudioManager.BUS_UI_SOUNDS)
# 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.
# Tick guard prevents re-triggering when the same tick is polled multiple
# times (client FPS > sim tick rate).
func _consume_monologue() -> void:
if GameState.current_monologue == null or not monologue_display:
return
if GameState.current_tick == _last_monologue_tick:
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),
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"):
_screen_flash(Constants.ENTITY_COLOR_POI, 0.15)
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:
if GameState.current_dialogue == null or not dialogue_box:
return
if GameState.current_tick == _last_dialogue_tick:
return
if dialogue_box.is_dialogue_active():
GameState.current_dialogue = null
return
_last_dialogue_tick = GameState.current_tick
# #264: Close journal when dialogue opens (cannot be open simultaneously)
if journal_panel and journal_panel.has_method("close"):
journal_panel.close()
var dlg: Dictionary = GameState.current_dialogue
_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", ""),
dlg.get("options", []),
_last_dialogue_npc_id
)
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.
# Updates dialogue_box entity display registry with speaker identity from the wire.
func _consume_dialogue_response() -> void:
if GameState.dialogue_response == null or not dialogue_box:
return
var dr: Dictionary = GameState.dialogue_response
# v0.1: falls back to _last_dialogue_npc_id if wire omits speaker_entity_id.
# Edge case: fast re-engagement with a different NPC could misattribute — low probability.
var speaker_entity_id: int = dr.get("speaker_entity_id", _last_dialogue_npc_id)
var speaker_color_index: int = dr.get("speaker_color_index", -1)
var speaker_name: String = dr.get("speaker_name", _last_dialogue_npc_name)
dialogue_box.update_entity_display(speaker_entity_id, speaker_name, speaker_color_index)
dialogue_box.append_dialogue_response(speaker_name, dr.get("text", ""), speaker_entity_id)
GameState.dialogue_response = null
# #554/#257: Show save/load result notification; hide loading screen on load complete.
func _consume_save_result() -> void:
if GameState.save_result == null:
return
var result: Dictionary = GameState.save_result
GameState.save_result = null # consume once
# #257: Dismiss loading screen regardless of success/failure
if loading_screen:
loading_screen.hide_loading()
var msg: String
if result.get("success", false):
if result.get("kind", "") == "save":
msg = UIStrings.get_text("notifications.save_complete")
else:
msg = UIStrings.get_text("notifications.load_complete")
else:
if result.get("kind", "") == "save":
msg = UIStrings.get_text("notifications.save_failed")
else:
msg = UIStrings.get_text("notifications.load_failed")
if monologue_display:
monologue_display.show_notification(msg)
# #581: Forward debug_response from server to the debug console.
func _consume_debug_response() -> void:
if GameState.debug_response == null or not debug_console:
return
debug_console.append_response(GameState.debug_response)
GameState.debug_response = null
# D-061: Handle dialogue option selection → send to server
func _on_dialogue_option_selected(response_id: String, text: String) -> void:
SimBridge.send_input({
"action": InputMapper.Action.INTERACT,
"timestamp_msec": Time.get_ticks_msec(),
"action_data": {
"target_entity_id": null,
"verb": "DialogueResponse",
"response_id": response_id,
},
})
# 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 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
func _on_dialogue_dismissed() -> void:
SimBridge.send_input({
"action": InputMapper.Action.INTERACT,
"timestamp_msec": Time.get_ticks_msec(),
"action_data": {
"target_entity_id": _last_dialogue_npc_id if _last_dialogue_npc_id >= 0 else null,
"verb": "WalkAway",
},
})
# D-020 (#558): Coordinator handles dialogue state changes from dialogue_box.
# Synchronous signal — GameState.dialogue_active updates same frame (D-064).
func _on_dialogue_state_changed(active: bool) -> void:
GameState.dialogue_active = active
# D-020 (#558): Coordinator routes audio dip requests from dialogue_box.
func _on_audio_dip_requested(profile: String) -> void:
AudioManager.apply_dip(profile)
# D-020 (#558): Coordinator routes audio dip clear from dialogue_box.
func _on_audio_dip_cleared() -> void:
AudioManager.clear_dip()
# #496: Finalize gauntlet stats on disconnect
func _on_connection_state_changed(old_state: SimBridge.ConnectionState, new_state: SimBridge.ConnectionState) -> void:
func _on_connection_state_changed(_old_state: SimBridge.ConnectionState, new_state: SimBridge.ConnectionState) -> void:
if new_state == SimBridge.ConnectionState.DISCONNECTED and gauntlet_hud:
gauntlet_hud.finalize()
# #257: Deferred LOAD_GAME dispatch — fires once when SimBridge reaches CONNECTED.
# pending_load_path is set by main_menu.gd before scene change.
func _on_sim_connected_for_load(_old_state: SimBridge.ConnectionState, new_state: SimBridge.ConnectionState) -> void:
if new_state != SimBridge.ConnectionState.CONNECTED:
return
@@ -554,19 +267,13 @@ func _dispatch_pending_load() -> void:
loading_screen.hide_loading(false)
# #501: Detect large position jump indicating a teleport (not normal movement).
const TELEPORT_DISTANCE_THRESHOLD: float = 5.0
# #501: Detect large position jump indicating a teleport.
func _detect_teleport(old_pos: Vector2, new_pos: Vector2) -> bool:
return old_pos.distance_to(new_pos) > TELEPORT_DISTANCE_THRESHOLD
# #501: Gauntlet dev teleport transition — snap camera + 0.3s fade-from-black.
# Clears dialogue/monologue/interaction state (server clears its side too).
# Scoped to Gauntlet testing only — production fast-travel uses diegetic gates.
func _teleport_transition() -> void:
# Set teleport flag — the camera tracking block in _process() will snap
# to the player's new position this frame (no lerp). Flag clears after snap.
_camera_anchored = true
_teleport_in_progress = true
@@ -574,12 +281,11 @@ func _teleport_transition() -> void:
GameState.current_monologue = null
GameState.current_dialogue = null
GameState.dialogue_active = false
_known_recognition_ids.clear() # D-067: reset chimes for new room
_known_triangle_ids.clear() # #590: reset activation chimes for new room
_consumers.clear_recognition_state()
if dialogue_box and dialogue_box.is_dialogue_active():
dialogue_box.hide_dialogue()
# Fade from black: instant black overlay, fades to transparent over 0.3s
# Fade from black
if _flash_rect and is_instance_valid(_flash_rect):
_flash_rect.queue_free()
_flash_rect = ColorRect.new()
@@ -592,35 +298,17 @@ func _teleport_transition() -> void:
tween.tween_callback(_flash_rect.queue_free)
# #174: Consume examine result — show overlay when server sends character-filtered observation.
# Clears after display (single-consume). Dismiss examine when dialogue opens.
func _consume_examine_result() -> void:
if GameState.current_examine_result == null or not examine_display:
return
var result: Dictionary = GameState.current_examine_result
# Dismiss existing examine result if dialogue is active (focus priority)
if dialogue_box and dialogue_box.is_dialogue_active():
if examine_display.has_method("dismiss"):
examine_display.dismiss()
else:
if examine_display.has_method("show_result"):
examine_display.show_result(result)
GameState.current_examine_result = null
# #264: Toggle journal panel. Called from input handler when J key pressed.
# #264: Toggle journal panel.
func _toggle_journal() -> void:
if not journal_panel:
return
# Journal and dialogue cannot be open simultaneously (sprint briefing)
if dialogue_box and dialogue_box.is_dialogue_active():
return
if journal_panel.has_method("toggle"):
journal_panel.toggle()
# #502: Full-screen color flash — fades from color to transparent over duration.
# Used for room reset amber flash. Creates ephemeral ColorRect on UILayer.
# #502: Full-screen color flash.
func _screen_flash(color: Color, duration: float) -> void:
if _flash_rect and is_instance_valid(_flash_rect):
_flash_rect.queue_free()
+2 -1
View File
@@ -93,7 +93,8 @@ func _try_extract_message() -> PackedByteArray:
if _pending_length > MAX_MESSAGE_SIZE:
# Stream is corrupt — we can't find the next valid frame boundary.
# Disconnect rather than silently discarding valid buffered data.
push_error("LocalBridge: incoming message too large: %d bytes (max %d) — disconnecting" % [_pending_length, MAX_MESSAGE_SIZE])
push_error(
"LocalBridge: incoming message too large: %d bytes (max %d) — disconnecting" % [_pending_length, MAX_MESSAGE_SIZE])
_corrupt = true
_pending_length = -1
_read_buffer.clear()
+13 -8
View File
@@ -35,7 +35,8 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
# Version check: reject snapshots from incompatible server
var version: Variant = raw.get("version")
if version != PROTOCOL_VERSION:
push_error("Protocol: version mismatch (got %s, expected %s). Server and client are out of sync." % [version, PROTOCOL_VERSION])
push_error(
"Protocol: version mismatch (got %s, expected %s). Server and client are out of sync." % [version, PROTOCOL_VERSION])
return null
var entities: Array[Dictionary] = []
@@ -49,7 +50,8 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
dropped += 1
if dropped > 0:
push_error("Protocol: %d/%d entities failed to decode (D-010 information boundary violation)" % [dropped, raw_entities.size()])
push_error(
"Protocol: %d/%d entities failed to decode (D-010 information boundary violation)" % [dropped, raw_entities.size()])
# GDScript int is signed 64-bit. Rust tick is u64 but will not exceed 2^63
# in any realistic scenario (would require ~29 billion years at 10 ticks/game-minute per D-031).
@@ -484,21 +486,22 @@ static func _decode_verb_option(raw) -> Variant:
static func _decode_enum_variant(raw) -> Dictionary:
if raw is String:
return { "variant": raw, "data": null }
elif raw is Dictionary and raw.size() == 1:
if raw is Dictionary and raw.size() == 1:
var variant_name: String = raw.keys()[0]
return { "variant": variant_name, "data": raw[variant_name] }
else:
push_warning("Protocol: unexpected enum encoding: %s" % str(raw))
return { "variant": "Unknown", "data": raw }
push_warning("Protocol: unexpected enum encoding: %s" % str(raw))
return { "variant": "Unknown", "data": raw }
# -- Encode: GDScript types → bytes to server ----------------------------------
## Encode a StartupMessage to MessagePack bytes (#175, #588).
## Encode a StartupMessage to MessagePack bytes (#175, #588, #718).
## Sent by the client immediately after handshake validation.
## Server reads this to initialize SimRng (D-010, D-029) and select monologue pool (D-032).
## character_archetype: "detective" → "Detective", "smuggler" → "Smuggler" (server enum variant).
static func encode_startup_message(world_seed: int, character_archetype: String = "detective") -> PackedByteArray:
## character_visual: optional CharacterVisualDescriptor — included as "character_visual_descriptor" dict.
static func encode_startup_message(
world_seed: int, character_archetype: String = "detective", character_visual: Variant = null) -> PackedByteArray:
# Map client lowercase archetype string to server PascalCase enum variant.
# Explicit match prevents unknown strings silently reaching the server as
# garbage enum values — fail loudly and fall back to "Detective".
@@ -515,6 +518,8 @@ static func encode_startup_message(world_seed: int, character_archetype: String
"world_seed": world_seed,
"character_archetype": archetype_variant,
}
if character_visual != null and character_visual.has_method("to_dict"):
msg["character_visual_descriptor"] = character_visual.to_dict()
var result = Messagepack.encode(msg)
if result.status != null:
push_error("Protocol: startup message encode failed: %s" % result.status)
+18 -19
View File
@@ -5,6 +5,20 @@ extends RefCounted
## interactions. Extracted from sim_bridge.gd to enforce D-020 information
## boundary (no game logic in the production client autoload).
const _WALLS: Array = [
# Room walls (8x8 room from (7,7) to (14,14))
Vector2i(7,7), Vector2i(8,7), Vector2i(9,7), Vector2i(10,7),
Vector2i(11,7), Vector2i(12,7), Vector2i(13,7), Vector2i(14,7),
Vector2i(7,14), Vector2i(8,14), Vector2i(9,14), Vector2i(10,14),
Vector2i(11,14), Vector2i(12,14), Vector2i(13,14), Vector2i(14,14),
Vector2i(7,8), Vector2i(7,9), Vector2i(7,10), Vector2i(7,11),
Vector2i(7,12), Vector2i(7,13),
Vector2i(14,8), Vector2i(14,9), Vector2i(14,10), Vector2i(14,11),
Vector2i(14,12), Vector2i(14,13),
# Interior wall blocking NPC
Vector2i(12, 10),
]
var tick: int = 0
var player_pos: Vector2i = Vector2i(10, 10)
var facing: String = "North"
@@ -116,9 +130,9 @@ func snapshot() -> Dictionary:
"npc_entity_id": 2,
"speech": "Haven't seen you around the transit hub before. You new to Sova, or just passing through?",
"options": [
{"text": "Just arrived. Still getting my bearings.", "response_id": "kael_greet_01", "priority": 1, "confrontation": false},
{"text": "Passing through. Know where I can find work?", "response_id": "kael_greet_02", "priority": 2, "confrontation": false},
{"text": "I saw you near the cargo bay last night.", "response_id": "kael_confront_01", "priority": 3, "confrontation": true},
{"text": "Just arrived. Still getting my bearings.", "response_id": "kael_greet_01", "priority": 1, "confrontation": false}, # gdlint:ignore = max-line-length
{"text": "Passing through. Know where I can find work?", "response_id": "kael_greet_02", "priority": 2, "confrontation": false}, # gdlint:ignore = max-line-length
{"text": "I saw you near the cargo bay last night.", "response_id": "kael_confront_01", "priority": 3, "confrontation": true}, # gdlint:ignore = max-line-length
],
}
@@ -146,7 +160,7 @@ func snapshot() -> Dictionary:
{"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": "Mira", "target": "Soren", "line": "Then explain the weight discrepancy. Two hundred kilos... just gone."}, # gdlint:ignore = max-line-length
{"speaker": "Soren", "target": "Mira", "line": "Fine. I'll pull the bay... tonight. But keep this between us."},
]
var conv_tick_interval := 5
@@ -259,21 +273,6 @@ func _get_tile_type(x: int, y: int) -> String:
# -- Spatial helpers -----------------------------------------------------------
const _WALLS: Array = [
# Room walls (8x8 room from (7,7) to (14,14))
Vector2i(7,7), Vector2i(8,7), Vector2i(9,7), Vector2i(10,7),
Vector2i(11,7), Vector2i(12,7), Vector2i(13,7), Vector2i(14,7),
Vector2i(7,14), Vector2i(8,14), Vector2i(9,14), Vector2i(10,14),
Vector2i(11,14), Vector2i(12,14), Vector2i(13,14), Vector2i(14,14),
Vector2i(7,8), Vector2i(7,9), Vector2i(7,10), Vector2i(7,11),
Vector2i(7,12), Vector2i(7,13),
Vector2i(14,8), Vector2i(14,9), Vector2i(14,10), Vector2i(14,11),
Vector2i(14,12), Vector2i(14,13),
# Interior wall blocking NPC
Vector2i(12, 10),
]
func _is_walkable(pos: Vector2i) -> bool:
return not _WALLS.has(pos)
+49 -10
View File
@@ -23,6 +23,7 @@ extends Node3D
## get_clothing_node_count() — number of clothing MeshInstance3D nodes
## get_accessory_node_count() — number of accessory BoneAttachment3D nodes
## get_skin_tone_texture_name(index) — skin tone texture filename key for index
## get_overhead_anchor() — Marker3D above Head bone for floating UI (#712)
##
## D-159 (11 body types), D-160 (18 segments), D-161 (head separate),
## D-162 (clothing pre-fitted), D-163 (heads via BoneAttachment3D), D-164 (skeleton fork)
@@ -101,6 +102,8 @@ var _clothing_meshes: Array[MeshInstance3D] = []
var _bone_attachments: Array[BoneAttachment3D] = []
var _outline_nodes: Array[MeshInstance3D] = []
var _accessory_attachments: Array[BoneAttachment3D] = []
var _overhead_anchor: Marker3D = null
var _overhead_attachment: BoneAttachment3D = null
# Inspectable state for tests
var _active_torso_variant: String = "full"
@@ -218,6 +221,13 @@ func get_skin_tone_texture_name(index: int) -> String:
return SKIN_TONES[clampi(index, 0, SKIN_TONES.size() - 1)]["tex"]
## Return the overhead anchor Marker3D (#712). Null if skeleton not loaded.
## Anchor point for floating UI elements: status indicators, thought bubbles,
## alert markers, speech icons. Positioned ~0.3m above the Head bone.
func get_overhead_anchor() -> Marker3D:
return _overhead_anchor
# =============================================================================
# Internal — teardown
# =============================================================================
@@ -234,6 +244,13 @@ func _clear() -> void:
node.free()
_outline_nodes.clear()
# #712: free overhead anchor before general bone attachments
if is_instance_valid(_overhead_attachment) and _overhead_attachment.get_parent():
_overhead_attachment.get_parent().remove_child(_overhead_attachment)
_overhead_attachment.free()
_overhead_attachment = null
_overhead_anchor = null
for att in _bone_attachments:
if is_instance_valid(att) and att.get_parent():
att.get_parent().remove_child(att)
@@ -291,6 +308,7 @@ func _load_skeleton() -> void:
for m in armature_meshes:
print(" armature mesh: ", m.name, " visible=", m.visible)
_validate_slot_bones()
_create_overhead_anchor()
## Warn on any SLOT_TO_BONE entry that doesn't exist in the loaded skeleton.
@@ -302,6 +320,26 @@ func _validate_slot_bones() -> void:
push_warning("CharacterVisual: SLOT_TO_BONE['%s'] = '%s' — bone not found in skeleton" % [slot, bone_name])
## #712: Create a Marker3D anchored ~0.3m above the Head bone via BoneAttachment3D.
## Anchor point for floating UI elements (status indicators, thought bubbles, etc.).
func _create_overhead_anchor() -> void:
if _skeleton == null:
return
var bone_idx := _skeleton.find_bone("Head")
if bone_idx == -1:
push_warning("CharacterVisual: Head bone not found — overhead anchor not created")
return
_overhead_attachment = BoneAttachment3D.new()
_overhead_attachment.bone_name = "Head"
_overhead_attachment.name = "OverheadAttachment"
_skeleton.add_child(_overhead_attachment)
_overhead_anchor = Marker3D.new()
_overhead_anchor.name = "OverheadAnchor"
_overhead_anchor.position = Vector3(0, 0.3, 0)
_overhead_attachment.add_child(_overhead_anchor)
# =============================================================================
# Internal — body segments (D-160)
# =============================================================================
@@ -413,7 +451,8 @@ func _load_eyebrows(_desc: CharacterVisualDescriptor) -> void:
pass
func _attach_to_bone(path: String, bone_name: String, tint: Color = Color.WHITE, render_priority: int = 0) -> BoneAttachment3D:
func _attach_to_bone(
path: String, bone_name: String, tint: Color = Color.WHITE, _render_priority: int = 0) -> BoneAttachment3D:
if _skeleton == null:
return null
if not ResourceLoader.exists(path):
@@ -464,14 +503,13 @@ func _attach_to_bone(path: String, bone_name: String, tint: Color = Color.WHITE,
_apply_tinted_shader(mi, tint, mask_tex)
inst.queue_free()
return null
else:
# Unskinned — rigid attachment via BoneAttachment3D
for mi in meshes:
mi.get_parent().remove_child(mi)
mi.owner = null
attachment.add_child(mi)
_apply_tinted_shader(mi, tint, mask_tex)
inst.queue_free()
# Unskinned — rigid attachment via BoneAttachment3D
for mi in meshes:
mi.get_parent().remove_child(mi)
mi.owner = null
attachment.add_child(mi)
_apply_tinted_shader(mi, tint, mask_tex)
inst.queue_free()
return attachment
@@ -591,7 +629,8 @@ func _load_accessories(desc: CharacterVisualDescriptor) -> void:
# Internal — tinting (hair, accessories, bone-attached assets with tint)
# =============================================================================
func _apply_tinted_shader(mi: MeshInstance3D, tint: Color, mask_tex: Texture2D = null, render_priority: int = 0) -> void:
func _apply_tinted_shader(
mi: MeshInstance3D, tint: Color, mask_tex: Texture2D = null, _render_priority: int = 0) -> void:
if mi.mesh == null:
return
for surf in range(mi.mesh.get_surface_count()):
+11 -11
View File
@@ -5,19 +5,11 @@ extends Node2D
## Insert-styled cursor on z-layer 7 (UILayer CanvasLayer).
## Detects entity hover via world-space proximity to visible entities.
enum State { DEFAULT, ENTITY_HOVER, OBJECT_HOVER, WEAPON_AIM }
# --- Public ---
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 state_changed(new_state: int)
signal hovered_entity_changed(entity_id: int)
enum State { DEFAULT, ENTITY_HOVER, OBJECT_HOVER, WEAPON_AIM }
# D-056 colors
const COLOR_DEFAULT := Color("#c8d0e0")
const COLOR_OBJECT := Color("#8b8ba0")
@@ -31,6 +23,14 @@ const HOVER_RADIUS_PX := 16.0 # World pixels — ~half a tile
const BRACKET_HALF := 26.0
const BRACKET_ARM := 8.0
# --- Public ---
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
# --- Transition state ---
var _target: State = State.DEFAULT
var _t: float = 1.0
+4 -4
View File
@@ -25,6 +25,8 @@ const ENTITY_OFFSET_Y: float = TILE_SIZE - ENTITY_HEIGHT # feet-anchored for co
# At 12.0: ~70% there after 0.1s, ~95% after 0.25s.
# Fast enough for Sprint snappiness, slow enough for Walk to show sliding.
const LERP_SPEED: float = 12.0
# #521: Color transition duration in seconds (D-033: "0.5s fade")
const COLOR_FADE_DURATION: float = 0.5
var entity_nodes: Dictionary = {} # entity_id -> Node2D
var _entity_targets: Dictionary = {} # entity_id -> Vector2 (target pixel position)
@@ -32,9 +34,6 @@ var _entity_relationships: Dictionary = {} # #521: entity_id -> String (last re
var _entity_tweens: Dictionary = {} # #521: entity_id -> {from: Color, target: Color, elapsed: float}
var _entity_facing: Dictionary = {} # #540: entity_id -> String ("north"/"east"/"south"/"west")
# #521: Color transition duration in seconds (D-033: "0.5s fade")
const COLOR_FADE_DURATION: float = 0.5
func _ready() -> void:
print("EntityRenderer: Initialized")
@@ -111,7 +110,8 @@ func _create_entity_node(entity_id: int, entity_data: Dictionary) -> void:
_entity_facing[entity_id] = direction
var tex := _load_sprite_texture(direction)
if tex == null:
push_error("EntityRenderer: no texture for entity %d direction '%s' — entity will be invisible" % [entity_id, direction])
push_error(
"EntityRenderer: no texture for entity %d direction '%s' — entity will be invisible" % [entity_id, direction])
entity_node.texture = tex
# D-033: self_modulate for relationship tinting; modulate.a is reserved for D-015 dimming.
+3 -2
View File
@@ -5,13 +5,14 @@ extends Node2D
## Architecture: docs/architecture/fog-shader-spec.md
signal fog_noise_ready
const TILE_SIZE := float(Constants.TILE_SIZE)
var _noise_ready: bool = false
var _fog_rect: ColorRect
var _shader_mat: ShaderMaterial
const TILE_SIZE := float(Constants.TILE_SIZE)
func _ready() -> void:
# Create the fog overlay ColorRect — transparent fallback so a shader failure
+2 -2
View File
@@ -11,11 +11,11 @@ extends TileMapLayer
# (3,0) = object — teal
# (4,0) = reset_plate — amber (#502)
enum TileType { FLOOR = 0, WALL = 1, DOOR = 2, OBJECT = 3, RESET_PLATE = 4 }
const TILE_SIZE: int = Constants.TILE_SIZE
const GROUND_FLOOR: int = 0 # Server floor level for ground — filter target in update_tiles()
enum TileType { FLOOR = 0, WALL = 1, DOOR = 2, OBJECT = 3, RESET_PLATE = 4 }
# Wire-format string to TileType mapping
const TILE_TYPE_MAP: Dictionary = {
"floor": TileType.FLOOR,
+2 -2
View File
@@ -13,13 +13,13 @@ extends Node2D
# Overhead (Node2D) — z:300 ceiling/upper structure (placeholder)
# FogOverlay (Node2D) — z:900 fog shader (OUTSIDE FogGroup)
var _last_tick: int = -1
@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
func _ready() -> void:
print("WorldRenderer: Initialized (D-049 z-stack)")
+194
View File
@@ -0,0 +1,194 @@
class_name SnapshotConsumers
## Non-dialogue snapshot consumers and event handlers extracted from main.gd (#775).
##
## Registered with SnapshotEventRouter; reads from GameState directly.
## UI node references passed via init(). All methods are zero-argument
## callables compatible with SnapshotEventRouter.
const LISTENING_FOCUS_TICKS: int = 30 # D-071: stationary ticks before ListeningFocus boost
var monologue_display: Node = null
var dialogue_box: Node = null
var examine_display: Node = null
var loading_screen: Node = null
var debug_console: Node = null
var cursor_renderer: Node = null
var interaction_list: Node = null
var interaction_prompt: Node = null
var minimap: Node = null
var star_map: Node = null
var _screen_flash_fn: Callable # Callable(color: Color, duration: float)
var _last_monologue_tick: int = -1
var _known_recognition_ids: Dictionary = {}
var _known_triangle_ids: Dictionary = {}
var _current_zone: String = ""
func init(refs: Dictionary, screen_flash: Callable) -> SnapshotConsumers:
monologue_display = refs.get("monologue_display")
dialogue_box = refs.get("dialogue_box")
examine_display = refs.get("examine_display")
loading_screen = refs.get("loading_screen")
debug_console = refs.get("debug_console")
cursor_renderer = refs.get("cursor_renderer")
interaction_list = refs.get("interaction_list")
interaction_prompt = refs.get("interaction_prompt")
minimap = refs.get("minimap")
star_map = refs.get("star_map")
_screen_flash_fn = screen_flash
return self
# OQ-07 (#522): Propagate insert state to all z-layer-6 display nodes.
func propagate_insert_state() -> void:
var insert_state := GameState.insert_active
if cursor_renderer:
cursor_renderer.set_insert_active(insert_state)
if interaction_list:
interaction_list.set_insert_active(insert_state)
if interaction_prompt:
interaction_prompt.set_insert_active(insert_state)
if minimap:
minimap.set_insert_active(insert_state)
if star_map:
star_map.set_insert_active(insert_state)
# D-057: Update interaction list from game state.
# Suppress during dialogue — player is in conversation, verb list is noise.
func update_interaction_list() -> void:
if not interaction_list:
return
if dialogue_box and dialogue_box.is_dialogue_active():
if interaction_list.is_showing():
interaction_list.hide_list()
else:
interaction_list.update_from_state()
# D-018 #125: Play close-range sound events — fired once per snapshot tick.
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.
func play_recognition_chimes() -> void:
for rec in GameState.pending_recognitions:
if not rec is Dictionary or not rec.has("entity_id"):
continue
var eid: int = rec.entity_id
if not _known_recognition_ids.has(eid):
_known_recognition_ids[eid] = true
AudioManager.play(AudioManager.CHIME_RECOGNITION)
# #590 D-072/D-089: Triangle activation consumer.
func handle_triangle_crisis_events() -> void:
var events: Array = GameState.current_snapshot.get("triangle_crisis_events", [])
for ev in events:
if not ev is Dictionary or not ev.has("triangle_id"):
continue
var tid: int = ev.triangle_id
if not _known_triangle_ids.has(tid):
_known_triangle_ids[tid] = true
AudioManager.play(AudioManager.CHIME_ACTIVATION, AudioManager.BUS_UI_SOUNDS)
# D-073 (#529): Zone ambient 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.
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.
func consume_monologue() -> void:
if GameState.current_monologue == null or not monologue_display:
return
if GameState.current_tick == _last_monologue_tick:
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),
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"):
_screen_flash_fn.call(Constants.ENTITY_COLOR_POI, 0.15)
GameState.current_monologue = null
# #554/#257: Show save/load result notification; hide loading screen on load complete.
func consume_save_result() -> void:
if GameState.save_result == null:
return
var result: Dictionary = GameState.save_result
GameState.save_result = null
if loading_screen:
loading_screen.hide_loading()
var msg: String
if result.get("success", false):
if result.get("kind", "") == "save":
msg = UIStrings.get_text("notifications.save_complete")
else:
msg = UIStrings.get_text("notifications.load_complete")
else:
if result.get("kind", "") == "save":
msg = UIStrings.get_text("notifications.save_failed")
else:
msg = UIStrings.get_text("notifications.load_failed")
if monologue_display:
monologue_display.show_notification(msg)
# #581: Forward debug_response from server to the debug console.
func consume_debug_response() -> void:
if GameState.debug_response == null or not debug_console:
return
debug_console.append_response(GameState.debug_response)
GameState.debug_response = null
# #174: Consume examine result — show overlay when server sends character-filtered observation.
func consume_examine_result() -> void:
if GameState.current_examine_result == null or not examine_display:
return
var result: Dictionary = GameState.current_examine_result
if dialogue_box and dialogue_box.is_dialogue_active():
if examine_display.has_method("dismiss"):
examine_display.dismiss()
else:
if examine_display.has_method("show_result"):
examine_display.show_result(result)
GameState.current_examine_result = null
## Clear session-scoped recognition state (call on teleport / room change).
func clear_recognition_state() -> void:
_known_recognition_ids.clear()
_known_triangle_ids.clear()
+254
View File
@@ -0,0 +1,254 @@
class_name SnapshotHandler
## Applies ObserverSnapshot data to GameState fields (D-020).
##
## Extracted from game_state.gd to separate snapshot parsing from state storage.
## All methods are static — no instance state required.
## Called via GameState.apply_snapshot() which delegates here.
# DEPRECATED: Client-side stationary_ticks fallback. Remove when server sends
# "stationary_ticks" in ObserverSnapshot (D-020 violation).
static var _prev_player_position: Vector2 = Vector2(-1e9, -1e9)
static func apply(snapshot: Dictionary) -> void:
GameState.current_snapshot = snapshot
if snapshot.has("tick"):
GameState.current_tick = snapshot.tick
if snapshot.has("entities"):
GameState.visible_entities = snapshot.entities
var found_player := false
for entity in GameState.visible_entities:
if entity.has("kind") and entity.kind is Dictionary and entity.kind.get("variant") == "Player":
GameState.player_position = Vector2(entity.x, entity.y)
if entity.has("entity_id"):
GameState.player_entity_id = entity.entity_id
found_player = true
break
if not found_player and GameState.visible_entities.size() > 0:
push_warning("GameState: no Player entity found in %d entities" % [
GameState.visible_entities.size()])
# D-020/D-071 (#530): Server-authoritative stationary_ticks for ListeningFocus boost.
if snapshot.has("stationary_ticks") and snapshot.stationary_ticks is int:
GameState.stationary_ticks = snapshot.stationary_ticks
else:
# DEPRECATED fallback — client-side accumulation. Remove when server sends field.
if GameState.player_position == _prev_player_position:
GameState.stationary_ticks += 1
else:
GameState.stationary_ticks = 0
_prev_player_position = GameState.player_position
# Tiles for rendering: test mode sends "tiles", live server sends "visible_tiles"
if snapshot.has("tiles"):
GameState.visible_tiles = snapshot.tiles
elif snapshot.has("visible_tiles") and snapshot.visible_tiles is Array and snapshot.visible_tiles.size() > 0:
var has_type := false
if snapshot.visible_tiles.size() > 0 and snapshot.visible_tiles[0] is Dictionary:
has_type = snapshot.visible_tiles[0].has("type")
if has_type:
GameState.visible_tiles = snapshot.visible_tiles
if snapshot.has("visible_positions"):
GameState.visible_positions.clear()
for pos in snapshot.visible_positions:
GameState.visible_positions[Vector2i(pos.x, pos.y)] = true
# v2: game_time (D-031)
if snapshot.has("game_time") and snapshot.game_time is Dictionary:
GameState.game_time = snapshot.game_time
# v2: player_facing (D-015)
if snapshot.has("player_facing") and snapshot.player_facing is String:
GameState.player_facing = snapshot.player_facing
# v4: nearby_interactions (#404/#405)
if snapshot.has("nearby_interactions") and snapshot.nearby_interactions is Array:
GameState.nearby_interactions = snapshot.nearby_interactions
else:
GameState.nearby_interactions = []
# v5: current_monologue (#414)
if snapshot.has("current_monologue") and snapshot.current_monologue is Dictionary:
GameState.current_monologue = snapshot.current_monologue
else:
GameState.current_monologue = null
# #122: lattice_profile
if snapshot.has("lattice_profile") and snapshot.lattice_profile is String:
GameState.lattice_profile = snapshot.lattice_profile
# v6: player_stance (#449, D-053)
if snapshot.has("player_stance") and snapshot.player_stance is String:
GameState.player_stance = snapshot.player_stance
# v6: player_inventory (#449, D-065)
if snapshot.has("player_inventory") and snapshot.player_inventory is Array:
GameState.player_inventory = snapshot.player_inventory
else:
GameState.player_inventory = []
# v7: current_dialogue (#434, D-061)
if snapshot.has("current_dialogue") and snapshot.current_dialogue is Dictionary:
GameState.current_dialogue = snapshot.current_dialogue
else:
GameState.current_dialogue = null
# v7: pending_recognitions (#431, D-059/D-060)
if snapshot.has("pending_recognitions") and snapshot.pending_recognitions is Array:
GameState.pending_recognitions = snapshot.pending_recognitions
else:
GameState.pending_recognitions = []
# v9: conversation_events (#535, D-078)
if snapshot.has("conversation_events") and snapshot.conversation_events is Array:
GameState.conversation_events = snapshot.conversation_events
else:
GameState.conversation_events = []
# v9: conversation_ended (#535, D-078)
if snapshot.has("conversation_ended") and snapshot.conversation_ended is Array:
GameState.conversation_ended = snapshot.conversation_ended
else:
GameState.conversation_ended = []
# v8: dialogue_response (#305, D-028)
if snapshot.has("dialogue_response") and snapshot.dialogue_response is Dictionary:
GameState.dialogue_response = snapshot.dialogue_response
else:
GameState.dialogue_response = null
# v8: gauntlet mode (#496)
if snapshot.has("gauntlet_mode") and snapshot.gauntlet_mode == true:
GameState.gauntlet_mode = true
else:
GameState.gauntlet_mode = false
if snapshot.has("room_id") and snapshot.room_id is String:
GameState.room_id = snapshot.room_id
else:
GameState.room_id = null
# OQ-07 (#522): insert_active
if snapshot.has("insert_active") and snapshot.insert_active is bool:
GameState.insert_active = snapshot.insert_active
else:
GameState.insert_active = true
# #507: rng_seed
if snapshot.has("rng_seed"):
GameState.rng_seed = snapshot.rng_seed
else:
GameState.rng_seed = null
# D-018: Sound events — partition by range_category.
if snapshot.has("sound_events") and snapshot.sound_events is Array:
GameState.medium_sound_events = []
GameState.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":
GameState.medium_sound_events.append(se)
elif rc == "Close":
GameState.close_sound_events.append(se)
else:
GameState.medium_sound_events = []
GameState.close_sound_events = []
# v10: discovered_pois (#151, D-013)
if snapshot.has("discovered_pois") and snapshot.discovered_pois is Array:
GameState.discovered_pois = snapshot.discovered_pois
elif snapshot.has("poi_list") and snapshot.poi_list is Array:
GameState.discovered_pois = snapshot.poi_list
# v14: examine_result (#174, #242)
if snapshot.has("examine_result") and snapshot.examine_result is Dictionary:
GameState.current_examine_result = snapshot.examine_result
else:
GameState.current_examine_result = null
# v15: save_result (#554, D-085)
if snapshot.has("save_result") and snapshot.save_result is Dictionary:
GameState.save_result = snapshot.save_result
else:
GameState.save_result = null
# v18: debug_response (#580)
if snapshot.has("debug_response") and snapshot.debug_response is Dictionary:
GameState.debug_response = snapshot.debug_response
else:
GameState.debug_response = null
# v20: settings_response (#627, D-138)
if snapshot.has("settings_response") and snapshot.settings_response is Dictionary:
GameState.settings_response = snapshot.settings_response
var sr: Dictionary = snapshot.settings_response
if sr.get("kind") == "full":
var sr_settings: Variant = sr.get("settings")
if sr_settings is Array:
for entry in sr_settings:
if not entry is Dictionary:
continue
if entry.get("key") == "ai_dialogue.enabled":
var val: Variant = entry.get("value")
if val != null:
GameState.ai_enhanced_dialogue_enabled = _extract_bool_setting("ai_dialogue.enabled", val)
else:
GameState.settings_response = null
# #718: character_visual_descriptor — restored from server snapshot on save/load.
if snapshot.has("character_visual_descriptor") and snapshot.character_visual_descriptor is Dictionary:
var CVD := load("res://scripts/rendering/character_visual_descriptor.gd")
if CVD != null:
var restored = CVD.from_dict(snapshot.character_visual_descriptor)
if restored != null:
GameState.character_visual_descriptor = restored
# v14: player_knowledge (#264, D-041)
if snapshot.has("player_knowledge") and snapshot.player_knowledge is Dictionary:
GameState.player_knowledge = snapshot.player_knowledge
# D-020/D-073 (#529): Server-authoritative zone_id for zone ambient crossfade.
if snapshot.has("zone_id") and snapshot.zone_id is String:
GameState.current_zone_id = snapshot.zone_id
else:
# DEPRECATED fallback — client-side tile lookup. Remove when server sends top-level "zone_id".
var tile_by_coord: Dictionary = {}
for vtile in GameState.visible_tiles:
if vtile is Dictionary and vtile.has("x") and vtile.has("y"):
tile_by_coord[Vector2i(vtile.x, vtile.y)] = vtile
var player_pos_key := Vector2i(int(GameState.player_position.x), int(GameState.player_position.y))
var player_tile = tile_by_coord.get(player_pos_key, null)
GameState.current_zone_id = player_tile.get("zone_id", "") if player_tile else ""
# v2: visible_tiles with visibility sectors
if snapshot.has("visible_tiles") and snapshot.visible_tiles is Array and snapshot.visible_tiles.size() > 0:
GameState.visibility_sectors.clear()
var has_explicit_positions := snapshot.has("visible_positions")
if not has_explicit_positions:
GameState.visible_positions.clear()
GameState.boundary_positions.clear()
for vtile in snapshot.visible_tiles:
if not vtile is Dictionary or not vtile.has("x") or not vtile.has("y"):
continue
var pos := Vector2i(vtile.x, vtile.y)
var vis_sector: String = vtile.get("visibility", "")
if vtile.has("visibility"):
GameState.visibility_sectors[pos] = vis_sector
if vis_sector == "BoundaryWall":
GameState.boundary_positions[pos] = true
elif not has_explicit_positions:
GameState.visible_positions[pos] = true
## Extract a bool from a tagged-union {"Bool": true} or plain bool value.
static func _extract_bool_setting(key: String, val: Variant) -> bool:
if val is bool:
return val
if val is Dictionary and val.has("Bool"):
return bool(val["Bool"])
push_warning("GameState: unexpected type for setting '%s': %s" % [key, str(val)])
return false
+4 -4
View File
@@ -10,7 +10,7 @@
## run before the project's class_name registry is fully populated.
extends SceneTree
var _Msgpack: GDScript
var _msgpack: GDScript
var _count := 0
var _errors := 0
var _output_dir: String
@@ -21,7 +21,7 @@ func _init():
func _run():
_Msgpack = load("res://addons/messagepack/messagepack.gd")
_msgpack = load("res://addons/messagepack/messagepack.gd")
# Resolve repo root from Godot project root (client/).
# Assumes client/ is one level below repo root — validated below.
@@ -81,7 +81,7 @@ func _encode_input(tick: int, action_name: String, action_data: Variant = null)
else:
action = action_name
var result = _Msgpack.encode({"tick": tick, "action": action})
var result = _msgpack.encode({"tick": tick, "action": action})
if result.status != null:
push_error("Encode failed: %s" % result.status)
return PackedByteArray()
@@ -104,7 +104,7 @@ func _encode_inputs(inputs: Array) -> PackedByteArray:
action = action_name
wire_inputs.append({"tick": input["tick"], "action": action})
var result = _Msgpack.encode(wire_inputs)
var result = _msgpack.encode(wire_inputs)
if result.status != null:
push_error("Batch encode failed: %s" % result.status)
return PackedByteArray()
+16 -15
View File
@@ -10,6 +10,7 @@
class_name TestAiDialogueSprint26
extends GdUnitTestSuite
const SETTINGS_DIALOG_SCENE = preload("res://ui/settings_dialog.tscn")
var _original_ai_enabled: bool = true
@@ -345,7 +346,7 @@ func test_hardware_classify_degradation_ok_at_exact_40_percent() -> void:
func test_settings_dialog_exposes_ai_dialogue_label_text_method() -> void:
# settings_dialog needs a testable API — hardcoded UI strings are easy to drift.
var scene := load("res://ui/settings_dialog.tscn") as PackedScene
var scene := SETTINGS_DIALOG_SCENE
if scene == null:
push_warning("TestAiDialogueSprint26: settings_dialog.tscn not found — skipped")
return
@@ -359,7 +360,7 @@ func test_settings_dialog_exposes_ai_dialogue_label_text_method() -> void:
func test_settings_dialog_ai_dialogue_label_is_correct() -> void:
# D-138: label must be exactly "AI-Enhanced Dialogue" (Jeroen's wording).
var scene := load("res://ui/settings_dialog.tscn") as PackedScene
var scene := SETTINGS_DIALOG_SCENE
if scene == null:
push_warning("TestAiDialogueSprint26: settings_dialog.tscn not found — skipped")
return
@@ -377,7 +378,7 @@ func test_settings_dialog_ai_dialogue_label_is_correct() -> void:
func test_settings_dialog_toggle_disabled_when_hardware_fails() -> void:
# D-138 §8: RAM < 1.6 GB → feature disabled, toggle greyed out.
# Player receives message but cannot enable the feature.
var scene := load("res://ui/settings_dialog.tscn") as PackedScene
var scene := SETTINGS_DIALOG_SCENE
if scene == null:
push_warning("TestAiDialogueSprint26: settings_dialog.tscn not found — skipped")
return
@@ -396,7 +397,7 @@ func test_settings_dialog_toggle_disabled_when_hardware_fails() -> void:
func test_settings_dialog_toggle_enabled_when_hardware_passes() -> void:
# "pass" → toggle available to interact with.
var scene := load("res://ui/settings_dialog.tscn") as PackedScene
var scene := SETTINGS_DIALOG_SCENE
if scene == null:
push_warning("TestAiDialogueSprint26: settings_dialog.tscn not found — skipped")
return
@@ -415,7 +416,7 @@ func test_settings_dialog_toggle_enabled_when_hardware_passes() -> void:
func test_settings_dialog_toggle_enabled_when_hardware_marginal() -> void:
# D-138 §8: "marginal" → warn but let player proceed. Never force-disable.
var scene := load("res://ui/settings_dialog.tscn") as PackedScene
var scene := SETTINGS_DIALOG_SCENE
if scene == null:
push_warning("TestAiDialogueSprint26: settings_dialog.tscn not found — skipped")
return
@@ -439,8 +440,8 @@ func test_hardware_detector_benchmark_cache_path_is_correct() -> void:
var det := _get_detector()
if det == null:
return
assert_str(det.BENCHMARK_CACHE_PATH).override_failure_message(
"BENCHMARK_CACHE_PATH must be 'user://ai-dialogue-config.json' (D-138 §8)"
assert_str(det.benchmark_cache_path).override_failure_message(
"benchmark_cache_path must be 'user://ai-dialogue-config.json' (D-138 §8)"
).is_equal("user://ai-dialogue-config.json")
@@ -766,7 +767,7 @@ func test_hardware_detector_battery_suspend_preserves_player_pref_false() -> voi
func test_settings_dialog_inference_suspended_state_defaults_false() -> void:
var scene := load("res://ui/settings_dialog.tscn") as PackedScene
var scene := SETTINGS_DIALOG_SCENE
if scene == null:
push_warning("TestAiDialogueSprint26: settings_dialog.tscn not found — skipped")
return
@@ -779,7 +780,7 @@ func test_settings_dialog_inference_suspended_state_defaults_false() -> void:
func test_settings_dialog_set_inference_suspended_true() -> void:
var scene := load("res://ui/settings_dialog.tscn") as PackedScene
var scene := SETTINGS_DIALOG_SCENE
if scene == null:
push_warning("TestAiDialogueSprint26: settings_dialog.tscn not found — skipped")
return
@@ -794,7 +795,7 @@ func test_settings_dialog_set_inference_suspended_true() -> void:
func test_settings_dialog_resume_clears_suspended_state() -> void:
# D-138 §8: resume when plugged in — suspended state clears.
var scene := load("res://ui/settings_dialog.tscn") as PackedScene
var scene := SETTINGS_DIALOG_SCENE
if scene == null:
push_warning("TestAiDialogueSprint26: settings_dialog.tscn not found — skipped")
return
@@ -813,7 +814,7 @@ func test_settings_dialog_toggle_remains_enabled_when_battery_suspended() -> voi
# Battery suspend auto-pauses inference but must NOT grey the toggle —
# the player can click it to override the suspension.
# Only hardware "fail" (RAM < 1.6 GB) may disable the toggle.
var scene := load("res://ui/settings_dialog.tscn") as PackedScene
var scene := SETTINGS_DIALOG_SCENE
if scene == null:
push_warning("TestAiDialogueSprint26: settings_dialog.tscn not found — skipped")
return
@@ -829,7 +830,7 @@ func test_settings_dialog_toggle_remains_enabled_when_battery_suspended() -> voi
func test_settings_dialog_toggle_enabled_after_resume() -> void:
# After resume (plug-in), toggle must be enabled again.
var scene := load("res://ui/settings_dialog.tscn") as PackedScene
var scene := SETTINGS_DIALOG_SCENE
if scene == null:
push_warning("TestAiDialogueSprint26: settings_dialog.tscn not found — skipped")
return
@@ -847,7 +848,7 @@ func test_settings_dialog_toggle_enabled_after_resume() -> void:
func test_settings_dialog_toggle_disabled_by_hardware_fail_even_when_suspended() -> void:
# Hardware "fail" disables the toggle regardless of battery state.
# RAM < 1.6 GB is the only hard disable — battery suspend is not.
var scene := load("res://ui/settings_dialog.tscn") as PackedScene
var scene := SETTINGS_DIALOG_SCENE
if scene == null:
push_warning("TestAiDialogueSprint26: settings_dialog.tscn not found — skipped")
return
@@ -864,7 +865,7 @@ func test_settings_dialog_toggle_disabled_by_hardware_fail_even_when_suspended()
func test_settings_dialog_warning_label_shown_when_battery_suspended() -> void:
# When inference is battery-suspended, a warning label must be visible
# so the player knows why inference isn't running (even though toggle is enabled).
var scene := load("res://ui/settings_dialog.tscn") as PackedScene
var scene := SETTINGS_DIALOG_SCENE
if scene == null:
push_warning("TestAiDialogueSprint26: settings_dialog.tscn not found — skipped")
return
@@ -882,7 +883,7 @@ func test_settings_dialog_warning_label_shown_when_battery_suspended() -> void:
func test_settings_dialog_warning_label_hidden_when_not_suspended() -> void:
# Warning label must not show when plugged in — no battery message needed.
var scene := load("res://ui/settings_dialog.tscn") as PackedScene
var scene := SETTINGS_DIALOG_SCENE
if scene == null:
push_warning("TestAiDialogueSprint26: settings_dialog.tscn not found — skipped")
return
+6 -5
View File
@@ -12,10 +12,11 @@
class_name TestAntiTedium
extends GdUnitTestSuite
const MAIN_SCENE = preload("res://scenes/main.tscn")
var _instance: Node = null
var GauntletHUDScript = load("res://ui/gauntlet_hud.gd")
var BugReportDialogScript = load("res://ui/bug_report_dialog.gd")
var _instance: Node = null
func before_test() -> void:
@@ -106,7 +107,7 @@ func _make_bug_report_dialog() -> Control:
func test_bug_report_dialog_exists_in_scene() -> void:
# Verify the BugReportDialog node is present and hidden by default.
var scene := load("res://scenes/main.tscn")
var scene := MAIN_SCENE
_instance = scene.instantiate()
auto_free(_instance)
add_child(_instance)
@@ -125,7 +126,7 @@ func test_bug_report_dialog_exists_in_scene() -> void:
func test_bug_report_activates_on_action() -> void:
# Inject BUG_REPORT action directly into the queue and verify main.gd
# triggers the dialog. This tests the full main._process() handling path.
var scene := load("res://scenes/main.tscn")
var scene := MAIN_SCENE
_instance = scene.instantiate()
auto_free(_instance)
add_child(_instance)
@@ -170,7 +171,7 @@ func test_bug_report_activates_on_action() -> void:
func test_no_gauntlet_ui_visible_in_default_mode() -> void:
# Load main scene — represents non-Gauntlet (default) play mode
var scene := load("res://scenes/main.tscn")
var scene := MAIN_SCENE
_instance = scene.instantiate()
auto_free(_instance)
add_child(_instance)
@@ -224,7 +225,7 @@ func test_snapshot_without_room_id_shows_no_gauntlet_ui() -> void:
func test_gauntlet_ui_stays_hidden_after_multiple_snapshots() -> void:
# Simulate several ticks of normal play — gauntlet UI must never appear.
var scene := load("res://scenes/main.tscn")
var scene := MAIN_SCENE
_instance = scene.instantiate()
auto_free(_instance)
add_child(_instance)
+2 -2
View File
@@ -18,13 +18,13 @@ func before_test() -> void:
for bus in AudioManager.BUSES:
AudioManager.set_volume(bus, 0.0)
GameState.stationary_ticks = 0
GameState._prev_player_position = Vector2(-1e9, -1e9)
SnapshotHandler._prev_player_position = Vector2(-1e9, -1e9)
func after_test() -> void:
AudioManager.clear_dip()
GameState.stationary_ticks = 0
GameState._prev_player_position = Vector2(-1e9, -1e9)
SnapshotHandler._prev_player_position = Vector2(-1e9, -1e9)
# ==============================================================================
+2 -2
View File
@@ -480,7 +480,7 @@ func test_d067_onset_is_when_remaining_equals_total_delay_ticks() -> void:
"tick": 1,
"pending_recognitions": [
{"entity_id": 99, "x": 10.0, "y": 10.0, "z": 0,
"remaining_ticks": 6, "total_delay_ticks": 6},
"remaining_ticks": 6, "total_delay_ticks": 6},
],
})
assert_that(GameState.pending_recognitions.size()).is_equal(1)
@@ -497,7 +497,7 @@ func test_d067_completion_is_when_entity_absent_from_pending() -> void:
"tick": 1,
"pending_recognitions": [
{"entity_id": 99, "x": 10.0, "y": 10.0, "z": 0,
"remaining_ticks": 1, "total_delay_ticks": 6},
"remaining_ticks": 1, "total_delay_ticks": 6},
],
})
assert_that(GameState.pending_recognitions.size()).is_equal(1)
+2 -3
View File
@@ -11,12 +11,11 @@
class_name TestBugReportRingBuffer
extends GdUnitTestSuite
var BugReportDialogScript = load("res://ui/bug_report_dialog.gd")
# Expected ring buffer capacity per spec.
const EXPECTED_CAPACITY := 60
var BugReportDialogScript = load("res://ui/bug_report_dialog.gd")
func after_each() -> void:
# Reset GameState fields mutated by tests to prevent cross-test leakage.
+7 -6
View File
@@ -9,6 +9,7 @@ extends GdUnitTestSuite
const EXPECTED_PLAYER_POS := Vector2(10, 10)
const EXPECTED_CAMERA_POS := Vector2(320, 320) # 10 * 32, 10 * 32
const MAIN_SCENE = preload("res://scenes/main.tscn")
var _instance: Node = null
@@ -67,7 +68,7 @@ func test_apply_snapshot_sets_player_position() -> void:
# --- Camera anchor after _ready() ---
func test_camera_position_after_ready() -> void:
var scene := load("res://scenes/main.tscn")
var scene := MAIN_SCENE
_instance = scene.instantiate()
auto_free(_instance)
add_child(_instance)
@@ -77,7 +78,7 @@ func test_camera_position_after_ready() -> void:
func test_camera_anchored_flag_after_ready() -> void:
var scene := load("res://scenes/main.tscn")
var scene := MAIN_SCENE
_instance = scene.instantiate()
auto_free(_instance)
add_child(_instance)
@@ -88,7 +89,7 @@ func test_camera_anchored_flag_after_ready() -> void:
func test_camera_smoothing_off_after_ready() -> void:
# Camera smoothing must be disabled during init to prevent lerp from (0,0).
# If this test fails, the camera will visibly drift from origin to player.
var scene := load("res://scenes/main.tscn")
var scene := MAIN_SCENE
_instance = scene.instantiate()
auto_free(_instance)
add_child(_instance)
@@ -100,7 +101,7 @@ func test_camera_smoothing_off_after_ready() -> void:
func test_camera_smoothing_stays_off_with_manual_lerp() -> void:
# #117: Manual lerp approach — Godot's built-in smoothing must stay OFF always.
# CAMERA_SMOOTHING_SPEED is used as the lerp weight, not Godot's position_smoothing_speed.
var scene := load("res://scenes/main.tscn")
var scene := MAIN_SCENE
_instance = scene.instantiate()
auto_free(_instance)
add_child(_instance)
@@ -114,7 +115,7 @@ func test_camera_smoothing_stays_off_with_manual_lerp() -> void:
# --- Camera behavior across frames ---
func test_camera_tracks_player_after_process() -> void:
var scene := load("res://scenes/main.tscn")
var scene := MAIN_SCENE
_instance = scene.instantiate()
auto_free(_instance)
add_child(_instance)
@@ -130,7 +131,7 @@ func test_camera_tracks_player_after_process() -> void:
func test_camera_lerps_toward_player_movement() -> void:
# #117: With manual lerp, camera moves TOWARD player position (not snapping).
# After one 16ms frame the camera should be partway between old and new position.
var scene := load("res://scenes/main.tscn")
var scene := MAIN_SCENE
_instance = scene.instantiate()
auto_free(_instance)
add_child(_instance)
@@ -161,7 +161,8 @@ func test_reload_descriptor_clears_previous_nodes() -> void:
# Second load must produce exactly the same child count — no more, no less.
# _clear() frees and rebuilds; any deviation indicates stale nodes accumulating.
assert_int(count_after_second).override_failure_message(
"load_descriptor must produce identical child count on reload — stale nodes detected if higher, missing cleanup if lower"
"load_descriptor must produce identical child count on reload" +
" — stale nodes detected if higher, missing cleanup if lower"
).is_equal(count_after_first)
@@ -305,7 +306,8 @@ func test_torso_hidden_when_full_coverage_clothing_worn() -> void:
desc.clothing_slots = {"torso": "coveralls_basic"}
node.load_descriptor(desc)
var coverage: Dictionary = node.get_active_coverage("coveralls_basic") if node.has_method("get_active_coverage") else {}
var coverage: Dictionary = node.get_active_coverage("coveralls_basic") \
if node.has_method("get_active_coverage") else {}
if coverage.is_empty():
push_warning("TestCharacterVisualSprint28: coveralls_basic/coverage.json not found — stub")
return
+7 -3
View File
@@ -57,7 +57,8 @@ func test_parse_top_level_quoted_string() -> void:
func test_parse_single_condition() -> void:
var yaml := "conditions:\n - id: test-1\n description: \"Test condition\"\n condition_type: player_near\n x: 10\n y: 20\n radius: 3.0"
var yaml := ("conditions:\n - id: test-1\n description: \"Test condition\"\n" +
" condition_type: player_near\n x: 10\n y: 20\n radius: 3.0")
var result: Dictionary = ChecklistEvaluatorScript.parse_checklist_yaml(yaml)
assert_that(result.has("conditions")).is_true()
var conditions: Array = result["conditions"]
@@ -70,7 +71,9 @@ func test_parse_single_condition() -> void:
func test_parse_multiple_conditions() -> void:
var yaml := "conditions:\n - id: cond-a\n condition_type: player_near\n x: 1\n y: 2\n radius: 1.0\n\n - id: cond-b\n condition_type: player_facing\n direction: East"
var yaml := ("conditions:\n - id: cond-a\n condition_type: player_near\n" +
" x: 1\n y: 2\n radius: 1.0\n\n" +
" - id: cond-b\n condition_type: player_facing\n direction: East")
var result: Dictionary = ChecklistEvaluatorScript.parse_checklist_yaml(yaml)
var conditions: Array = result["conditions"]
assert_that(conditions.size()).is_equal(2)
@@ -80,7 +83,8 @@ func test_parse_multiple_conditions() -> void:
func test_parse_comments_ignored() -> void:
var yaml := "# This is a comment\nroom_id: test\n# Another comment\nconditions:\n - id: c1\n condition_type: entity_present\n entity_id: 5"
var yaml := ("# This is a comment\nroom_id: test\n# Another comment\n" +
"conditions:\n - id: c1\n condition_type: entity_present\n entity_id: 5")
var result: Dictionary = ChecklistEvaluatorScript.parse_checklist_yaml(yaml)
assert_that(result.get("room_id")).is_equal("test")
var conditions: Array = result["conditions"]
-1
View File
@@ -352,7 +352,6 @@ func test_fog_overlay_z_layer() -> void:
return
# This test needs the scene tree to be set up
# Verify via scene file inspection rather than runtime
pass
# -- Noise animation cycles (D-059) -------------------------------------------
+7 -3
View File
@@ -19,7 +19,7 @@ func before_each() -> void:
GameState.player_stance = "Walk"
GameState.player_inventory = []
GameState.stationary_ticks = 0
GameState._prev_player_position = Vector2(-1e9, -1e9)
SnapshotHandler._prev_player_position = Vector2(-1e9, -1e9)
GameState.current_zone_id = ""
GameState.insert_active = true
@@ -67,7 +67,8 @@ func test_apply_snapshot_player_facing_missing_keeps_default() -> void:
func test_apply_snapshot_sets_nearby_interactions() -> void:
var interactions := [
{"entity_id": 5, "entity_type": "Npc", "distance": 1.2, "verbs": [{"kind": "Talk", "label": "Talk", "priority": 1, "available": true}]},
{"entity_id": 5, "entity_type": "Npc", "distance": 1.2,
"verbs": [{"kind": "Talk", "label": "Talk", "priority": 1, "available": true}]},
]
GameState.apply_snapshot({"tick": 1, "entities": [], "nearby_interactions": interactions})
assert_that(GameState.nearby_interactions.size()).is_equal(1)
@@ -83,7 +84,10 @@ func test_apply_snapshot_nearby_interactions_absent_clears_list() -> void:
# -- v5: current_monologue (#414) -----------------------------------------
func test_apply_snapshot_sets_monologue() -> void:
var monologue := {"id": "m1", "text": "Something is off here.", "duration_seconds": 4.0, "priority": 1, "is_urgent": false}
var monologue := {
"id": "m1", "text": "Something is off here.",
"duration_seconds": 4.0, "priority": 1, "is_urgent": false,
}
GameState.apply_snapshot({"tick": 1, "entities": [], "current_monologue": monologue})
assert_that(GameState.current_monologue).is_not_null()
assert_that(GameState.current_monologue.get("text")).is_equal("Something is off here.")
+1 -1
View File
@@ -14,7 +14,7 @@ extends GdUnitTestSuite
func before_each() -> void:
GameState.stationary_ticks = 0
GameState._prev_player_position = Vector2(-1e9, -1e9)
SnapshotHandler._prev_player_position = Vector2(-1e9, -1e9)
GameState.current_zone_id = ""
GameState.player_position = Vector2.ZERO
GameState.visible_tiles = []
+2 -2
View File
@@ -8,6 +8,8 @@
class_name TestHubTeleport
extends GdUnitTestSuite
const _THRESHOLD: float = 5.0 # Mirror of main.gd TELEPORT_DISTANCE_THRESHOLD
# -- Fixtures ------------------------------------------------------------------
@@ -178,8 +180,6 @@ func test_test_mode_teleport_clears_dialogue() -> void:
# Threshold constant lives on the main scene node (TELEPORT_DISTANCE_THRESHOLD = 5.0).
# These tests verify the distance math against that threshold.
const _THRESHOLD: float = 5.0 # Mirror of main.gd TELEPORT_DISTANCE_THRESHOLD
func test_detect_teleport_large_jump() -> void:
# Position jump > threshold should be detected as teleport
var old_pos := Vector2(10.0, 10.0)
+1 -1
View File
@@ -44,7 +44,7 @@ func _make_cursor_or_skip() -> Node:
func _make_interaction_list() -> Node:
for path in ["res://ui/interaction_list.tscn", "res://ui/entity_interaction_list.tscn",
"res://scenes/interaction_list.tscn"]:
"res://scenes/interaction_list.tscn"]:
if ResourceLoader.exists(path):
var scene = load(path)
var node = scene.instantiate()
+2 -2
View File
@@ -12,7 +12,7 @@ extends GdUnitTestSuite
func _interaction_list_exists() -> bool:
for path in ["res://ui/interaction_list.tscn", "res://ui/entity_interaction_list.tscn",
"res://scenes/interaction_list.tscn"]:
"res://scenes/interaction_list.tscn"]:
if ResourceLoader.exists(path):
return true
return false
@@ -20,7 +20,7 @@ func _interaction_list_exists() -> bool:
func _make_interaction_list() -> Node:
for path in ["res://ui/interaction_list.tscn", "res://ui/entity_interaction_list.tscn",
"res://scenes/interaction_list.tscn"]:
"res://scenes/interaction_list.tscn"]:
if ResourceLoader.exists(path):
var scene = load(path)
var node = scene.instantiate()
+2 -2
View File
@@ -14,7 +14,7 @@ func test_protocol_decode_v4_with_nearby_interactions() -> void:
"version": Protocol.PROTOCOL_VERSION,
"entities": [
{"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": "Player",
"visibility": "Forward", "relationship": "Unknown", "observation": "Visible"},
"visibility": "Forward", "relationship": "Unknown", "observation": "Visible"},
],
"nearby_interactions": [{
"entity_id": 2,
@@ -93,7 +93,7 @@ func test_protocol_decode_v4_entity_relationship() -> void:
"version": Protocol.PROTOCOL_VERSION,
"entities": [
{"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": "Npc",
"visibility": "Forward", "relationship": "Friendly", "observation": "Visible"},
"visibility": "Forward", "relationship": "Friendly", "observation": "Visible"},
],
}
var encoded = Messagepack.encode(raw)
+2 -1
View File
@@ -1,5 +1,6 @@
## Smoke tests for main scene initialization
## Run with: godot4 --headless --path . -s res://addons/gdUnit4/bin/GdUnitCmdTool.gd --ignoreHeadlessMode -a res://tests/
## Run with: godot4 --headless --path . -s res://addons/gdUnit4/bin/GdUnitCmdTool.gd
## --ignoreHeadlessMode -a res://tests/
class_name TestMainScene
extends GdUnitTestSuite
+3 -2
View File
@@ -16,6 +16,7 @@ extends GdUnitTestSuite
# ---------------------------------------------------------------------------
const MINIMAP_SCENE_PATH: String = "res://ui/minimap.tscn"
const MAIN_SCENE = preload("res://scenes/main.tscn")
func _make_minimap() -> Control:
if not ResourceLoader.exists(MINIMAP_SCENE_PATH):
@@ -199,7 +200,7 @@ func test_minimap_in_main_scene_on_insert_overlay() -> void:
if not ResourceLoader.exists("res://scenes/main.tscn"):
push_warning("TestMinimapSprint18: main.tscn not found — scene tree test skipped")
return
var scene: Node = load("res://scenes/main.tscn").instantiate()
var scene: Node = MAIN_SCENE.instantiate()
auto_free(scene)
add_child(scene)
@@ -226,7 +227,7 @@ func test_insert_overlay_is_canvas_layer_10() -> void:
if not ResourceLoader.exists("res://scenes/main.tscn"):
push_warning("TestMinimapSprint18: main.tscn not found — canvas layer test skipped")
return
var scene: Node = load("res://scenes/main.tscn").instantiate()
var scene: Node = MAIN_SCENE.instantiate()
auto_free(scene)
add_child(scene)
+4 -2
View File
@@ -11,6 +11,8 @@ class_name TestP0Regressions
extends GdUnitTestSuite
const MAIN_SCENE = preload("res://scenes/main.tscn")
var _instance: Node = null
@@ -174,7 +176,7 @@ func test_monologue_carry_forward_preserves_newest() -> void:
func test_camera_static_during_pause() -> void:
# 1. Instantiate main scene — camera anchors at test mode player position
var scene := load("res://scenes/main.tscn")
var scene := MAIN_SCENE
_instance = scene.instantiate()
auto_free(_instance)
add_child(_instance)
@@ -204,7 +206,7 @@ func test_camera_static_during_pause() -> void:
func test_camera_anchored_after_pause_unpause() -> void:
# Verify camera stays properly anchored through a pause → unpause cycle.
var scene := load("res://scenes/main.tscn")
var scene := MAIN_SCENE
_instance = scene.instantiate()
auto_free(_instance)
add_child(_instance)
+1 -1
View File
@@ -346,7 +346,7 @@ func test_full_v6_snapshot_decode() -> void:
],
"entities": [
{"entity_id": 1, "x": 10.0, "y": 10.0, "z": 0, "kind": "Player",
"visibility": "Forward", "relationship": "Unknown", "observation": "Visible"},
"visibility": "Forward", "relationship": "Unknown", "observation": "Visible"},
],
"visible_tiles": [
{"x": 10, "y": 10, "z": 0, "visibility": "Forward", "tile_kind": "Floor"},
+1 -1
View File
@@ -339,7 +339,7 @@ func test_full_v7_snapshot_decode() -> void:
"player_inventory": [{"item_id": 100, "name": "Access Token", "slot": 0}],
"entities": [
{"entity_id": 1, "x": 10.0, "y": 10.0, "z": 0, "kind": "Player",
"visibility": "Forward", "relationship": "Unknown", "observation": "Visible"},
"visibility": "Forward", "relationship": "Unknown", "observation": "Visible"},
],
"visible_tiles": [],
"nearby_interactions": [],
+2 -1
View File
@@ -281,7 +281,8 @@ func test_entity_renderer_npc_uses_unknown_teal() -> void:
func test_entity_renderer_object_uses_grey() -> void:
var renderer := _make_entity_renderer()
var obj := [{"entity_id": 3, "x": 1.0, "y": 1.0, "z": 0, "kind": {"variant": "Object", "data": null}, "visibility": "Forward"}]
var obj := [{"entity_id": 3, "x": 1.0, "y": 1.0, "z": 0,
"kind": {"variant": "Object", "data": null}, "visibility": "Forward"}]
renderer.update_entities(obj)
var node = renderer.entity_nodes[3] as Sprite2D
assert_that(node.self_modulate).is_equal(Constants.ENTITY_COLOR_OBJECT)
@@ -4,6 +4,8 @@
class_name TestSessionManagerSprint19
extends GdUnitTestSuite
const MAIN_MENU_SCENE = preload("res://scenes/main_menu.tscn")
# Game IDs created during the current test — deleted in after_test().
var _created_ids: Array = []
@@ -166,7 +168,7 @@ func test_main_menu_instantiates_without_crash() -> void:
if not ResourceLoader.exists("res://scenes/main_menu.tscn"):
push_warning("TestSessionManagerSprint19: main_menu.tscn not found — skip")
return
var scene: Node = load("res://scenes/main_menu.tscn").instantiate()
var scene: Node = MAIN_MENU_SCENE.instantiate()
auto_free(scene)
add_child(scene)
assert_that(scene).is_not_null()
@@ -175,7 +177,7 @@ func test_main_menu_instantiates_without_crash() -> void:
func test_main_menu_has_new_game_button() -> void:
if not ResourceLoader.exists("res://scenes/main_menu.tscn"):
return
var scene: Node = load("res://scenes/main_menu.tscn").instantiate()
var scene: Node = MAIN_MENU_SCENE.instantiate()
auto_free(scene)
add_child(scene)
var btn := scene.get_node_or_null("VBox/NewGameBtn")
@@ -187,7 +189,7 @@ func test_main_menu_has_new_game_button() -> void:
func test_main_menu_has_continue_button() -> void:
if not ResourceLoader.exists("res://scenes/main_menu.tscn"):
return
var scene: Node = load("res://scenes/main_menu.tscn").instantiate()
var scene: Node = MAIN_MENU_SCENE.instantiate()
auto_free(scene)
add_child(scene)
var btn := scene.get_node_or_null("VBox/ContinueBtn")
+5 -3
View File
@@ -10,6 +10,8 @@
class_name TestSignalSprint24
extends GdUnitTestSuite
const NEWS_TICKER_SCENE = preload("res://ui/news_ticker.tscn")
# -- #588: Character archetype field ------------------------------------------
@@ -204,7 +206,7 @@ func test_protocol_decode_current_ticker_null_when_absent() -> void:
func test_news_ticker_hidden_when_snapshot_has_no_ticker() -> void:
# update_from_state() must hide ticker when current_ticker is null.
var ticker_scene := load("res://ui/news_ticker.tscn") as PackedScene
var ticker_scene := NEWS_TICKER_SCENE
assert_that(ticker_scene).is_not_null()
var ticker := ticker_scene.instantiate()
auto_free(ticker)
@@ -224,7 +226,7 @@ func test_news_ticker_hidden_when_snapshot_has_no_ticker() -> void:
func test_news_ticker_visible_when_snapshot_has_ticker() -> void:
# update_from_state() must show ticker when current_ticker has text.
var ticker_scene := load("res://ui/news_ticker.tscn") as PackedScene
var ticker_scene := NEWS_TICKER_SCENE
assert_that(ticker_scene).is_not_null()
var ticker := ticker_scene.instantiate()
auto_free(ticker)
@@ -244,7 +246,7 @@ func test_news_ticker_visible_when_snapshot_has_ticker() -> void:
func test_news_ticker_hides_when_ticker_becomes_null() -> void:
# Ticker shown then hidden: update_from_state() with null current_ticker hides it.
var ticker_scene := load("res://ui/news_ticker.tscn") as PackedScene
var ticker_scene := NEWS_TICKER_SCENE
assert_that(ticker_scene).is_not_null()
var ticker := ticker_scene.instantiate()
auto_free(ticker)
+11 -9
View File
@@ -4,6 +4,8 @@
class_name TestSmoothCameraSprint15
extends GdUnitTestSuite
const MAIN_SCENE = preload("res://scenes/main.tscn")
var _instance: Node = null
@@ -42,7 +44,7 @@ func test_camera_smoothing_speed_constant_reasonable() -> void:
func test_godot_smoothing_disabled_at_ready() -> void:
# #117: Godot's built-in Camera2D smoothing must be OFF (manual lerp replaces it).
var scene := load("res://scenes/main.tscn")
var scene := MAIN_SCENE
_instance = scene.instantiate()
auto_free(_instance)
add_child(_instance)
@@ -53,7 +55,7 @@ func test_godot_smoothing_disabled_at_ready() -> void:
func test_godot_smoothing_stays_off_after_frames() -> void:
# #117: Smoothing must NOT be re-enabled at any point — manual lerp only.
var scene := load("res://scenes/main.tscn")
var scene := MAIN_SCENE
_instance = scene.instantiate()
auto_free(_instance)
add_child(_instance)
@@ -70,7 +72,7 @@ func test_godot_smoothing_stays_off_after_frames() -> void:
func test_camera_lerps_not_snaps_on_player_move() -> void:
# #117: When player moves, camera should lerp (not snap) to new position.
# After 1 frame at ~60fps, camera should be partway there — not at target.
var scene := load("res://scenes/main.tscn")
var scene := MAIN_SCENE
_instance = scene.instantiate()
auto_free(_instance)
add_child(_instance)
@@ -90,7 +92,7 @@ func test_camera_lerps_not_snaps_on_player_move() -> void:
func test_camera_converges_to_player_over_multiple_frames() -> void:
# #117: After enough frames the camera should be within 1px of target.
# At LERP_SPEED=8: ~95% convergence in 0.25s, >99% in 0.5s.
var scene := load("res://scenes/main.tscn")
var scene := MAIN_SCENE
_instance = scene.instantiate()
auto_free(_instance)
add_child(_instance)
@@ -111,7 +113,7 @@ func test_camera_converges_to_player_over_multiple_frames() -> void:
func test_camera_stationary_player_no_drift() -> void:
# #117: When player is stationary, camera should not drift (lerp to same point).
var scene := load("res://scenes/main.tscn")
var scene := MAIN_SCENE
_instance = scene.instantiate()
auto_free(_instance)
add_child(_instance)
@@ -133,7 +135,7 @@ func test_camera_stationary_player_no_drift() -> void:
func test_teleport_snaps_camera_immediately() -> void:
# #117: _teleport_in_progress causes camera to snap (not lerp) in the same frame.
# Manually displace camera, set the flag, call _process — camera should snap to target.
var scene := load("res://scenes/main.tscn")
var scene := MAIN_SCENE
_instance = scene.instantiate()
auto_free(_instance)
add_child(_instance)
@@ -155,7 +157,7 @@ func test_teleport_snaps_camera_immediately() -> void:
func test_teleport_flag_cleared_after_snap() -> void:
# #117: _teleport_in_progress must be false after the snap frame.
var scene := load("res://scenes/main.tscn")
var scene := MAIN_SCENE
_instance = scene.instantiate()
auto_free(_instance)
add_child(_instance)
@@ -170,7 +172,7 @@ func test_teleport_flag_cleared_after_snap() -> void:
func test_camera_resumes_lerp_after_teleport() -> void:
# #117: Frame after teleport snap must resume lerp (not continue snapping).
# After teleport flag clears, any position delta produces lerp movement.
var scene := load("res://scenes/main.tscn")
var scene := MAIN_SCENE
_instance = scene.instantiate()
auto_free(_instance)
add_child(_instance)
@@ -200,7 +202,7 @@ func test_camera_resumes_lerp_after_teleport() -> void:
func test_camera_no_rotation() -> void:
# D-015: Camera must be fixed-north in v0.1 — no rotation regardless of facing.
var scene := load("res://scenes/main.tscn")
var scene := MAIN_SCENE
_instance = scene.instantiate()
auto_free(_instance)
add_child(_instance)
+590
View File
@@ -0,0 +1,590 @@
## Sprint 30 — QA acceptance tests
##
## Covers all 5 Sprint 30 client tickets:
## #718 — Persist CharacterVisualDescriptor on new game start
## #719 — Hair highlight: make swatch read-only (Option B — no compositor yet)
## #720 — Replace DirAccess scanning with manifest JSON for export builds
## #712 — BoneAttachment3D marker above Head bone for floating icons
## #674 — Star map insert module (test-first: scene must exist when implemented)
##
## Test convention:
## - Tests that should PASS immediately = regression guards on existing code
## - Tests prefixed [ACCEPTANCE] = will FAIL until the ticket is implemented
##
## Ticket refs: #718, #719, #720, #712, #674
class_name TestSprint30
extends GdUnitTestSuite
func before_each() -> void:
# Reset GameState fields touched by #718 tests to avoid cross-test pollution.
GameState.character_visual_descriptor = null
func after_each() -> void:
GameState.character_visual_descriptor = null
# =============================================================================
# #718 — Persist CharacterVisualDescriptor
# =============================================================================
func test_game_state_has_character_visual_descriptor_field() -> void:
## GameState.character_visual_descriptor must exist and default to null.
## Confirms the field added in game_state.gd line 103 is present.
var gs := GameState.new()
auto_free(gs)
# The field is declared on the class — access it without error
var val: Variant = gs.get("character_visual_descriptor")
# Field should exist (not return null from missing property vs. null value)
assert_bool(gs.has_method("apply_snapshot")).override_failure_message(
"GameState must be a valid autoload class with apply_snapshot"
).is_true()
# The property itself must be gettable and null by default
assert_bool(val == null).override_failure_message(
"GameState.character_visual_descriptor must default to null"
).is_true()
func test_descriptor_to_dict_includes_all_required_fields() -> void:
## CharacterVisualDescriptor.to_dict() must include all wire-format fields.
var desc := CharacterVisualDescriptor.new()
auto_free(desc)
var d := desc.to_dict()
var required_keys := [
"body_type", "head_id", "hair_id", "hair_tint",
"facial_hair_id", "facial_hair_tint", "eyebrow_id", "eyebrow_tint",
"eye_color", "skin_tone", "clothing_slots", "clothing_tints",
"accessory_slots", "accessory_tints",
]
for key in required_keys:
assert_bool(d.has(key)).override_failure_message(
"to_dict() must include field '%s'" % key
).is_true()
func test_descriptor_to_dict_body_type_is_wire_string() -> void:
## body_type in to_dict() must be a string (rmp_serde unit enum), not an int.
var desc := CharacterVisualDescriptor.new()
auto_free(desc)
desc.body_type = CharacterVisualDescriptor.BodyType.AVERAGE_M
var d := desc.to_dict()
assert_str(d["body_type"]).override_failure_message(
"to_dict() body_type must be wire string 'AverageM'"
).is_equal("AverageM")
func test_descriptor_round_trip_preserves_fields() -> void:
## from_dict(to_dict(desc)) must preserve all scalar fields.
var original := CharacterVisualDescriptor.new()
auto_free(original)
original.body_type = CharacterVisualDescriptor.BodyType.THIN_F
original.head_id = "head_002"
original.hair_id = "bob"
original.hair_tint = Color(0.8, 0.4, 0.2)
original.skin_tone = 3
var wire := original.to_dict()
var restored := CharacterVisualDescriptor.from_dict(wire)
assert_bool(restored != null).override_failure_message(
"from_dict() must return a descriptor for valid wire data"
).is_true()
if restored == null:
return
assert_int(int(restored.body_type)).override_failure_message(
"body_type must survive round-trip"
).is_equal(int(CharacterVisualDescriptor.BodyType.THIN_F))
assert_str(restored.head_id).override_failure_message(
"head_id must survive round-trip"
).is_equal("head_002")
assert_str(restored.hair_id).override_failure_message(
"hair_id must survive round-trip"
).is_equal("bob")
assert_int(restored.skin_tone).override_failure_message(
"skin_tone must survive round-trip"
).is_equal(3)
func test_descriptor_from_dict_returns_null_when_missing_body_type() -> void:
## from_dict() must return null if body_type is absent (required field).
var d := {"head_id": "head_001"} # missing body_type
var result := CharacterVisualDescriptor.from_dict(d)
assert_bool(result == null).override_failure_message(
"from_dict() must return null when body_type is missing"
).is_true()
func test_descriptor_color_encoding_is_float_array() -> void:
## Colors must encode as [r, g, b, a] float arrays for rmp_serde compatibility.
var desc := CharacterVisualDescriptor.new()
auto_free(desc)
desc.eye_color = Color(0.1, 0.2, 0.3, 1.0)
var d := desc.to_dict()
var encoded: Variant = d["eye_color"]
assert_bool(encoded is Array).override_failure_message(
"eye_color must encode as an Array [r, g, b, a]"
).is_true()
if not (encoded is Array):
return
assert_int((encoded as Array).size()).override_failure_message(
"eye_color array must have 4 elements"
).is_equal(4)
assert_float((encoded as Array)[0]).override_failure_message(
"eye_color[0] (r) must be approx 0.1"
).is_equal_approx(0.1, 0.001)
func test_apply_snapshot_restores_character_visual_descriptor() -> void:
## #718: apply_snapshot() must restore character_visual_descriptor from
## the "character_visual_descriptor" key in ObserverSnapshot (save/load path).
var desc := CharacterVisualDescriptor.new()
desc.body_type = CharacterVisualDescriptor.BodyType.MUSCULAR_F
desc.head_id = "head_003"
desc.hair_id = "dreads"
desc.skin_tone = 5
var snapshot := {
"character_visual_descriptor": desc.to_dict(),
}
GameState.apply_snapshot(snapshot)
var restored: Variant = GameState.character_visual_descriptor
assert_bool(restored != null).override_failure_message(
"apply_snapshot() must restore character_visual_descriptor from snapshot"
).is_true()
if restored != null and restored is CharacterVisualDescriptor:
var r := restored as CharacterVisualDescriptor
assert_int(int(r.body_type)).override_failure_message(
"restored body_type must match"
).is_equal(int(CharacterVisualDescriptor.BodyType.MUSCULAR_F))
assert_str(r.head_id).override_failure_message(
"restored head_id must match"
).is_equal("head_003")
assert_int(r.skin_tone).override_failure_message(
"restored skin_tone must match"
).is_equal(5)
func test_apply_snapshot_preserves_descriptor_when_field_absent() -> void:
## #718: If snapshot lacks "character_visual_descriptor", the existing field
## must not be overwritten (server only sends when descriptor changes).
var desc := CharacterVisualDescriptor.new()
desc.hair_id = "bob"
GameState.character_visual_descriptor = desc
# Snapshot with no character_visual_descriptor key
GameState.apply_snapshot({"tick": 1})
var after: Variant = GameState.character_visual_descriptor
assert_bool(after != null).override_failure_message(
"apply_snapshot() must NOT clear descriptor when field is absent"
).is_true()
if after is CharacterVisualDescriptor:
assert_str((after as CharacterVisualDescriptor).hair_id).override_failure_message(
"descriptor must be unchanged after snapshot with no character_visual_descriptor key"
).is_equal("bob")
func test_protocol_encode_startup_includes_descriptor() -> void:
## #718: Protocol.encode_startup_message() must include "character_visual_descriptor"
## in the encoded payload when a descriptor is provided.
var desc := CharacterVisualDescriptor.new()
desc.body_type = CharacterVisualDescriptor.BodyType.THIN_M
desc.hair_id = "buzzed"
var bytes := Protocol.encode_startup_message(12345, "detective", desc)
assert_bool(bytes.size() > 0).override_failure_message(
"encode_startup_message() must produce non-empty bytes"
).is_true()
# Decode and verify the field is present (Messagepack.decode returns {status, value})
var raw = Messagepack.decode(bytes)
assert_bool(raw.status == null).override_failure_message(
"encode_startup_message() output must be valid msgpack"
).is_true()
if raw.status != null:
return
var msg: Dictionary = raw.value as Dictionary
assert_bool(msg.has("character_visual_descriptor")).override_failure_message(
"StartupMessage must include 'character_visual_descriptor' key when descriptor is provided"
).is_true()
if msg.has("character_visual_descriptor"):
assert_bool(msg["character_visual_descriptor"] is Dictionary).override_failure_message(
"character_visual_descriptor in StartupMessage must be a Dictionary"
).is_true()
func test_protocol_encode_startup_omits_descriptor_when_null() -> void:
## #718: encode_startup_message() must still produce valid bytes when descriptor is null.
var bytes := Protocol.encode_startup_message(0, "detective", null)
assert_bool(bytes.size() > 0).override_failure_message(
"encode_startup_message() must produce valid bytes even with null descriptor"
).is_true()
func test_descriptor_has_no_hair_highlight_tint_field() -> void:
## CharacterVisualDescriptor must NOT have a hair_highlight_tint field.
## The highlight is always auto-derived from hair_tint (Option B of #719).
## to_dict() must not include it in the wire format.
var desc := CharacterVisualDescriptor.new()
auto_free(desc)
var d := desc.to_dict()
assert_bool(d.has("hair_highlight_tint")).override_failure_message(
"to_dict() must NOT include hair_highlight_tint — highlight is auto-derived"
).is_false()
assert_bool("hair_highlight_tint" in desc).override_failure_message(
"CharacterVisualDescriptor must not define a hair_highlight_tint property"
).is_false()
# =============================================================================
# #719 — Hair highlight swatch (Option B: read-only, auto-derived)
# =============================================================================
func test_derive_hair_highlight_lightens_primary() -> void:
## _derive_hair_highlight() must return primary.lightened(0.3).
## Tests the derivation formula in character_creation.gd:1917.
var cc_scene_path := "res://scenes/character_creation.tscn"
if not ResourceLoader.exists(cc_scene_path):
push_warning("test_derive_hair_highlight_lightens_primary: scene not available in headless — skipping")
return
var packed := load(cc_scene_path) as PackedScene
if packed == null:
return
var cc := packed.instantiate() as CharacterCreation
if cc == null:
push_warning("test_derive_hair_highlight_lightens_primary: failed to instantiate — skipping")
return
auto_free(cc)
# CharacterCreation._derive_hair_highlight is a private method but testable via call()
var primary := Color(0.4, 0.3, 0.5)
var expected := primary.lightened(0.3)
var result: Variant = cc.call("_derive_hair_highlight", primary)
assert_bool(result is Color).override_failure_message(
"_derive_hair_highlight must return a Color"
).is_true()
if not (result is Color):
return
var r := result as Color
assert_float(r.r).override_failure_message("derived highlight.r incorrect").is_equal_approx(expected.r, 0.001)
assert_float(r.g).override_failure_message("derived highlight.g incorrect").is_equal_approx(expected.g, 0.001)
assert_float(r.b).override_failure_message("derived highlight.b incorrect").is_equal_approx(expected.b, 0.001)
func test_hair_highlight_swatch_exists_in_ui() -> void:
## [ACCEPTANCE #719] After fix, _hair_highlight_swatch must be non-null
## (a display node must be created in _build_hair_color_dock).
## WILL FAIL until #719 is implemented.
var cc_scene_path := "res://scenes/character_creation.tscn"
if not ResourceLoader.exists(cc_scene_path):
push_warning("test_hair_highlight_swatch_exists_in_ui: scene not available in headless — skipping")
return
var packed := load(cc_scene_path) as PackedScene
if packed == null:
return
var cc := packed.instantiate() as CharacterCreation
if cc == null:
return
auto_free(cc)
add_child(cc)
await get_tree().process_frame
# _hair_highlight_swatch must be set after _ready() builds the hair color dock
var swatch: Variant = cc.get("_hair_highlight_swatch")
assert_bool(swatch != null).override_failure_message(
"[#719] _hair_highlight_swatch must not be null — a display node must be created"
).is_true()
func test_hair_highlight_swatch_is_not_interactive() -> void:
## [ACCEPTANCE #719] The highlight swatch must be non-interactive.
## Either mouse_filter = IGNORE, or the node is a ColorRect (not a Button with a callback).
## WILL FAIL until #719 is implemented.
var cc_scene_path := "res://scenes/character_creation.tscn"
if not ResourceLoader.exists(cc_scene_path):
push_warning("test_hair_highlight_swatch_is_not_interactive: scene not available — skipping")
return
var packed := load(cc_scene_path) as PackedScene
if packed == null:
return
var cc := packed.instantiate() as CharacterCreation
if cc == null:
return
auto_free(cc)
add_child(cc)
await get_tree().process_frame
var swatch: Variant = cc.get("_hair_highlight_swatch")
if swatch == null:
push_warning("test_hair_highlight_swatch_is_not_interactive: swatch not found — #719 not yet implemented")
return
# If swatch is a Control node, mouse_filter must be IGNORE (2)
if swatch is Control:
var ctrl := swatch as Control
assert_int(ctrl.mouse_filter).override_failure_message(
"[#719] hair highlight swatch must have mouse_filter=IGNORE (non-interactive)"
).is_equal(Control.MOUSE_FILTER_IGNORE)
# =============================================================================
# #720 — Manifest JSON completeness (replaces DirAccess scanning)
# =============================================================================
func test_manifest_json_is_parseable() -> void:
## manifest.json must exist and parse as a Dictionary.
var path := "res://assets/characters/manifest.json"
assert_bool(ResourceLoader.exists(path) or FileAccess.file_exists(path)).override_failure_message(
"manifest.json must exist at res://assets/characters/manifest.json"
).is_true()
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
push_warning("test_manifest_json_is_parseable: file not openable — skipping")
return
var parsed: Variant = JSON.parse_string(file.get_as_text())
file.close()
assert_bool(parsed is Dictionary).override_failure_message(
"manifest.json must parse as a JSON object (Dictionary)"
).is_true()
func test_manifest_hair_includes_all_asset_dirs() -> void:
## [ACCEPTANCE #720] manifest.json "hair" array must include every .glb in
## assets/characters/hair/. Currently missing: balding, buzzed_female, dreads,
## long_dreads, mohawk, ponytail_f, simple_parted, slick_back.
## WILL FAIL until #720 populates the manifest fully.
var path := "res://assets/characters/manifest.json"
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
push_warning("test_manifest_hair_includes_all_asset_dirs: manifest not readable — skipping")
return
var parsed: Variant = JSON.parse_string(file.get_as_text())
file.close()
if not (parsed is Dictionary):
return
var manifest := parsed as Dictionary
var hair_list: Array = manifest.get("hair", [])
# All hair IDs confirmed from assets/characters/hair/*.glb scan (2026-04-04)
var expected_hair := [
"bald", "balding", "bob", "buns", "buzzed", "buzzed_female",
"dreads", "long", "long_dreads", "mohawk", "ponytail", "ponytail_f",
"simple_parted", "slick_back",
]
for hair_id in expected_hair:
assert_bool(hair_list.has(hair_id)).override_failure_message(
"[#720] manifest 'hair' must include '%s'" % hair_id
).is_true()
func test_manifest_heads_are_populated() -> void:
## [ACCEPTANCE #720] manifest.json "heads" must not be empty.
## heads/templates/ contains head_001..head_004 — all must be listed.
## WILL FAIL until #720 populates the manifest.
var path := "res://assets/characters/manifest.json"
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
push_warning("test_manifest_heads_are_populated: manifest not readable — skipping")
return
var parsed: Variant = JSON.parse_string(file.get_as_text())
file.close()
if not (parsed is Dictionary):
return
var manifest := parsed as Dictionary
var heads_list: Array = manifest.get("heads", [])
var expected_heads := ["head_001", "head_002", "head_003", "head_004"]
assert_bool(not heads_list.is_empty()).override_failure_message(
"[#720] manifest 'heads' must not be empty — 4 head templates exist"
).is_true()
for head_id in expected_heads:
assert_bool(heads_list.has(head_id)).override_failure_message(
"[#720] manifest 'heads' must include '%s'" % head_id
).is_true()
func test_manifest_body_types_includes_all_11() -> void:
## [ACCEPTANCE #720] manifest.json "body_types" must include all 11 types.
## Currently has 6; missing: thin_m, thin_f, heavy_m, heavy_f, child.
## WILL FAIL until #720 updates the manifest.
var path := "res://assets/characters/manifest.json"
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
push_warning("test_manifest_body_types_includes_all_11: manifest not readable — skipping")
return
var parsed: Variant = JSON.parse_string(file.get_as_text())
file.close()
if not (parsed is Dictionary):
return
var manifest := parsed as Dictionary
var bt_list: Array = manifest.get("body_types", [])
var expected_types := [
"thin_m", "thin_f", "average_m", "average_f",
"muscular_m", "muscular_f", "teen_m", "teen_f",
"heavy_m", "heavy_f", "child",
]
for bt in expected_types:
assert_bool(bt_list.has(bt)).override_failure_message(
"[#720] manifest 'body_types' must include '%s'" % bt
).is_true()
func test_manifest_clothing_includes_all_items() -> void:
## [ACCEPTANCE #720] manifest.json "clothing" must include all items in
## assets/characters/clothing/. Currently missing: boots_work, coveralls_basic,
## jacket_utility, pants_cargo, shirt_henley.
## WILL FAIL until #720 populates the manifest.
var path := "res://assets/characters/manifest.json"
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
push_warning("test_manifest_clothing_includes_all_items: manifest not readable — skipping")
return
var parsed: Variant = JSON.parse_string(file.get_as_text())
file.close()
if not (parsed is Dictionary):
return
var manifest := parsed as Dictionary
var clothing_data: Variant = manifest.get("clothing", {})
var clothing_keys: Array = []
if clothing_data is Dictionary:
clothing_keys = (clothing_data as Dictionary).keys()
# All clothing item IDs confirmed from assets/characters/clothing/ scan (2026-04-04)
var expected_items := [
"boots_work", "coveralls_basic", "jacket_utility", "pants_cargo",
"peasant_pants", "peasant_shoes", "peasant_tunic", "shirt_henley",
]
for item_id in expected_items:
assert_bool(clothing_keys.has(item_id)).override_failure_message(
"[#720] manifest 'clothing' must include '%s'" % item_id
).is_true()
func test_manifest_eyebrows_are_populated() -> void:
## [ACCEPTANCE #720] manifest.json "eyebrows" must list all eyebrow styles.
## assets/characters/eyebrows/ has: female, regular, teen, thick.
## WILL FAIL until #720 populates the manifest.
var path := "res://assets/characters/manifest.json"
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
push_warning("test_manifest_eyebrows_are_populated: manifest not readable — skipping")
return
var parsed: Variant = JSON.parse_string(file.get_as_text())
file.close()
if not (parsed is Dictionary):
return
var manifest := parsed as Dictionary
var eb_list: Array = manifest.get("eyebrows", [])
var expected_eyebrows := ["female", "regular", "teen", "thick"]
assert_bool(not eb_list.is_empty()).override_failure_message(
"[#720] manifest 'eyebrows' must not be empty"
).is_true()
for eb_id in expected_eyebrows:
assert_bool(eb_list.has(eb_id)).override_failure_message(
"[#720] manifest 'eyebrows' must include '%s'" % eb_id
).is_true()
func test_dir_access_scan_functions_removed() -> void:
## [ACCEPTANCE #720] After fix, _scan_subdirs and _scan_asset_ids must be
## removed from CharacterCreation. These functions fail in exported PCK builds.
## WILL FAIL until #720 removes the DirAccess fallbacks.
var cc_scene_path := "res://scenes/character_creation.tscn"
if not ResourceLoader.exists(cc_scene_path):
push_warning("test_dir_access_scan_functions_removed: scene not available — skipping")
return
var packed := load(cc_scene_path) as PackedScene
if packed == null:
return
var cc := packed.instantiate() as CharacterCreation
if cc == null:
return
auto_free(cc)
assert_bool(cc.has_method("_scan_subdirs")).override_failure_message(
"[#720] _scan_subdirs must be removed — use manifest JSON instead"
).is_false()
assert_bool(cc.has_method("_scan_asset_ids")).override_failure_message(
"[#720] _scan_asset_ids must be removed — use manifest JSON instead"
).is_false()
# =============================================================================
# #712 — BoneAttachment3D overhead anchor in CharacterVisual
# =============================================================================
func test_character_visual_has_get_overhead_anchor() -> void:
## CharacterVisual must expose get_overhead_anchor() as part of its public API.
## This is a static assertion — no 3D assets required.
var cv := CharacterVisual.new()
auto_free(cv)
assert_bool(cv.has_method("get_overhead_anchor")).override_failure_message(
"CharacterVisual must have get_overhead_anchor() method (#712)"
).is_true()
func test_overhead_anchor_is_null_before_load() -> void:
## get_overhead_anchor() must return null before load_descriptor() is called.
## The anchor is created during skeleton load, not at construction.
var cv := CharacterVisual.new()
auto_free(cv)
var anchor: Variant = cv.get_overhead_anchor()
assert_bool(anchor == null).override_failure_message(
"get_overhead_anchor() must be null before load_descriptor() is called"
).is_true()
func test_overhead_anchor_offset_constant() -> void:
## [ACCEPTANCE #712] If CharacterVisual exposes the overhead anchor offset
## as a constant or via get_overhead_anchor(), the offset must be Vector3(0, 0.3, 0).
## Verified via code inspection: _overhead_anchor.position = Vector3(0, 0.3, 0).
## This test loads a scene and verifies if assets are present.
var cv := CharacterVisual.new()
auto_free(cv)
add_child(cv)
# Without GLB assets available in headless, skeleton load is a no-op.
# Check that _overhead_attachment is also null before load (belt-and-suspenders).
var attachment: Variant = cv.get("_overhead_attachment")
assert_bool(attachment == null).override_failure_message(
"_overhead_attachment must be null before skeleton is loaded"
).is_true()
# =============================================================================
# #674 — Star map insert module (test-first)
# =============================================================================
func test_star_map_scene_exists() -> void:
## [ACCEPTANCE #674] The star map scene must exist at the expected path.
## WILL FAIL until #674 is implemented.
var expected_path := "res://ui/star_map.tscn"
assert_bool(ResourceLoader.exists(expected_path)).override_failure_message(
"[#674] Star map scene must exist at res://ui/star_map.tscn"
).is_true()
func test_star_map_is_accessible_from_insert_ui() -> void:
## [ACCEPTANCE #674] The star map module must be reachable from the insert UI.
## Verify via HUD or main scene that a star_map node/scene is connected.
## WILL FAIL until #674 wires the scene into the insert layer.
var hud_scene_path := "res://ui/hud.tscn"
if not ResourceLoader.exists(hud_scene_path):
push_warning("test_star_map_is_accessible_from_insert_ui: HUD scene not found — skipping")
return
var packed := load(hud_scene_path) as PackedScene
if packed == null:
return
var hud := packed.instantiate()
if hud == null:
return
auto_free(hud)
add_child(hud)
await get_tree().process_frame
# Star map must be reachable as a named node from the HUD or insert layer
var star_map := hud.get_node_or_null("StarMap")
assert_bool(star_map != null).override_failure_message(
"[#674] HUD must contain a StarMap node accessible from the insert UI"
).is_true()
+4 -2
View File
@@ -17,6 +17,8 @@
class_name TestTimeDisplaySprint17
extends GdUnitTestSuite
const MAIN_SCENE = preload("res://scenes/main.tscn")
var _clock: Control = null
@@ -275,7 +277,7 @@ func test_sim_bridge_day_phase_is_valid() -> void:
# -------------------------------------------------------------------------
func test_insert_clock_exists_in_ui_layer() -> void:
var scene := load("res://scenes/main.tscn")
var scene := MAIN_SCENE
var instance = scene.instantiate()
auto_free(instance)
add_child(instance)
@@ -283,7 +285,7 @@ func test_insert_clock_exists_in_ui_layer() -> void:
assert_that(instance.get_node_or_null("InsertOverlay/TimeDisplay")).is_not_null()
func test_insert_clock_time_str_updates_after_process() -> void:
var scene := load("res://scenes/main.tscn")
var scene := MAIN_SCENE
var instance = scene.instantiate()
auto_free(instance)
add_child(instance)
+19 -17
View File
@@ -4,6 +4,8 @@
class_name TestUIFrameworkSprint15
extends GdUnitTestSuite
const MAIN_SCENE = preload("res://scenes/main.tscn")
var _instance: Node = null
@@ -41,7 +43,7 @@ func test_dialogue_max_width_set() -> void:
func test_insert_overlay_is_canvas_layer_10() -> void:
# D-049: InsertOverlay = conceptual layer 6 (insert scope) = CanvasLayer 10.
# Constants.CANVAS_INSERT must match.
var scene := load("res://scenes/main.tscn")
var scene := MAIN_SCENE
_instance = scene.instantiate()
auto_free(_instance)
add_child(_instance)
@@ -53,7 +55,7 @@ func test_insert_overlay_is_canvas_layer_10() -> void:
func test_ui_layer_is_canvas_layer_20() -> void:
# D-049: UILayer = conceptual layer 7 (UI/monologue scope) = CanvasLayer 20.
var scene := load("res://scenes/main.tscn")
var scene := MAIN_SCENE
_instance = scene.instantiate()
auto_free(_instance)
add_child(_instance)
@@ -65,7 +67,7 @@ func test_ui_layer_is_canvas_layer_20() -> void:
func test_modal_layer_is_canvas_layer_30() -> void:
# D-049: ModalLayer = pause/inventory modal scope = CanvasLayer 30.
var scene := load("res://scenes/main.tscn")
var scene := MAIN_SCENE
_instance = scene.instantiate()
auto_free(_instance)
add_child(_instance)
@@ -91,7 +93,7 @@ func test_modal_layer_above_ui_layer() -> void:
func test_monologue_display_exists_in_ui_layer() -> void:
# D-049 / #117 / #414: MonologueDisplay must be in UILayer (layer 7).
var scene := load("res://scenes/main.tscn")
var scene := MAIN_SCENE
_instance = scene.instantiate()
auto_free(_instance)
add_child(_instance)
@@ -101,7 +103,7 @@ func test_monologue_display_exists_in_ui_layer() -> void:
func test_stance_indicator_exists_in_ui_layer() -> void:
# D-053: StanceIndicator must be in UILayer (layer 7), top-right, color-coded.
var scene := load("res://scenes/main.tscn")
var scene := MAIN_SCENE
_instance = scene.instantiate()
auto_free(_instance)
add_child(_instance)
@@ -112,7 +114,7 @@ func test_stance_indicator_exists_in_ui_layer() -> void:
func test_minimap_placeholder_exists_in_ui_layer() -> void:
# D-013/D-049: Minimap is on InsertOverlay (z-layer 6), NOT UILayer.
# Sprint 18 #151 (Stig): moved from UILayer to InsertOverlay per D-049 spec.
var scene := load("res://scenes/main.tscn")
var scene := MAIN_SCENE
_instance = scene.instantiate()
auto_free(_instance)
add_child(_instance)
@@ -122,7 +124,7 @@ func test_minimap_placeholder_exists_in_ui_layer() -> void:
func test_hud_exists_in_ui_layer() -> void:
# D-049: HUD must be in UILayer.
var scene := load("res://scenes/main.tscn")
var scene := MAIN_SCENE
_instance = scene.instantiate()
auto_free(_instance)
add_child(_instance)
@@ -132,7 +134,7 @@ func test_hud_exists_in_ui_layer() -> void:
func test_interaction_list_exists_in_insert_overlay() -> void:
# D-057: InteractionList must be in InsertOverlay (z-layer 6).
var scene := load("res://scenes/main.tscn")
var scene := MAIN_SCENE
_instance = scene.instantiate()
auto_free(_instance)
add_child(_instance)
@@ -142,7 +144,7 @@ func test_interaction_list_exists_in_insert_overlay() -> void:
func test_dialogue_box_exists_in_insert_overlay() -> void:
# D-061: DialogueBox must be in InsertOverlay (z-layer 6).
var scene := load("res://scenes/main.tscn")
var scene := MAIN_SCENE
_instance = scene.instantiate()
auto_free(_instance)
add_child(_instance)
@@ -152,7 +154,7 @@ func test_dialogue_box_exists_in_insert_overlay() -> void:
func test_world_radial_exists_in_insert_overlay() -> void:
# D-058: WorldRadial must be in InsertOverlay (z-layer 6).
var scene := load("res://scenes/main.tscn")
var scene := MAIN_SCENE
_instance = scene.instantiate()
auto_free(_instance)
add_child(_instance)
@@ -162,7 +164,7 @@ func test_world_radial_exists_in_insert_overlay() -> void:
func test_cursor_renderer_exists_in_ui_layer() -> void:
# D-056: CursorRenderer must be in UILayer (topmost, z-layer 7).
var scene := load("res://scenes/main.tscn")
var scene := MAIN_SCENE
_instance = scene.instantiate()
auto_free(_instance)
add_child(_instance)
@@ -182,7 +184,7 @@ func test_gamestate_insert_active_defaults_true() -> void:
func test_insert_active_propagates_on_process() -> void:
# OQ-07 (#522): After apply_snapshot with insert_active=false,
# the next _process() call must propagate the state to z-layer-6 nodes.
var scene := load("res://scenes/main.tscn")
var scene := MAIN_SCENE
_instance = scene.instantiate()
auto_free(_instance)
add_child(_instance)
@@ -235,7 +237,7 @@ func test_follow_target_id_is_negative_one_by_default() -> void:
func test_monologue_display_receives_first_tick_monologue() -> void:
# #414 / #74: MonologueDisplay must show monologue from tick 1 test snapshot.
# Verifies the wiring: GameState.current_monologue → main.gd → MonologueDisplay.
var scene := load("res://scenes/main.tscn")
var scene := MAIN_SCENE
_instance = scene.instantiate()
auto_free(_instance)
add_child(_instance)
@@ -254,7 +256,7 @@ func test_monologue_display_receives_first_tick_monologue() -> void:
func test_fog_group_exists_in_world() -> void:
# Sprint 14 regression: fog rendering must still be present after sprint 15 changes.
var scene := load("res://scenes/main.tscn")
var scene := MAIN_SCENE
_instance = scene.instantiate()
auto_free(_instance)
add_child(_instance)
@@ -264,7 +266,7 @@ func test_fog_group_exists_in_world() -> void:
func test_floor_tiles_in_fog_group() -> void:
# Sprint 14 regression: FloorTiles must be in FogGroup (D-049 z:0).
var scene := load("res://scenes/main.tscn")
var scene := MAIN_SCENE
_instance = scene.instantiate()
auto_free(_instance)
add_child(_instance)
@@ -274,7 +276,7 @@ func test_floor_tiles_in_fog_group() -> void:
func test_entities_in_ysort_group() -> void:
# Sprint 14 regression: Entities must be in YSortGroup for y-sort ordering (D-049 z:100).
var scene := load("res://scenes/main.tscn")
var scene := MAIN_SCENE
_instance = scene.instantiate()
auto_free(_instance)
add_child(_instance)
@@ -288,7 +290,7 @@ func test_entities_in_ysort_group() -> void:
func test_tile_renderer_skips_nonzero_z() -> void:
# #71: Tiles with z != 0 must be filtered out by update_tiles().
var scene := load("res://scenes/main.tscn")
var scene := MAIN_SCENE
_instance = scene.instantiate()
auto_free(_instance)
add_child(_instance)
+2 -1
View File
@@ -250,7 +250,8 @@ func test_dialogue_theme_format() -> void:
func test_checklist_format() -> void:
## checklist.yaml: top-level kv + conditions array with typed values.
var yaml := "room_id: inventory_warehouse\nconditions:\n - id: test-1\n condition_type: player_near\n x: 10\n y: 20\n radius: 3.0"
var yaml := ("room_id: inventory_warehouse\nconditions:\n - id: test-1\n" +
" condition_type: player_near\n x: 10\n y: 20\n radius: 3.0")
var result := YamlParser.parse(yaml)
assert_that(result["room_id"]).is_equal("inventory_warehouse")
var cond: Dictionary = result["conditions"][0]
+1 -1
View File
@@ -31,7 +31,7 @@ func _init():
_run.call_deferred()
func _run():
func _run(): # gdlint:disable=max-returns
# Parse CLI args (after -- separator)
_parse_args()
-1
View File
@@ -41,7 +41,6 @@ func apply_setup(scenario_name: String, tree_root: Node) -> bool:
sim_bridge.harness.player_pos = Vector2i(10, 10)
# Zone tint is written from visible_tiles with zone_id field.
# We patch GameState.visible_tiles after the first snapshot in post_setup().
pass
"fog_debug":
# Raw exploration overlay (green/blue/red).
+44 -71
View File
@@ -1,3 +1,4 @@
# gdlint:disable=max-file-lines
class_name CharacterCreation
extends Control
## #705: Character creation screen.
@@ -105,18 +106,14 @@ const PALETTE_COLORS: Array[Array] = [
const PALETTE_COLS := 9
const RECENT_SLOTS := 9
# --- @onready references to .tscn nodes ---
@onready var _viewport: SubViewport = $Layout/PreviewPanel/SubViewportContainer/SubViewport
@onready var _char_anchor: Node3D = $Layout/PreviewPanel/SubViewportContainer/SubViewport/CharacterVisualAnchor
@onready var _preview_camera: Camera3D = $Layout/PreviewPanel/SubViewportContainer/SubViewport/PreviewCamera
@onready var _rotate_left_btn: Button = $Layout/PreviewPanel/PreviewOverlay/RotateButtons/RotateLeftBtn
@onready var _rotate_right_btn: Button = $Layout/PreviewPanel/PreviewOverlay/RotateButtons/RotateRightBtn
@onready var _cam_angle_btn: Button = $Layout/PreviewPanel/PreviewOverlay/CamAngleBtn
var _tab_container: TabContainer = null # resolved in _ready() — @onready path fails when instantiated as child
@onready var _footer_back: Button = $Footer/BackBtn
@onready var _footer_randomize: Button = $Footer/RandomizeBtn
@onready var _footer_start: Button = $Footer/StartBtn
@onready var _modal_root: Control = $ColorPickerModal
const CAM_ZOOM_MIN: float = 0.25 # closest zoom (face detail)
const CAM_ZOOM_MAX: float = 3.0 # farthest zoom (crowd level)
const CAM_ZOOM_STEP: float = 0.12
const MANIFEST_PATH := "res://assets/characters/manifest.json"
const SCREENSHOT_DIR := "user://screenshots/"
const CARDINAL_NAMES: Array[String] = ["south", "east", "north", "west"]
# --- Descriptor and preview state ---
var _descriptor: CharacterVisualDescriptor
@@ -125,9 +122,6 @@ var _facing_idx: int = 0 # index into CARDINAL_DIRS (0 = south, default face
var _cam_pitch_idx: int = 0 # 0=frontal(-5°), 1=dramatic(-30°), 2=overhead(-80°)
var _cam_zoom: float = 1.0 # 1.0 = default distance, <1.0 = zoomed in
var _cam_zoom_offset: Vector3 = Vector3.ZERO # camera offset toward cursor when zoomed
const CAM_ZOOM_MIN: float = 0.25 # closest zoom (face detail)
const CAM_ZOOM_MAX: float = 3.0 # farthest zoom (crowd level)
const CAM_ZOOM_STEP: float = 0.12
# --- Tab active slot state ---
var _active_clothing_slot: String = "torso"
@@ -197,7 +191,31 @@ var _tab_grids: Array[GridContainer] = [null, null, null, null, null]
# --- Asset manifest (loaded once, replaces filesystem scanning) ---
var _manifest: Dictionary = {}
const MANIFEST_PATH := "res://assets/characters/manifest.json"
# --- Debug tab state ---
var _debug_toggles: Dictionary = {} # seg_name -> CheckButton
# --- Screenshot / automated testing state ---
var _screenshot_delay_frames: int = 5 # wait N frames for scene to render
var _screenshot_pending: bool = false
var _screenshot_frame_count: int = 0
var _quit_after_screenshot: bool = false
var _screenshot_cardinals: bool = false
var _screenshot_cardinal_idx: int = 0
# resolved in _ready() — @onready path fails when instantiated as child
var _tab_container: TabContainer = null
# --- @onready references to .tscn nodes ---
@onready var _viewport: SubViewport = $Layout/PreviewPanel/SubViewportContainer/SubViewport
@onready var _char_anchor: Node3D = $Layout/PreviewPanel/SubViewportContainer/SubViewport/CharacterVisualAnchor
@onready var _preview_camera: Camera3D = $Layout/PreviewPanel/SubViewportContainer/SubViewport/PreviewCamera
@onready var _rotate_left_btn: Button = $Layout/PreviewPanel/PreviewOverlay/RotateButtons/RotateLeftBtn
@onready var _rotate_right_btn: Button = $Layout/PreviewPanel/PreviewOverlay/RotateButtons/RotateRightBtn
@onready var _cam_angle_btn: Button = $Layout/PreviewPanel/PreviewOverlay/CamAngleBtn
@onready var _footer_back: Button = $Footer/BackBtn
@onready var _footer_randomize: Button = $Footer/RandomizeBtn
@onready var _footer_start: Button = $Footer/StartBtn
@onready var _modal_root: Control = $ColorPickerModal
# =============================================================================
@@ -341,7 +359,8 @@ func _cam_zoom_toward_cursor(_screen_pos: Vector2, zoom_delta: float) -> void:
if _char_visual and _char_visual._skeleton:
var head_idx := _char_visual._skeleton.find_bone("Head")
if head_idx >= 0:
var head_pos := _char_visual._skeleton.global_transform * _char_visual._skeleton.get_bone_global_pose(head_idx).origin
var head_pos := _char_visual._skeleton.global_transform \
* _char_visual._skeleton.get_bone_global_pose(head_idx).origin
# Blend from body center toward head as zoom increases
var blend := 1.0 - _cam_zoom # 0 at default, 0.75 at max zoom
_cam_zoom_offset.y = (head_pos.y - CAM_TARGET_HEIGHT) * blend
@@ -669,6 +688,11 @@ func _build_hair_color_dock() -> Control:
func(c): _on_hair_primary_changed(c))
row.add_child(_hair_primary_swatch)
# #719 (Option B): highlight is auto-derived from primary — display-only, not editable.
_hair_highlight_swatch = _make_display_swatch(
_derive_hair_highlight(_descriptor.hair_tint), "Highlight")
row.add_child(_hair_highlight_swatch)
_eyebrow_tint_swatch = _make_color_swatch(_descriptor.hair_tint, "Brows ●",
func(c): _on_eyebrow_tint_changed(c))
row.add_child(_eyebrow_tint_swatch)
@@ -1079,15 +1103,6 @@ func _update_accessory_item_btns() -> void:
# Screenshot & automated testing
# =============================================================================
const SCREENSHOT_DIR := "user://screenshots/"
var _screenshot_delay_frames: int = 5 # wait N frames for scene to render
var _screenshot_pending: bool = false
var _screenshot_frame_count: int = 0
var _quit_after_screenshot: bool = false
var _screenshot_cardinals: bool = false
var _screenshot_cardinal_idx: int = 0
const CARDINAL_NAMES: Array[String] = ["south", "east", "north", "west"]
func _schedule_screenshot() -> void:
_screenshot_pending = true
_screenshot_frame_count = 0
@@ -1126,8 +1141,7 @@ func _take_screenshot(suffix: String = "") -> void:
# More directions to capture
_schedule_screenshot()
return
else:
_screenshot_cardinals = false
_screenshot_cardinals = false
if _quit_after_screenshot:
get_tree().quit()
@@ -1198,8 +1212,6 @@ func _load_test_config() -> void:
# Debug tab — segment visibility toggles
# =============================================================================
var _debug_toggles: Dictionary = {} # seg_name -> CheckButton
func _build_debug_tab(tab: Control) -> void:
var vbox := _make_tab_vbox(tab)
@@ -1496,7 +1508,7 @@ func _input(event: InputEvent) -> void:
_cam_zoom_toward_cursor(mb.position, -CAM_ZOOM_STEP)
get_viewport().set_input_as_handled()
return
elif mb.button_index == MOUSE_BUTTON_WHEEL_DOWN:
if mb.button_index == MOUSE_BUTTON_WHEEL_DOWN:
_cam_zoom_toward_cursor(mb.position, CAM_ZOOM_STEP)
get_viewport().set_input_as_handled()
return
@@ -1840,45 +1852,6 @@ func _apply_search_filter(grid: GridContainer, query: String) -> void:
child.visible = lower_q.is_empty() or (child as Button).text.to_lower().contains(lower_q)
# =============================================================================
# Asset scanning helpers
# =============================================================================
## Scan a directory for subdirectory names (item_id directories like clothing/coveralls_basic/).
## Returns fallback list if the directory is absent or empty.
## TODO: replace with manifest JSON for export builds (DirAccess won't list res:// in PCK).
static func _scan_subdirs(dir_path: String, fallback: Array) -> Array:
var dir := DirAccess.open(dir_path)
if dir == null:
return fallback
var ids: Array = []
dir.list_dir_begin()
var name := dir.get_next()
while name != "":
if dir.current_is_dir() and not name.begins_with("."):
ids.append(name)
name = dir.get_next()
dir.list_dir_end()
return ids if not ids.is_empty() else fallback
## Scan a directory for .glb asset IDs. Returns fallback list if directory absent.
## TODO: replace with manifest JSON for export builds (DirAccess won't list res:// in PCK).
static func _scan_asset_ids(dir_path: String, ext: String, fallback: Array) -> Array:
var dir := DirAccess.open(dir_path)
if dir == null:
return fallback
var ids: Array = []
dir.list_dir_begin()
var name := dir.get_next()
while name != "":
if not dir.current_is_dir() and name.ends_with(ext):
ids.append(name.get_basename())
name = dir.get_next()
dir.list_dir_end()
return ids if not ids.is_empty() else fallback
func _get_clothing_ids_for_slot(slot: String) -> Array:
# Clothing items from manifest — slot assignment is explicit, not prefix-based.
var clothing_data: Variant = _manifest.get("clothing", {})
@@ -1907,7 +1880,7 @@ func _get_accessory_ids_for_slot(slot: String) -> Array:
"wrist_l", "wrist_r": return all_ids.filter(func(id: String) -> bool: return id.begins_with("wrist"))
"earring_l", "earring_r": return all_ids.filter(func(id: String) -> bool: return id.begins_with("earring"))
"necklace": return all_ids.filter(func(id: String) -> bool: return id.begins_with("necklace"))
_: return all_ids
_: return all_ids # gdlint:ignore = max-returns
# =============================================================================
+2 -2
View File
@@ -6,7 +6,7 @@ extends Control
##
## Spec ref: D-030 (testability), #503, Sprint 10 Completion Proof.
const _ChecklistEvaluator = preload("res://scripts/checklist/checklist_evaluator.gd")
const ChecklistEvaluator = preload("res://scripts/checklist/checklist_evaluator.gd")
const BG_COLOR := Color(0.05, 0.05, 0.08, 0.45)
const MET_COLOR := Color("#6bc9a6") # Friendly green — condition met
@@ -26,7 +26,7 @@ var _cached_font: Font = null # Cached to avoid per-frame theme lookup
func _ready() -> void:
mouse_filter = Control.MOUSE_FILTER_IGNORE
visible = false
_evaluator = _ChecklistEvaluator.new()
_evaluator = ChecklistEvaluator.new()
_cached_font = get_theme_default_font()
+10 -5
View File
@@ -232,11 +232,15 @@ func _draw_stats_panel() -> void:
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():
draw_string(font, Vector2(PADDING.x, y), left_lines[i][0] + ": ", HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, LABEL_COLOR)
draw_string(font, Vector2(PADDING.x + left_label_w, y), left_lines[i][1], HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, VALUE_COLOR)
draw_string(font, Vector2(PADDING.x, y), left_lines[i][0] + ": ",
HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, LABEL_COLOR)
draw_string(font, Vector2(PADDING.x + left_label_w, y), left_lines[i][1],
HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, VALUE_COLOR)
if i < right_lines.size():
draw_string(font, Vector2(right_x, y), right_lines[i][0] + ": ", HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, LABEL_COLOR)
draw_string(font, Vector2(right_x + right_label_w, y), right_lines[i][1], HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, VALUE_COLOR)
draw_string(font, Vector2(right_x, y), right_lines[i][0] + ": ",
HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, LABEL_COLOR)
draw_string(font, Vector2(right_x + right_label_w, y), right_lines[i][1],
HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, VALUE_COLOR)
y += LINE_HEIGHT
@@ -335,7 +339,8 @@ func _draw_npc_paths(canvas_xf: Transform2D) -> void:
var a_screen := _w2s(path[i - 1], canvas_xf)
var b_screen := _w2s(path[i], canvas_xf)
var alpha := float(i) / float(path.size())
draw_line(a_screen, b_screen, Color(NPC_PATH_COLOR.r, NPC_PATH_COLOR.g, NPC_PATH_COLOR.b, NPC_PATH_COLOR.a * alpha), 1.5)
draw_line(a_screen, b_screen,
Color(NPC_PATH_COLOR.r, NPC_PATH_COLOR.g, NPC_PATH_COLOR.b, NPC_PATH_COLOR.a * alpha), 1.5)
draw_circle(_w2s(path.back(), canvas_xf), NPC_DOT_RADIUS, NPC_DOT_COLOR)
+29 -29
View File
@@ -21,9 +21,26 @@ signal dialogue_state_changed(active: bool)
signal audio_dip_requested(profile: String)
signal audio_dip_cleared
@onready var panel: PanelContainer = $PanelContainer
@onready var dialogue_log: RichTextLabel = $PanelContainer/MarginContainer/VBoxContainer/DialogueLog
@onready var options_container: VBoxContainer = $PanelContainer/MarginContainer/VBoxContainer/OptionsContainer
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 = 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] = [
&"move_north", &"move_south", &"move_east", &"move_west",
&"move_northeast", &"move_southeast", &"move_southwest", &"move_northwest",
]
# -- Log state --
# Entry format (legacy): {speaker: String, target: String, text, is_passive, pinned, timestamp_msec}
@@ -67,26 +84,9 @@ var _passive_opacity: float = 0.9
var _entry_lifetime: float = 45.0
var _entry_fade: float = 5.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 = 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] = [
&"move_north", &"move_south", &"move_east", &"move_west",
&"move_northeast", &"move_southeast", &"move_southwest", &"move_northwest",
]
@onready var panel: PanelContainer = $PanelContainer
@onready var dialogue_log: RichTextLabel = $PanelContainer/MarginContainer/VBoxContainer/DialogueLog
@onready var options_container: VBoxContainer = $PanelContainer/MarginContainer/VBoxContainer/OptionsContainer
func _ready() -> void:
@@ -197,7 +197,8 @@ func _update_layout() -> void:
## Active conversation entries are pinned (no timeout) while _in_player_conversation.
## speaker_entity_id/target_entity_id: optional entity IDs for stable color lookup (#573).
## TODO Phase 2: 6 positional params is unwieldy — consider dictionary-options overload.
func append_line(speaker: String, target: String, text: String, is_passive: bool = false, speaker_entity_id: int = -1, target_entity_id: int = -1) -> void:
func append_line(speaker: String, target: String, text: String,
is_passive: bool = false, speaker_entity_id: int = -1, target_entity_id: int = -1) -> void:
var pinned := not is_passive and _in_player_conversation
var entry: Dictionary = {
"speaker": speaker,
@@ -572,11 +573,10 @@ func _format_entry(entry: Dictionary, alpha: float) -> String:
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
]
# 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).
+3 -3
View File
@@ -21,12 +21,12 @@ const CONFIDENCE_ALPHA: Dictionary = {
"Suspects": 0.6,
}
@onready var panel: PanelContainer = $PanelContainer
@onready var text_label: RichTextLabel = $PanelContainer/MarginContainer/TextLabel
var _dismiss_tween: Tween = null
var _active: bool = false
@onready var panel: PanelContainer = $PanelContainer
@onready var text_label: RichTextLabel = $PanelContainer/MarginContainer/TextLabel
func _ready() -> void:
mouse_filter = Control.MOUSE_FILTER_IGNORE
modulate.a = 0.0
+2 -1
View File
@@ -114,7 +114,8 @@ func _draw() -> void:
# PB text (different color)
if not pb_text.is_empty():
var pb_color: Color = NEW_PB_COLOR if _new_pb_flash > 0.0 else PB_COLOR
draw_string(font, Vector2(PADDING.x + timer_size.x, y_offset), pb_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, pb_color)
draw_string(font, Vector2(PADDING.x + timer_size.x, y_offset), pb_text,
HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, pb_color)
static func _format_time(seconds: float) -> String:
+16 -10
View File
@@ -15,7 +15,7 @@ const TPT_YELLOW_THRESHOLD := 3.0 # tokens/sec — partial pre-voicing not
const TPT_DEGRADATION_THRESHOLD := 0.4 # fraction — >40% sustained drop → yellow
## Benchmark cache path — reads from PlatformInfo for cross-platform correctness.
var BENCHMARK_CACHE_PATH: String:
var benchmark_cache_path: String:
get:
return PlatformInfo.benchmark_cache_path
@@ -45,10 +45,9 @@ func _ready() -> void:
func classify_ram(free_mb: float) -> String:
if free_mb >= RAM_PASS_THRESHOLD_MB:
return "pass"
elif free_mb >= RAM_MARGINAL_THRESHOLD_MB:
if free_mb >= RAM_MARGINAL_THRESHOLD_MB:
return "marginal"
else:
return "fail"
return "fail"
## Query RAM via PlatformInfo and return classification + raw MB value.
@@ -65,10 +64,9 @@ func check_ram() -> Dictionary:
func classify_tpt(tps: float) -> String:
if tps >= TPT_GREEN_THRESHOLD:
return "green"
elif tps >= TPT_YELLOW_THRESHOLD:
if tps >= TPT_YELLOW_THRESHOLD:
return "yellow"
else:
return "red"
return "red"
## Read the cached TPT benchmark result written by the server on first model load.
@@ -192,12 +190,20 @@ func _send_settings_change(enabled: bool) -> void:
## Returns empty string when no message is needed.
func status_message(ram_classification: String, tpt_classification: String, free_mb: float, tps: float) -> String:
if ram_classification == "fail":
return "AI-Enhanced Dialogue requires 2 GB of free memory. Your system currently has %.0f MB available. Close other applications and try again, or leave the setting off — the game is complete either way." % free_mb
return (
"AI-Enhanced Dialogue requires 2 GB of free memory. Your system currently has %.0f MB available."
+ " Close other applications and try again, or leave the setting off — the game is complete either way."
) % free_mb
if ram_classification == "marginal":
return "Only %.0f MB free — performance may vary. You can still enable it." % free_mb
match tpt_classification:
"yellow":
return "Running at %.0f t/s — pre-voicing will work for main characters and key scenes. Background NPCs may show base text until the queue catches up." % tps
return (
"Running at %.0f t/s — pre-voicing will work for main characters and key scenes."
+ " Background NPCs may show base text until the queue catches up."
) % tps
"red":
return "Running very slowly at %.0f t/s — we recommend leaving this off, but the choice is yours." % tps
return (
"Running very slowly at %.0f t/s — we recommend leaving this off, but the choice is yours."
) % tps
return ""
+6 -1
View File
@@ -1,6 +1,7 @@
[gd_scene load_steps=2 format=3 uid="uid://cq1y5w3hmxr8b"]
[gd_scene load_steps=3 format=3 uid="uid://cq1y5w3hmxr8b"]
[ext_resource type="Script" path="res://ui/hud.gd" id="1_hud"]
[ext_resource type="PackedScene" path="res://ui/star_map.tscn" id="2_starmap"]
[node name="HUD" type="Control"]
layout_mode = 3
@@ -36,3 +37,7 @@ text = "Mode: Baseline"
[node name="TimeLabel" type="Label" parent="MarginContainer/VBoxContainer"]
layout_mode = 2
text = "Time: 08:00"
; #674: Star map insert — concentric hop-ring view, hidden by default, toggled via keybind
[node name="StarMap" parent="." instance=ExtResource("2_starmap")]
visible = false
+2 -3
View File
@@ -32,6 +32,8 @@ var _verb_labels: Array[Label] = []
# #537: D-033 relationship color — cached per target, drawn as left-edge accent bar
var _relationship_color: Color = Constants.IMPLANT_TEXT_DIM
var _last_screen_pos: Vector2 = Vector2.ZERO
@onready var _vbox: VBoxContainer = $VBox
@@ -40,9 +42,6 @@ func _ready() -> void:
visible = false
mouse_filter = Control.MOUSE_FILTER_IGNORE
var _last_screen_pos: Vector2 = Vector2.ZERO
func _process(_delta: float) -> void:
if _showing:
_update_screen_position()
+3 -3
View File
@@ -8,7 +8,8 @@ extends PanelContainer
# v0.2: Will be replaced/extended with radial verb menu.
# Public interface: get_interaction_target(), get_selected_verb()
@onready var prompt_label: Label = $MarginContainer/PromptLabel
const FADE_IN: float = 0.15
const FADE_OUT: float = 0.15
var _is_showing: bool = false
var _active_tween: Tween = null
@@ -16,8 +17,7 @@ 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
@onready var prompt_label: Label = $MarginContainer/PromptLabel
func _ready() -> void:
modulate.a = 0.0
+2 -1
View File
@@ -94,7 +94,8 @@ func _draw() -> void:
# Hotkey number (top-left corner)
var hotkey := str(slot_idx + 1)
draw_string(font, pos + Vector2(3, HOTKEY_SIZE + 2), hotkey, HORIZONTAL_ALIGNMENT_LEFT, -1, HOTKEY_SIZE, SLOT_TEXT_DIM)
draw_string(font, pos + Vector2(3, HOTKEY_SIZE + 2), hotkey,
HORIZONTAL_ALIGNMENT_LEFT, -1, HOTKEY_SIZE, SLOT_TEXT_DIM)
func _unhandled_input(event: InputEvent) -> void:
+5 -4
View File
@@ -23,13 +23,14 @@ const FADE_OUT: float = 0.25
# Keys: knowledge_panel.confidence_{lower} and knowledge_panel.source_{lower}
# Fallback: raw value if key not found (UIStrings returns the key itself).
var _visible_state: bool = false
var _last_rendered_tick: int = -1
@onready var panel: PanelContainer = $PanelContainer
@onready var title_label: Label = $PanelContainer/MarginContainer/VBoxContainer/TitleLabel
@onready var scroll: ScrollContainer = $PanelContainer/MarginContainer/VBoxContainer/ScrollContainer
@onready var entries_container: VBoxContainer = $PanelContainer/MarginContainer/VBoxContainer/ScrollContainer/EntriesContainer
var _visible_state: bool = false
var _last_rendered_tick: int = -1
@onready var entries_container: VBoxContainer = \
$PanelContainer/MarginContainer/VBoxContainer/ScrollContainer/EntriesContainer
func _ready() -> void:
mouse_filter = Control.MOUSE_FILTER_IGNORE
+1 -1
View File
@@ -40,5 +40,5 @@ func show_loading() -> void:
## Hide the loading overlay. success=false is reserved for future failure-state UI.
func hide_loading(success: bool = true) -> void:
func hide_loading(_success: bool = true) -> void:
visible = false
+4 -6
View File
@@ -16,6 +16,9 @@ const FONT_SIZE_TITLE := 36
const FONT_SIZE_SUBTITLE := 14
const FONT_SIZE_BTN := 15
var _char_creation: Control = null
var _list_built: bool = false
@onready var _new_game_btn: Button = $VBox/NewGameBtn
@onready var _continue_btn: Button = $VBox/ContinueBtn
@onready var _load_game_btn: Button = $VBox/LoadGameBtn
@@ -24,8 +27,6 @@ const FONT_SIZE_BTN := 15
@onready var _saves_list: VBoxContainer = $LoadGamePanel/VBox/SavesScroll/SavesList
@onready var _load_back_btn: Button = $LoadGamePanel/VBox/BackBtn
var _char_creation: Control = null
func _ready() -> void:
_new_game_btn.pressed.connect(_on_new_game)
@@ -62,7 +63,7 @@ func _show_character_creation() -> void:
_char_creation.creation_cancelled.connect(_on_creation_cancelled)
func _on_creation_confirmed(descriptor: CharacterVisualDescriptor) -> void:
func _on_creation_confirmed(descriptor) -> void:
if _char_creation != null and is_instance_valid(_char_creation):
_char_creation.queue_free()
_char_creation = null
@@ -93,9 +94,6 @@ func _on_continue() -> void:
get_tree().change_scene_to_file(GAME_SCENE)
var _list_built: bool = false
func _on_load_game_browse() -> void:
if not _list_built:
_build_saves_list()
+14 -5
View File
@@ -38,8 +38,6 @@ const _FALLBACK_URGENT: Color = Color("#e0e8f8")
const _NOTIFICATION_COLOR: Color = Color("#8890a0") # #554: neutral system notification
const _NOTIFICATION_DURATION: float = 2.5
@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}
@@ -47,6 +45,8 @@ var _queue: Array[Dictionary] = []
# Msec timestamp when the next fade-in may begin (stagger enforcement)
var _next_fade_in_msec: float = 0.0
@onready var _vbox: VBoxContainer = $VBoxContainer
func _ready() -> void:
pass
@@ -85,7 +85,10 @@ func show_notification(text: String) -> void:
if _visible.size() < MAX_VISIBLE and now >= _next_fade_in_msec:
_show_notification_line(text)
else:
var entry := {text = text, duration = _NOTIFICATION_DURATION, priority = 1, is_urgent = false, lattice_profile = "", is_notification = true}
var entry := {
text = text, duration = _NOTIFICATION_DURATION, priority = 1,
is_urgent = false, lattice_profile = "", is_notification = true
}
if _queue.size() < MAX_QUEUE:
_queue.append(entry)
_queue.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return a.priority > b.priority)
@@ -173,13 +176,19 @@ func _retire_slot(slot: Dictionary) -> void:
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.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[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
+2 -2
View File
@@ -10,12 +10,12 @@ const FONT_SIZE := 13
const SCROLL_SPEED := 60.0 # pixels per second
const BAR_HEIGHT := 28
@onready var _label: Label = $TickerLabel
var _text: String = ""
var _scroll_x: float = 0.0
var _content_width: float = 0.0
@onready var _label: Label = $TickerLabel
func _ready() -> void:
mouse_filter = Control.MOUSE_FILTER_IGNORE
+4 -4
View File
@@ -6,6 +6,10 @@ extends Control
## Volumes persist via AudioManager._save_prefs() on each slider change.
## AI Dialogue toggle persists via ConfigFile (client-local) + ChangeSettings IPC (server SQLite).
signal closed
signal debug_console_toggled(enabled: bool) # #581: debug console enabled/disabled
signal ai_dialogue_toggled(enabled: bool) # #646: AI-Enhanced Dialogue enabled/disabled
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)
@@ -42,10 +46,6 @@ var _ai_check_node: CheckButton = null
var _ai_inference_suspended: bool = false # D-138 §8 Layer 3 battery auto-suspend state
var _ai_battery_warning_label: Label = null # shown when on battery; toggle stays enabled
signal closed
signal debug_console_toggled(enabled: bool) # #581: debug console enabled/disabled
signal ai_dialogue_toggled(enabled: bool) # #646: AI-Enhanced Dialogue enabled/disabled
func _ready() -> void:
visible = false
+598
View File
@@ -0,0 +1,598 @@
class_name StarMapRenderer
extends Control
## Star map — concentric hop-ring view of the Settled Reach gate network (#674).
## Renders 301 systems as dots on concentric rings (hop distance from Gateway).
## Sector-colored: core (white-gold), north (blue), south (orange), east (green), west (tan).
##
## Data source: res://data/star_map_data.json (generated from star-map.json + systems.db + wiki).
## Regenerate with: tooling/generate-star-map-data.py
##
## D-013: Diegetic neural insert overlay. Accessible from the insert UI.
## Parent epic: #51 (Diegetic Insert/Minimap), ticket #674.
## Ticket #780: Click-through popup — wiki/GTTR content on system select.
const DATA_PATH := "res://data/star_map_data.json"
# Layout
const MAP_CENTER_FRACTION := Vector2(0.5, 0.5) # center of control
const MIN_RING_RADIUS: float = 30.0 # innermost ring (hop 0 = gateway dot only)
const RING_SPACING: float = 22.0 # pixels between hop rings
const MAX_HOP_RINGS: int = 24 # max hop distance we render rings for
# Dot sizing
const DOT_RADIUS_HUB: float = 4.5
const DOT_RADIUS_JUNCTION: float = 3.5
const DOT_RADIUS_DEFAULT: float = 2.5
const DOT_RADIUS_DEAD_END: float = 2.0
const GATEWAY_RADIUS: float = 6.0
# Selection
const SELECTION_RING_RADIUS: float = 8.0
const HIT_RADIUS: float = 10.0 # click tolerance
# Edge rendering — only shown for selected system (ticket #780 UX rule)
const EDGE_WIDTH: float = 0.8
const EDGE_SELECTED_ALPHA: float = 0.55
# Info popup — expanded with wiki/GTTR content (#780)
const POPUP_WIDTH: float = 300.0
const POPUP_MARGIN: float = 16.0
const POPUP_PADDING: float = 12.0
const POPUP_LINE_H: float = 17.0
const POPUP_GTTR_FONT_SIZE: int = 10
const POPUP_GTTR_MAX_LINES: int = 7
# Colors — sector palette from wireframe
const COLOR_BG: Color = Color("#0d1117")
const COLOR_RING: Color = Color("#1a2030")
const COLOR_RING_MAJOR: Color = Color("#222a3a")
const COLOR_GATEWAY: Color = Color("#f0d060")
const COLOR_SELECTION: Color = Color("#f0d060")
const COLOR_TEXT: Color = Color("#c8d0e0")
const COLOR_TEXT_DIM: Color = Color("#667788")
const COLOR_INFO_BG: Color = Color(0.05, 0.08, 0.14, 0.92)
const SECTOR_COLORS: Dictionary = {
"core": Color("#c8d0e0"),
"north_reach": Color("#4488aa"),
"south_reach": Color("#aa6644"),
"east_reach": Color("#44aa66"),
"west_reach": Color("#aa8844"),
"deep_frontier": Color("#556677"),
"unknown": Color("#445566"),
}
const SECTOR_LABELS: Dictionary = {
"north_reach": "NORTH REACH",
"south_reach": "SOUTH REACH",
"east_reach": "EAST REACH",
"west_reach": "WEST REACH",
}
# Quadrant angles for sector placement (radians, 0 = right, counterclockwise)
# North = top (-PI/2), East = right (0), South = bottom (PI/2), West = left (PI)
const SECTOR_ANGLE_CENTER: Dictionary = {
"north_reach": -PI / 2.0,
"east_reach": 0.0,
"south_reach": PI / 2.0,
"west_reach": PI,
}
const SECTOR_ANGLE_SPREAD: float = PI / 2.5 # each sector occupies ~72° of arc
const CORE_ANGLE_SPREAD: float = TAU # core systems spread full circle
const DEEP_FRONTIER_ANGLE_SPREAD: float = TAU # deep frontier wraps entire outer edge
# Pan/zoom
const ZOOM_MIN: float = 0.3
const ZOOM_MAX: float = 3.0
const ZOOM_STEP: float = 0.15
# Internal state
var _nodes: Array = []
var _edges: Array = []
var _node_positions: Dictionary = {} # system_id -> Vector2 (screen coords relative to map center)
var _node_lookup: Dictionary = {} # system_id -> node dict
var _selected_system: String = ""
var _hovered_system: String = ""
var _zoom: float = 1.0
var _pan_offset: Vector2 = Vector2.ZERO
var _is_panning: bool = false
var _pan_start: Vector2 = Vector2.ZERO
var _pan_start_offset: Vector2 = Vector2.ZERO
var _data_loaded: bool = false
var _insert_active: bool = true
var _dirty: bool = true # redraw needed — set by state changes, cleared after _draw
func _ready() -> void:
mouse_filter = Control.MOUSE_FILTER_STOP
_load_data()
if _data_loaded:
_compute_layout()
func _process(_delta: float) -> void:
if not visible:
return
if _dirty and _data_loaded:
queue_redraw()
_dirty = false
## Called from main.gd when insert state changes.
## Only force-hides when insert is inactive. Does NOT auto-show — star map is
## modal (player opens via toggle_visible()), not always-on like the minimap.
func set_insert_active(active: bool) -> void:
_insert_active = active
if not active:
visible = false
## Toggle visibility (e.g., from a keybind or button).
func toggle_visible() -> void:
visible = not visible
if visible:
_dirty = true
## Return the currently selected system data, or empty dict.
func get_selected_system() -> Dictionary:
return _node_lookup.get(_selected_system, {})
## Return total system count.
func get_system_count() -> int:
return _nodes.size()
## Return total edge count.
func get_edge_count() -> int:
return _edges.size()
# =============================================================================
# Data loading
# =============================================================================
func _load_data() -> void:
if not FileAccess.file_exists(DATA_PATH):
push_warning("StarMapRenderer: data file not found at %s" % DATA_PATH)
return
var file := FileAccess.open(DATA_PATH, FileAccess.READ)
if file == null:
push_warning("StarMapRenderer: could not open %s" % DATA_PATH)
return
var parsed: Variant = JSON.parse_string(file.get_as_text())
file.close()
if not (parsed is Dictionary):
push_warning("StarMapRenderer: invalid JSON in %s" % DATA_PATH)
return
var data := parsed as Dictionary
_nodes = data.get("nodes", [])
_edges = data.get("edges", [])
for node: Dictionary in _nodes:
_node_lookup[node.get("system_id", "")] = node
_data_loaded = true
# =============================================================================
# Layout — place systems on concentric rings by hop distance
# =============================================================================
func _compute_layout() -> void:
_node_positions.clear()
# Group nodes by hop distance
var rings: Dictionary = {} # hop -> Array of nodes
for node: Dictionary in _nodes:
var hop: int = int(node.get("hop_distance", 0))
if not rings.has(hop):
rings[hop] = []
rings[hop].append(node)
# Place each ring
for hop: int in rings:
var ring_nodes: Array = rings[hop]
var radius: float = MIN_RING_RADIUS + hop * RING_SPACING
if hop == 0:
# Gateway at center
for node: Dictionary in ring_nodes:
_node_positions[node["system_id"]] = Vector2.ZERO
continue
# Sort nodes within ring by sector for angular grouping
ring_nodes.sort_custom(_sort_by_sector_angle)
# Distribute nodes within their sector's angular range
var sector_groups: Dictionary = {}
for node: Dictionary in ring_nodes:
var sector: String = node.get("geographic_sector", "unknown")
if not sector_groups.has(sector):
sector_groups[sector] = []
sector_groups[sector].append(node)
for sector: String in sector_groups:
var group: Array = sector_groups[sector]
var count: int = group.size()
var center_angle: float
var spread: float
if sector == "core":
center_angle = 0.0
spread = CORE_ANGLE_SPREAD
elif sector == "deep_frontier":
center_angle = 0.0
spread = DEEP_FRONTIER_ANGLE_SPREAD
elif SECTOR_ANGLE_CENTER.has(sector):
center_angle = SECTOR_ANGLE_CENTER[sector]
spread = SECTOR_ANGLE_SPREAD
else:
center_angle = 0.0
spread = TAU
# Distribute evenly within sector arc, with deterministic offset per system
for i: int in range(count):
var node: Dictionary = group[i]
var t: float
if count == 1:
t = 0.0
else:
t = float(i) / float(count) - 0.5 # -0.5 to +0.5
var angle: float = center_angle + t * spread
# Add small per-node jitter based on system_id hash for visual variety
var jitter: float = _system_hash(node["system_id"]) * 0.08
angle += jitter
# Slight radial variation to avoid perfect circles
var r_var: float = radius + _system_hash(node["system_id"] + "r") * RING_SPACING * 0.3
_node_positions[node["system_id"]] = Vector2(cos(angle), sin(angle)) * r_var
func _sort_by_sector_angle(a: Dictionary, b: Dictionary) -> bool:
var sa: float = _sector_sort_key(a)
var sb: float = _sector_sort_key(b)
if sa != sb:
return sa < sb
return a.get("system_id", "") < b.get("system_id", "")
func _sector_sort_key(node: Dictionary) -> float:
var sector: String = node.get("geographic_sector", "unknown")
match sector:
"core": return 0.0
"north_reach": return 1.0
"east_reach": return 2.0
"south_reach": return 3.0
"west_reach": return 4.0
"deep_frontier": return 5.0
_: return 6.0 # gdlint:ignore = max-returns
## Deterministic float in [-1, 1] from a string key.
func _system_hash(key: String) -> float:
var h: int = key.hash() & 0x7FFFFFFF # mask to 31-bit positive range
return float(h) / 2147483647.0 * 2.0 - 1.0
# =============================================================================
# Drawing
# =============================================================================
func _draw() -> void:
if not _data_loaded:
return
var sz: Vector2 = get_rect().size
var center: Vector2 = sz * MAP_CENTER_FRACTION + _pan_offset
# Background
draw_rect(Rect2(Vector2.ZERO, sz), COLOR_BG)
# Hop rings (concentric circles)
_draw_rings(center)
# Sector labels
_draw_sector_labels(center)
# Edges — only draw from selected system (UX rule: full edge web is too dense)
if _selected_system != "":
_draw_edges(center)
# System dots
_draw_systems(center)
# Selection highlight
if _selected_system != "":
_draw_selection(center)
# Info panel for selected system
if _selected_system != "":
_draw_info_panel(sz)
# Title
_draw_title()
func _draw_rings(center: Vector2) -> void:
for hop: int in range(MAX_HOP_RINGS + 1):
var radius: float = (MIN_RING_RADIUS + hop * RING_SPACING) * _zoom
if radius < 1.0 or radius > 2000.0:
continue
var color: Color = COLOR_RING_MAJOR if hop % 5 == 0 else COLOR_RING
draw_arc(center, radius, 0.0, TAU, 64, color, 0.5 if hop % 5 == 0 else 0.3, true)
func _draw_sector_labels(center: Vector2) -> void:
var label_radius: float = (MIN_RING_RADIUS + 12 * RING_SPACING) * _zoom
for sector: String in SECTOR_LABELS:
var angle: float = SECTOR_ANGLE_CENTER.get(sector, 0.0)
var pos: Vector2 = center + Vector2(cos(angle), sin(angle)) * label_radius
var label: String = SECTOR_LABELS[sector]
var color: Color = SECTOR_COLORS.get(sector, COLOR_TEXT_DIM)
var font := get_theme_default_font()
var font_size: int = 10
var text_size: Vector2 = font.get_string_size(label, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size)
draw_string(font, pos - text_size / 2.0, label, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size, color)
func _draw_systems(center: Vector2) -> void:
for node: Dictionary in _nodes:
var sid: String = node.get("system_id", "")
if not _node_positions.has(sid):
continue
var pos: Vector2 = center + _node_positions[sid] * _zoom
var sector: String = node.get("geographic_sector", "unknown")
var topology: String = node.get("gate_topology", "")
var color: Color = SECTOR_COLORS.get(sector, COLOR_TEXT_DIM)
var radius: float = _dot_radius(topology)
# Gateway gets special treatment
if node.get("is_gateway", false):
color = COLOR_GATEWAY
radius = GATEWAY_RADIUS
# Dim deep frontier slightly
if sector == "deep_frontier":
color.a = 0.7
# Hover highlight
if sid == _hovered_system and sid != _selected_system:
draw_arc(pos, radius + 3.0, 0.0, TAU, 16, Color(color.r, color.g, color.b, 0.4), 1.0, true)
draw_circle(pos, radius, color)
func _draw_edges(center: Vector2) -> void:
# Only draw edges connected to the selected system (full web is unreadable at 301 systems)
var node: Dictionary = _node_lookup.get(_selected_system, {})
var adj: Array = node.get("adjacent_systems", [])
if adj.is_empty():
return
var color := Color(COLOR_TEXT.r, COLOR_TEXT.g, COLOR_TEXT.b, EDGE_SELECTED_ALPHA)
var sel_pos: Vector2 = center + _node_positions.get(_selected_system, Vector2.ZERO) * _zoom
for neighbor_id: String in adj:
if not _node_positions.has(neighbor_id):
continue
var neighbor_pos: Vector2 = center + _node_positions[neighbor_id] * _zoom
draw_line(sel_pos, neighbor_pos, color, EDGE_WIDTH, true)
func _draw_selection(center: Vector2) -> void:
if not _node_positions.has(_selected_system):
return
var pos: Vector2 = center + _node_positions[_selected_system] * _zoom
draw_arc(pos, SELECTION_RING_RADIUS, 0.0, TAU, 24, COLOR_SELECTION, 1.2, true)
# Draw label next to selection
var node: Dictionary = _node_lookup.get(_selected_system, {})
var label: String = node.get("proper_name", _selected_system)
if label.is_empty():
label = _selected_system
var font := get_theme_default_font()
draw_string(font, pos + Vector2(SELECTION_RING_RADIUS + 4, 4), label,
HORIZONTAL_ALIGNMENT_LEFT, -1, 12, COLOR_SELECTION)
func _draw_info_panel(sz: Vector2) -> void:
var node: Dictionary = _node_lookup.get(_selected_system, {})
if node.is_empty():
return
var font := get_theme_default_font()
var panel_w: float = POPUP_WIDTH
var pad: float = POPUP_PADDING
var inner_w: float = panel_w - pad * 2.0
# Measure GTTR text height first so we can size the panel
var gttr: String = node.get("gttr_excerpt", "")
# Strip markdown bold markers for display
gttr = gttr.replace("**", "")
var gttr_block_h: float = 0.0
if not gttr.is_empty():
var gttr_size: Vector2 = font.get_multiline_string_size(
gttr, HORIZONTAL_ALIGNMENT_LEFT, inner_w, POPUP_GTTR_FONT_SIZE,
POPUP_GTTR_MAX_LINES)
gttr_block_h = gttr_size.y + 6.0
# Fixed rows: name, id, star+hop, corridor, bodies+pop, adjacent, separator lines
var fixed_h: float = (
POPUP_LINE_H * 2.0 # name + id
+ 4.0 # separator
+ POPUP_LINE_H * 3.0 # star+hop, corridor, bodies+pop
+ 4.0 # separator
+ gttr_block_h
+ 4.0 # separator (before adjacent)
+ POPUP_LINE_H # adjacent systems label
+ pad * 2.0
)
var panel_h: float = fixed_h
var panel_pos := Vector2(sz.x - panel_w - POPUP_MARGIN, POPUP_MARGIN)
var x: float = panel_pos.x + pad
var y: float = panel_pos.y + pad + POPUP_LINE_H # baseline offset
# Background + border
draw_rect(Rect2(panel_pos, Vector2(panel_w, panel_h)), COLOR_INFO_BG)
draw_rect(Rect2(panel_pos, Vector2(panel_w, panel_h)),
Color(COLOR_TEXT.r, COLOR_TEXT.g, COLOR_TEXT.b, 0.15), false, 1.0)
# ── System name ──────────────────────────────────────────────────────────
var sys_name: String = node.get("proper_name", "")
if sys_name.is_empty():
sys_name = node.get("system_id", "Unknown")
draw_string(font, Vector2(x, y), sys_name, HORIZONTAL_ALIGNMENT_LEFT, inner_w, 15, COLOR_TEXT)
y += POPUP_LINE_H
draw_string(font, Vector2(x, y), node.get("system_id", ""), HORIZONTAL_ALIGNMENT_LEFT, inner_w, 10, COLOR_TEXT_DIM)
y += POPUP_LINE_H
# Separator
draw_line(Vector2(x, y + 1.0), Vector2(panel_pos.x + panel_w - pad, y + 1.0),
Color(COLOR_TEXT.r, COLOR_TEXT.g, COLOR_TEXT.b, 0.12), 1.0)
y += 6.0
# ── Stats block ──────────────────────────────────────────────────────────
var star_type: String = node.get("star_type", "")
var hop: int = int(node.get("hop_distance", 0))
var stat_line_1: String
if star_type.is_empty():
stat_line_1 = "Hop %d from Gateway" % hop
else:
stat_line_1 = "%s star · hop %d" % [star_type, hop]
draw_string(font, Vector2(x, y), stat_line_1, HORIZONTAL_ALIGNMENT_LEFT, inner_w, 11, COLOR_TEXT_DIM)
y += POPUP_LINE_H
var sector_str: String = node.get("geographic_sector", "unknown").replace("_", " ").to_upper()
var sector_color: Color = SECTOR_COLORS.get(node.get("geographic_sector", ""), COLOR_TEXT_DIM)
draw_string(font, Vector2(x, y), sector_str + " corridor", HORIZONTAL_ALIGNMENT_LEFT, inner_w, 11, sector_color)
y += POPUP_LINE_H
var bodies: String = node.get("bodies", "")
var population: String = node.get("population", "")
var stat_line_3: String
if not bodies.is_empty() and not population.is_empty():
stat_line_3 = "%s · pop %s" % [bodies, population]
elif not bodies.is_empty():
stat_line_3 = bodies
elif not population.is_empty():
stat_line_3 = "Population: " + population
else:
stat_line_3 = "%d aperture%s · %s" % [
int(node.get("aperture_count", 0)),
"s" if int(node.get("aperture_count", 0)) != 1 else "",
node.get("gate_topology", "")]
draw_string(font, Vector2(x, y), stat_line_3, HORIZONTAL_ALIGNMENT_LEFT, inner_w, 11, COLOR_TEXT_DIM)
y += POPUP_LINE_H
# ── GTTR excerpt ─────────────────────────────────────────────────────────
if not gttr.is_empty():
draw_line(Vector2(x, y + 1.0), Vector2(panel_pos.x + panel_w - pad, y + 1.0),
Color(COLOR_TEXT.r, COLOR_TEXT.g, COLOR_TEXT.b, 0.12), 1.0)
y += 6.0
draw_multiline_string(font, Vector2(x, y), gttr, HORIZONTAL_ALIGNMENT_LEFT,
inner_w, POPUP_GTTR_FONT_SIZE, POPUP_GTTR_MAX_LINES,
Color(COLOR_TEXT.r, COLOR_TEXT.g, COLOR_TEXT.b, 0.75))
y += gttr_block_h
# ── Adjacent systems ──────────────────────────────────────────────────────
draw_line(Vector2(x, y + 1.0), Vector2(panel_pos.x + panel_w - pad, y + 1.0),
Color(COLOR_TEXT.r, COLOR_TEXT.g, COLOR_TEXT.b, 0.12), 1.0)
y += 6.0
var adj: Array = node.get("adjacent_systems", [])
if adj.is_empty():
draw_string(font, Vector2(x, y), "No gate connections", HORIZONTAL_ALIGNMENT_LEFT, inner_w, 10, COLOR_TEXT_DIM)
else:
var adj_names: Array = []
for neighbor_id: String in adj:
var neighbor: Dictionary = _node_lookup.get(neighbor_id, {})
var n_name: String = neighbor.get("proper_name", "")
adj_names.append(n_name if not n_name.is_empty() else neighbor_id)
var adj_line: String = " · ".join(adj_names)
draw_multiline_string(font, Vector2(x, y), adj_line, HORIZONTAL_ALIGNMENT_LEFT,
inner_w, 10, 2, COLOR_TEXT_DIM)
func _draw_title() -> void:
var font := get_theme_default_font()
draw_string(font, Vector2(16, 28), "THE REACH — NAVIGATOR", HORIZONTAL_ALIGNMENT_LEFT, -1, 16, COLOR_TEXT)
draw_string(font, Vector2(16, 44), "Concord Assembly Gate Network — %d Systems" % _nodes.size(),
HORIZONTAL_ALIGNMENT_LEFT, -1, 10, COLOR_TEXT_DIM)
func _dot_radius(topology: String) -> float:
match topology:
"hub": return DOT_RADIUS_HUB
"junction": return DOT_RADIUS_JUNCTION
"dead_end": return DOT_RADIUS_DEAD_END
_: return DOT_RADIUS_DEFAULT
# =============================================================================
# Input — selection, pan, zoom
# =============================================================================
func _gui_input(event: InputEvent) -> void:
if event is InputEventMouseButton:
var mb := event as InputEventMouseButton
if mb.pressed:
match mb.button_index:
MOUSE_BUTTON_LEFT:
_handle_click(mb.position)
MOUSE_BUTTON_MIDDLE:
_is_panning = true
_pan_start = mb.position
_pan_start_offset = _pan_offset
MOUSE_BUTTON_WHEEL_UP:
var old_zoom := _zoom
_zoom = clampf(_zoom + ZOOM_STEP, ZOOM_MIN, ZOOM_MAX)
if _zoom != old_zoom:
_dirty = true
MOUSE_BUTTON_WHEEL_DOWN:
var old_zoom := _zoom
_zoom = clampf(_zoom - ZOOM_STEP, ZOOM_MIN, ZOOM_MAX)
if _zoom != old_zoom:
_dirty = true
else:
if mb.button_index == MOUSE_BUTTON_MIDDLE:
_is_panning = false
elif event is InputEventMouseMotion:
var mm := event as InputEventMouseMotion
if _is_panning:
_pan_offset = _pan_start_offset + (mm.position - _pan_start)
_dirty = true
else:
_update_hover(mm.position)
## Find the system_id of the nearest node to screen position, or "" if none within HIT_RADIUS.
func _find_nearest_system(pos: Vector2) -> String:
var sz: Vector2 = get_rect().size
var center: Vector2 = sz * MAP_CENTER_FRACTION + _pan_offset
var best_dist: float = HIT_RADIUS
var best_sid: String = ""
for node: Dictionary in _nodes:
var sid: String = node.get("system_id", "")
if not _node_positions.has(sid):
continue
var node_pos: Vector2 = center + _node_positions[sid] * _zoom
var dist: float = pos.distance_to(node_pos)
if dist < best_dist:
best_dist = dist
best_sid = sid
return best_sid
func _handle_click(pos: Vector2) -> void:
var nearest := _find_nearest_system(pos)
_selected_system = nearest
_dirty = true
func _update_hover(pos: Vector2) -> void:
var nearest := _find_nearest_system(pos)
if nearest != _hovered_system:
_hovered_system = nearest
_dirty = true
+18
View File
@@ -0,0 +1,18 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://ui/star_map.gd" id="1_starmap"]
; #674: Star map insert module — concentric hop-ring view of the Settled Reach gate network.
; Sector-colored, interactive selection, pan/zoom. Accessible from the insert UI.
; Data source: res://data/star_map_data.json (enriched from star-map.json + systems.db).
; Positioned as full-size overlay. Toggle visibility via set_insert_active() or toggle_visible().
[node name="StarMapRenderer" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 1
script = ExtResource("1_starmap")
+11 -1
View File
@@ -604,4 +604,14 @@ How narrative, NPCs, and world content are created: content tiers, NPC generatio
---
*45 decisions. Last updated: 2026-03-24 (D-104, D-105, D-107 supersession notes added; D-167 corridor cultural system filed)*
### D-168: Iserlohn (GJ-532c) planet name — keep, real-city origin documented
- **Date:** 2026-04-05
- **Decision:** The planet name "Iserlohn" (GJ-532c, in the Kettenschmied system) is **retained**. The name derives from Iserlohn, a real industrial city in the Sauerland region of North Rhine-Westphalia, Germany — historically a centre of wire-drawing, chain manufacturing, and metal fabrication. This origin is directly coherent with Kettenschmied's identity as a west_reach German-heritage metalworking community. The real-city reading is the intended and primary reading.
- **IP note:** Iserlohn Fortress is an iconic location in *Legend of the Galactic Heroes* (Ginga Eiyuu Densetsu). The name collision is acknowledged. The decision to retain is deliberate: the real-world German city predates the anime by centuries; real geographic names are not protectable; and the concept diverges fundamentally — a cold marginal mining/fabrication planet (1,000 population, component fabrication, asteroid mining) is the structural opposite of a strategic military megastructure. No reader can point at GJ-532c and say "that's the LoGH fortress" — the contexts share only a name.
- **Preferred alternative on record (if future team prefers a rename):** **Altena** — a Sauerland city at the confluence of Lenne and Volme rivers, historically the actual birthplace of German wire-drawing and chain manufacture. The Altena castle became the site of the first industrial wire-drawing operation in the German states. Altena has no known SF franchise association and carries stronger historical precision for chain/cable fabrication. It would require an atlas rename of `GJ532c` and corresponding wiki edits.
- **Raised by:** Miri (Sprint 31, ticket #773)
- **Dissent:** None.
---
*46 decisions. Last updated: 2026-04-05 (D-168 Iserlohn IP evaluation filed)*
+1 -1
View File
@@ -1,7 +1,7 @@
{
"system_id": "GJ 1111",
"proper_name": "Zenzele",
"star_type": "unusual",
"star_type": "M",
"spectral_class": "M6.5",
"wiki_data": {
"habitable_count": 1,
+13 -13
View File
@@ -1,11 +1,11 @@
{
"system_id": "GJ 3943",
"proper_name": "GJ 3943",
"star_type": "K+M",
"proper_name": "Brandpunt",
"star_type": "binary",
"spectral_class": "K5V+M3V",
"wiki_data": {
"habitable_count": 1,
"inhabited_count": 0,
"inhabited_count": 1,
"has_gas_giant": false,
"has_asteroid_belt": true,
"has_horizon_station": true,
@@ -14,12 +14,12 @@
"bodies": [
{
"body_id": "GJ3943b",
"proper_name": null,
"proper_name": "Skuiling",
"body_type": "planet",
"orbit_index": 1,
"parent_body_id": null,
"inhabited": false,
"population": null,
"inhabited": true,
"population": 1800,
"mass_class": "terrestrial",
"surface_gravity": 0.62,
"orbital_period_days": 130.0,
@@ -27,10 +27,10 @@
"atmosphere": "thin",
"biome_summary": "cold_arid",
"hydrosphere": "ice",
"economic_role": null,
"settlement_pattern": null,
"industrial_corridor": null,
"notes": "inner HZ marginal; thin atmosphere, low gravity, polar ice deposits; within parameters for emergency habitation only; not recommended for permanent settlement; unsettled"
"economic_role": "research",
"settlement_pattern": "urban_concentrated",
"industrial_corridor": "research_export",
"notes": "polar ice extraction provides water supply and habitat foundation; pressurized dome settlements anchored to ice-extraction framework; Agricultural Spectrum Research Compact facilities; primary K5V+M3V binary oscillation testing environment; 1,800 permanent residents plus rotating research cohort from member communities"
},
{
"body_id": "GJ3943-belt",
@@ -176,14 +176,14 @@
"stations": [
{
"station_id": "GJ3943-oort-S1",
"proper_name": "GJ 3943 Horizon",
"proper_name": "Brandpunt Horizon",
"orbits_body_id": "GJ3943-oort",
"station_type": "horizon",
"population": 0,
"population": 120,
"economic_role": "transit",
"docking_class": "major",
"has_gate_infrastructure": true,
"notes": "horizon station — 2 apertures; through_route; connects Pedra Seca and GJ 546; unsettled gap in corridor; k-m transitional binary (shifting light registers as color oscillation on viewport); automated; Gate Corporation biennial maintenance"
"notes": "horizon station — 2 apertures; through_route; connects Pedra Seca and Eerste Wacht; wave_5 settlement; Agricultural Spectrum Research Compact operations; Commission research contract; researcher rotation transit; K5V+M3V binary (shifting light registers as color oscillation on viewport)"
}
]
}
+2 -2
View File
@@ -1,8 +1,8 @@
{
"system_id": "GJ 4056",
"proper_name": "GJ 4056",
"star_type": "unusual",
"spectral_class": "g",
"star_type": "M",
"spectral_class": "M4V",
"wiki_data": {
"habitable_count": 0,
"inhabited_count": 0,
+5 -5
View File
@@ -44,12 +44,12 @@
"surface_gravity": 0.89,
"orbital_period_days": 200.0,
"rotation_period_hours": 24.8,
"atmosphere": "breathable",
"atmosphere": "standard",
"biome_summary": "temperate",
"hydrosphere": "rivers-lakes",
"hydrosphere": "liquid_water",
"economic_role": "agricultural",
"settlement_pattern": "distributed-rural",
"industrial_corridor": "west_reach",
"settlement_pattern": "dispersed",
"industrial_corridor": "agricultural_export",
"notes": "primary inhabited world; K2V habitable zone; genuine agricultural potential found by founding pastoral cooperative; temperate conditions, good soil, river-valley farmland; Wave 3 settlement ~300 years old; population peaked ~80 years ago and in slow consistent decline as young adults leave for inner-corridor systems offering more economic variety; self-governing council of landholders (reformed ~150 years ago to include non-agricultural members); no Assembly or Compact presence; Gate Corporation maintenance rotation is extent of institutional contact"
},
{
@@ -165,7 +165,7 @@
"orbital_period_days": 27.8,
"rotation_period_hours": 667.2,
"atmosphere": "none",
"biome_summary": "ice",
"biome_summary": "frozen",
"hydrosphere": null,
"economic_role": null,
"settlement_pattern": null,
+1 -1
View File
@@ -70,7 +70,7 @@
"economic_role": "agricultural",
"settlement_pattern": "dispersed",
"industrial_corridor": "agricultural_export",
"notes": "primary inhabited world; wave_1 German heritage ('edge'); 2-aperture through_route; F2III bright giant — hot equator, settlement in polar and temperate zones; open-field agriculture with substantial surplus; light manufacturing (four centuries institutional investment); Council of Rand governance (nine seats, staggered terms, founding charter continuity); Compact of Westphalia founding member and treaty drafter; five centuries continuous self-governance; oldest governing body in outer west"
"notes": "primary inhabited world; wave_1 German heritage ('edge'); 2-aperture through_route; F2III bright giant — significantly more UV-intense than a main-sequence F; equatorial zones inhospitable without shielding, settlement concentrated in polar and high-latitude temperate regions; star is post-main-sequence and progressing toward giant phase on geological timescales, but current habitability is stable for millions of years; open-field agriculture with substantial surplus; light manufacturing (four centuries institutional investment); Council of Rand governance (nine seats, staggered terms, founding charter continuity); Compact of Westphalia founding member and treaty drafter; five centuries continuous self-governance; oldest governing body in outer west"
},
{
"body_id": "GJ601A-belt",
+2 -2
View File
@@ -1,8 +1,8 @@
{
"system_id": "GJ 707",
"proper_name": "Dois Sóis",
"star_type": "unusual",
"spectral_class": "binary",
"star_type": "binary",
"spectral_class": "K4V+K8V",
"wiki_data": {
"habitable_count": 1,
"inhabited_count": 1,
+141
View File
@@ -0,0 +1,141 @@
# Cultural Stereotyping Audit — The Settled Reach Wiki
**Created:** 2026-04-05
**Sprint:** 31
**Ticket:** #766
**Scope:** 229 wiki entries across 5 sectors (301 total systems; ~72 unnamed)
---
## Purpose
This audit examines the cultural representation patterns in the Settled Reach wiki entries. The concern: the wiki's founding-culture palette is heavily weighted toward Portuguese, Afrikaans, German, Zulu, and Dutch community archetypes, each tending to reproduce a narrow set of social patterns. Systems should reflect the full range of what a culture can do — not just its stereotype.
**Stereotyping definition:** A cultural group is "stereotyped" when all or nearly all of its systems in the wiki confirm the same narrow cultural pattern. A "subversion" is a system with that cultural heritage that does something the stereotype does not predict.
**Sprint 30 baseline:** Afrikaans flagged as worst at ~15:1 confirming:subverting ratio, with Brandpunt (GJ-3943) added as first deliberate subversion.
---
## Top 3 Most Imbalanced Groups — Sprint 31 Findings
### 1. Afrikaans / Dutch (West/South Reach)
**Estimated ratio at Sprint 31 start:** ~15:1
**Stereotyping pattern:** Frontier farming cooperatives with conservative self-sufficient governance. Dutch/Afrikaner family networks. Practical, independent, pre-industrial in character. Relationship to land and labor as primary identity.
**Named Afrikaans/Dutch systems (partial):**
- Traagwater, Breëvlei, Wagtoring, Groenland, Kaapse Baai, Stilwater, Bestevaer, Schuilhoek, Dokkum, Vuurkloof
- Deep frontier: Pedra Seca, Droëland, Eerste Wacht, Skuilplek, Ouplaas (agricultural farming communities)
**Existing subversions:**
- Brandpunt (GJ-3943) — agricultural research collective (Sprint 30)
- Kaapse Baai (GJ-213) — Cape Malay spice culture, cosmopolitan commercial identity
- Wagtoring (GJ-410) — secret military/surveillance outpost, not farming
**Sprint 31 additions:**
- **Breëvlei (GJ-661B)** — enriched: ecological research community organized around pre-settlement marsh stakes; farming is the economic substrate but xenobiology is the community's driving purpose
- **Bestevaer (GJ-103)** — enriched: Dutch maritime culture organized around an unexplained deep-ocean restricted zone ("De Grens"); marine engineering program covertly building capacity to investigate
**Remaining ratio estimate:** ~12:5 (improving; further work needed)
---
### 2. German / West Reach
**Estimated ratio at Sprint 31 start:** ~20:0 (no identified subversions)
**Stereotyping pattern:** Industrial/manufacturing cooperatives, mining operations, agricultural systems. Practical work ethic, guild-structure governance, Compact of Westphalia affiliation. Quality craftsmanship as practical output, not aesthetic pursuit.
**Named German systems (partial):**
- Kettenschmied, Hansestadt, Neustadt, Altmark, Marktfeld, Bergtor, Freiholt, Steinfeld, Lichtung, Haltefenn, Lichtbrücke, Knotenpunkt, Weitblick, Brückenau, Grenzstein, Ostmark, Eisfeld, Dunkelholz, Gammelstad, Sonnwend, Yttermark, Echternach, Langemark
**Existing subversions:** None confirmed. (Seiðrmaðr is Scandinavian/Norse, not German; already subversive via tech company.)
**Sprint 31 additions:**
- **Lichtung (GJ-356A)** — full rewrite: artists' colony founded by a cultural collective who came specifically for the extraordinary F-type light environment; no industrial or agricultural focus; exports art, residencies, light-recordings
- **Haltefenn (GJ-1248)** — enriched: 400-year-old bog-fuel community whose craft tradition (Moorlesen — peat-layer reading as artistic practice) has become economically significant; inner-orbit antiquarian collectors now represent a larger revenue stream than peat fuel
**Remaining ratio estimate:** ~21:2 (still heavily imbalanced; requires continued work)
---
### 3. Portuguese / South Reach
**Estimated ratio at Sprint 31 start:** ~14:1 (estimated)
**Stereotyping pattern:** Agricultural and fishing communities. Extended family/syndic networks. Relational governance by personal trust rather than institutional procedure. Warm-climate cultivation, emotional expressiveness, saudade-inflected place names.
**Named Portuguese/Iberian/South_reach systems (partial):**
- Caparica, Carvalhais, Confluência, Ribeiro's Star, Ribeirão, Nascente, Espinho, Quilombo, Velha Guarda, Aguas Bravas, Rionegro, Fragua, Puerto Último, Mirante, Roça Nova, Morales' End, Okahandja
**Existing subversions (underrecognized):**
- Espinho (GJ-1075) — Portuguese-named but NOT agricultural; stellar research community, CME observatory, science publishing (clearly subversive of the farming stereotype but not flagged)
- Quilombo (GJ-423B) — Afro-Brazilian self-governance tradition; explicitly political, non-familial governance model; "we do not ask for permission to exist"
- Fragua (GJ-567) — Spanish heritage but industrial adaptation workshop, not farming
- Puerto Último (GJ-197) — Spanish agricultural community facing transformation through new connectivity; complex identity negotiation
**Sprint 31 additions:**
- **Espinho (GJ-1075)** — enriched: made the science/research identity and the departure from south_reach farming stereotype explicit in the text; the community's distinctiveness from neighboring Portuguese systems is now on the page
- **Nascente (GJ-2097)** — enriched: the Irmandade do Alcance's four centuries of transit record-keeping reframed as a commercial dynasty; the mutual aid society is a cover for an organization that has accumulated trade intelligence across the entire south_reach corridor
**Remaining ratio estimate:** ~10:6 (better balanced; Espinho and Quilombo already present as subversions)
---
## Other Cultural Groups — Representation Assessment
### Underrepresented groups (ticket #766 scope)
The following cultural groups appear in very few or no wiki systems, requiring wiki-level authoring before corridor balance can improve:
| Group | Current presence | Status |
|-------|-----------------|--------|
| Italian | 1 (Nova Roma — named but culturally Germanic industrial) | Near-zero presence |
| Greek | 0 confirmed | Absent |
| Maghreb / North African | 0 confirmed | Absent |
| Caribbean | 0 confirmed | Absent |
| Pacific Island | 1-2 (Rekohu = Māori name; Mabuhay/Dagat = Filipino) | Minimal |
| Indigenous American | 1 (Athabasca = Canadian Indigenous toponym) | Minimal |
| West African (non-Igbo) | 2-3 (Kumasi, Kampala Gate, Enugu) | Thin |
| Middle Eastern / Arab | 1 (Dalim = Arabic/Persian for moon) | Minimal |
| Central European (Polish, Czech) | 2-3 (Kraków Junction, Nová Tržnice, Nowa Huta) | Thin |
These groups require new named system proposals and wiki entries, which is beyond the Sprint 31 scope but should feed into Sprint 32+ work.
### Well-represented groups (may not need more subversions before balance in underrepresented groups is addressed)
- East Asian (Chinese, Korean, Japanese, Vietnamese): Good spread, multiple subversions visible
- South Asian (Indian, Sikh, Bangladeshi): Present at several hops, growing
- Commonwealth (British/Australian/Canadian/South African): Strong north_reach presence
- Scandinavian: West reach, good density; Seiðrmaðr is existing subversion
---
## Recommendations for Follow-up Work
### Immediate (Sprint 32)
1. **Italian representation** — The south_reach and core systems have essentially zero Italian cultural presence despite the demographic plausibility of Italian settlement. Nova Roma's name is Italian but the cultural subtext is Germanic. Recommend: author 2-3 Italian-heritage systems. Suggested archetypes: artistic dynasty (fashion/design house operating across the Reach), engineering family that rivals MVG in specific niche, culinary culture built around terraformed viticulture.
2. **Greek representation** — Completely absent. Plausible: east_reach academic or mathematical community; astronomical heritage community (Greeks have deep historical astronomy associations); merchant trading family at a through_route junction.
3. **Maghreb/North African** — Completely absent. Plausible: Trans-Saharan trading tradition adapted to deep frontier transit; Berber or Amazigh mountain-culture heritage on a high-altitude world; Islamic academic institution.
### Medium-term (Sprint 32-33)
- Caribbean representation (Afro-Caribbean, Indo-Caribbean)
- Pacific Island representation (Māori, Tongan, Samoan, Hawaiian)
- Central American and Andean (distinct from Portuguese/Spanish South_reach pattern)
### Corridor rule reminder
**Corridors are tendencies, not borders.** Every direction needs cross-cultural names. An Italian or Greek community in the west_reach is not an anomaly — it is the expected texture of a Reach where people followed economic opportunity rather than cultural clustering instructions. New names in underrepresented groups should appear across multiple corridors, not concentrated in a single area.
---
## Sprint 31 Deliverables Summary
| System | GJ ID | Cultural Group | Subversion Type | Action |
|--------|-------|----------------|-----------------|--------|
| Breëvlei | GJ-661B | Afrikaans | Scientific/xenobiology research | Enriched |
| Bestevaer | GJ-103 | Dutch/Afrikaans | Maritime mystery culture | Enriched |
| Lichtung | GJ-356A | German | Artists' colony | Full rewrite |
| Haltefenn | GJ-1248 | German | Craft/artistic tradition as primary identity | Enriched |
| Espinho | GJ-1075 | Portuguese | Science research hub (made explicit) | Enriched |
| Nascente | GJ-2097 | Portuguese | Commercial dynasty via transit records | Enriched |
D-168 filed: Iserlohn (GJ-532c) planet name IP evaluation — retained, real-city origin documented.
Binary file not shown.
+57
View File
@@ -0,0 +1,57 @@
# Sprint 31: Bedrock — CI Tasks
**Goal:** Complete Phase 1 wiki content (cultural sweep, GTTR depth, star map popups) and refactor accumulated complexity in atlas.rs, main.gd, and Python tooling.
**Branch:** `sprint-31/ci`
**Agents:** Justine (build/deploy), Hoshe (QA)
## New Tickets
| # | Title | Blocked by |
|---|-------|------------|
| #777 | Add ruff linting + shared DB path module for Python scripts | — |
Use `tooling/db/ticket show <id>` for full details.
## Key Decisions
- `decisions/process.md` — development process decisions
## Notes
### #777 ruff linting + shared DB path module (refactor)
Python scripts in `tooling/db/` lack linting and share duplicated patterns.
**Deliver:**
1. **pyproject.toml** at repo root with ruff config (line length, target Python version, rule selection)
2. **Shared module** `tooling/db/common.py` extracting:
- `resolve_db_path()` — uses `PROJECT_DB_PATH` env var with parent-dir fallback
- `load_config()` — reads `tooling/db/config.json`
- `get_connection()` — returns WAL-mode SQLite connection
3. **Update all consumers**`sqlite_connector.py`, `ticket`, `sprint`, `decisions_sync.py` to import from `common.py` instead of duplicating
4. **Pre-push hook integration** — add `ruff check tooling/` to the pre-push hook alongside GDScript and Rust checks
5. **Makefile target**`make lint-python` running ruff
**Key files:**
- `tooling/db/sqlite_connector.py` — current DB path resolution
- `tooling/db/ticket` — duplicated resolve pattern
- `tooling/db/sprint` — duplicated resolve pattern
- `tooling/db/decisions_sync.py` — duplicated resolve pattern
- `tooling/db-backup` — bash equivalent
**Verify:** `ruff check tooling/` passes. All DB CLI commands still work
(`tooling/db/ticket count`, `tooling/db/sprint status`).
## Dependency Chain
```
#777 (ruff + shared module) → standalone
```
## PR Workflow
```bash
tea pr create --repo jpmschweitzer/settled-reach --login schweitz \
--title "chore(ci): Sprint 31 — ruff linting + shared DB module" \
--description "body" --base main --head sprint-31/ci
```

Some files were not shown because too many files have changed in this diff Show More