Compare commits

...
86 Commits
Author SHA1 Message Date
jpmschweitzerandClaude Opus 4.6 e2d2211ff6 chore(meta): release v0.1.10
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 09:13:49 +01:00
jpmschweitzer 62d48dc7b4 Merge remote-tracking branch 'origin/client' 2026-02-19 09:10:37 +01:00
jpmschweitzerandClaude Opus 4.6 7640a9ab87 fix(client): address PR #38 review — 6 warnings + 4 suggestions
Hoshe:
- COLOR_FADE_DURATION 0.7 → 0.5 to match D-033 spec ("0.5s fade")
- Gauntlet guard tests now exercise InputMapper._unhandled_input()
  with synthesized InputEventKey instead of asserting a bool
- Buffer clearing tests use SimBridge pipeline instead of manual nulls
- Add mid-transition re-trigger test (rapid relationship changes)
- Add relationship field to test snapshot NPC

Tyre:
- Add _teleport_in_progress flag to defer smoothing re-enable by one
  frame after teleport (prevents same-_process() re-enable race)
- Add _test_gauntlet_mode to SimBridge test snapshot
- Extract TELEPORT_DISTANCE_THRESHOLD constant, mirror in tests
- Add comments: flash preemption, modulate/color independence
- Rename "hub teleport" → "Gauntlet dev teleport" in code comments
  to clarify this is not production fast-travel

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 09:06:42 +01:00
jpmschweitzerandClaude Opus 4.6 6d99f9bc8c docs(decisions): add Q-027 fast-travel system design
Re-scope #501 hub teleport as Gauntlet-only dev tool. Production
travel must be diegetic: local gate → system gate → target menu →
destination, gated by region safety, with asymmetric information
exposure (NPCs observe arrivals/departures).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 09:03:16 +01:00
jpmschweitzerandClaude Opus 4.6 58e592fd5e feat(client): confrontation D-033 color shift (#521, D-063)
Entity renderer now tracks relationship per entity and tweens D-033
tint color over 0.7s when relationship changes (e.g. on confrontation
delivery). Uses manual lerp in _process() for testability instead of
SceneTree tweens. Cursor hover tint cascades automatically via
Constants.color_for_entity_kind().

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 08:46:12 +01:00
jpmschweitzerandClaude Opus 4.6 7274151671 feat(client): hub teleport UX — Home key, fade transition (#501)
Home key sends TeleportToHub in Gauntlet mode. Camera snaps to hub
spawn with 0.3s fade-from-black. Clears dialogue/monologue buffers
on teleport. Teleport detection uses distance threshold (>5 tiles)
so future teleport types get the transition for free.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 08:46:02 +01:00
jpmschweitzer fd2101368e Merge remote-tracking branch 'origin/client'
# Conflicts:
#	CHANGELOG.md
2026-02-18 18:35:15 +01:00
jpmschweitzerandClaude Opus 4.6 503644beb1 fix(client): address PR #36 re-review — 7 items from Hoshe + Tyre
Warnings: facing indicator tests use Godot-normalized rotation range
(-PI, PI] instead of raw addition (SW/W/NW in test_rendering,
West in test_client_p3). Suggestions: cache font in world_radial
_draw(), fix docstring on deactivate_insert() trigger, document
tile-coordinate system on _eval_player_near (D-066), add public
reset_facing_state() to InputMapper (D-030 testability).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 18:28:37 +01:00
jpmschweitzer c6b4017621 Merge remote-tracking branch 'origin/server' 2026-02-18 18:26:27 +01:00
jpmschweitzer 54f43ee58c Merge remote-tracking branch 'origin/ci'
# Conflicts:
#	CHANGELOG.md
2026-02-18 18:26:23 +01:00
jpmschweitzerandClaude Opus 4.6 52eb8bfc81 fix(simulation): address PR #37 re-review — stale strings, test coverage, confrontation symmetry
Hoshe re-review (3 items):
- content_scaling.rs:185: doc "StableId 0-51" → references constant
- content_scaling.rs:256: assertion message "id<=51" → "id <= max_baseline_id"
- input.rs: teleport test now asserts WalkAwayRequest + ConfrontationDelivered
  are cleared (was only checking TalkRequest + ActiveDialogue)

Tyre re-review (2 items):
- input.rs: same teleport test coverage (overlaps Hoshe #3)
- dialogue.rs: process_confrontation_response now inserts RoutineDeviation
  with DeviationTrigger::Confrontation — symmetric with walk-away path.
  Test updated to verify deviation is recorded.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 18:22:32 +01:00
jpmschweitzerandClaude Opus 4.6 d92a2e5f50 fix(simulation): address PR #37 review — doc corrections, race fix, marker cleanup
Hoshe review (4 items):
- types.rs: doc comment "Current: 6" → "Current: 9"
- dialogue.rs: walk-away doc duplicated numbering (items 4-5 were 2-3)
- test_world/mod.rs: comment "Reset plates at 49-51" → "49-55"
- content_scaling.rs: magic number 51 → constants::RESET_PLATE_STABLE_IDS.1

Tyre review (3 items):
- knowledge/types.rs: guard comments on decrement() floor at Hostile
- input.rs: TeleportToHub now clears ConfrontationDelivered marker
- content_scaling.rs: same magic number fix (covered above)

Additional:
- content_runtime.rs: barrier-based shutdown handshake fixes TCP RST
  race condition under parallel test execution
- dialogue_room.rs: clippy type_complexity allow on NPCS tuple array

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 17:50:22 +01:00
jpmschweitzerandClaude Opus 4.6 8d8da1b546 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 13:12:23 +01:00
jpmschweitzerandClaude Opus 4.6 d008874c14 chore(skills): add commit step to push-pr workflow
push-pr now runs /commit first when uncommitted changes are detected,
making it a one-stop commit→push→PR command. Added Skill to
allowed-tools for cross-skill invocation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 13:12:11 +01:00
jpmschweitzerandClaude Opus 4.6 919ef39cbd fix(client): address PR #36 review — 10 items from Hoshe + Tyre
Critical:
- deactivate_insert() now called when selecting non-Insert spoke,
  cancelling with no selection, or pressing Escape while insert is
  active. Fixes simulation staying paused permanently after Insert.

Warnings:
- Checklist conditions with empty id excluded from get_results() and
  get_total_count() — prevents impossible-to-complete checklists.
  Warns at load time when empty-id conditions are found.
- _content_base now checks res://content/ first (exported builds),
  falls back to ../content for editor/dev mode.
- 26 new tests for D-054 functions: _angle_to_octant (8 octants),
  _snap_to_octant_dir (9 cases incl. zero/tiny), _wasd_to_world_dir
  (8 facing/movement combos). New test file: test_input_mapper_facing.gd.

Suggestions:
- Cached get_theme_default_font() in checklist overlay _ready().
- Documented InputMapper → GameState coupling as intentional.
- Documented YAML parser # truncation limitation.
- _insert_active reset on Escape dismiss (Tyre #3).
- SimBridge test mode SetFacing reads action_data.facing instead of
  InputMapper global (Tyre #4).
- Removed dead _facing_to_rotation() from entity_renderer.gd (Tyre #5).
- Fixed 2 failing facing indicator tests to use InputMapper.facing_angle
  instead of GameState.player_facing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 13:09:38 +01:00
jpmschweitzerandClaude Opus 4.6 d0596d2763 fix(ci): address PR #35 review comments
- Document non-blocking receive contract in perf_bench.rs docstring,
  confirming no TCP deadlock race (Hoshe #1, critical)
- Make shadowcast parser order-independent — flush on new config header
  instead of requiring Recursive after Symmetric (Hoshe #2)
- Fix p95 calculation: use floor(0.95*(N-1)) nearest-rank instead of
  ceil(0.95*N)-1 which was off-by-one at N=50 (Hoshe #4)
- Error on --compare when no baseline file exists (Hoshe #5)
- Add D-031 10tps assumption comment to TICK_BUDGET_US (Tyre #1)
- Strengthen snapshot assertion: require warmup + half measurement
  window instead of warmup + 1 (Tyre #2)
- Regenerate baseline with corrected p95 (356µs, was 526µs)

Hoshe #3 (.PHONY) was already addressed — perf-baseline is in the
.PHONY declaration on Makefile line 10.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 13:07:46 +01:00
jpmschweitzerandClaude Opus 4.6 6c62e2228f test(simulation): sprint 10 — replay loading, content scaling, serialization v9, observer tests
#483: Replay loading in test-client — JSONL file loading, tick-scheduled
PlayerInput sending, 13 unit tests, 3 sample replay files.
#500: Content scaling test — baseline + extra NPC comparative, tick budget
assertion (D-026), determinism check across content packs.
#514: Serialization tests for protocol v9 — blocked_entities roundtrip,
backward compat (v5→v9, v8→v9), regenerated msgpack fixtures.
Observer perception tests for confrontation + walk-away mechanics.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 12:58:27 +01:00
jpmschweitzerandClaude Opus 4.6 273d29f26f feat(simulation): sprint 10 — hub teleport, confrontation response, walk-away phase 2, gauntlet rooms 4-7, blocked_entities debug field
#491: PlayerAction::TeleportToHub — moves player to hub spawn, clears
dialogue/monologue/interaction buffer, Gauntlet-only with log warning.
#520: ConfrontationDelivered event — Tier 2 animation shift, relationship
state decrement (D-033), monologue spike emission.
#519: Walk-away Phase 2 — NPC animation shift + routine deviation on
dialogue exit (D-064).
#498: Four new Gauntlet rooms — Interaction Gallery, Fog Theater, Crowd
Plaza, Dialogue Room with constants and wiring.
#514: blocked_entities Vec<u64> on ObserverSnapshot, protocol v9.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 12:58:14 +01:00
jpmschweitzerandClaude Opus 4.6 ab85fbd652 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 12:53:50 +01:00
jpmschweitzerandClaude Opus 4.6 eb1d89ea86 feat(client): room reset UX, insert pause wiring, auto-checklist (#502, #518, #503)
Room reset (#502): amber reset_plate tile type in TileRenderer, 0.15s
screen flash on room_reset monologue, 'Reset Room' verb via existing
nearby_interactions.

Insert pause (#518, D-058): explicit PauseSimulation on insert open,
ResumeSimulation on close. Replaces toggle-style pause with idempotent
pair per D-058.

Auto-checklist (#503): ChecklistEvaluator parses room YAML, evaluates
7 condition types against GameState with latching. ChecklistOverlay
renders progress in gauntlet mode only. 48 tests covering parser,
evaluation, latching, visibility, and integration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 12:53:35 +01:00
jpmschweitzerandClaude Opus 4.6 97cb69e6a4 feat(client): mouse-relative facing and movement (#526, D-054)
Mouse position now determines facing direction as a client-side float.
WASD remapped: W=toward cursor, S=away, A/D=strafe. Facing octant
derived from mouse angle and sent to server via SET_FACING action only
when it changes. EntityRenderer facing indicator uses continuous angle
for smooth rotation. SimBridge test mode updated to handle SetFacing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 12:53:19 +01:00
jpmschweitzerandClaude Opus 4.6 80d5a57b5d feat(client): introduce Michroma font and implant UI theme (#517)
Add Michroma-Regular.ttf (OFL license) as the game font. Create
FontVariation with +1px tracking and a global Theme resource with
cyan-white (#E0F7FA) implant text color. Wire theme into project.godot
so all Label/RichTextLabel/Button/LineEdit nodes inherit automatically.
Add IMPLANT_TEXT_COLOR/DIM/PULSE constants to constants.gd.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 12:53:08 +01:00
jpmschweitzerandClaude Opus 4.6 370649b3df chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 12:53:00 +01:00
jpmschweitzerandClaude Opus 4.6 f13aafb1dd docs(meta): add asset pipeline and remote terminal proxy ideas to scratchpad
Two new scratchpad entries: asset generation pipeline improvements
(registry, status tracking, prompt versioning) and a remote terminal
proxy concept for mobile monitoring of Claude Code permission prompts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 12:52:49 +01:00
jpmschweitzerandClaude Opus 4.6 c1d7c072ae chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 12:44:34 +01:00
jpmschweitzerandClaude Opus 4.6 08052c6459 feat(ci): add performance baseline tooling (#499)
Adds `make perf-baseline` — boots the full server plugin stack with
real content, measures 50 ticks (5 warmup), captures per-tick timing,
entity counts, and process RSS. Includes shadowcast benchmarks. Saves
structured JSON to tests/perf/baseline.json for regression detection.
Supports --compare mode (>20% threshold).

First baseline: mean 366µs, p95 526µs (0.5% of D-026 100ms budget).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 12:42:09 +01:00
jpmschweitzerandClaude Opus 4.6 76efc979b9 fix(ci): correct bidirectional relationship check target (#515)
Check 9 tested `target in npc_rels` which missed NPCs with no
relationship entries. Changed to `target in self.npcs` so the
advisory warning fires correctly for all NPCs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 12:41:35 +01:00
jpmschweitzerandClaude Opus 4.6 c2a3a251b4 feat(skills): start-sprint main workflow as state machine
On main, the skill now assesses sprint state and does the next right
thing: close active sprint (Case A), activate planned sprint (Case B),
or suggest /plan-sprint (Case C). Cases chain automatically — closing
flows into activation if a planned sprint is waiting.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 12:15:56 +01:00
jpmschweitzerandClaude Opus 4.6 b4a3784218 docs(sprints): add Sprint 10 Prove briefings
Server (7), client (7), CI (3), audio (1) — 18 tickets total.
Gauntlet expansion, audio wiring, client UX polish, CI pipeline.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 12:12:45 +01:00
jpmschweitzerandClaude Opus 4.6 407018ecdf chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 12:05:41 +01:00
jpmschweitzerandClaude Opus 4.6 973c804b52 feat(meta): add project.yaml as version source of truth
Technical project descriptor with game architecture, simulation
parameters, and content model. Version field (0.1.9) is now the
canonical version — Cargo.toml synced, CLAUDE.md and start-sprint
skill updated to reference it during release workflow.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 12:05:29 +01:00
jpmschweitzerandClaude Opus 4.6 2df7cc2153 chore(meta): release v0.1.9
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 12:02:41 +01:00
jpmschweitzerandClaude Opus 4.6 3ca12e33a3 feat(skills): start-sprint marks actionable tickets in_progress
Step 7 now transitions all unblocked sprint tickets to in_progress
immediately rather than deferring to agents (who forget). Completes
the ticket lifecycle: start-sprint → in_progress, push-pr → review,
sprint stop → done.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 11:59:06 +01:00
jpmschweitzerandClaude Opus 4.6 5f3514b3c4 feat(skills): push-pr marks referenced tickets as review
Step 7 scans commit messages for #NNN ticket refs and transitions
in_progress tickets to review status. Only in_progress → review;
other statuses are left alone.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 11:57:39 +01:00
jpmschweitzerandClaude Opus 4.6 9728eb5d1d feat(db): auto-close picked-up tickets on sprint stop
sprint stop now marks in_progress/review tickets as done
automatically — agents often forget to update status after merging.
Backlog/ready tickets are left as carry-over candidates since they
were never started.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 11:56:46 +01:00
jpmschweitzerandClaude Opus 4.6 a91f042d2d feat(skills): add sprint close + version bump workflow to start-sprint
When invoked on main, /start-sprint now closes the active sprint,
bumps the version to v0.1.N, moves changelog entries under the new
version heading, tags, and pushes. Team branch behavior unchanged.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 11:55:07 +01:00
jpmschweitzerandClaude Opus 4.6 9cd6969db1 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 11:51:58 +01:00
jpmschweitzerandClaude Opus 4.6 d627c2bc0a chore(client): add missing .uid and .import files from Godot 4.6
Generated by headless --import after audio merge. These are needed
for stable UID references across editor sessions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 11:51:41 +01:00
jpmschweitzerandClaude Opus 4.6 843dbce1ba feat(db): add --description flag to ticket create CLI
ticket create previously had no way to set descriptions, causing
agents to work around with raw SQL or mangled titles.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 11:51:33 +01:00
jpmschweitzerandClaude Opus 4.6 4d44863552 fix(client): make game depends on full build, use --import for client
build-client used --quit without --import, which failed to create
.godot/ and register class_name types after a clean. game target
only depended on build-server, skipping client entirely. clean now
preserves the .godot/ directory itself while clearing contents.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 11:51:27 +01:00
jpmschweitzerandClaude Opus 4.6 61b5ca7178 chore(db): backup after audio merge
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 11:21:00 +01:00
jpmschweitzer fdf82a89ff Merge remote-tracking branch 'origin/audio' 2026-02-18 11:20:16 +01:00
jpmschweitzerandClaude Opus 4.6 0adfb15bc9 chore(db): backup after client PR #34 merge
Tickets #495, #496 marked done.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 11:14:22 +01:00
jpmschweitzer 5a4156c967 Merge remote-tracking branch 'origin/client' 2026-02-18 11:12:42 +01:00
jpmschweitzerandClaude Opus 4.6 dd8718c762 fix(client): address PR #34 review — 8 items from Hoshe and Tyre
- Cast Variant to String via str() before passing to _on_room_change
- Clear _current_room_id on null room transition (fixes re-entry skip)
- Add push_error for failed dir creation and file writes in _save_report
- Fix docstring: tests/gauntlet-stats.json → user://dev/gauntlet-stats.json
- Namespace stats path to user://dev/ to avoid save data collision
- Replace print() with push_warning in _save_report (codebase consistency)
- Downgrade client-only wire guard from push_warning to silent return

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 11:06:56 +01:00
jpmschweitzerandClaude Opus 4.6 ddf7e482cd chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 11:02:57 +01:00
jpmschweitzerandClaude Opus 4.6 408f54f686 fix(ci): address PR #33 review — golden restore safety, pre-pr gate
- golden-diff restores committed file even on cargo test failure
- Wire checklist-validate into pre-pr-content gate
- Fix schema description: condition IDs are globally unique, not
  per-file; document room_id prefix naming convention
- Add schema file missing error handling in validate-checklist
- Add scope discriminator field (per_room/cross_room) to schema
- Check pyyaml and jsonschema packages in setup-tooling

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 11:02:24 +01:00
jpmschweitzerandClaude Opus 4.6 68848377c7 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 11:01:41 +01:00
jpmschweitzerandClaude Opus 4.6 20427b9d8b feat(audio): revise Sprint 7 UI audio assets for #453
Re-generate monologue chimes (were oversized placeholders) and
fog_recognition (was silent). Fix sample rate headers on remaining
assets (192kHz→44.1kHz). All 8 UI assets now at 44.1kHz stereo,
LUFS -16 normalized, Vorbis q6, durations within D-038 spec.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 11:01:19 +01:00
jpmschweitzerandClaude Opus 4.6 9e027c5e9d chore(meta): update changelog for sprint 9 client work
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 10:53:42 +01:00
jpmschweitzerandClaude Opus 4.6 a292674342 feat(client): add 24 gauntlet + bug report tests (#495, #496)
Replace stub F12 tests with BugReportDialog integration tests (dialog
exists, activates on action, pause/unpause, wire guard, text render
with entities/monologue/dialogue, empty snapshot edge case). Add 16
GauntletHUD tests (format_time, visibility toggle, timer lifecycle,
room change reset, personal bests record/overwrite/preserve, null room,
timer paused when hidden, finalize, session attempts, snapshot
roundtrip). Anti-tedium assertions now falsifiable against real
GameState.room_id and gauntlet_mode properties.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 10:53:26 +01:00
jpmschweitzerandClaude Opus 4.6 d7755698b2 feat(client): add WRONG button F12 bug report capture (#495)
F12 pauses simulation, shows modal LineEdit prompt, saves three files
to user://bug-reports/gauntlet-t{tick}-{timestamp}/: snapshot.json
(full ObserverSnapshot), render.txt (simplified client-side text
render), description.txt (tester notes + tick/room/stance metadata).
Esc cancels without saving. Double-activation guard prevents stacking.

BUG_REPORT action added to InputMapper with wire guard in SimBridge
(client-only, never sent to server). Dialog on ModalLayer (CL 30).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 10:53:15 +01:00
jpmschweitzerandClaude Opus 4.6 bc073c891f feat(client): add gauntlet room timer + personal bests (#496)
GauntletHUD in UILayer shows TIMER: MM:SS (PB: MM:SS). Timer starts on
room entry, resets on room change, records personal bests to
user://gauntlet-stats.json. Session summary printed on disconnect.
Hidden in non-gauntlet mode (anti-tedium guard).

Adds room_id and gauntlet_mode fields to GameState, parsed from
ObserverSnapshot. Main.gd wires update_from_state() and finalize().

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 10:53:05 +01:00
jpmschweitzerandClaude Opus 4.6 c4f78d8926 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 10:44:50 +01:00
jpmschweitzerandClaude Opus 4.6 d8d005169a feat(ci): add golden file targets and checklist schema (#486, #497)
Golden targets: make golden-diff shows color diff if simulation output
changed, make golden-update regenerates and stages for review.

Checklist schema: JSON Schema for 7 condition types evaluable from
ObserverSnapshot. Per-room YAML checklists for 3 Gauntlet rooms plus
cross-room checks. Validation script + make checklist-validate/generate.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 10:44:33 +01:00
jpmschweitzerandClaude Opus 4.6 318a1e8b07 chore(db): snapshot database after Sprint 9 PR merges
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 10:24:25 +01:00
jpmschweitzer bb94a6581c Merge remote-tracking branch 'origin/ci' 2026-02-18 10:24:13 +01:00
jpmschweitzer ffe3fdaf3e Merge remote-tracking branch 'origin/client' 2026-02-18 10:23:58 +01:00
jpmschweitzer 445f34f22e Merge remote-tracking branch 'origin/server' 2026-02-18 10:23:33 +01:00
jpmschweitzerandClaude Opus 4.6 48802f6b48 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 10:13:17 +01:00
jpmschweitzerandClaude Opus 4.6 f1e5d76ad8 chore(skills): clarify verdict rules in review-pr skill
Add explicit mapping: critical/warning → REQUEST_CHANGES,
suggestion-only or clean → APPROVE.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 10:13:06 +01:00
jpmschweitzerandClaude Opus 4.6 4f20a996aa fix(client): address PR #30 review — 9 items from Hoshe and Tyre
- Add SimBridge._last_snapshot = null to P3 before_test() (contamination risk)
- Clarify mono.is_visible is custom property, not CanvasItem builtin
- Camera smoothing test uses approximate equality instead of exact
- Propagate inner _encode_message errors in array/dict encoding
- Add explanatory comment on int_64 encoder branch
- Document unfalsifiable gauntlet guard as intentional future-proof
- Remove loose D-053 citation from LERP_SPEED pin
- Assert FogOverlay is sibling of FogGroup under World
- Add CANVAS_MODAL=30 (ModalLayer) assertion in P3-Z04

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 09:46:08 +01:00
jpmschweitzerandClaude Opus 4.6 f8db996eb9 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 09:45:31 +01:00
jpmschweitzerandClaude Opus 4.6 aadfe64cfb chore(meta): update changelog for PR #32 review fixes
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 09:45:30 +01:00
jpmschweitzerandClaude Opus 4.6 09eb6d7582 refactor(simulation): address PR #32 review — 14 items from Hoshe + Tyre
Hoshe (code quality):
- Remove dead RoomMember component from reset.rs
- Remove execute_reset (dual API trap); plan_reset is sole production path
- .unwrap() → .expect() on reset_plate in setup_gauntlet boot path
- Add 10s read timeout to TCP runtime test (prevents hangs)
- Register player in EntityRegistry in runtime boot test
- Document room_at z-range and corridor overlap assumptions
- Derive entity count from EXPECTED_ENTITY_COUNT constant (was hardcoded 24)
- Add reset plate (49-51) verification to stable_id_ranges_match_spec
- Add debounce exact boundary test (tick 9 rejected, tick 10 accepted)

Tyre (architecture):
- Gate test_world rooms/constants/setup behind "gauntlet" feature (default-on);
  reset module stays always-compiled (production dependency via input system)
- Document setup_gauntlet scheduler bypass for future tracking
- Extract runtime TCP test to content_runtime.rs (separate failure modes)

507 tests passing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 09:45:17 +01:00
jpmschweitzerandClaude Opus 4.6 3ccac5432b fix(ci): address PR #31 review — harden cross-encoder fixture pipeline
- Fail on encode errors instead of silently writing empty .msgpack files
- Fail test on missing/empty fixture dir instead of silent skip
- Add all missing action variants (MoveSouth, MoveEast, MoveWest,
  Unpause, ToggleStanceDown, WalkAway) to GDScript fixture generator
- Add GDScript fixture staleness check to make pre-pr
- Validate repo root detection before writing outside client/
- Add file.flush() before close in headless mode
- Document fixture failure recovery in DEVOPS.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 09:45:13 +01:00
jpmschweitzerandClaude Opus 4.6 e66352e0ea feat(simulation): sprint 9 gauntlet — test infrastructure and first 3 rooms
Add Gauntlet test world with 3 rooms (Inventory Warehouse, Occlusion
Corridor, Pause Chamber) + Central Hub, room constants module, room
reset trigger mechanism, Layer 3 subprocess integration test, golden
file comparison engine and test suite, and content runtime validation.

Tickets: #482, #484, #485, #487, #488, #489, #490

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 02:25:57 +01:00
jpmschweitzerandClaude Opus 4.6 24289a6ded chore(meta): update changelog for whatsinagame kit
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 02:22:48 +01:00
jpmschweitzerandClaude Opus 4.6 908691bf0b chore(skills): add domain-action naming convention to create-skill
Documents the {domain}-{action} naming pattern for new skills
so future skills follow the convention established in the kit.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 02:22:35 +01:00
jpmschweitzerandClaude Opus 4.6 7158ae9cf5 feat(meta): add whatsinagame multi-agent team starter kit
Claude-native starter kit that bootstraps multi-agent team
infrastructure for any project. Clone once, install as a global
skill, run /kit-install in any project directory.

Includes:
- 3-tier profile system (minimal/standard/full: 3-12 agents)
- 16 agent archetype templates with personality spectrum
- 18 skill templates using domain-action naming convention
- Stakeholder persona panel for workshops and PR reviews
- SQLite ticketing DB with CLI tools (config-based DB paths)
- Decision tracking, sprint lifecycle, workshop orchestration
- Multi-git-host support (GitHub, Gitea, GitLab)
- /kit-update skill for syncing with source repo evolution
- Naming theme support for agent identity/flavor
- Smoke tests for all three profile tiers

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 02:22:30 +01:00
jpmschweitzerandClaude Opus 4.6 da63aa580e feat(ci): add make fixtures-client target for GDScript->Rust cross-encoder validation (#475)
Closes the bidirectional protocol compatibility loop (D-030 Layer 1):
- GDScript fixture generator (20 fixtures: inputs, boundary ticks, batch)
- Rust decoder test verifying all GDScript-encoded fixtures deserialize
- Makefile target with generation + verification in one step

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 02:20:24 +01:00
jpmschweitzerandClaude Opus 4.6 1254fa1b28 chore(meta): update changelog for sprint 9 client work
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 02:15:05 +01:00
jpmschweitzerandClaude Opus 4.6 4648ce62bb feat(client): add 5 anti-tedium regression tests (#494)
Regression guards for Sprint 9 QA features:
- F12 bug report (2): no-crash without handler, no queued input action
  (stubs for when #495 WRONG button lands)
- Gauntlet UI hidden (3): no gauntlet nodes visible in default mode,
  no room_id/gauntlet_mode in normal snapshots, stays hidden across
  multiple ticks

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 02:14:46 +01:00
jpmschweitzerandClaude Opus 4.6 f82f97afcb feat(client): add 12 P3 tests — z-layer, entity lerp, Tyre additions (#493)
gdUnit4 tests covering:
- Z-layer ordering (4): floor below ysort, entities in ysort at z:0,
  fog exact z_index values (Z_FOG=900, Z_FOG_ENTITIES=950), UI layers
- Entity lerp (3): snap on first appear, moves toward target,
  converges within 0.3s at LERP_SPEED=12.0
- Tyre additions (5): recognition progress, facing rotation, lerp
  weight scaling with delta, blob removal, LERP_SPEED constant guard

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 02:14:42 +01:00
jpmschweitzerandClaude Opus 4.6 5820609594 feat(client): add 16 P2 tests — camera, entity alpha/color, UI (#492)
gdUnit4 tests covering:
- Camera (5): zoom default, smoothing convergence, viewport tracking,
  follow movement, no-panning lock per D-014
- Entity alpha/color (4): null visibility default, peripheral/forward
  transition, terrain object color, player color constant
- UI (7): monologue visible/hidden, interaction verbs, inventory grid,
  dialogue overlay, pause indicator, fog blob count, fog z_index

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 02:14:37 +01:00
jpmschweitzerandClaude Opus 4.6 1435a77dcc fix(client): fix MessagePack int_64 encoder dead code branch (#516)
The int_64 branch condition `-(1 << 63) <= value` overflowed in
GDScript's signed 64-bit arithmetic, making the branch unreachable.
Negative values beyond int_32 range were incorrectly encoded as
uint_64 (0xcf) instead of int_64 (0xd3). Replaced with `value < 0`.

Updated boundary tests BV-N15 and BV-N16 to expect correct int_64
header byte.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 02:14:29 +01:00
jpmschweitzer 58072bc119 Merge remote-tracking branch 'origin/client'
# Conflicts:
#	CHANGELOG.md
2026-02-18 01:57:07 +01:00
jpmschweitzer ca84753182 Merge remote-tracking branch 'origin/server'
# Conflicts:
#	CHANGELOG.md
2026-02-18 01:56:48 +01:00
jpmschweitzerandClaude Opus 4.6 44d6960758 chore(meta): update changelog for protocol v8 sync
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 01:41:44 +01:00
jpmschweitzerandClaude Opus 4.6 674c7147de feat(client): sync protocol to v8 — dialogue_response + Interact encoding
Bump PROTOCOL_VERSION from 7 to 8 to match server. Three changes:

1. Add dialogue_response field decoding (DialogueResponseEvent with
   line_id, text, speaker_entity_id) from server #305/D-028.

2. Fix Interact encoding: server changed PlayerAction::Interact from
   unit variant to struct variant with {target_entity_id, verb}.
   Extract _encode_action() helper to handle this consistently.

3. Update all test assertions that checked version=7 or expected
   Interact as a bare string.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 01:41:27 +01:00
jpmschweitzerandClaude Opus 4.6 dcb76d1221 fix(client): fix monologue duplication test using wrong poll path
test_monologue_not_duplicated_after_consumption was failing because
poll_snapshot() in test mode returns _test_snapshot() without
consuming _last_snapshot. The carry-forward logic then incorrectly
preserved the monologue. Fix: directly clear _last_snapshot to
simulate the live-mode consumption path.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 01:41:18 +01:00
jpmschweitzerandClaude Opus 4.6 dde68104fc chore(config): add test-client Cargo.lock and gitignore target/
Follows existing tooling crate pattern (content-converter, line-previewer).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 01:40:35 +01:00
jpmschweitzerandClaude Opus 4.6 932a708cde chore(meta): update changelog for #480 and #481
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 01:39:56 +01:00
jpmschweitzerandClaude Opus 4.6 d5a1f6dadb refactor(simulation): apply rustfmt formatting
Formatting-only changes across server source and test files.
No logic changes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 01:39:44 +01:00
jpmschweitzerandClaude Opus 4.6 1da81a21ea feat(simulation): add test client binary scaffolding (#480)
Separate workspace crate at tooling/test-client/ importing bridge
types from server crate. CLI: --connect, --replay, --text, --json,
--quiet, --golden, --ticks. Exit codes: 0=success, 1=golden mismatch,
2=connection error. Golden file comparison with recursive JSON diff.
JSONL replay loader for tick-scheduled input sending.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 01:39:37 +01:00
jpmschweitzerandClaude Opus 4.6 f83815fd9a feat(simulation): add snapshot text renderer library (#481)
format_snapshot_text(&ObserverSnapshot) -> String pub-exported from
server crate. Entity labels as kind:entity_id sorted by distance,
room name stubbed as (unknown) until Gauntlet constants land.
10 unit tests. Consumed by tooling/test-client for --text mode.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 01:39:28 +01:00
268 changed files with 27411 additions and 316 deletions
+5 -1
View File
@@ -178,7 +178,11 @@ For design patterns, consult:
**Writing guidelines:** Always use imperative/infinitive form.
**Frontmatter:**
- `name`: The skill name
- `name`: The skill name — use **domain-action** naming: `{domain}-{action}`.
The domain is the system/area the skill operates on, the action is what it does.
Examples: `pr-review`, `sprint-plan`, `docs-search`, `git-commit`, `debt-scan`.
Multi-action wrappers (like `ticket`) can use the domain name alone.
The directory name must match the `name` field.
- `description`: Primary triggering mechanism. Include what the skill does AND
specific triggers/contexts. All "when to use" info goes here (not in body).
+40 -9
View File
@@ -8,7 +8,7 @@ description: >
existing PR was updated. NEVER merges the PR into main — this skill only
pushes to the branch and manages the PR lifecycle.
user-invocable: true
allowed-tools: Bash, Read, Grep, Glob, AskUserQuestion
allowed-tools: Bash, Read, Grep, Glob, AskUserQuestion, Skill
---
# Push PR Skill
@@ -34,17 +34,29 @@ git branch --show-current
If on `main`, stop: "You're on main. Switch to a team branch first."
### 2. Check for unpushed commits
### 2. Commit uncommitted changes
```bash
git status
```
If there are uncommitted changes (staged or unstaged), run the **commit skill**
first. Use the `/commit` skill to group changes into logical commits with
proper conventional commit messages. Wait for commit to complete before
proceeding.
If the working tree is clean (no uncommitted changes), skip to step 3.
### 3. Check for unpushed commits
```bash
git fetch --all
git status
git log --oneline origin/<branch>..<branch>
```
If no unpushed commits, skip to step 4 (PR check).
If no unpushed commits, skip to step 5 (PR check).
### 3. Check for conflicts with main
### 4. Check for conflicts with main
```bash
git merge-tree --write-tree origin/main HEAD 2>&1
@@ -59,7 +71,7 @@ git merge origin/main --no-edit
If merge conflicts, **stop and report** — let the user resolve.
If clean, continue.
### 4. Push
### 5. Push
```bash
git push origin <branch>
@@ -67,7 +79,7 @@ git push origin <branch>
If push fails, stop and report. Never force-push without explicit request.
### 5. Check for existing PR
### 6. Check for existing PR
```bash
tea pr list --login schweitz --repo jpmschweitzer/settled-reach --state open --output simple
@@ -76,9 +88,9 @@ tea pr list --login schweitz --repo jpmschweitzer/settled-reach --state open --o
Match current branch name in PR list.
- **PR exists**: Report "Pushed N commits to `<branch>`. PR #X updated." Done.
- **No PR**: Continue to step 6.
- **No PR**: Continue to step 7.
### 6. Create a new PR
### 7. Create a new PR
```bash
git log --oneline main..<branch>
@@ -103,6 +115,25 @@ tea pr create \
Report PR URL when done.
### 8. Update ticket status to review
Scan all commit messages in the PR for ticket references (`#NNN`):
```bash
git log --oneline main..<branch>
```
Extract ticket IDs from `#NNN` patterns. For each ticket that is
currently `in_progress`, update it to `review`:
```bash
db/connectors/ticket status <id> review
```
Report which tickets were moved to review. Skip tickets that are
already `done`, `review`, `cancelled`, or `backlog` (only transition
`in_progress` → `review`).
## Arguments
If the user passes arguments (e.g., `/push-pr "my title"`), use them as the
+4
View File
@@ -140,6 +140,10 @@ Respond with:
- File path and approximate location
- Severity: critical / warning / suggestion
- Description of the issue
Verdict rules:
- Any **critical** or **warning** issue → REQUEST_CHANGES
- Only **suggestion**-level issues (or none) → APPROVE
If no issues found, say APPROVE with a brief positive summary.
```
+128 -10
View File
@@ -1,10 +1,12 @@
---
name: start-sprint
description: >
Start sprint work on a team branch. Use when the user says "start sprint",
"start working on the server/client/copy", "begin sprint", or invokes
/start-sprint. Merges main into the team branch, finds the active sprint,
reads the sprint briefing, and presents the work plan with ticket details.
Manage the sprint lifecycle from main, or start sprint work on a team
branch. Use when the user says "start sprint", "start working on the
server/client/copy", "begin sprint", or invokes /start-sprint. On main:
assesses sprint state and does the next right thing (close, activate, or
guide). On a team branch: merges main, loads the briefing, presents the
work plan.
user-invocable: true
allowed-tools: Bash, Read, Grep, Glob, TeamCreate, Task, TaskCreate, TaskUpdate, TaskList, SendMessage, AskUserQuestion
---
@@ -24,7 +26,118 @@ git branch --show-current
```
Valid team branches: `server`, `client`, `copy`, `audio`, `visual`, `ci`.
If on `main`, ask the user which team branch to check out first.
If on `main`, follow the **Main branch workflow** below instead of
the team branch workflow (steps 2–8).
---
## Main branch workflow (sprint lifecycle management)
When `/start-sprint` is run on `main`, assess the current sprint state
and do the next right thing. Query the database to determine the state:
```bash
db/connectors/sqlite-query "SELECT id, name, status FROM sprints ORDER BY id DESC LIMIT 3"
```
Then follow the **first matching case**:
### Case A: An active sprint exists
The active sprint needs to be closed before moving on.
#### A1. Close the active sprint
```bash
db/connectors/sprint stop
```
This marks the active sprint as completed and lists carry-over candidates.
Note the sprint number (N) from the output.
#### A2. Bump the version
The project version scheme is `v0.1.{sprint_number}`. After closing
sprint N, the version is `v0.1.N`.
Update `project.yaml`:
- Set the `version` field to `0.1.N` (this is the source of truth).
Update `server/Cargo.toml`:
- Set `version = "0.1.N"` in `[package]`.
Update `CHANGELOG.md`:
- Move all entries under `## [Unreleased]` into a new section
`## [v0.1.N] — YYYY-MM-DD` (using today's date).
- Leave `## [Unreleased]` as an empty section above the new version.
- Keep the existing sub-headings (Added, Fixed, Changed, Removed) —
only move entries that have content.
#### A3. Commit the release
Stage and commit `project.yaml`, `server/Cargo.toml`, and `CHANGELOG.md`:
```
chore(meta): release v0.1.N
```
#### A4. Tag the release
```bash
git tag v0.1.N
```
#### A5. Push
```bash
git push && git push --tags
```
#### A6. Check for a planned sprint
After closing, re-query the database. If a sprint in `planning` status
exists, continue to **Case B**. Otherwise, report the close and suggest
running `/plan-sprint`.
---
### Case B: No active sprint, but a planned sprint exists
A sprint is ready to activate. Verify it looks complete:
1. Check that briefing files exist at `docs/sprints/sprint-N/`:
```bash
ls docs/sprints/sprint-N/
```
2. Check the ticket count:
```bash
db/connectors/sprint status --sprint N
```
If briefings are missing or the sprint has 0 tickets, report the gap
and suggest running `/plan-sprint` to complete planning.
If everything looks ready, activate the sprint:
```bash
db/connectors/sprint start
```
Then report:
- Sprint activated (name, ticket count per team)
- Remind the user to switch to a team branch and run `/start-sprint`
there (or `cd` into the relevant worktree)
---
### Case C: No active sprint and no planned sprint
Nothing is ready. Report the state and suggest running `/plan-sprint`
to plan the next sprint.
---
## Team branch workflow
### 2. Sync with main
@@ -68,18 +181,23 @@ db/connectors/ticket show <id>
Read the decision files referenced in the sprint briefing so the agent has
full architectural context before starting work.
### 7. Present the work plan
### 7. Mark tickets in progress and present the work plan
Output a summary:
Mark all actionable (unblocked, non-done) tickets in the sprint as
`in_progress`:
```bash
db/connectors/ticket status <id> in_progress
```
Then output a summary:
- Sprint name and goal
- Branch status (clean merge or conflicts)
- Actionable tickets (unblocked, ready to start)
- Tickets marked in_progress (list with IDs)
- Blocked tickets (and what blocks them)
- Key decisions loaded
- Suggested first task (lowest ID unblocked ticket)
Do NOT mark any ticket as `in_progress` yet.
### 8. Confirm and spawn the team
Before spawning agents, use `AskUserQuestion` to confirm the work plan and
+1
View File
@@ -3,6 +3,7 @@
server/target/
tooling/content-converter/target/
tooling/line-previewer/target/
tooling/test-client/target/
content-ron/
# Godot client (further ignores managed by client team)
+73 -2
View File
@@ -6,12 +6,83 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
## [Unreleased]
### Added
- Sprint 9 (Gauntlet) briefing files — server, client, CI, audio, joint — 23 tickets across 4 teams
## [v0.1.10] — 2026-02-19
### Added
- `project.yaml` — technical project descriptor with version, architecture, simulation, and content model as the canonical version source of truth
- Scratchpad: asset generation pipeline idea (registry, status tracking, prompt versioning, pre-sprint cohesion)
- Scratchpad: remote terminal proxy idea for mobile monitoring of Claude Code permission prompts and interactive elements
- `make perf-baseline` — full plugin stack tick benchmark (50 measured ticks, 5 warmup) capturing per-tick timing, entity counts, process RSS, and shadowcast benchmarks; outputs structured JSON to `tests/perf/baseline.json` with `--compare` mode for regression detection (>20% threshold, D-026 budget check)
- Michroma font integration (#517) — Michroma-Regular.ttf as game font with +1px tracking FontVariation, global Theme with cyan-white (#E0F7FA) implant text color, IMPLANT_TEXT_COLOR/DIM/PULSE constants
- Mouse-relative facing and movement (#526, D-054) — mouse position determines facing direction (client-side float), WASD remapped to cursor-relative (W=toward, S=away, A/D=strafe), SET_FACING action sends octant to server, smooth facing indicator rotation
- Room reset client UX (#502) — amber reset_plate tile type, 0.15s screen flash on room reset, 'Reset Room' interaction verb
- Auto-checklist progress tracking (#503) — ChecklistEvaluator parses room YAML and evaluates 7 condition types against GameState with latching, ChecklistOverlay renders progress in gauntlet mode only, 48 new tests
### Changed
- `push-pr` skill now runs `/commit` first when uncommitted changes are detected
- Insert open/close now sends explicit PauseSimulation/ResumeSimulation (#518, D-058) — replaces toggle-style pause with idempotent pair
- Interaction list colors reference Constants.IMPLANT_TEXT_COLOR instead of hardcoded values
- World radial menu uses theme font instead of ThemeDB.fallback_font
### Fixed
- Bidirectional relationship check (#515) — Check 9 tested `target in npc_rels` which missed NPCs with no relationship entries; changed to `target in self.npcs`
## [v0.1.9] — 2026-02-18
### Fixed
- `make game` now builds client before launching — was missing `build-client` dependency, causing class_name registration failures after `make clean`
- `build-client` uses `--import --quit` instead of just `--quit` — ensures `.godot/` cache and `global_script_class_cache.cfg` are created from scratch
- `make clean` preserves `client/.godot/` directory (clears contents only) to avoid Godot startup issues
### Added
- `--description TEXT` flag for `ticket create` CLI — previously required raw SQL workaround to set ticket descriptions
### Changed
- UI audio assets revised — monologue chimes re-generated (0.8s, insert-tech aesthetic), fog_recognition re-generated (was silent), all 8 assets normalized to 44.1kHz stereo LUFS -16 (#453)
- review-pr skill: explicit verdict rules — critical/warning → REQUEST_CHANGES, suggestion-only → APPROVE
### Added
- `make golden-diff` + `make golden-update` targets (#486) — developer workflow for golden file comparison and regeneration; safe restore on cargo failure
- Gauntlet checklist YAML schema (#497) — 7 condition types evaluable from ObserverSnapshot, per-room checklists for 3 rooms, `make checklist-validate` and `make checklist-generate` targets, wired into `pre-pr-content` gate
- Gauntlet room timer + personal bests (#496) — GauntletHUD shows TIMER: MM:SS (PB: MM:SS), starts on room entry, resets on room change, persists stats to user://dev/gauntlet-stats.json, session summary on disconnect, hidden in non-gauntlet mode
- WRONG button F12 MVP (#495) — bug report capture: pause sim, show modal prompt, save snapshot.json + render.txt + description.txt to user://bug-reports/, Esc to cancel
- GameState.room_id and gauntlet_mode fields — parsed from ObserverSnapshot, enabling gauntlet UI
- BUG_REPORT action in InputMapper (F12 binding) with SimBridge wire guard (client-only)
- 24 new anti-tedium tests — GauntletHUD lifecycle (16: timer, PB, visibility, room change, session tracking) + BugReportDialog (8: pause/unpause, wire guard, text render, edge cases)
- whatsinagame starter kit — reusable multi-agent team bootstrap for any project (3-tier profiles, 18 skills, 16 agent archetypes, stakeholder personas, ticketing DB, decision tracking)
- Domain-action naming convention for skills documented in create-skill guide
- `gauntlet` feature flag (default-on) — allows stripping Gauntlet test world from release builds with `--no-default-features`
- `EXPECTED_ENTITY_COUNT` and `RESET_PLATE_STABLE_IDS` constants — entity counts derived from StableId ranges instead of hardcoded values
- Debounce exact-boundary test for room reset (tick 9 rejected, tick 10 accepted)
- Reset plate StableId verification in `stable_id_ranges_match_spec`
- Runtime content test (`content_runtime.rs`) separated from structural loading tests
- Client P2 tests (#492) — 16 gdUnit4 tests for camera (smoothing, zoom, viewport, follow, no-pan), entity alpha/color (peripheral, forward, NPC color, player constant), UI (monologue, interaction, inventory, dialogue, pause, fog blob, fog z_index)
- Client P3 tests (#493) — 12 gdUnit4 tests for z-layer ordering (floor/ysort/fog/UI), entity lerp (snap, converge, LERP_SPEED=12.0), Tyre additions (recognition, facing, delta scaling, blob removal)
- Anti-tedium regression tests (#494) — 5 tests: F12 no-crash guard, no queued input, gauntlet UI hidden in default/normal snapshot/multi-tick modes
### Changed
- Room reset API consolidated to `plan_reset` only — `execute_reset` removed (was a maintenance trap; production uses Commands via `plan_reset`)
- `room_at()` documented with z-range and corridor overlap assumptions
- TCP runtime test now has 10s read timeout and registers player in EntityRegistry
### Removed
- Dead `RoomMember` component from reset.rs (defined but never used)
- Protocol v8: dialogue_response field decoding (DialogueResponseEvent with line_id, text, speaker_entity_id) from server #305/D-028
- Test client binary scaffolding (#480) — standalone crate at `tooling/test-client/` with CLI (--connect, --replay, --text, --json, --quiet, --golden, --ticks), exit codes (0/1/2), golden file JSON diff, JSONL replay loader
- Snapshot text renderer (#481) — `format_snapshot_text(&ObserverSnapshot)` pub-exported from server crate, entity labels as kind:entity_id sorted by distance, room name stub, 10 unit tests
- Sprint 9 (Gauntlet) briefing files — server, client, CI, audio, joint — 23 tickets across 4 teams
- Weapon aim lock audio (`sfx_weapon_aim_lock.ogg`) — clinical targeting confirmation tone for weapon aim state (#440)
- Stance change audio (`sfx_stance_change.ogg`) — subtle mechanical click for stance toggle feedback (#440)
### Fixed
- MessagePack int_64 encoder dead code branch (#516) — `-(1 << 63)` overflowed making int_64 branch unreachable; negative values beyond int_32 now correctly encode as 0xd3 instead of 0xcf
- Cross-encoder fixture pipeline hardened — encode failures now exit non-zero instead of writing empty .msgpack files (#475 review)
- GDScript fixture test no longer silently skips on missing/empty fixture dir — asserts instead (#475 review)
- GDScript fixture generator covers all PlayerAction variants (added MoveSouth, MoveEast, MoveWest, Unpause, ToggleStanceDown, WalkAway)
- `make pre-pr` now checks GDScript fixture staleness alongside Rust fixtures
- Protocol version bumped from 7 to 8 to match server — fixes 5 test failures from version mismatch
- Interact action encoding changed from unit variant to struct variant to match server's PlayerAction::Interact { target_entity_id, verb }
- Monologue duplication test (test_monologue_not_duplicated_after_consumption) fixed — was using poll_snapshot() which doesn't consume _last_snapshot in test mode
- `make pre-pr` target — full pre-PR verification chain: lint → build → test → content validation → fixture staleness (#460, #465)
- Branch-specific pre-PR variants: `make pre-pr-server`, `make pre-pr-client`, `make pre-pr-content`
- Content cross-reference validation (9 checks) — canonical_id uniqueness, relationship targets, location slugs, dialogue locations, fact_ids, triangle membership, npc_count, dialogue line_ids, bidirectional relationships (#464)
+2 -1
View File
@@ -3,7 +3,8 @@
A top-down immersive sim — occlusion-based detective game with combat elements, set in an original science fiction universe. Single-character perspective, asymmetric information as core mechanic, Rimworld-style storyteller. Godot 4 client + Rust/bevy_ecs simulation server via subprocess/IPC (D-020).
**Official Title:** The Settled Reach (D-021)
**Repository name:** commonwealth (historical code name, retained for path stability)
**Repository name:** settled-reach (formerly commonwealth, renamed for clarity)
**Version source of truth:** `project.yaml` (root `version` field, scheme: `0.1.{sprint_number}`)
## Project Structure
+79 -8
View File
@@ -4,7 +4,10 @@ GODOT := $(shell command -v godot4 2>/dev/null || command -v godot 2>/dev/null)
decisions-sync decisions-coverage decisions-active decisions-orphan \
db-backup db-install validate-content content-ron check-fact-ids setup-hooks \
pre-pr pre-pr-lint pre-pr-build pre-pr-test pre-pr-validate pre-pr-fixtures \
pre-pr-server pre-pr-client pre-pr-content
pre-pr-server pre-pr-client pre-pr-content \
fixtures-client golden-diff golden-update \
checklist-validate checklist-generate \
perf-baseline
# --- Configuration ---
@@ -37,6 +40,12 @@ help:
@echo " make validate-content Validate content YAML against schemas"
@echo " make check-fact-ids Check fact_id references against knowledge catalogs"
@echo " make content-ron Convert content YAML to RON (build-time)"
@echo " make fixtures-client Generate GDScript->Rust cross-encoder fixtures (#475)"
@echo " make golden-diff Show diff if golden file output has changed"
@echo " make golden-update Regenerate golden file and stage for commit"
@echo " make checklist-validate Validate checklist YAML against schema"
@echo " make checklist-generate Validate checklists + print condition summary"
@echo " make perf-baseline Run performance benchmarks and save baseline"
@echo ""
@echo " make pre-pr Run all pre-PR checks (lint, build, test, validate, fixtures)"
@echo " make pre-pr-server Server-scoped pre-PR (lint, build, test, fixtures)"
@@ -63,6 +72,8 @@ setup-tooling:
@command -v python3 >/dev/null 2>&1 || { echo "Install Python 3"; exit 1; }
@command -v curl >/dev/null 2>&1 || { echo "Install curl (required for Godot download)"; exit 1; }
@command -v unzip >/dev/null 2>&1 || { echo "Install unzip (required for Godot download)"; exit 1; }
@python3 -c "import yaml" 2>/dev/null || { echo "Install PyYAML: pip install pyyaml"; exit 1; }
@python3 -c "import jsonschema" 2>/dev/null || { echo "Install jsonschema: pip install jsonschema"; exit 1; }
setup-hooks:
@git config core.hooksPath .config/hooks
@@ -77,7 +88,7 @@ build-server:
build-client:
@test -n "$(GODOT)" || { echo "Godot not found. Run 'make setup' first."; exit 1; }
$(GODOT) --headless --path client --quit
$(GODOT) --headless --path client --import --quit
# --- Run ---
@@ -88,7 +99,7 @@ client:
@test -n "$(GODOT)" || { echo "Godot not found. Run 'make setup' first."; exit 1; }
$(GODOT) --path client
game: stop build-server
game: stop build
@test -n "$(GODOT)" || { echo "Godot not found. Run 'make setup' first."; exit 1; }
@echo "Starting server..."
@cd server && cargo run &
@@ -111,6 +122,41 @@ test-server:
fixtures:
cd server && cargo test --test gen_fixtures -- --ignored
fixtures-client:
@test -n "$(GODOT)" || { echo "Godot not found. Run 'make setup' first."; exit 1; }
@echo "Generating GDScript fixtures for Rust decoder..."
$(GODOT) --headless --path client -s res://tests/gen_client_fixtures.gd
@echo "Verifying Rust can decode GDScript fixtures..."
cd server && cargo test --test serialization gdscript_generated_fixtures
@echo "--- Fixtures-client: PASS ---"
golden-diff:
@echo "Regenerating golden output for comparison..."
@mkdir -p .cache/golden
@cp server/tests/golden/proof_room_tick_10.json .cache/golden/before.json
@cd server && UPDATE_GOLDEN=1 cargo test --test golden_suite -- proof_room_tick_10_matches_golden; \
rc=$$?; \
cp ../server/tests/golden/proof_room_tick_10.json ../.cache/golden/after.json 2>/dev/null; \
cp ../.cache/golden/before.json ../server/tests/golden/proof_room_tick_10.json; \
if [ $$rc -ne 0 ]; then echo "Cargo test failed (golden file restored)."; exit $$rc; fi
@if diff -q .cache/golden/before.json .cache/golden/after.json >/dev/null 2>&1; then \
echo "--- Golden file: UP TO DATE ---"; \
else \
echo "--- Golden file has CHANGED ---"; \
echo ""; \
diff --color -u .cache/golden/before.json .cache/golden/after.json || true; \
echo ""; \
echo "Run 'make golden-update' to accept changes."; \
exit 1; \
fi
golden-update:
@echo "Regenerating golden file..."
@cd server && UPDATE_GOLDEN=1 cargo test --test golden_suite -- proof_room_tick_10_matches_golden
@git add server/tests/golden/
@echo "--- Golden file updated and staged ---"
@echo "Review with: git diff --cached -- server/tests/golden/"
test-client:
@test -n "$(GODOT)" || { echo "Godot not found. Run 'make setup' first."; exit 1; }
$(GODOT) --headless --path client -s res://addons/gdUnit4/bin/GdUnitCmdTool.gd --ignoreHeadlessMode -a res://tests/
@@ -148,13 +194,13 @@ pre-pr-validate: validate-content check-fact-ids
@echo "--- Content validation: PASS ---"
pre-pr-fixtures:
@echo "Checking fixture staleness..."
@echo "Checking Rust->GDScript fixture staleness..."
@cd server && cargo test --test gen_fixtures -- --ignored
@if git diff --quiet client/tests/fixtures/; then \
echo "--- Fixtures: UP TO DATE ---"; \
echo "--- Rust fixtures: UP TO DATE ---"; \
else \
echo ""; \
echo "--- FIXTURES STALE ---"; \
echo "--- RUST FIXTURES STALE ---"; \
echo " Protocol changed but fixtures not regenerated."; \
echo " Stale fixtures make all client tests FALSE POSITIVES."; \
echo ""; \
@@ -164,6 +210,22 @@ pre-pr-fixtures:
echo " Fix: commit the updated fixtures with your protocol change."; \
exit 1; \
fi
@echo "Checking GDScript->Rust fixture staleness..."
@$(MAKE) fixtures-client
@if git diff --quiet server/tests/fixtures/gdscript/; then \
echo "--- GDScript fixtures: UP TO DATE ---"; \
else \
echo ""; \
echo "--- GDSCRIPT FIXTURES STALE ---"; \
echo " Protocol changed but GDScript fixtures not regenerated."; \
echo " Stale fixtures make cross-encoder tests FALSE POSITIVES."; \
echo ""; \
echo " Changed files:"; \
git diff --stat server/tests/fixtures/gdscript/; \
echo ""; \
echo " Fix: commit the updated fixtures with your protocol change."; \
exit 1; \
fi
# Branch-specific variants (faster, scope-appropriate)
@@ -173,7 +235,7 @@ pre-pr-server: lint-server build-server test-server pre-pr-fixtures
pre-pr-client: lint-client build-client test-client
@echo "=== Client pre-PR: PASSED ==="
pre-pr-content: validate-content check-fact-ids
pre-pr-content: validate-content check-fact-ids checklist-validate
@echo "=== Content pre-PR: PASSED ==="
# --- CI (run locally) ---
@@ -214,6 +276,15 @@ validate-content:
check-fact-ids:
@tooling/check-fact-ids
checklist-validate:
@tooling/validate-checklist --check
checklist-generate:
@tooling/validate-checklist
perf-baseline:
@tooling/perf-baseline
content-ron:
cd tooling/content-converter && cargo build --release
tooling/content-converter/target/release/content-converter --input content --output content-ron --verbose
@@ -223,5 +294,5 @@ content-ron:
clean:
cd server && cargo clean || true
rm -rf .cache/*
rm -rf client/.godot client/reports
rm -rf client/.godot/* client/reports
@echo "Clean complete."
+11 -4
View File
@@ -87,7 +87,8 @@ static func _encode_message(buffer: StreamPeerBuffer, value):
elif 0 <= value and value <= (1 << 32) - 1:
buffer.put_u8(types["uint_32"])
buffer.put_u32(value)
elif - (1 << 63) <= value and value < (1 << 63):
elif value < 0:
# Negative beyond int_32 range — encode as int_64 (0xd3)
buffer.put_u8(types["int_64"])
buffer.put_64(value)
else:
@@ -133,7 +134,9 @@ static func _encode_message(buffer: StreamPeerBuffer, value):
return ERR_INVALID_DATA
for obj in value:
_encode_message(buffer, obj)
var inner_err = _encode_message(buffer, obj)
if inner_err:
return inner_err
TYPE_DICTIONARY:
var size = value.size()
@@ -150,8 +153,12 @@ static func _encode_message(buffer: StreamPeerBuffer, value):
return ERR_INVALID_DATA
for key in value:
_encode_message(buffer, key)
_encode_message(buffer, value[key])
var key_err = _encode_message(buffer, key)
if key_err:
return key_err
var val_err = _encode_message(buffer, value[key])
if val_err:
return val_err
TYPE_PACKED_BYTE_ARRAY:
var size = value.size()
Binary file not shown.
@@ -0,0 +1,19 @@
[remap]
importer="oggvorbisstr"
type="AudioStreamOggVorbis"
uid="uid://uflbvtm1aquk"
path="res://.godot/imported/cursor_hover.ogg-f20fa5adecb73cc9163569968f5867a3.oggvorbisstr"
[deps]
source_file="res://assets/audio/cursor_hover.ogg"
dest_files=["res://.godot/imported/cursor_hover.ogg-f20fa5adecb73cc9163569968f5867a3.oggvorbisstr"]
[params]
loop=false
loop_offset=0
bpm=0
beat_count=0
bar_beats=4
Binary file not shown.
@@ -0,0 +1,19 @@
[remap]
importer="oggvorbisstr"
type="AudioStreamOggVorbis"
uid="uid://bw0mfil2343po"
path="res://.godot/imported/fog_recognition.ogg-fd0dc7f57c3509774a533bc71a7687e7.oggvorbisstr"
[deps]
source_file="res://assets/audio/fog_recognition.ogg"
dest_files=["res://.godot/imported/fog_recognition.ogg-fd0dc7f57c3509774a533bc71a7687e7.oggvorbisstr"]
[params]
loop=false
loop_offset=0
bpm=0
beat_count=0
bar_beats=4
Binary file not shown.
@@ -0,0 +1,19 @@
[remap]
importer="oggvorbisstr"
type="AudioStreamOggVorbis"
uid="uid://dei8ikwaqyn67"
path="res://.godot/imported/implant_open.ogg-46eadc4ed252693e3a936e75ddfdf4bc.oggvorbisstr"
[deps]
source_file="res://assets/audio/implant_open.ogg"
dest_files=["res://.godot/imported/implant_open.ogg-46eadc4ed252693e3a936e75ddfdf4bc.oggvorbisstr"]
[params]
loop=false
loop_offset=0
bpm=0
beat_count=0
bar_beats=4
Binary file not shown.
@@ -0,0 +1,19 @@
[remap]
importer="oggvorbisstr"
type="AudioStreamOggVorbis"
uid="uid://b7iopmg6ftp4k"
path="res://.godot/imported/sfx_monologue_chime.ogg-5981f28716fbe14273c052560195d890.oggvorbisstr"
[deps]
source_file="res://assets/audio/sfx_monologue_chime.ogg"
dest_files=["res://.godot/imported/sfx_monologue_chime.ogg-5981f28716fbe14273c052560195d890.oggvorbisstr"]
[params]
loop=false
loop_offset=0
bpm=0
beat_count=0
bar_beats=4
Binary file not shown.
@@ -0,0 +1,19 @@
[remap]
importer="oggvorbisstr"
type="AudioStreamOggVorbis"
uid="uid://4m87qby6qdhc"
path="res://.godot/imported/sfx_monologue_chime_urgent.ogg-696a4dbc297417724610e01fa44fa575.oggvorbisstr"
[deps]
source_file="res://assets/audio/sfx_monologue_chime_urgent.ogg"
dest_files=["res://.godot/imported/sfx_monologue_chime_urgent.ogg-696a4dbc297417724610e01fa44fa575.oggvorbisstr"]
[params]
loop=false
loop_offset=0
bpm=0
beat_count=0
bar_beats=4
Binary file not shown.
@@ -0,0 +1,19 @@
[remap]
importer="oggvorbisstr"
type="AudioStreamOggVorbis"
uid="uid://dbu2lr4scswyn"
path="res://.godot/imported/sfx_stance_change.ogg-042fce976b2bd1b0806c72aa5dd64755.oggvorbisstr"
[deps]
source_file="res://assets/audio/sfx_stance_change.ogg"
dest_files=["res://.godot/imported/sfx_stance_change.ogg-042fce976b2bd1b0806c72aa5dd64755.oggvorbisstr"]
[params]
loop=false
loop_offset=0
bpm=0
beat_count=0
bar_beats=4
Binary file not shown.
@@ -0,0 +1,19 @@
[remap]
importer="oggvorbisstr"
type="AudioStreamOggVorbis"
uid="uid://c5lduxqgx0vme"
path="res://.godot/imported/sfx_weapon_aim_lock.ogg-649a54b41e9bf6dd8ab414c03bb48cfa.oggvorbisstr"
[deps]
source_file="res://assets/audio/sfx_weapon_aim_lock.ogg"
dest_files=["res://.godot/imported/sfx_weapon_aim_lock.ogg-649a54b41e9bf6dd8ab414c03bb48cfa.oggvorbisstr"]
[params]
loop=false
loop_offset=0
bpm=0
beat_count=0
bar_beats=4
Binary file not shown.
+19
View File
@@ -0,0 +1,19 @@
[remap]
importer="oggvorbisstr"
type="AudioStreamOggVorbis"
uid="uid://c2bqso5hkljq1"
path="res://.godot/imported/weapon_aim.ogg-0bff40b0820b0f9db4c2b0ae2542bfa0.oggvorbisstr"
[deps]
source_file="res://assets/audio/weapon_aim.ogg"
dest_files=["res://.godot/imported/weapon_aim.ogg-0bff40b0820b0f9db4c2b0ae2542bfa0.oggvorbisstr"]
[params]
loop=false
loop_offset=0
bpm=0
beat_count=0
bar_beats=4
Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2011 The Michroma Project Authors (https://github.com/googlefonts/Michroma-font)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+8
View File
@@ -0,0 +1,8 @@
[gd_resource type="FontVariation" load_steps=2 format=3]
[ext_resource type="FontFile" path="res://assets/fonts/Michroma-Regular.ttf" id="1_base"]
[resource]
base_font = ExtResource("1_base")
spacing_glyph = 1
spacing_space = 1
+20
View File
@@ -0,0 +1,20 @@
[gd_resource type="Theme" load_steps=2 format=3]
[ext_resource type="FontVariation" path="res://assets/fonts/michroma_ui.tres" id="1_font"]
[resource]
default_font = ExtResource("1_font")
default_font_size = 14
Label/colors/font_color = Color(0.878, 0.969, 0.98, 1)
Label/font_sizes/font_size = 14
RichTextLabel/colors/default_color = Color(0.878, 0.969, 0.98, 1)
RichTextLabel/font_sizes/normal_font_size = 14
Button/colors/font_color = Color(0.878, 0.969, 0.98, 1)
Button/colors/font_hover_color = Color(0.91, 0.773, 0.278, 1)
Button/font_sizes/font_size = 14
LineEdit/colors/font_color = Color(0.878, 0.969, 0.98, 1)
LineEdit/font_sizes/font_size = 14
+15
View File
@@ -24,6 +24,11 @@ UIStrings="*res://scripts/autoloads/ui_strings.gd"
FogState="*res://scripts/autoloads/fog_state.gd"
AudioManager="*res://scripts/autoloads/audio_manager.gd"
[gui]
theme/custom="res://assets/theme/game_theme.tres"
theme/custom_font="res://assets/fonts/michroma_ui.tres"
[display]
window/size/viewport_width=1920
@@ -106,6 +111,16 @@ stance_down={
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":88,"key_label":0,"unicode":120,"location":0,"echo":false,"script":null)
]
}
bug_report={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194343,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
teleport_hub={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194317,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
[rendering]
+13 -2
View File
@@ -1,4 +1,4 @@
[gd_scene load_steps=17 format=3 uid="uid://bswrmh7w8dbgm"]
[gd_scene load_steps=20 format=3 uid="uid://bswrmh7w8dbgm"]
[ext_resource type="Script" path="res://scripts/main.gd" id="1_main"]
[ext_resource type="Script" path="res://scripts/rendering/world_renderer.gd" id="2_world"]
@@ -16,6 +16,9 @@
[ext_resource type="PackedScene" path="res://ui/world_radial.tscn" id="14_radial"]
[ext_resource type="PackedScene" path="res://ui/dialogue_box.tscn" id="15_dialogue"]
[ext_resource type="Script" path="res://scripts/rendering/fog_entities.gd" id="16_fogent"]
[ext_resource type="PackedScene" path="res://ui/gauntlet_hud.tscn" id="17_gauntlet"]
[ext_resource type="PackedScene" path="res://ui/checklist_overlay.tscn" id="18_checklist"]
[ext_resource type="PackedScene" path="res://ui/bug_report_dialog.tscn" id="19_bugreport"]
[node name="Game" type="Node2D"]
script = ExtResource("1_main")
@@ -131,6 +134,12 @@ layer = 20
; D-053: Stance indicator — top-right, color-coded
[node name="StanceIndicator" parent="UILayer" instance=ExtResource("13_stance")]
; #496: Gauntlet HUD — room timer + personal bests, hidden in non-gauntlet mode
[node name="GauntletHUD" parent="UILayer" instance=ExtResource("17_gauntlet")]
; #503: Auto-checklist overlay — condition progress in gauntlet mode
[node name="ChecklistOverlay" parent="UILayer" instance=ExtResource("18_checklist")]
; D-065: Inventory grid — 3x3, bottom-right, 40x40px, 1-9 hotkeys
[node name="InventoryGrid" parent="UILayer" instance=ExtResource("12_inv")]
@@ -140,6 +149,8 @@ script = ExtResource("10_cursor")
; --- Modal layer (CanvasLayer 30) ---
; Full-screen overlays: pause menu, inventory modal, death screen.
; Empty for Sprint 6 — exists so the layer is reserved in the tree.
[node name="ModalLayer" type="CanvasLayer" parent="."]
layer = 30
; #495: WRONG button (F12) — bug report capture dialog
[node name="BugReportDialog" parent="ModalLayer" instance=ExtResource("19_bugreport")]
@@ -0,0 +1 @@
uid://cdkut8u7sxwmb
@@ -0,0 +1 @@
uid://de816lfn5xeqs
+14
View File
@@ -36,6 +36,10 @@ var current_dialogue: Variant = null # {npc_name, npc_entity_id, speech, option
# InputMapper suppresses movement when this is true.
var dialogue_active: bool = false
# v8 fields (#496): Gauntlet mode — room timer + personal bests
var room_id: Variant = null # String room_id from snapshot, null in non-gauntlet mode
var gauntlet_mode: bool = false # true when snapshot includes gauntlet_mode flag
# v7 fields (#431, D-059/D-060)
var pending_recognitions: Array = [] # [{entity_id, x, y, z, remaining_ticks, total_delay_ticks}]
@@ -118,6 +122,16 @@ func apply_snapshot(snapshot: Dictionary) -> void:
else:
pending_recognitions = []
# v8: gauntlet mode (#496) — room_id and gauntlet_mode
if snapshot.has("gauntlet_mode") and snapshot.gauntlet_mode == true:
gauntlet_mode = true
else:
gauntlet_mode = false
if snapshot.has("room_id") and snapshot.room_id is String:
room_id = snapshot.room_id
else:
room_id = null
# v2: visible_tiles with visibility sectors
# Derives visible_positions when not explicitly provided (real server mode)
if snapshot.has("visible_tiles") and snapshot.visible_tiles is Array and snapshot.visible_tiles.size() > 0:
+126 -12
View File
@@ -3,7 +3,11 @@ extends Node
# Semantic actions — NO raw key codes cross the bridge
# Movement uses hold-to-move (polled each frame in _process).
# Discrete actions (interact, stance, etc.) use press events (_unhandled_input).
# Composite diagonals: holding W+D simultaneously → northeast.
#
# D-054: Mouse-relative facing and movement.
# Mouse position determines facing direction (client-side float).
# WASD is relative to facing: W = toward cursor, S = away, A/D = strafe.
# Server receives facing octant only — the full float stays client-side.
#
# Movement throttle: client-side rate limit per stance (D-053).
# Sprint=5/s, Walk=2.5/s, Careful=1.7/s, Crouch=1.25/s.
@@ -14,10 +18,19 @@ enum Action {
MOVE_SOUTH, MOVE_SOUTHWEST, MOVE_WEST, MOVE_NORTHWEST,
INTERACT, USE_PERCEPTION_MODE, OPEN_MENU, PAUSE, UNPAUSE,
TOGGLE_STANCE_UP, TOGGLE_STANCE_DOWN,
BUG_REPORT, # #495: F12 WRONG button — client-only, not sent to server
SET_FACING, # D-054: facing octant update (no movement)
TELEPORT_HUB, # #501: Home key — Gauntlet dev teleport (not production fast-travel)
}
var input_queue: Array[Dictionary] = []
# D-054: Client-side facing angle (radians). 0=East, -PI/2=North, PI/2=South.
# Updated every frame from mouse position. EntityRenderer reads this for indicator.
var facing_angle: float = -PI / 2.0 # Default: North
var facing_octant: String = "North" # Derived from facing_angle
var _last_sent_octant: String = "North" # Track to avoid redundant sends
# Minimum milliseconds between movement commands, per stance.
# Tuned so Walk feels like walking, Sprint feels fast but readable.
const MOVE_INTERVAL_MS := {
@@ -30,27 +43,44 @@ var _last_move_msec: int = 0
# Hold-to-move: poll held direction keys each frame, throttled by stance.
# D-054: WASD is now mouse-relative. W = toward cursor, A/D = strafe.
# Server-side cooldown (D-053) is authoritative; this prevents client flooding.
# D-064: movement suppressed during dialogue (walk-away handled by dialogue_box).
func _process(_delta: float) -> void:
# D-054: Update facing angle from mouse position every frame
_update_facing_from_mouse()
if GameState.dialogue_active:
return
var dir := Vector2i.ZERO
if Input.is_action_pressed("move_north"):
dir.y -= 1
if Input.is_action_pressed("move_south"):
dir.y += 1
if Input.is_action_pressed("move_east"):
dir.x += 1
if Input.is_action_pressed("move_west"):
dir.x -= 1
if dir != Vector2i.ZERO:
# D-054: Send facing octant to server when it changes (even without movement)
if facing_octant != _last_sent_octant:
_last_sent_octant = facing_octant
input_queue.append({
"action": Action.SET_FACING,
"timestamp_msec": Time.get_ticks_msec(),
"action_data": {"facing": facing_octant},
})
# Poll held WASD keys
var raw_dir := Vector2i.ZERO
if Input.is_action_pressed("move_north"):
raw_dir.y -= 1
if Input.is_action_pressed("move_south"):
raw_dir.y += 1
if Input.is_action_pressed("move_east"):
raw_dir.x += 1
if Input.is_action_pressed("move_west"):
raw_dir.x -= 1
if raw_dir != Vector2i.ZERO:
var now := Time.get_ticks_msec()
var interval: int = MOVE_INTERVAL_MS.get(GameState.player_stance, 200)
if now - _last_move_msec >= interval:
_last_move_msec = now
var action: Action = _dir_to_action(dir)
# D-054: Transform WASD input relative to mouse facing
var world_dir := _wasd_to_world_dir(raw_dir)
var action: Action = _dir_to_action(world_dir)
input_queue.append({
"action": action,
"timestamp_msec": now,
@@ -74,6 +104,11 @@ func _unhandled_input(event: InputEvent) -> void:
action = Action.TOGGLE_STANCE_UP
elif event.is_action_pressed("stance_down"):
action = Action.TOGGLE_STANCE_DOWN
elif event.is_action_pressed("bug_report"):
action = Action.BUG_REPORT
elif event.is_action_pressed("teleport_hub"):
if GameState.gauntlet_mode:
action = Action.TELEPORT_HUB
if action != -1:
input_queue.append({
@@ -89,6 +124,85 @@ func flush_queue() -> Array[Dictionary]:
return queue
## Reset facing state to default (North). Use in tests per D-030 testability.
func reset_facing_state() -> void:
facing_angle = -PI / 2.0
facing_octant = "North"
_last_sent_octant = "North"
# D-054: Compute facing angle from mouse position relative to player screen position.
# Uses viewport canvas transform to convert world coords to screen coords.
# Intentional coupling: reads GameState.player_position directly — InputMapper is an
# autoload that runs before game loop rendering, so position is always current-tick.
func _update_facing_from_mouse() -> void:
var vp := get_viewport()
if vp == null:
return
var canvas_xf := vp.get_canvas_transform()
var player_world_px := GameState.player_position * Constants.TILE_SIZE
var player_screen := canvas_xf * player_world_px
var mouse_screen := vp.get_mouse_position()
var delta := mouse_screen - player_screen
# Only update if mouse is meaningfully distant from player (avoid jitter at center)
if delta.length_squared() > 4.0:
facing_angle = delta.angle()
facing_octant = _angle_to_octant(facing_angle)
# D-054: Transform raw WASD input (screen-space) to world direction relative to mouse facing.
# W (+Y up in input, mapped to forward), S (backward), A (strafe left), D (strafe right).
# Raw input: W=(-Y), S=(+Y), A=(-X), D=(+X) in screen coords.
# Forward = facing_angle direction. Output: nearest octant direction vector.
func _wasd_to_world_dir(raw_dir: Vector2i) -> Vector2i:
# Build a continuous direction vector relative to facing.
# raw_dir.y: -1 = W (forward), +1 = S (backward)
# raw_dir.x: -1 = A (strafe left), +1 = D (strafe right)
var forward := Vector2(cos(facing_angle), sin(facing_angle))
var right := Vector2(-forward.y, forward.x) # 90° clockwise
# Combine: forward/back from W/S, strafe from A/D
var world_float := forward * float(-raw_dir.y) + right * float(raw_dir.x)
# Snap to nearest octant direction
return _snap_to_octant_dir(world_float)
# Snap a floating-point direction vector to the nearest of 8 cardinal/diagonal directions.
static func _snap_to_octant_dir(dir: Vector2) -> Vector2i:
if dir.length_squared() < 0.001:
return Vector2i.ZERO
var angle := dir.angle()
# Quantize to nearest 45° (PI/4)
var octant := roundi(angle / (PI / 4.0))
match octant:
0: return Vector2i(1, 0) # East
1: return Vector2i(1, 1) # Southeast
2, -6: return Vector2i(0, 1) # South
3, -5: return Vector2i(-1, 1) # Southwest
4, -4: return Vector2i(-1, 0) # West
-3, 5: return Vector2i(-1, -1) # Northwest
-2: return Vector2i(0, -1) # North
-1: return Vector2i(1, -1) # Northeast
_: return Vector2i.ZERO
# D-054: Convert a facing angle (radians) to the nearest octant name.
# Godot 2D: 0=East, PI/2=South, -PI/2=North.
static func _angle_to_octant(angle: float) -> String:
var octant := roundi(angle / (PI / 4.0))
match octant:
0: return "East"
1: return "Southeast"
2, -6: return "South"
3, -5: return "Southwest"
4, -4: return "West"
-3, 5: return "Northwest"
-2: return "North"
-1: return "Northeast"
_: return "East"
# Map a direction vector to the corresponding movement Action.
# Handles all 8 directions via composite W+D, W+A, etc.
static func _dir_to_action(dir: Vector2i) -> Action:
+28 -5
View File
@@ -10,6 +10,8 @@ var _test_player_pos: Vector2i = Vector2i(10, 10)
var _test_facing: String = "North"
var _test_input_queue: Array = [] # Queued actions for test mode
var _test_in_dialogue: bool = false # Mock dialogue state (#434)
var _test_gauntlet_mode: bool = false # #501: Gauntlet mode for dev teleport guard
var _test_npc_relationship: String = "Unknown" # #521: NPC relationship for D-033 color
var _last_snapshot: Variant = null # Most recent decoded snapshot (consumed by poll_snapshot)
var _outbound_buffer: Array[Dictionary] = [] # Raw inputs awaiting batch encode + transport
@@ -40,6 +42,8 @@ func reset_test_state() -> void:
_test_facing = "North"
_test_input_queue.clear()
_test_in_dialogue = false
_test_gauntlet_mode = false
_test_npc_relationship = "Unknown"
# Change connection state and emit signal
func _set_state(new_state: ConnectionState) -> void:
@@ -177,7 +181,16 @@ func send_input(player_input: Dictionary) -> Error:
var action: int = player_input.get("action", -1)
var wire_name: String = _action_enum_to_wire(action)
if not wire_name.is_empty():
_test_input_queue.append(wire_name)
if wire_name == "SetFacing":
# D-054: Use action_data.facing from the input dict, not InputMapper global
var facing: String = ""
var action_data: Variant = player_input.get("action_data")
if action_data is Dictionary:
facing = str(action_data.get("facing", ""))
if not facing.is_empty():
_test_facing = facing
else:
_test_input_queue.append(wire_name)
return OK
var action_name := _action_enum_to_wire(player_input.get("action", -1))
if action_name.is_empty():
@@ -255,9 +268,13 @@ static func _action_enum_to_wire(action: int) -> String:
InputMapper.Action.TOGGLE_STANCE_UP: return "ToggleStanceUp"
InputMapper.Action.TOGGLE_STANCE_DOWN: return "ToggleStanceDown"
InputMapper.Action.OPEN_MENU:
# Client-only action, not part of wire protocol
push_warning("SimBridge: OPEN_MENU is client-only, not sent to server")
return ""
return "" # Client-only action, not part of wire protocol
InputMapper.Action.BUG_REPORT:
return "" # Client-only action (#495), not part of wire protocol
InputMapper.Action.SET_FACING:
return "SetFacing" # D-054: facing octant update (no movement)
InputMapper.Action.TELEPORT_HUB:
return "TeleportToHub" # #501: Gauntlet dev teleport (not production fast-travel)
_:
push_warning("SimBridge: unknown action enum %s" % action)
return ""
@@ -273,6 +290,11 @@ func _test_snapshot() -> Dictionary:
# Process queued inputs
for action_name in _test_input_queue:
if action_name == "TeleportToHub":
# #501: Reset to hub spawn position, clear dialogue
_test_player_pos = Vector2i(10, 10)
_test_in_dialogue = false
continue
if action_name == "Interact":
# Mock dialogue trigger (#434): if near NPC, start dialogue
var npc_pos := Vector2i(12, 9)
@@ -285,7 +307,6 @@ func _test_snapshot() -> Dictionary:
if _test_is_walkable(new_pos):
_test_player_pos = new_pos
if delta != Vector2i.ZERO:
_test_facing = _delta_to_facing(delta)
# Walk-away dismisses dialogue (D-064)
if _test_in_dialogue:
_test_in_dialogue = false
@@ -316,6 +337,7 @@ func _test_snapshot() -> Dictionary:
"z": 0,
"kind": { "variant": "Npc", "data": null },
"visibility": sector,
"relationship": _test_npc_relationship,
})
# v4: nearby_interactions when NPC is nearby and visible (#404/#405)
@@ -394,6 +416,7 @@ func _test_snapshot() -> Dictionary:
"current_monologue": monologue,
"current_dialogue": dialogue,
"pending_recognitions": pending_recs,
"gauntlet_mode": _test_gauntlet_mode,
}
# Generate a small test room: 8x6 room with walls, a door, and floor
@@ -0,0 +1,354 @@
extends RefCounted
## #503: Auto-checklist progress tracking — evaluates ObserverSnapshot against
## checklist YAML conditions and latches satisfied conditions.
##
## Usage:
## var evaluator := ChecklistEvaluator.new()
## evaluator.load_room("inventory_warehouse")
## evaluator.evaluate() # call each tick
## var results := evaluator.get_results()
##
## Condition types (per checklist.schema.json):
## player_near, player_facing, entity_present, entity_absent,
## expected_monologue, expected_dialogue, expected_interaction_verb
##
## Spec ref: D-030 (testability), checklist.schema.json (#497).
var _room_conditions: Array = [] # Conditions from per-room checklist
var _cross_conditions: Array = [] # Conditions from cross_room_checks.yaml
var _latched: Dictionary = {} # condition_id -> true (once met, stays met)
var _current_room_id: String = ""
var _content_base: String = "" # Absolute path to content/ directory
var _loaded: bool = false
func _init() -> void:
# Content directory lives at repo root (content/), one level above the Godot
# project (client/). In editor/dev mode we resolve via the project path.
# In exported builds, content is expected at res://content/ (copied by export
# preset) — the globalize fallback won't exist, so check res:// first.
if DirAccess.dir_exists_absolute("res://content"):
_content_base = ProjectSettings.globalize_path("res://content")
else:
var project_path := ProjectSettings.globalize_path("res://")
_content_base = project_path.path_join("../content")
## Load checklist for a room. Clears per-room latches; cross-room latches persist.
func load_room(room_id: String) -> void:
if room_id == _current_room_id and _loaded:
return
_current_room_id = room_id
_room_conditions.clear()
# Clear per-room latches (keep cross-room latches)
var cross_ids := {}
for cond in _cross_conditions:
cross_ids[cond.get("id", "")] = true
var kept := {}
for cid in _latched:
if cross_ids.has(cid):
kept[cid] = true
_latched = kept
# Load per-room checklist
var room_path := _content_base.path_join(
"gauntlet/rooms/%s/checklist.yaml" % room_id)
var room_data := _load_checklist_file(room_path)
if room_data.has("conditions"):
_room_conditions = room_data["conditions"]
_warn_empty_ids(_room_conditions, room_path)
# Load cross-room checks (only on first load)
if _cross_conditions.is_empty():
var cross_path := _content_base.path_join("gauntlet/cross_room_checks.yaml")
var cross_data := _load_checklist_file(cross_path)
if cross_data.has("conditions"):
_cross_conditions = cross_data["conditions"]
_warn_empty_ids(_cross_conditions, cross_path)
_loaded = true
## Evaluate all conditions against current GameState. Latches newly met conditions.
func evaluate() -> void:
for cond in _room_conditions + _cross_conditions:
var cid: String = cond.get("id", "")
if cid.is_empty() or _latched.has(cid):
continue
if _evaluate_condition(cond):
_latched[cid] = true
## Returns array of {id, description, met} for all loaded conditions.
## Conditions with empty id are excluded (invalid, cannot be latched).
func get_results() -> Array:
var results: Array = []
for cond in _room_conditions + _cross_conditions:
var cid: String = cond.get("id", "")
if cid.is_empty():
continue
results.append({
"id": cid,
"description": cond.get("description", ""),
"condition_type": cond.get("condition_type", ""),
"met": _latched.has(cid),
})
return results
## Total number of loaded conditions (excludes conditions with empty id).
func get_total_count() -> int:
var count: int = 0
for cond in _room_conditions + _cross_conditions:
if not cond.get("id", "").is_empty():
count += 1
return count
## Number of latched (met) conditions.
func get_met_count() -> int:
return _latched.size()
## Whether all conditions are met.
func is_complete() -> bool:
return get_met_count() >= get_total_count() and get_total_count() > 0
## Whether any checklist is loaded.
func is_loaded() -> bool:
return _loaded
## Reset all state (room change to null, or disconnect).
func reset() -> void:
_room_conditions.clear()
_cross_conditions.clear()
_latched.clear()
_current_room_id = ""
_loaded = false
static func _warn_empty_ids(conditions: Array, path: String) -> void:
for i in conditions.size():
if conditions[i].get("id", "").is_empty():
push_warning("ChecklistEvaluator: condition at index %d in %s has empty id — will be excluded from results" % [i, path])
# -- Condition evaluation ------------------------------------------------------
func _evaluate_condition(cond: Dictionary) -> bool:
match cond.get("condition_type", ""):
"player_near":
return _eval_player_near(cond)
"player_facing":
return _eval_player_facing(cond)
"entity_present":
return _eval_entity_present(cond)
"entity_absent":
return _eval_entity_absent(cond)
"expected_monologue":
return _eval_expected_monologue(cond)
"expected_dialogue":
return _eval_expected_dialogue(cond)
"expected_interaction_verb":
return _eval_expected_interaction_verb(cond)
push_warning("ChecklistEvaluator: unknown condition_type '%s'" % cond.get("condition_type", ""))
return false
## x/y and radius are in tile coordinates (matching GameState.player_position),
## not pixels. D-066 dual-scale: YAML authors write tile coords, pixel conversion
## happens only at render time.
func _eval_player_near(cond: Dictionary) -> bool:
var tx: float = float(cond.get("x", 0))
var ty: float = float(cond.get("y", 0))
var radius: float = float(cond.get("radius", 0.0))
var target := Vector2(tx, ty)
return GameState.player_position.distance_to(target) <= radius
func _eval_player_facing(cond: Dictionary) -> bool:
var direction: String = str(cond.get("direction", ""))
# Schema uses 4-cardinal (North/South/East/West).
# GameState uses 8-directional. Exact match only.
return GameState.player_facing == direction
func _eval_entity_present(cond: Dictionary) -> bool:
var entity_id: int = int(cond.get("entity_id", -1))
return _find_entity(entity_id)
func _eval_entity_absent(cond: Dictionary) -> bool:
var entity_id: int = int(cond.get("entity_id", -1))
return not _find_entity(entity_id)
func _eval_expected_monologue(cond: Dictionary) -> bool:
var contains: String = str(cond.get("contains", ""))
if GameState.current_monologue == null:
return false
var text: String = str(GameState.current_monologue.get("text", ""))
return text.find(contains) >= 0
func _eval_expected_dialogue(cond: Dictionary) -> bool:
var contains: String = str(cond.get("contains", ""))
if GameState.current_dialogue == null:
return false
var text: String = str(GameState.current_dialogue.get("speech", ""))
return text.find(contains) >= 0
func _eval_expected_interaction_verb(cond: Dictionary) -> bool:
var entity_id: int = int(cond.get("entity_id", -1))
var verb: String = str(cond.get("verb", ""))
for interaction in GameState.nearby_interactions:
if not interaction is Dictionary:
continue
if int(interaction.get("entity_id", -1)) != entity_id:
continue
var verbs: Array = interaction.get("verbs", [])
for v in verbs:
if not v is Dictionary:
continue
if str(v.get("label", "")) == verb or str(v.get("kind", "")) == verb:
if v.get("available", true):
return true
return false
# -- Helpers -------------------------------------------------------------------
func _find_entity(entity_id: int) -> bool:
for entity in GameState.visible_entities:
if not entity is Dictionary:
continue
if int(entity.get("entity_id", -1)) == entity_id:
return true
return false
# -- YAML parsing (checklist-specific) -----------------------------------------
# Handles the constrained checklist YAML format: top-level key:value pairs,
# a conditions array of flat dictionaries. No nested arrays or anchors.
#
# Limitation: unquoted values containing " #" are truncated at the comment marker.
# Use quoted strings ("value # with hash") if values must contain literal hashes.
func _load_checklist_file(path: String) -> Dictionary:
if not FileAccess.file_exists(path):
return {}
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
push_warning("ChecklistEvaluator: cannot open %s" % path)
return {}
var text := file.get_as_text()
file.close()
return parse_checklist_yaml(text)
static func parse_checklist_yaml(text: String) -> Dictionary:
var result := {}
var conditions: Array = []
var current_item: Dictionary = {}
var in_conditions := false
for line in text.split("\n"):
var stripped := line.strip_edges(false, true)
if stripped.is_empty() or stripped.strip_edges().begins_with("#"):
continue
var indent := line.length() - line.lstrip(" ").length()
var content := stripped.strip_edges()
# Detect conditions: array header
if content == "conditions:":
in_conditions = true
continue
if not in_conditions:
# Top-level key: value
var colon := content.find(":")
if colon >= 0:
var key := content.substr(0, colon).strip_edges()
var val_str := content.substr(colon + 1).strip_edges()
result[key] = _parse_value(val_str)
else:
if content.begins_with("- "):
# New array item — flush previous
if not current_item.is_empty():
conditions.append(current_item)
current_item = {}
var rest := content.substr(2).strip_edges()
var colon := rest.find(":")
if colon >= 0:
var key := rest.substr(0, colon).strip_edges()
var val_str := rest.substr(colon + 1).strip_edges()
current_item[key] = _parse_value(val_str)
elif indent >= 2 and not current_item.is_empty():
# Continuation of current array item
var colon := content.find(":")
if colon >= 0:
var key := content.substr(0, colon).strip_edges()
var val_str := content.substr(colon + 1).strip_edges()
current_item[key] = _parse_value(val_str)
elif indent == 0:
# Back to top level — shouldn't happen in valid checklist YAML
in_conditions = false
if not current_item.is_empty():
conditions.append(current_item)
current_item = {}
var colon := content.find(":")
if colon >= 0:
var key := content.substr(0, colon).strip_edges()
var val_str := content.substr(colon + 1).strip_edges()
result[key] = _parse_value(val_str)
# Flush last item
if not current_item.is_empty():
conditions.append(current_item)
if not conditions.is_empty():
result["conditions"] = conditions
return result
static func _parse_value(val: String) -> Variant:
if val.is_empty():
return ""
# Strip inline comments (not inside quotes)
if not val.begins_with("\""):
var comment_pos := val.find(" #")
if comment_pos >= 0:
val = val.substr(0, comment_pos).strip_edges()
# Quoted string
if val.begins_with("\""):
var end_quote := val.find("\"", 1)
if end_quote > 0:
return val.substr(1, end_quote - 1)
return val.substr(1)
# Boolean
if val == "true":
return true
if val == "false":
return false
# Float (contains decimal point)
if val.contains(".") and val.is_valid_float():
return val.to_float()
# Integer
if val.is_valid_int():
return val.to_int()
# Plain string
return val
+18 -2
View File
@@ -59,13 +59,22 @@ const ENTITY_COLOR_HOSTILE: Color = Color("#d45d5d") # Hostile/Dangerous —
const ENTITY_COLOR_OBJECT: Color = Color("#8b8ba0") # Static objects — muted grey
const ENTITY_COLOR_PLAYER: Color = Color("#e0e8ff") # Player character (detective)
# D-033 color lookup by entity kind (Phase 1: defaults, Phase 2 #361: relationship-based)
# D-033 color lookup by relationship string (#521)
static func color_for_relationship(relationship: String) -> Color:
match relationship:
"Friendly": return ENTITY_COLOR_FRIENDLY
"PersonOfInterest": return ENTITY_COLOR_POI
"Hostile": return ENTITY_COLOR_HOSTILE
"Unknown": return ENTITY_COLOR_UNKNOWN
_: return ENTITY_COLOR_UNKNOWN
# D-033 color lookup by entity data — uses relationship for NPCs (#521)
static func color_for_entity_kind(entity_data: Dictionary) -> Color:
var kind_variant: String = entity_data.get("kind", {}).get("variant", "")
match kind_variant:
"Player": return ENTITY_COLOR_PLAYER
"Npc": return ENTITY_COLOR_UNKNOWN
"Object", "Terrain": return ENTITY_COLOR_OBJECT
"Npc": return color_for_relationship(entity_data.get("relationship", "Unknown"))
_: return ENTITY_COLOR_OBJECT
# D-048/D-056: Insert-styled UI color palette
@@ -80,3 +89,10 @@ const PERIPHERAL_ALPHA: float = 0.5
# Facing direction indicator
const FACING_INDICATOR_SIZE: float = 6.0
const FACING_INDICATOR_OFFSET: float = 14.0
# #517: Implant UI font color grading — avoid pure white, project through a lens
const IMPLANT_TEXT_COLOR: Color = Color("#E0F7FA") # Cyan-white — primary text
const IMPLANT_TEXT_DIM: Color = Color("#9EBFC4") # Dimmed variant — secondary text
const IMPLANT_PULSE_MIN: float = 0.85 # Alpha pulse floor
const IMPLANT_PULSE_MAX: float = 1.0 # Alpha pulse ceiling
const IMPLANT_PULSE_PERIOD: float = 2.5 # Seconds per pulse cycle
+99 -2
View File
@@ -12,11 +12,16 @@ extends Node2D
@onready var inventory_grid = $UILayer/InventoryGrid # D-065: z-layer 7
@onready var stance_indicator = $UILayer/StanceIndicator # D-053: z-layer 7
@onready var cursor_renderer = $UILayer/CursorRenderer # D-056: z-layer 7
@onready var gauntlet_hud = $UILayer/GauntletHUD # #496: room timer + personal bests
@onready var checklist_overlay = $UILayer/ChecklistOverlay # #503: auto-checklist progress
@onready var bug_report_dialog = $ModalLayer/BugReportDialog # #495: F12 WRONG button
var _last_dialogue_npc_id: int = -1 # D-064: NPC entity_id for WalkAway input
var _camera_anchored: bool = false
var _last_monologue_tick: int = -1 # Prevent re-consuming monologue when same tick polled twice
var _last_dialogue_tick: int = -1
var _flash_rect: ColorRect = null # #502/#501: ephemeral screen flash overlay (shared: teleport preempts amber)
var _teleport_in_progress: bool = false # #501: defer smoothing re-enable by one frame after teleport
func _ready() -> void:
print("The Settled Reach — client initialized")
@@ -49,13 +54,22 @@ func _ready() -> void:
dialogue_box.dialogue_dismissed.connect(_on_dialogue_dismissed)
dialogue_box.confrontation_monologue.connect(_on_confrontation_monologue)
# #496: Print gauntlet session summary on disconnect
if gauntlet_hud:
SimBridge.connection_state_changed.connect(_on_connection_state_changed)
func _process(_delta: float) -> void:
# Main game loop: poll snapshot, apply state, flush input
var snapshot: Variant = SimBridge.poll_snapshot()
if snapshot != null:
var old_pos := GameState.player_position
GameState.apply_snapshot(snapshot)
# #501: Detect teleport (large position jump > 5 tiles) and trigger fade
if _camera_anchored and _detect_teleport(old_pos, GameState.player_position):
_teleport_transition()
# Late anchor: live mode — first snapshot arrives during _process.
# Smoothing is already OFF (disabled in _ready), so setting
# global_position takes effect immediately with no lerp.
@@ -88,6 +102,14 @@ func _process(_delta: float) -> void:
if fog_entities and fog_entities.has_method("update_from_state"):
fog_entities.update_from_state()
# #496: Update gauntlet HUD (room timer + personal bests)
if gauntlet_hud and gauntlet_hud.has_method("update_from_state"):
gauntlet_hud.update_from_state()
# #503: Update checklist overlay (auto-checklist progress tracking)
if checklist_overlay and checklist_overlay.has_method("update_from_state"):
checklist_overlay.update_from_state()
# Show monologue if server sent one this tick (#414)
_consume_monologue()
@@ -102,13 +124,24 @@ func _process(_delta: float) -> void:
# rendered used smoothing=OFF (correct viewport from frame one). Now we
# turn smoothing back on and sync its internal state so subsequent frames
# get smooth camera tracking during gameplay.
# #501: Skip re-enable during teleport — _teleport_transition() disables
# smoothing for a clean camera snap. Defer by one frame to avoid the
# re-enable block in the same _process() call undoing the snap.
if _camera_anchored and not camera.position_smoothing_enabled:
camera.position_smoothing_enabled = true
camera.reset_smoothing()
if _teleport_in_progress:
_teleport_in_progress = false
else:
camera.position_smoothing_enabled = true
camera.reset_smoothing()
# Send queued input to simulation
var inputs = InputMapper.flush_queue()
for input in inputs:
# #495: F12 WRONG button — client-only, trigger bug report capture
if input.action == InputMapper.Action.BUG_REPORT:
if bug_report_dialog and not bug_report_dialog.is_active():
bug_report_dialog.start_capture()
continue
if input.action == InputMapper.Action.INTERACT:
# D-057: prefer interaction list (multi-verb), fall back to prompt (v0.1)
var target_id: int = -1
@@ -144,6 +177,10 @@ func _consume_monologue() -> void:
_last_monologue_tick = GameState.current_tick
var mono: Dictionary = GameState.current_monologue
monologue_display.show_monologue(mono.get("text", ""), mono.get("duration_seconds", 5.0))
# #502: Amber flash on room reset
var mono_id: String = mono.get("id", "")
if mono_id.begins_with("room_reset"):
_screen_flash(Constants.ENTITY_COLOR_POI, 0.15)
GameState.current_monologue = null
@@ -197,3 +234,63 @@ func _on_dialogue_dismissed() -> void:
"verb": "WalkAway",
},
})
# #496: Finalize gauntlet stats on disconnect
func _on_connection_state_changed(old_state: SimBridge.ConnectionState, new_state: SimBridge.ConnectionState) -> void:
if new_state == SimBridge.ConnectionState.DISCONNECTED and gauntlet_hud:
gauntlet_hud.finalize()
# #501: Detect large position jump indicating a teleport (not normal movement).
const TELEPORT_DISTANCE_THRESHOLD: float = 5.0
func _detect_teleport(old_pos: Vector2, new_pos: Vector2) -> bool:
return old_pos.distance_to(new_pos) > TELEPORT_DISTANCE_THRESHOLD
# #501: Gauntlet dev teleport transition — snap camera + 0.3s fade-from-black.
# Clears dialogue/monologue/interaction state (server clears its side too).
# Scoped to Gauntlet testing only — production fast-travel uses diegetic gates.
func _teleport_transition() -> void:
# Snap camera: disable smoothing, force re-anchor.
# _teleport_in_progress defers smoothing re-enable by one frame so the
# re-enable block at the bottom of _process() doesn't undo the snap.
camera.position_smoothing_enabled = false
camera.global_position = GameState.player_position * Constants.TILE_SIZE
_camera_anchored = true
_teleport_in_progress = true
# Clear client-side buffers
GameState.current_monologue = null
GameState.current_dialogue = null
GameState.dialogue_active = false
if dialogue_box and dialogue_box.is_dialogue_active():
dialogue_box.hide_dialogue()
# Fade from black: instant black overlay, fades to transparent over 0.3s
if _flash_rect and is_instance_valid(_flash_rect):
_flash_rect.queue_free()
_flash_rect = ColorRect.new()
_flash_rect.color = Color(0, 0, 0, 1.0)
_flash_rect.anchors_preset = Control.PRESET_FULL_RECT
_flash_rect.mouse_filter = Control.MOUSE_FILTER_IGNORE
$UILayer.add_child(_flash_rect)
var tween := create_tween()
tween.tween_property(_flash_rect, "color:a", 0.0, 0.3)
tween.tween_callback(_flash_rect.queue_free)
# #502: Full-screen color flash — fades from color to transparent over duration.
# Used for room reset amber flash. Creates ephemeral ColorRect on UILayer.
func _screen_flash(color: Color, duration: float) -> void:
if _flash_rect and is_instance_valid(_flash_rect):
_flash_rect.queue_free()
_flash_rect = ColorRect.new()
_flash_rect.color = Color(color.r, color.g, color.b, 0.4)
_flash_rect.anchors_preset = Control.PRESET_FULL_RECT
_flash_rect.mouse_filter = Control.MOUSE_FILTER_IGNORE
$UILayer.add_child(_flash_rect)
var tween := create_tween()
tween.tween_property(_flash_rect, "color:a", 0.0, duration)
tween.tween_callback(_flash_rect.queue_free)
+27 -14
View File
@@ -11,7 +11,7 @@ class_name Protocol
## Protocol version — must match server PROTOCOL_VERSION in bridge/types.rs.
## Reject snapshots where version != this value.
const PROTOCOL_VERSION: int = 7
const PROTOCOL_VERSION: int = 8
# -- Decode: bytes from server → GDScript types --------------------------------
@@ -169,6 +169,17 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
"options": dialogue_options,
}
# v8: dialogue_response (#305, D-028) — NPC's spoken dialogue line from server
# Separate from current_dialogue (client-side rich dialogue state).
var dialogue_response: Variant = null
var raw_dr: Variant = raw.get("dialogue_response")
if raw_dr is Dictionary and raw_dr.has("text"):
dialogue_response = {
"line_id": str(raw_dr.get("line_id", "")),
"text": str(raw_dr["text"]),
"speaker_entity_id": int(raw_dr.get("speaker_entity_id", -1)),
}
return {
"tick": tick,
"entities": entities,
@@ -182,6 +193,7 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
"nearby_interactions": nearby_interactions,
"current_monologue": current_monologue,
"current_dialogue": current_dialogue,
"dialogue_response": dialogue_response,
"pending_recognitions": pending_recognitions,
}
@@ -293,13 +305,7 @@ static func _decode_enum_variant(raw) -> Dictionary:
## "Interact", "UsePerceptionMode", "Pause", "Unpause"
## action_data: null for unit variants, String for UsePerceptionMode
static func encode_player_input(tick: int, action_name: String, action_data: Variant = null) -> PackedByteArray:
var action_value: Variant
if action_data != null:
# Data variant → single-element map
action_value = { action_name: action_data }
else:
# Unit variant → bare string
action_value = action_name
var action_value: Variant = _encode_action(action_name, action_data)
var input := {
"tick": tick,
@@ -322,14 +328,9 @@ static func encode_player_inputs(inputs: Array) -> PackedByteArray:
for input in inputs:
var action_name: String = input["action_name"]
var action_data: Variant = input.get("action_data")
var action_value: Variant
if action_data != null:
action_value = { action_name: action_data }
else:
action_value = action_name
wire_inputs.append({
"tick": input["tick"],
"action": action_value,
"action": _encode_action(action_name, action_data),
})
var result = Messagepack.encode(wire_inputs)
@@ -340,6 +341,18 @@ static func encode_player_inputs(inputs: Array) -> PackedByteArray:
return result.value
## Encode a PlayerAction for the wire.
## Struct variants (Interact) always need their fields even when null.
## Unit variants (MoveNorth, Pause, etc.) encode as bare strings.
static func _encode_action(action_name: String, action_data: Variant) -> Variant:
if action_data != null:
return { action_name: action_data }
# Interact is a struct variant — server expects named fields, not a bare string
if action_name == "Interact":
return { "Interact": { "target_entity_id": null, "verb": null } }
return action_name
## Decode a PlayerInput from MessagePack bytes (used in tests / echo scenarios).
## Returns { "tick": int, "action": { "variant": String, "data": Variant } } or null.
static func decode_player_input(bytes: PackedByteArray) -> Variant:
@@ -0,0 +1 @@
uid://bkbfcalfo1kk8
+46 -17
View File
@@ -23,6 +23,11 @@ const LERP_SPEED: float = 12.0
var entity_nodes: Dictionary = {} # entity_id -> Node2D
var _entity_targets: Dictionary = {} # entity_id -> Vector2 (target pixel position)
var _entity_relationships: Dictionary = {} # #521: entity_id -> String (last relationship)
var _entity_tweens: Dictionary = {} # #521: entity_id -> {target: Color, elapsed: float}
# #521: Color transition duration in seconds (D-033: "0.5s fade")
const COLOR_FADE_DURATION: float = 0.5
func _ready() -> void:
print("EntityRenderer: Initialized")
@@ -40,6 +45,22 @@ func _process(delta: float) -> void:
if not node.position.is_equal_approx(target):
node.position = node.position.lerp(target, weight)
# #521: Advance color transitions (manual lerp, testable without SceneTree)
var finished_ids: Array = []
for entity_id in _entity_tweens.keys():
if not entity_nodes.has(entity_id):
finished_ids.append(entity_id)
continue
var tween_data: Dictionary = _entity_tweens[entity_id]
tween_data.elapsed += delta
var t := clampf(tween_data.elapsed / COLOR_FADE_DURATION, 0.0, 1.0)
var node_c: ColorRect = entity_nodes[entity_id] as ColorRect
node_c.color = tween_data.from.lerp(tween_data.target, t)
if t >= 1.0:
finished_ids.append(entity_id)
for eid in finished_ids:
_entity_tweens.erase(eid)
# Update entities from snapshot data
func update_entities(entities: Array) -> void:
@@ -75,12 +96,12 @@ func _create_entity_node(entity_id: int, entity_data: Dictionary) -> void:
entity_node.size = Vector2(ENTITY_SIZE, ENTITY_SIZE)
entity_node.pivot_offset = Vector2(ENTITY_SIZE / 2.0, ENTITY_SIZE / 2.0)
# D-033 color by entity kind (Phase 1 default)
# TODO(#361): derive from RelationshipState via knowledge graph
# D-033 color by relationship (#521)
entity_node.color = _color_for_kind(entity_data)
add_child(entity_node)
entity_nodes[entity_id] = entity_node
_entity_relationships[entity_id] = entity_data.get("relationship", "Unknown")
# Add facing indicator for the player entity
if entity_id == GameState.player_entity_id:
@@ -112,6 +133,22 @@ func _update_entity_node(entity_id: int, entity_data: Dictionary) -> void:
floorf(entity_data.y) * TILE_SIZE + ENTITY_OFFSET
)
# #521: Detect relationship change → fade D-033 color (0.5s via _process)
var new_rel: String = entity_data.get("relationship", "Unknown")
var old_rel: String = _entity_relationships.get(entity_id, "Unknown")
if new_rel != old_rel:
_entity_relationships[entity_id] = new_rel
var new_color := _color_for_kind(entity_data)
_entity_tweens[entity_id] = {
"from": entity_node.color,
"target": new_color,
"elapsed": 0.0,
}
# Note: modulate.a (peripheral dimming below) and color (D-033 tint above)
# are compositionally independent — both can change simultaneously without
# interference. If alpha tweening is added later, coordinate with color tween.
# v2: Peripheral vision dimming (D-015)
# null visibility (v1 backward compat) defaults to full alpha
var visibility: Variant = entity_data.get("visibility")
@@ -119,11 +156,14 @@ func _update_entity_node(entity_id: int, entity_data: Dictionary) -> void:
if not is_equal_approx(entity_node.modulate.a, target_alpha):
entity_node.modulate.a = target_alpha
# v2: Update facing indicator rotation (player entity only)
# D-054: Update facing indicator from client-side mouse angle (not server).
# InputMapper.facing_angle is a continuous float — smoother than octant snapping.
if entity_id == GameState.player_entity_id:
var indicator = entity_node.get_node_or_null("FacingIndicator")
if indicator != null:
indicator.rotation = _facing_to_rotation(GameState.player_facing)
# facing_angle: 0=East, -PI/2=North. Indicator: 0=North (up).
# Rotate from North basis: add PI/2 to convert.
indicator.rotation = InputMapper.facing_angle + PI / 2.0
# Remove an entity node
func _remove_entity_node(entity_id: int) -> void:
@@ -134,6 +174,8 @@ func _remove_entity_node(entity_id: int) -> void:
entity_node.queue_free()
entity_nodes.erase(entity_id)
_entity_targets.erase(entity_id)
_entity_relationships.erase(entity_id)
_entity_tweens.erase(entity_id)
# D-033 color by entity kind — delegates to Constants.color_for_entity_kind
static func _color_for_kind(entity_data: Dictionary) -> Color:
@@ -155,16 +197,3 @@ func _add_facing_indicator(parent_node: Control) -> void:
# Position at center of parent ColorRect — rotation around this point
indicator.position = Vector2(ENTITY_SIZE / 2.0, ENTITY_SIZE / 2.0)
parent_node.add_child(indicator)
# Convert facing direction string to rotation in radians (0 = North/up)
static func _facing_to_rotation(facing: String) -> float:
match facing:
"North": return 0.0
"Northeast": return PI / 4.0
"East": return PI / 2.0
"Southeast": return 3.0 * PI / 4.0
"South": return PI
"Southwest": return 5.0 * PI / 4.0
"West": return 3.0 * PI / 2.0
"Northwest": return 7.0 * PI / 4.0
_: return 0.0
@@ -0,0 +1 @@
uid://cw154tgjdyhe1
@@ -0,0 +1 @@
uid://dje4vilxh5xxu
+11 -7
View File
@@ -5,14 +5,15 @@ extends TileMapLayer
# Uses a programmatic TileSet with placeholder colored rectangles (D-014)
#
# Tile types (atlas coords in the programmatic source):
# (0,0) = floor — dark gray
# (1,0) = wall — lighter gray
# (2,0) = door — brown
# (3,0) = object — teal
# (0,0) = floor — dark gray
# (1,0) = wall — lighter gray
# (2,0) = door — brown
# (3,0) = object — teal
# (4,0) = reset_plate — amber (#502)
const TILE_SIZE: int = Constants.TILE_SIZE
enum TileType { FLOOR = 0, WALL = 1, DOOR = 2, OBJECT = 3 }
enum TileType { FLOOR = 0, WALL = 1, DOOR = 2, OBJECT = 3, RESET_PLATE = 4 }
# Wire-format string to TileType mapping
const TILE_TYPE_MAP: Dictionary = {
@@ -20,6 +21,7 @@ const TILE_TYPE_MAP: Dictionary = {
"wall": TileType.WALL,
"door": TileType.DOOR,
"object": TileType.OBJECT,
"reset_plate": TileType.RESET_PLATE,
}
var _initialized: bool = false
@@ -36,7 +38,7 @@ func _setup_tileset() -> void:
# Create an atlas source backed by a programmatic image
var source := TileSetAtlasSource.new()
var img := Image.create(TILE_SIZE * 4, TILE_SIZE, false, Image.FORMAT_RGBA8)
var img := Image.create(TILE_SIZE * 5, TILE_SIZE, false, Image.FORMAT_RGBA8)
# Floor (0,0) — dark gray
_fill_tile(img, 0, Color(0.18, 0.18, 0.22))
@@ -46,13 +48,15 @@ func _setup_tileset() -> void:
_fill_tile_with_border(img, 2, Color(0.5, 0.35, 0.2), Color(0.35, 0.25, 0.15))
# Object (3,0) — teal
_fill_tile(img, 3, Color(0.2, 0.45, 0.45))
# Reset plate (4,0) — amber (#502)
_fill_tile_with_border(img, 4, Color(0.91, 0.77, 0.28), Color(0.65, 0.55, 0.2))
var tex := ImageTexture.create_from_image(img)
source.texture = tex
source.texture_region_size = Vector2i(TILE_SIZE, TILE_SIZE)
# Create tile entries in the atlas
for i in range(4):
for i in range(5):
source.create_tile(Vector2i(i, 0))
var source_id := ts.add_source(source)
+1
View File
@@ -0,0 +1 @@
uid://bcc1wwpk3moh1
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+173
View File
@@ -0,0 +1,173 @@
## Generate MessagePack fixtures from GDScript encoder for Rust decoder testing.
## Run with: make fixtures-client
## (or: godot --headless --path client -s res://tests/gen_client_fixtures.gd)
##
## Reverse direction of server/tests/gen_fixtures.rs: GDScript encodes -> Rust decodes.
## Together with the Rust-generated fixtures, this closes the cross-encoder
## compatibility loop (D-030 Layer 1, #475).
##
## Loads Messagepack encoder directly (not via class_name) because -s scripts
## run before the project's class_name registry is fully populated.
extends SceneTree
var _Msgpack: GDScript
var _count := 0
var _errors := 0
var _output_dir: String
func _init():
_run.call_deferred()
func _run():
_Msgpack = load("res://addons/messagepack/messagepack.gd")
# Resolve repo root from Godot project root (client/).
# Assumes client/ is one level below repo root — validated below.
var project_root := ProjectSettings.globalize_path("res://")
var repo_root := project_root.rstrip("/").get_base_dir()
_output_dir = repo_root.path_join("server/tests/fixtures/gdscript")
if not DirAccess.dir_exists_absolute(repo_root.path_join("server")):
push_error("Repo root detection failed: %s/server/ does not exist" % repo_root)
quit(1)
return
DirAccess.make_dir_recursive_absolute(_output_dir)
_generate_inputs()
_generate_boundary_inputs()
_generate_batch()
if _errors > 0:
push_error("FAILED: %d encode errors encountered" % _errors)
quit(1)
return
if _count == 0:
push_error("FAILED: no fixtures generated")
quit(1)
return
print("Generated %d GDScript fixtures at %s" % [_count, _output_dir])
quit()
func _write_fixture(name: String, bytes: PackedByteArray) -> void:
if bytes.is_empty():
push_error("Encode produced empty bytes for fixture: %s" % name)
_errors += 1
return
var path := _output_dir.path_join(name + ".msgpack")
var file := FileAccess.open(path, FileAccess.WRITE)
if file == null:
push_error("Failed to write fixture: %s (error: %d)" % [path, FileAccess.get_open_error()])
_errors += 1
return
file.store_buffer(bytes)
file.flush()
file.close()
print(" Wrote %s (%d bytes)" % [name, bytes.size()])
_count += 1
## Encode a single PlayerInput to MessagePack bytes.
## Mirrors Protocol.encode_player_input() from protocol.gd.
func _encode_input(tick: int, action_name: String, action_data: Variant = null) -> PackedByteArray:
var action: Variant
if action_data != null:
action = {action_name: action_data}
elif action_name == "Interact":
action = {"Interact": {"target_entity_id": null, "verb": null}}
else:
action = action_name
var result = _Msgpack.encode({"tick": tick, "action": action})
if result.status != null:
push_error("Encode failed: %s" % result.status)
return PackedByteArray()
return result.value
## Encode an array of PlayerInputs to MessagePack bytes.
## Mirrors Protocol.encode_player_inputs() from protocol.gd.
func _encode_inputs(inputs: Array) -> PackedByteArray:
var wire_inputs: Array = []
for input in inputs:
var action_name: String = input["action_name"]
var action_data: Variant = input.get("action_data")
var action: Variant
if action_data != null:
action = {action_name: action_data}
elif action_name == "Interact":
action = {"Interact": {"target_entity_id": null, "verb": null}}
else:
action = action_name
wire_inputs.append({"tick": input["tick"], "action": action})
var result = _Msgpack.encode(wire_inputs)
if result.status != null:
push_error("Batch encode failed: %s" % result.status)
return PackedByteArray()
return result.value
func _generate_inputs() -> void:
# Unit variants: all 8 movement directions (tick=100)
for dir_name in [
"MoveNorth", "MoveSouth", "MoveEast", "MoveWest",
"MoveNortheast", "MoveSoutheast", "MoveSouthwest", "MoveNorthwest",
]:
_write_fixture("input_%s" % dir_name.to_snake_case(),
_encode_input(100, dir_name))
# Data variant: UsePerceptionMode (tick=200)
_write_fixture("input_perception_mode",
_encode_input(200, "UsePerceptionMode", "thermal"))
# Struct variant: Interact with null fields (tick=100)
_write_fixture("input_interact",
_encode_input(100, "Interact"))
# Other unit variants
_write_fixture("input_pause",
_encode_input(100, "Pause"))
_write_fixture("input_unpause",
_encode_input(100, "Unpause"))
_write_fixture("input_toggle_stance_up",
_encode_input(100, "ToggleStanceUp"))
_write_fixture("input_toggle_stance_down",
_encode_input(100, "ToggleStanceDown"))
_write_fixture("input_walk_away",
_encode_input(100, "WalkAway"))
func _generate_boundary_inputs() -> void:
# Tick values at MessagePack encoding format boundaries.
# GDScript encodes 256-32767 as int_16 (0xd1); Rust encodes as uint_16 (0xcd).
# GDScript encodes 65536-2147483647 as int_32 (0xd2); Rust as uint_32 (0xce).
# Both are valid MessagePack. Rust's rmp_serde must accept both.
var boundary_ticks: Array = [
[0, "boundary_tick_0"],
[127, "boundary_tick_127"],
[128, "boundary_tick_128"],
[255, "boundary_tick_255"],
[256, "boundary_tick_256"], # int_16 asymmetry start
[32767, "boundary_tick_32767"], # int_16 asymmetry end
[32768, "boundary_tick_32768"],
[65535, "boundary_tick_65535"],
[65536, "boundary_tick_65536"], # int_32 asymmetry start
[2147483647, "boundary_tick_2147483647"], # int_32 asymmetry end
]
for pair in boundary_ticks:
_write_fixture(pair[1],
_encode_input(pair[0], "Pause"))
func _generate_batch() -> void:
# Vec<PlayerInput> with two actions (mirrors Rust input_batch_two fixture)
_write_fixture("input_batch_two",
_encode_inputs([
{"tick": 0, "action_name": "MoveNorth"},
{"tick": 0, "action_name": "Interact"},
]))
+1
View File
@@ -0,0 +1 @@
uid://njychcgqlvtv
+612
View File
@@ -0,0 +1,612 @@
## Anti-tedium regression tests (#494):
## Guard against UI clutter that makes testing tedious.
##
## Test 1: F12 bug report capture — pressing F12 must not crash; when #495 lands,
## the handler should pause, show capture dialog, save 3 files, unpause.
## Test 2: Gauntlet progress UI hidden — in non-Gauntlet mode (no room_id or
## gauntlet_mode flag in snapshot), timer and personal-bests must not appear.
##
## #495 blocked by #481/#490 (server). #496 blocked by #487 (server).
## Tests stub blocked features and verify anti-tedium guards.
## Spec ref: Sprint 9 briefing (client.md), #495, #496.
class_name TestAntiTedium
extends GdUnitTestSuite
var _instance: Node = null
var GauntletHUDScript = load("res://ui/gauntlet_hud.gd")
var BugReportDialogScript = load("res://ui/bug_report_dialog.gd")
func before_test() -> void:
SimBridge.reset_test_state()
SimBridge._last_snapshot = null
GameState.current_tick = 0
GameState.player_position = Vector2.ZERO
GameState.visible_entities = []
GameState.visible_tiles = []
GameState.visible_positions = {}
GameState.current_monologue = null
GameState.current_dialogue = null
GameState.game_time = {}
GameState.pending_recognitions = []
GameState.room_id = null
GameState.gauntlet_mode = false
func after_test() -> void:
if _instance and is_instance_valid(_instance):
_instance.queue_free()
_instance = null
# -- Helpers -------------------------------------------------------------------
## Encode a minimal valid snapshot as MessagePack bytes (same helper as P0 tests).
func _make_snapshot_bytes(overrides: Dictionary = {}) -> PackedByteArray:
var snapshot := {
"tick": overrides.get("tick", 1),
"version": Protocol.PROTOCOL_VERSION,
"entities": overrides.get("entities", [{
"entity_id": 1,
"x": 10.0,
"y": 10.0,
"z": 0,
"kind": "Player",
"visibility": "Forward",
}]),
"game_time": {
"day": 0,
"time_of_day": 0,
"day_phase": "Morning",
"tick_rate": overrides.get("tick_rate", "Full"),
},
"player_facing": "North",
"player_stance": "Walk",
"player_inventory": [],
"visible_tiles": [],
"nearby_interactions": [],
"pending_recognitions": [],
}
if overrides.has("current_monologue"):
snapshot["current_monologue"] = overrides["current_monologue"]
if overrides.has("current_dialogue"):
snapshot["current_dialogue"] = overrides["current_dialogue"]
# Gauntlet fields: only include if explicitly provided (absence = non-gauntlet)
if overrides.has("room_id"):
snapshot["room_id"] = overrides["room_id"]
if overrides.has("gauntlet_mode"):
snapshot["gauntlet_mode"] = overrides["gauntlet_mode"]
var result = Messagepack.encode(snapshot)
return result.value
func _make_gauntlet_hud() -> Control:
var hud = Control.new()
hud.set_script(GauntletHUDScript)
auto_free(hud)
add_child(hud)
hud._personal_bests = {} # Clear any stats loaded from disk
return hud
func _make_bug_report_dialog() -> Control:
var dialog = Control.new()
dialog.set_script(BugReportDialogScript)
auto_free(dialog)
add_child(dialog)
return dialog
# -- Test 1: F12 Bug Report Capture -------------------------------------------
# #495: BUG_REPORT action triggers the WRONG button capture flow.
# Verifies: dialog exists, activates on action, no state corruption.
# Note: Input.parse_input_event + _unhandled_input is unreliable in headless
# test mode. Tests drive the input queue directly for deterministic coverage.
func test_bug_report_dialog_exists_in_scene() -> void:
# Verify the BugReportDialog node is present and hidden by default.
var scene := load("res://scenes/main.tscn")
_instance = scene.instantiate()
auto_free(_instance)
add_child(_instance)
_instance._process(0.016)
var dialog: Node = _find_node_recursive(_instance, "BugReportDialog")
assert_that(dialog).override_failure_message(
"BugReportDialog node should exist in scene tree"
).is_not_null()
if dialog is CanvasItem:
assert_that((dialog as CanvasItem).visible).override_failure_message(
"BugReportDialog should be hidden by default"
).is_false()
func test_bug_report_activates_on_action() -> void:
# Inject BUG_REPORT action directly into the queue and verify main.gd
# triggers the dialog. This tests the full main._process() handling path.
var scene := load("res://scenes/main.tscn")
_instance = scene.instantiate()
auto_free(_instance)
add_child(_instance)
_instance._process(0.016)
# Record baseline state
var mono_before: Variant = GameState.current_monologue
var dialogue_before: Variant = GameState.current_dialogue
# Inject BUG_REPORT action into InputMapper queue
InputMapper.input_queue.append({
"action": InputMapper.Action.BUG_REPORT,
"timestamp_msec": Time.get_ticks_msec(),
})
# Process a frame — main.gd flushes the queue and triggers dialog
_instance._process(0.016)
# Assert: dialog should be active
var dialog: Node = _find_node_recursive(_instance, "BugReportDialog")
assert_that(dialog).is_not_null()
if dialog and dialog.has_method("is_active"):
assert_that(dialog.is_active()).override_failure_message(
"BugReportDialog should be active after BUG_REPORT action"
).is_true()
# Assert: no unintended state corruption
assert_that(GameState.current_monologue).override_failure_message(
"BUG_REPORT should not spawn a monologue"
).is_equal(mono_before)
assert_that(GameState.current_dialogue).override_failure_message(
"BUG_REPORT should not spawn a dialogue"
).is_equal(dialogue_before)
# -- Test 2: Gauntlet Progress UI Hidden in Non-Gauntlet Mode -----------------
# When #496 lands, it adds a room timer and personal-bests overlay.
# Anti-tedium guard: these must NOT be visible in normal (non-Gauntlet) play.
#
# Current state: no gauntlet UI exists in the scene tree.
# This test guards against #496 accidentally showing gauntlet UI in all modes.
func test_no_gauntlet_ui_visible_in_default_mode() -> void:
# Load main scene — represents non-Gauntlet (default) play mode
var scene := load("res://scenes/main.tscn")
_instance = scene.instantiate()
auto_free(_instance)
add_child(_instance)
# Process one frame to initialize all children
_instance._process(0.016)
# Check that no gauntlet-specific UI nodes are visible in the scene tree.
# When #496 adds GauntletHUD/RoomTimer/PersonalBests, they must be hidden
# by default (only shown when gauntlet_mode is true in the snapshot).
var gauntlet_node_names := [
"GauntletHUD", "RoomTimer", "PersonalBests", "GauntletOverlay",
"GauntletTimer", "GauntletProgress",
]
for node_name in gauntlet_node_names:
var node: Node = _find_node_recursive(_instance, node_name)
if node != null and node is CanvasItem:
assert_that((node as CanvasItem).visible).override_failure_message(
"Gauntlet UI node '%s' must not be visible in non-Gauntlet mode" % node_name
).is_false()
func test_snapshot_without_room_id_shows_no_gauntlet_ui() -> void:
# A snapshot that lacks room_id and gauntlet_mode fields represents
# normal play. Apply it and verify no gauntlet state leaks into GameState.
var bytes := _make_snapshot_bytes({"tick": 1})
SimBridge.receive_bytes(bytes)
var snapshot: Variant = SimBridge._last_snapshot
assert_that(snapshot).is_not_null()
# Snapshot should NOT contain gauntlet fields
assert_that(snapshot.has("room_id")).override_failure_message(
"Non-gauntlet snapshot must not contain room_id"
).is_false()
assert_that(snapshot.has("gauntlet_mode")).override_failure_message(
"Non-gauntlet snapshot must not contain gauntlet_mode"
).is_false()
# Apply to GameState — gauntlet-related state should not exist
GameState.apply_snapshot(snapshot)
# #496: GameState now has room_id (Variant, null) and gauntlet_mode (bool, false).
# A non-gauntlet snapshot must leave these at their defaults.
assert_that(GameState.room_id).override_failure_message(
"GameState.room_id should be null in non-gauntlet mode"
).is_null()
assert_that(GameState.gauntlet_mode).override_failure_message(
"GameState.gauntlet_mode should be false in non-gauntlet mode"
).is_false()
func test_gauntlet_ui_stays_hidden_after_multiple_snapshots() -> void:
# Simulate several ticks of normal play — gauntlet UI must never appear.
var scene := load("res://scenes/main.tscn")
_instance = scene.instantiate()
auto_free(_instance)
add_child(_instance)
# Process 5 frames with normal (non-gauntlet) snapshots
for tick in range(1, 6):
var bytes := _make_snapshot_bytes({"tick": tick})
SimBridge.receive_bytes(bytes)
_instance._process(0.016)
# After 5 frames, no gauntlet nodes should have appeared
var gauntlet_node_names := [
"GauntletHUD", "RoomTimer", "PersonalBests", "GauntletOverlay",
"GauntletTimer", "GauntletProgress",
]
for node_name in gauntlet_node_names:
var node: Node = _find_node_recursive(_instance, node_name)
if node != null and node is CanvasItem:
assert_that((node as CanvasItem).visible).override_failure_message(
"Gauntlet UI '%s' must stay hidden after %d non-gauntlet ticks" % [node_name, 5]
).is_false()
# -- GauntletHUD Feature Tests (#496) ----------------------------------------
# Timer lifecycle, personal bests, room change, visibility.
# Spec: sprint-9/client.md #496. Ticket: db/connectors/ticket show 496.
func test_format_time_zero() -> void:
# Static utility: 0 seconds → "00:00"
var hud := _make_gauntlet_hud()
assert_that(hud._format_time(0.0)).is_equal("00:00")
func test_format_time_sub_minute() -> void:
# 47.9 seconds (truncates, no rounding) → "00:47"
var hud := _make_gauntlet_hud()
assert_that(hud._format_time(47.9)).is_equal("00:47")
func test_format_time_over_minute() -> void:
# 98.3 seconds → 1 min 38 sec → "01:38"
var hud := _make_gauntlet_hud()
assert_that(hud._format_time(98.3)).is_equal("01:38")
func test_gauntlet_hud_shows_when_gauntlet_mode_active() -> void:
# HUD must become visible when gauntlet_mode is true in the snapshot.
var hud := _make_gauntlet_hud()
assert_that(hud.visible).is_false()
GameState.gauntlet_mode = true
GameState.room_id = "room_1"
hud.update_from_state()
assert_that(hud.visible).override_failure_message(
"GauntletHUD must be visible when gauntlet_mode is true"
).is_true()
func test_gauntlet_hud_hides_when_gauntlet_mode_deactivates() -> void:
# HUD must hide when gauntlet_mode transitions from true to false.
var hud := _make_gauntlet_hud()
GameState.gauntlet_mode = true
GameState.room_id = "room_1"
hud.update_from_state()
assert_that(hud.visible).is_true()
# Switch to non-gauntlet
GameState.gauntlet_mode = false
GameState.room_id = null
hud.update_from_state()
assert_that(hud.visible).override_failure_message(
"GauntletHUD must hide when gauntlet_mode becomes false"
).is_false()
func test_gauntlet_hud_timer_starts_on_room_entry() -> void:
# Timer starts running and tracks room_id after room entry.
var hud := _make_gauntlet_hud()
GameState.gauntlet_mode = true
GameState.room_id = "room_1"
hud.update_from_state()
assert_that(hud.is_timer_running()).override_failure_message(
"Timer should be running after room entry"
).is_true()
assert_that(hud.get_current_room_id()).is_equal("room_1")
func test_gauntlet_hud_timer_increments_with_delta() -> void:
# Timer accumulates real time via _process(delta).
var hud := _make_gauntlet_hud()
GameState.gauntlet_mode = true
GameState.room_id = "room_1"
hud.update_from_state()
hud._process(0.5)
assert_that(hud.get_timer_seconds()).override_failure_message(
"Timer should increment by delta (0.5s)"
).is_equal_approx(0.5, 0.01)
hud._process(1.0)
assert_that(hud.get_timer_seconds()).override_failure_message(
"Timer should accumulate (0.5 + 1.0 = 1.5s)"
).is_equal_approx(1.5, 0.01)
func test_gauntlet_hud_timer_resets_on_room_change() -> void:
# Changing room_id resets the timer to 0 and starts counting for the new room.
var hud := _make_gauntlet_hud()
GameState.gauntlet_mode = true
GameState.room_id = "room_1"
hud.update_from_state()
hud._process(5.0)
assert_that(hud.get_timer_seconds() > 4.0).is_true()
# Change room
GameState.room_id = "room_2"
hud.update_from_state()
assert_that(hud.get_timer_seconds()).override_failure_message(
"Timer should reset to 0 on room change"
).is_equal_approx(0.0, 0.01)
assert_that(hud.get_current_room_id()).is_equal("room_2")
assert_that(hud.is_timer_running()).is_true()
func test_gauntlet_hud_records_personal_best() -> void:
# Completing a room (by entering a new room) records a personal best.
var hud := _make_gauntlet_hud()
GameState.gauntlet_mode = true
GameState.room_id = "room_1"
hud.update_from_state()
hud._process(10.0)
# Entering room_2 triggers _record_room_completion for room_1
GameState.room_id = "room_2"
hud.update_from_state()
assert_that(hud.get_personal_best("room_1")).override_failure_message(
"PB for room_1 should be ~10.0s after first completion"
).is_equal_approx(10.0, 0.1)
func test_gauntlet_hud_non_pb_does_not_overwrite() -> void:
# A worse time must not overwrite an existing personal best.
var hud := _make_gauntlet_hud()
hud._personal_bests["room_1"] = 5.0 # Existing PB of 5 seconds
GameState.gauntlet_mode = true
GameState.room_id = "room_1"
hud.update_from_state()
hud._process(10.0) # Worse time than existing PB
GameState.room_id = "room_2"
hud.update_from_state()
assert_that(hud.get_personal_best("room_1")).override_failure_message(
"PB should remain 5.0 when new time (10.0) is worse"
).is_equal_approx(5.0, 0.01)
func test_gauntlet_hud_pb_overwrites_when_faster() -> void:
# A faster time must overwrite an existing personal best.
var hud := _make_gauntlet_hud()
hud._personal_bests["room_1"] = 15.0 # Existing PB of 15 seconds
GameState.gauntlet_mode = true
GameState.room_id = "room_1"
hud.update_from_state()
hud._process(8.0) # Better time
GameState.room_id = "room_2"
hud.update_from_state()
assert_that(hud.get_personal_best("room_1")).override_failure_message(
"PB should update to 8.0 when new time beats old PB (15.0)"
).is_equal_approx(8.0, 0.1)
func test_gauntlet_hud_null_room_in_gauntlet_mode() -> void:
# Gauntlet mode active but no room_id yet — HUD visible, timer stopped.
var hud := _make_gauntlet_hud()
GameState.gauntlet_mode = true
GameState.room_id = null
hud.update_from_state()
assert_that(hud.visible).override_failure_message(
"HUD should be visible in gauntlet mode even without room_id"
).is_true()
assert_that(hud.is_timer_running()).override_failure_message(
"Timer should NOT run when room_id is null"
).is_false()
func test_gauntlet_hud_timer_paused_when_not_visible() -> void:
# Timer should not accumulate when HUD is not visible (non-gauntlet mode).
var hud := _make_gauntlet_hud()
GameState.gauntlet_mode = true
GameState.room_id = "room_1"
hud.update_from_state()
hud._process(2.0)
assert_that(hud.get_timer_seconds()).is_equal_approx(2.0, 0.01)
# Switch to non-gauntlet (hides HUD)
GameState.gauntlet_mode = false
hud.update_from_state()
hud._process(5.0) # Should NOT accumulate
assert_that(hud.get_timer_seconds()).override_failure_message(
"Timer must not accumulate when HUD is hidden"
).is_equal_approx(2.0, 0.01)
func test_gauntlet_hud_finalize_records_current_room() -> void:
# finalize() records the current room's time and stops the timer.
# Called on disconnect via _on_connection_state_changed.
var hud := _make_gauntlet_hud()
GameState.gauntlet_mode = true
GameState.room_id = "room_1"
hud.update_from_state()
hud._process(7.5)
hud.finalize()
assert_that(hud.is_timer_running()).override_failure_message(
"Timer should stop after finalize"
).is_false()
assert_that(hud.get_personal_best("room_1")).override_failure_message(
"Finalize should record current room's time as PB"
).is_equal_approx(7.5, 0.1)
func test_gauntlet_hud_session_tracks_attempts() -> void:
# Each room entry increments the attempt counter in _session_rooms.
var hud := _make_gauntlet_hud()
GameState.gauntlet_mode = true
GameState.room_id = "room_1"
hud.update_from_state()
hud._process(3.0)
# Re-enter same room (via room change and back)
GameState.room_id = "room_2"
hud.update_from_state()
GameState.room_id = "room_1"
hud.update_from_state()
assert_that(hud._session_rooms.has("room_1")).is_true()
assert_that(hud._session_rooms["room_1"]["attempts"]).override_failure_message(
"Room should show 2 attempts after two entries"
).is_equal(2)
func test_gauntlet_snapshot_roundtrip_via_apply() -> void:
# Snapshot with gauntlet fields applied to GameState, consumed by HUD.
# End-to-end: bytes → decode → apply → update_from_state.
var bytes := _make_snapshot_bytes({
"tick": 10,
"gauntlet_mode": true,
"room_id": "warehouse_01",
})
SimBridge.receive_bytes(bytes)
GameState.apply_snapshot(SimBridge._last_snapshot)
assert_that(GameState.gauntlet_mode).is_true()
assert_that(GameState.room_id).is_equal("warehouse_01")
var hud := _make_gauntlet_hud()
hud.update_from_state()
assert_that(hud.visible).is_true()
assert_that(hud.get_current_room_id()).is_equal("warehouse_01")
assert_that(hud.is_timer_running()).is_true()
# -- BugReportDialog Feature Tests (#495) ------------------------------------
# Pause/unpause lifecycle, wire guard, text render, state machine.
# Spec: sprint-9/client.md #495. Ticket: db/connectors/ticket show 495.
func test_bug_report_sends_pause_on_open() -> void:
# start_capture() must send Pause to the server.
SimBridge.connect_to_sim()
SimBridge._test_input_queue.clear()
var dialog := _make_bug_report_dialog()
dialog.start_capture()
assert_that(dialog.is_active()).is_true()
assert_that(SimBridge._test_input_queue.has("Pause")).override_failure_message(
"Opening bug report should send Pause to server"
).is_true()
func test_bug_report_sends_unpause_on_close() -> void:
# _close() must send Unpause to the server.
SimBridge.connect_to_sim()
var dialog := _make_bug_report_dialog()
dialog.start_capture()
SimBridge._test_input_queue.clear()
dialog._close()
assert_that(dialog.is_active()).is_false()
assert_that(SimBridge._test_input_queue.has("Unpause")).override_failure_message(
"Closing bug report should send Unpause to server"
).is_true()
func test_bug_report_not_double_activatable() -> void:
# Calling start_capture() twice must not double-activate or send duplicate Pause.
SimBridge.connect_to_sim()
var dialog := _make_bug_report_dialog()
dialog.start_capture()
assert_that(dialog.is_active()).is_true()
SimBridge._test_input_queue.clear()
dialog.start_capture() # Second call — should be blocked
assert_that(SimBridge._test_input_queue.size()).override_failure_message(
"Double-activation should be blocked (no second Pause sent)"
).is_equal(0)
func test_bug_report_action_not_on_wire() -> void:
# BUG_REPORT is client-only — must not produce a wire-format action name.
var wire_name := SimBridge._action_enum_to_wire(InputMapper.Action.BUG_REPORT)
assert_that(wire_name).override_failure_message(
"BUG_REPORT must not produce a wire action name (client-only)"
).is_equal("")
func test_bug_report_render_text_contains_tick_and_entities() -> void:
# _render_snapshot_text() must include tick number and entity data from GameState.
GameState.current_tick = 42
GameState.player_position = Vector2(5.0, 7.0)
GameState.player_facing = "South"
GameState.player_stance = "Crouch"
GameState.visible_entities = [{
"entity_id": 1, "x": 5.0, "y": 7.0, "z": 0,
"kind": {"variant": "Player", "data": null}, "visibility": "Forward",
}]
GameState.visible_tiles = []
GameState.game_time = {"day": 1, "day_phase": "Evening", "tick_rate": "Full"}
GameState.current_monologue = null
GameState.current_dialogue = null
var dialog := _make_bug_report_dialog()
var text: String = dialog._render_snapshot_text()
assert_that(text.contains("t42")).override_failure_message(
"Render text should contain tick number"
).is_true()
assert_that(text.contains("Entities (1)")).override_failure_message(
"Render text should show entity count"
).is_true()
assert_that(text.contains("Player")).override_failure_message(
"Render text should contain entity kind"
).is_true()
assert_that(text.contains("South")).override_failure_message(
"Render text should contain player facing"
).is_true()
func test_bug_report_render_text_with_monologue_and_dialogue() -> void:
# _render_snapshot_text() should include active monologue and dialogue.
GameState.current_tick = 10
GameState.player_position = Vector2(3.0, 3.0)
GameState.player_facing = "North"
GameState.player_stance = "Walk"
GameState.visible_entities = []
GameState.visible_tiles = []
GameState.game_time = {"day": 0, "day_phase": "Morning", "tick_rate": "Full"}
GameState.current_monologue = {"text": "Something is wrong here."}
GameState.current_dialogue = {"npc_name": "Kael", "speech": "Who are you?"}
var dialog := _make_bug_report_dialog()
var text: String = dialog._render_snapshot_text()
assert_that(text.contains("Something is wrong here.")).override_failure_message(
"Render text should include monologue text"
).is_true()
assert_that(text.contains("Kael")).override_failure_message(
"Render text should include dialogue NPC name"
).is_true()
assert_that(text.contains("Who are you?")).override_failure_message(
"Render text should include dialogue speech"
).is_true()
func test_bug_report_render_text_empty_snapshot() -> void:
# Edge case: F12 pressed before any snapshot data arrives.
GameState.current_tick = 0
GameState.player_position = Vector2.ZERO
GameState.player_facing = "North"
GameState.player_stance = "Walk"
GameState.visible_entities = []
GameState.visible_tiles = []
GameState.game_time = {}
GameState.current_monologue = null
GameState.current_dialogue = null
var dialog := _make_bug_report_dialog()
var text: String = dialog._render_snapshot_text()
# Should not crash, should produce some output
assert_that(text.length() > 0).override_failure_message(
"Render text should not be empty even with no snapshot data"
).is_true()
assert_that(text.contains("Entities (0)")).override_failure_message(
"Empty snapshot should show 0 entities"
).is_true()
# -- Helper: recursive node search --------------------------------------------
func _find_node_recursive(root: Node, target_name: String) -> Node:
if root.name == target_name:
return root
for child in root.get_children():
var found := _find_node_recursive(child, target_name)
if found != null:
return found
return null
+1
View File
@@ -0,0 +1 @@
uid://d3fj584itowcl
+1
View File
@@ -0,0 +1 @@
uid://vw0iqhai1kum
+692
View File
@@ -0,0 +1,692 @@
## #503: Auto-checklist progress tracking — unit + integration tests.
##
## Tests cover:
## 1. YAML parser: basic types, conditions array, edge cases
## 2. Condition evaluation: all 7 condition types
## 3. Latching: conditions stay met once satisfied
## 4. Room change: per-room conditions reset, cross-room conditions persist
## 5. Overlay: visibility gating on gauntlet_mode
## 6. Integration: snapshot -> GameState -> evaluator -> overlay
##
## Spec ref: D-030 (testability), checklist.schema.json (#497), Sprint 10 Completion Proof.
class_name TestChecklist
extends GdUnitTestSuite
var ChecklistOverlayScript = load("res://ui/checklist_overlay.gd")
var ChecklistEvaluatorScript = load("res://scripts/checklist/checklist_evaluator.gd")
func before_test() -> void:
SimBridge.reset_test_state()
SimBridge._last_snapshot = null
GameState.current_tick = 0
GameState.player_position = Vector2.ZERO
GameState.visible_entities = []
GameState.visible_tiles = []
GameState.visible_positions = {}
GameState.current_monologue = null
GameState.current_dialogue = null
GameState.nearby_interactions = []
GameState.game_time = {}
GameState.pending_recognitions = []
GameState.room_id = null
GameState.gauntlet_mode = false
GameState.player_facing = "North"
GameState.player_stance = "Walk"
GameState.player_inventory = []
# -- YAML Parser Tests ---------------------------------------------------------
func test_parse_empty_yaml() -> void:
var result: Dictionary = ChecklistEvaluatorScript.parse_checklist_yaml("")
assert_that(result.size()).is_equal(0)
func test_parse_top_level_string() -> void:
var yaml := "room_id: inventory_warehouse"
var result: Dictionary = ChecklistEvaluatorScript.parse_checklist_yaml(yaml)
assert_that(result.get("room_id")).is_equal("inventory_warehouse")
func test_parse_top_level_quoted_string() -> void:
var yaml := 'description: "Tests D-065 (9-slot inventory)."'
var result: Dictionary = ChecklistEvaluatorScript.parse_checklist_yaml(yaml)
assert_that(result.get("description")).is_equal("Tests D-065 (9-slot inventory).")
func test_parse_single_condition() -> void:
var yaml := "conditions:\n - id: test-1\n description: \"Test condition\"\n condition_type: player_near\n x: 10\n y: 20\n radius: 3.0"
var result: Dictionary = ChecklistEvaluatorScript.parse_checklist_yaml(yaml)
assert_that(result.has("conditions")).is_true()
var conditions: Array = result["conditions"]
assert_that(conditions.size()).is_equal(1)
assert_that(conditions[0]["id"]).is_equal("test-1")
assert_that(conditions[0]["condition_type"]).is_equal("player_near")
assert_that(conditions[0]["x"]).is_equal(10)
assert_that(conditions[0]["y"]).is_equal(20)
assert_that(conditions[0]["radius"]).is_equal_approx(3.0, 0.001)
func test_parse_multiple_conditions() -> void:
var yaml := "conditions:\n - id: cond-a\n condition_type: player_near\n x: 1\n y: 2\n radius: 1.0\n\n - id: cond-b\n condition_type: player_facing\n direction: East"
var result: Dictionary = ChecklistEvaluatorScript.parse_checklist_yaml(yaml)
var conditions: Array = result["conditions"]
assert_that(conditions.size()).is_equal(2)
assert_that(conditions[0]["id"]).is_equal("cond-a")
assert_that(conditions[1]["id"]).is_equal("cond-b")
assert_that(conditions[1]["direction"]).is_equal("East")
func test_parse_comments_ignored() -> void:
var yaml := "# This is a comment\nroom_id: test\n# Another comment\nconditions:\n - id: c1\n condition_type: entity_present\n entity_id: 5"
var result: Dictionary = ChecklistEvaluatorScript.parse_checklist_yaml(yaml)
assert_that(result.get("room_id")).is_equal("test")
var conditions: Array = result["conditions"]
assert_that(conditions.size()).is_equal(1)
assert_that(conditions[0]["entity_id"]).is_equal(5)
func test_parse_integer_and_float_values() -> void:
var yaml := "conditions:\n - id: t\n condition_type: player_near\n x: 42\n y: -3\n radius: 2.5"
var result: Dictionary = ChecklistEvaluatorScript.parse_checklist_yaml(yaml)
var cond: Dictionary = result["conditions"][0]
assert_that(cond["x"]).is_equal(42)
assert_that(typeof(cond["radius"])).is_equal(TYPE_FLOAT)
func test_parse_scope_field() -> void:
var yaml := "scope: cross_room\nconditions:\n - id: cr-1\n condition_type: entity_present\n entity_id: 0"
var result: Dictionary = ChecklistEvaluatorScript.parse_checklist_yaml(yaml)
assert_that(result.get("scope")).is_equal("cross_room")
func test_parse_inline_comment_stripped() -> void:
var yaml := "room_id: test # this is a comment"
var result: Dictionary = ChecklistEvaluatorScript.parse_checklist_yaml(yaml)
assert_that(result.get("room_id")).is_equal("test")
# -- Condition Evaluation Tests ------------------------------------------------
func _make_evaluator(conditions: Array):
var evaluator = ChecklistEvaluatorScript.new()
evaluator._room_conditions = conditions
evaluator._loaded = true
return evaluator
func test_eval_player_near_within_radius() -> void:
GameState.player_position = Vector2(10.0, 20.0)
var evaluator = _make_evaluator([{
"id": "near-1", "condition_type": "player_near",
"x": 10, "y": 21, "radius": 2.0,
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).override_failure_message(
"player_near: player at (10,20), target (10,21), radius 2.0 — should be met"
).is_equal(1)
func test_eval_player_near_outside_radius() -> void:
GameState.player_position = Vector2(10.0, 20.0)
var evaluator = _make_evaluator([{
"id": "near-2", "condition_type": "player_near",
"x": 10, "y": 30, "radius": 2.0,
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).override_failure_message(
"player_near: player at (10,20), target (10,30), radius 2.0 — should NOT be met"
).is_equal(0)
func test_eval_player_near_exact_boundary() -> void:
GameState.player_position = Vector2(10.0, 20.0)
var evaluator = _make_evaluator([{
"id": "near-3", "condition_type": "player_near",
"x": 10, "y": 22, "radius": 2.0,
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).override_failure_message(
"player_near: distance exactly equals radius — should be met (<=)"
).is_equal(1)
func test_eval_player_facing_match() -> void:
GameState.player_facing = "East"
var evaluator = _make_evaluator([{
"id": "face-1", "condition_type": "player_facing",
"direction": "East",
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).is_equal(1)
func test_eval_player_facing_no_match() -> void:
GameState.player_facing = "North"
var evaluator = _make_evaluator([{
"id": "face-2", "condition_type": "player_facing",
"direction": "East",
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).is_equal(0)
func test_eval_player_facing_diagonal_no_match() -> void:
# 8-directional facing "Northeast" should NOT match "East" or "North"
GameState.player_facing = "Northeast"
var evaluator = _make_evaluator([{
"id": "face-diag", "condition_type": "player_facing",
"direction": "East",
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).override_failure_message(
"player_facing: Northeast should NOT match East (exact match only)"
).is_equal(0)
func test_eval_entity_present_found() -> void:
GameState.visible_entities = [
{"entity_id": 5, "x": 1.0, "y": 1.0, "z": 0, "kind": "Npc"},
{"entity_id": 10, "x": 2.0, "y": 2.0, "z": 0, "kind": "Object"},
]
var evaluator = _make_evaluator([{
"id": "present-1", "condition_type": "entity_present",
"entity_id": 10,
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).is_equal(1)
func test_eval_entity_present_not_found() -> void:
GameState.visible_entities = [
{"entity_id": 5, "x": 1.0, "y": 1.0, "z": 0, "kind": "Npc"},
]
var evaluator = _make_evaluator([{
"id": "present-2", "condition_type": "entity_present",
"entity_id": 99,
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).is_equal(0)
func test_eval_entity_absent_when_not_visible() -> void:
GameState.visible_entities = [
{"entity_id": 5, "x": 1.0, "y": 1.0, "z": 0, "kind": "Npc"},
]
var evaluator = _make_evaluator([{
"id": "absent-1", "condition_type": "entity_absent",
"entity_id": 99,
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).override_failure_message(
"entity_absent: entity 99 not in visible_entities — should be met"
).is_equal(1)
func test_eval_entity_absent_when_visible() -> void:
GameState.visible_entities = [
{"entity_id": 10, "x": 1.0, "y": 1.0, "z": 0, "kind": "Npc"},
]
var evaluator = _make_evaluator([{
"id": "absent-2", "condition_type": "entity_absent",
"entity_id": 10,
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).override_failure_message(
"entity_absent: entity 10 IS visible — should NOT be met"
).is_equal(0)
func test_eval_expected_monologue_match() -> void:
GameState.current_monologue = {"id": "m1", "text": "Something is wrong here.", "duration_seconds": 5.0}
var evaluator = _make_evaluator([{
"id": "mono-1", "condition_type": "expected_monologue",
"contains": "wrong here",
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).is_equal(1)
func test_eval_expected_monologue_no_match() -> void:
GameState.current_monologue = {"id": "m1", "text": "All clear.", "duration_seconds": 5.0}
var evaluator = _make_evaluator([{
"id": "mono-2", "condition_type": "expected_monologue",
"contains": "wrong here",
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).is_equal(0)
func test_eval_expected_monologue_null() -> void:
GameState.current_monologue = null
var evaluator = _make_evaluator([{
"id": "mono-3", "condition_type": "expected_monologue",
"contains": "test",
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).override_failure_message(
"expected_monologue: null monologue should not match"
).is_equal(0)
func test_eval_expected_dialogue_match() -> void:
GameState.current_dialogue = {"npc_name": "Kael", "speech": "Who are you?", "options": []}
var evaluator = _make_evaluator([{
"id": "dlg-1", "condition_type": "expected_dialogue",
"contains": "Who are you",
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).is_equal(1)
func test_eval_expected_dialogue_no_match() -> void:
GameState.current_dialogue = {"npc_name": "Kael", "speech": "Hello.", "options": []}
var evaluator = _make_evaluator([{
"id": "dlg-2", "condition_type": "expected_dialogue",
"contains": "Goodbye",
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).is_equal(0)
func test_eval_expected_dialogue_null() -> void:
GameState.current_dialogue = null
var evaluator = _make_evaluator([{
"id": "dlg-3", "condition_type": "expected_dialogue",
"contains": "test",
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).override_failure_message(
"expected_dialogue: null dialogue should not match"
).is_equal(0)
func test_eval_interaction_verb_match() -> void:
GameState.nearby_interactions = [{
"entity_id": 13,
"entity_type": "Object",
"distance": 1,
"verbs": [
{"kind": "Take", "label": "Pickup", "priority": 1, "available": true},
{"kind": "Observe", "label": "Examine", "priority": 2, "available": true},
],
}]
var evaluator = _make_evaluator([{
"id": "verb-1", "condition_type": "expected_interaction_verb",
"entity_id": 13, "verb": "Pickup",
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).is_equal(1)
func test_eval_interaction_verb_by_kind() -> void:
GameState.nearby_interactions = [{
"entity_id": 13,
"entity_type": "Object",
"distance": 1,
"verbs": [
{"kind": "Take", "label": "Pickup", "priority": 1, "available": true},
],
}]
var evaluator = _make_evaluator([{
"id": "verb-kind", "condition_type": "expected_interaction_verb",
"entity_id": 13, "verb": "Take",
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).override_failure_message(
"expected_interaction_verb: should match by kind='Take' as well as label"
).is_equal(1)
func test_eval_interaction_verb_wrong_entity() -> void:
GameState.nearby_interactions = [{
"entity_id": 13,
"entity_type": "Object",
"distance": 1,
"verbs": [{"kind": "Take", "label": "Pickup", "priority": 1, "available": true}],
}]
var evaluator = _make_evaluator([{
"id": "verb-wrong", "condition_type": "expected_interaction_verb",
"entity_id": 99, "verb": "Pickup",
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).override_failure_message(
"expected_interaction_verb: wrong entity_id should not match"
).is_equal(0)
func test_eval_interaction_verb_unavailable() -> void:
GameState.nearby_interactions = [{
"entity_id": 13,
"entity_type": "Object",
"distance": 1,
"verbs": [{"kind": "Take", "label": "Pickup", "priority": 1, "available": false}],
}]
var evaluator = _make_evaluator([{
"id": "verb-unavail", "condition_type": "expected_interaction_verb",
"entity_id": 13, "verb": "Pickup",
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).override_failure_message(
"expected_interaction_verb: unavailable verb should not match"
).is_equal(0)
func test_eval_interaction_verb_no_interactions() -> void:
GameState.nearby_interactions = []
var evaluator = _make_evaluator([{
"id": "verb-none", "condition_type": "expected_interaction_verb",
"entity_id": 13, "verb": "Pickup",
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).is_equal(0)
# -- Latching Tests ------------------------------------------------------------
func test_latching_condition_stays_met() -> void:
# Condition met on first evaluate, stays met even when state changes.
GameState.player_facing = "East"
var evaluator = _make_evaluator([{
"id": "latch-1", "condition_type": "player_facing",
"direction": "East",
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).is_equal(1)
# Change state so condition would be false if re-evaluated fresh
GameState.player_facing = "North"
evaluator.evaluate()
assert_that(evaluator.get_met_count()).override_failure_message(
"Latched condition should stay met even after state changes"
).is_equal(1)
func test_latching_monologue_transient() -> void:
# Monologue appears for one tick, then disappears. Condition should latch.
var evaluator = _make_evaluator([{
"id": "mono-latch", "condition_type": "expected_monologue",
"contains": "recalibrated",
}])
# Tick 1: no monologue
GameState.current_monologue = null
evaluator.evaluate()
assert_that(evaluator.get_met_count()).is_equal(0)
# Tick 2: monologue fires
GameState.current_monologue = {"id": "m1", "text": "Systems recalibrated.", "duration_seconds": 3.0}
evaluator.evaluate()
assert_that(evaluator.get_met_count()).is_equal(1)
# Tick 3: monologue consumed (null)
GameState.current_monologue = null
evaluator.evaluate()
assert_that(evaluator.get_met_count()).override_failure_message(
"Monologue condition should stay latched after monologue disappears"
).is_equal(1)
func test_multiple_conditions_partial_latching() -> void:
var evaluator = _make_evaluator([
{"id": "c1", "condition_type": "player_facing", "direction": "East"},
{"id": "c2", "condition_type": "entity_present", "entity_id": 5},
{"id": "c3", "condition_type": "player_near", "x": 50, "y": 50, "radius": 1.0},
])
# Tick 1: only facing matches
GameState.player_facing = "East"
GameState.visible_entities = []
GameState.player_position = Vector2(0, 0)
evaluator.evaluate()
assert_that(evaluator.get_met_count()).is_equal(1)
assert_that(evaluator.get_total_count()).is_equal(3)
# Tick 2: entity also visible
GameState.visible_entities = [{"entity_id": 5, "x": 1.0, "y": 1.0, "z": 0, "kind": "Npc"}]
evaluator.evaluate()
assert_that(evaluator.get_met_count()).is_equal(2)
# Tick 3: player moves to target
GameState.player_position = Vector2(50.0, 50.0)
evaluator.evaluate()
assert_that(evaluator.get_met_count()).is_equal(3)
assert_that(evaluator.is_complete()).is_true()
# -- Room Change Tests ---------------------------------------------------------
func test_room_change_resets_per_room_conditions() -> void:
var evaluator = ChecklistEvaluatorScript.new()
# Manually set conditions to avoid file loading
evaluator._room_conditions = [
{"id": "r1-c1", "condition_type": "player_facing", "direction": "East"},
]
evaluator._loaded = true
GameState.player_facing = "East"
evaluator.evaluate()
assert_that(evaluator.get_met_count()).is_equal(1)
# Simulate room change by loading a new "room"
evaluator._current_room_id = "old_room"
evaluator._room_conditions = [
{"id": "r2-c1", "condition_type": "player_facing", "direction": "North"},
]
# Clear latches for the new room (simulating load_room behavior)
evaluator._latched.clear()
evaluator.evaluate()
assert_that(evaluator.get_met_count()).override_failure_message(
"After room change, old latches should be cleared; new condition not met"
).is_equal(0)
func test_reset_clears_all_state() -> void:
var evaluator = _make_evaluator([
{"id": "c1", "condition_type": "player_facing", "direction": "East"},
])
GameState.player_facing = "East"
evaluator.evaluate()
assert_that(evaluator.get_met_count()).is_equal(1)
evaluator.reset()
assert_that(evaluator.is_loaded()).is_false()
assert_that(evaluator.get_met_count()).is_equal(0)
assert_that(evaluator.get_total_count()).is_equal(0)
# -- get_results Tests ---------------------------------------------------------
func test_get_results_structure() -> void:
var evaluator = _make_evaluator([
{"id": "c1", "description": "Test condition", "condition_type": "player_facing", "direction": "North"},
])
GameState.player_facing = "North"
evaluator.evaluate()
var results: Array = evaluator.get_results()
assert_that(results.size()).is_equal(1)
assert_that(results[0]["id"]).is_equal("c1")
assert_that(results[0]["description"]).is_equal("Test condition")
assert_that(results[0]["condition_type"]).is_equal("player_facing")
assert_that(results[0]["met"]).is_true()
func test_get_results_unmet() -> void:
var evaluator = _make_evaluator([
{"id": "c1", "description": "Test", "condition_type": "player_facing", "direction": "South"},
])
GameState.player_facing = "North"
evaluator.evaluate()
var results: Array = evaluator.get_results()
assert_that(results[0]["met"]).is_false()
# -- Overlay Visibility Tests --------------------------------------------------
func _make_checklist_overlay() -> Control:
var overlay = Control.new()
overlay.set_script(ChecklistOverlayScript)
auto_free(overlay)
add_child(overlay)
return overlay
func test_overlay_hidden_in_non_gauntlet_mode() -> void:
var overlay := _make_checklist_overlay()
GameState.gauntlet_mode = false
overlay.update_from_state()
assert_that(overlay.visible).override_failure_message(
"Checklist overlay must be hidden in non-gauntlet mode"
).is_false()
func test_overlay_visible_in_gauntlet_mode() -> void:
var overlay := _make_checklist_overlay()
GameState.gauntlet_mode = true
GameState.room_id = "test_room"
overlay.update_from_state()
assert_that(overlay.visible).override_failure_message(
"Checklist overlay must be visible in gauntlet mode"
).is_true()
func test_overlay_hides_on_gauntlet_deactivation() -> void:
var overlay := _make_checklist_overlay()
GameState.gauntlet_mode = true
GameState.room_id = "test_room"
overlay.update_from_state()
assert_that(overlay.visible).is_true()
GameState.gauntlet_mode = false
overlay.update_from_state()
assert_that(overlay.visible).override_failure_message(
"Overlay must hide when gauntlet mode deactivates"
).is_false()
func test_overlay_evaluator_accessible() -> void:
var overlay := _make_checklist_overlay()
var evaluator = overlay.get_evaluator()
assert_that(evaluator).override_failure_message(
"Overlay should expose evaluator via get_evaluator()"
).is_not_null()
func test_overlay_in_main_scene() -> void:
var scene: PackedScene = load("res://scenes/main.tscn")
var instance: Node = scene.instantiate()
auto_free(instance)
add_child(instance)
instance._process(0.016)
var overlay: Node = _find_node_recursive(instance, "ChecklistOverlay")
assert_that(overlay).override_failure_message(
"ChecklistOverlay node should exist in main scene tree"
).is_not_null()
if overlay is CanvasItem:
assert_that((overlay as CanvasItem).visible).override_failure_message(
"ChecklistOverlay should be hidden by default (non-gauntlet mode)"
).is_false()
# -- Integration: Snapshot -> Evaluation ----------------------------------------
func test_integration_snapshot_to_evaluator() -> void:
# Integration test: GameState snapshot data -> evaluator -> correct results.
# Tests the evaluator directly (overlay wiring tested separately).
var evaluator = ChecklistEvaluatorScript.new()
evaluator._room_conditions = [
{"id": "int-1", "description": "Player entity present", "condition_type": "entity_present", "entity_id": 1},
{"id": "int-2", "description": "Player faces East", "condition_type": "player_facing", "direction": "East"},
]
evaluator._loaded = true
# Simulate gauntlet snapshot with player entity
GameState.visible_entities = [
{"entity_id": 1, "x": 10.0, "y": 10.0, "z": 0, "kind": "Player"},
]
GameState.player_facing = "North"
evaluator.evaluate()
# Entity present should be met, facing should not
assert_that(evaluator.get_met_count()).is_equal(1)
assert_that(evaluator.get_total_count()).is_equal(2)
# Change facing — second condition should also latch
GameState.player_facing = "East"
evaluator.evaluate()
assert_that(evaluator.get_met_count()).is_equal(2)
assert_that(evaluator.is_complete()).is_true()
# Verify results array contains both conditions as met
var results: Array = evaluator.get_results()
for r in results:
assert_that(r["met"]).override_failure_message(
"Condition '%s' should be met after snapshot sequence" % r["id"]
).is_true()
# -- Edge Cases ----------------------------------------------------------------
func test_empty_entity_list_entity_present() -> void:
GameState.visible_entities = []
var evaluator = _make_evaluator([{
"id": "edge-empty", "condition_type": "entity_present",
"entity_id": 0,
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).is_equal(0)
func test_empty_entity_list_entity_absent() -> void:
GameState.visible_entities = []
var evaluator = _make_evaluator([{
"id": "edge-absent-empty", "condition_type": "entity_absent",
"entity_id": 99,
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).override_failure_message(
"entity_absent with empty visible_entities should be met"
).is_equal(1)
func test_zero_radius_player_near() -> void:
GameState.player_position = Vector2(10.0, 20.0)
var evaluator = _make_evaluator([{
"id": "edge-zero-radius", "condition_type": "player_near",
"x": 10, "y": 20, "radius": 0.0,
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).override_failure_message(
"player_near with radius 0 at exact position should be met"
).is_equal(1)
func test_unknown_condition_type() -> void:
var evaluator = _make_evaluator([{
"id": "edge-unknown", "condition_type": "nonexistent_type",
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).override_failure_message(
"Unknown condition type should not be met"
).is_equal(0)
func test_no_conditions_loaded() -> void:
var evaluator = ChecklistEvaluatorScript.new()
assert_that(evaluator.is_loaded()).is_false()
assert_that(evaluator.get_total_count()).is_equal(0)
assert_that(evaluator.get_met_count()).is_equal(0)
assert_that(evaluator.is_complete()).is_false()
# -- Helper: recursive node search --------------------------------------------
func _find_node_recursive(root: Node, target_name: String) -> Node:
if root.name == target_name:
return root
for child in root.get_children():
var found := _find_node_recursive(child, target_name)
if found != null:
return found
return null
+1
View File
@@ -0,0 +1 @@
uid://cw0yyp8mrchi7
+320
View File
@@ -0,0 +1,320 @@
## P2 client tests: camera (5), entity alpha/color (4), UI (7).
## Validates camera tracking, entity visual state, and UI component lifecycle.
## Spec ref: sprint-9/client.md #492.
class_name TestClientP2
extends GdUnitTestSuite
var EntityRendererScript = load("res://scripts/rendering/entity_renderer.gd")
var FogEntitiesScript = load("res://scripts/rendering/fog_entities.gd")
var _instance: Node = null
func before_test() -> void:
SimBridge.reset_test_state()
SimBridge._last_snapshot = null
GameState.current_tick = 0
GameState.player_position = Vector2.ZERO
GameState.visible_entities = []
GameState.visible_tiles = []
GameState.visible_positions = {}
GameState.current_monologue = null
GameState.current_dialogue = null
GameState.game_time = {}
GameState.pending_recognitions = []
GameState.nearby_interactions = []
GameState.player_inventory = []
GameState.player_stance = "Walk"
GameState.dialogue_active = false
func after_test() -> void:
if _instance and is_instance_valid(_instance):
_instance.queue_free()
_instance = null
# -- Helpers -------------------------------------------------------------------
func _make_scene() -> Node:
var scene := load("res://scenes/main.tscn")
_instance = scene.instantiate()
auto_free(_instance)
add_child(_instance)
return _instance
func _make_entity_renderer() -> Node2D:
var renderer = Node2D.new()
renderer.set_script(EntityRendererScript)
add_child(renderer)
return renderer
func _make_fog_entities() -> Node2D:
var node = Node2D.new()
node.set_script(FogEntitiesScript)
add_child(node)
return node
# -- Camera (5) ----------------------------------------------------------------
func test_camera_zoom_default_2x() -> void:
# P2-C01: Camera zoom should be 2x as set in main.tscn.
var inst := _make_scene()
var camera: Camera2D = inst.get_node("Camera2D")
assert_that(camera.zoom).is_equal(Vector2(2, 2))
func test_camera_smoothing_convergence() -> void:
# P2-C02: After first _process, smoothing re-enables for gameplay feel.
# After several frames, camera position should still match player position
# (smoothing converges because target == position when stationary).
var inst := _make_scene()
var camera: Camera2D = inst.get_node("Camera2D")
# First frame re-enables smoothing
inst._process(0.016)
assert_that(camera.position_smoothing_enabled).is_true()
# Several more frames — stationary player, camera converges
for i in 5:
inst._process(0.016)
var expected := GameState.player_position * Constants.TILE_SIZE
var dist := camera.global_position.distance_to(expected)
assert_that(dist < 0.1).override_failure_message(
"Camera should converge to player position (dist: %.4f)" % dist
).is_true()
func test_camera_viewport_tracks_player_position() -> void:
# P2-C03: Camera position always equals player_position * TILE_SIZE.
# No offset, no viewport clamping beyond player tracking.
var inst := _make_scene()
var camera: Camera2D = inst.get_node("Camera2D")
var expected := GameState.player_position * Constants.TILE_SIZE
assert_that(camera.global_position).override_failure_message(
"Camera must track player position exactly"
).is_equal(expected)
func test_camera_follows_player_after_movement() -> void:
# P2-C04: After player moves, camera position updates to new player position.
var inst := _make_scene()
var camera: Camera2D = inst.get_node("Camera2D")
var initial_pos := camera.global_position
# Move player south via SimBridge test mode
SimBridge._test_input_queue.append("MoveSouth")
inst._process(0.016)
# Camera should have moved with player
assert_that(camera.global_position.y > initial_pos.y).override_failure_message(
"Camera Y should increase after moving south"
).is_true()
assert_that(camera.global_position).is_equal(
GameState.player_position * Constants.TILE_SIZE)
func test_camera_no_panning_locked_to_player() -> void:
# P2-C05 (D-014): Camera is locked to player — no panning.
# Camera position equals player_position * TILE_SIZE every frame.
var inst := _make_scene()
var camera: Camera2D = inst.get_node("Camera2D")
# Process multiple frames
for i in 10:
inst._process(0.016)
var expected := GameState.player_position * Constants.TILE_SIZE
assert_that(camera.global_position).override_failure_message(
"Frame %d: camera must be locked to player" % i
).is_equal(expected)
# -- Entity alpha/color (4) ---------------------------------------------------
func test_entity_null_visibility_defaults_full_alpha() -> void:
# P2-E01: Entity with no visibility field (v1 backward compat) → alpha 1.0.
var renderer := _make_entity_renderer()
var entity := [{"entity_id": 50, "x": 3.0, "y": 3.0, "z": 0,
"kind": {"variant": "Npc", "data": null}}]
renderer.update_entities(entity)
var node = renderer.entity_nodes[50]
assert_that(node.modulate.a).override_failure_message(
"Null visibility should default to full alpha"
).is_equal_approx(1.0, 0.01)
renderer.queue_free()
func test_entity_peripheral_to_forward_alpha_transition() -> void:
# P2-E02: Updating entity from Peripheral to Forward changes alpha.
var renderer := _make_entity_renderer()
# First: Peripheral
var entity_p := [{"entity_id": 60, "x": 4.0, "y": 4.0, "z": 0,
"kind": {"variant": "Npc", "data": null}, "visibility": "Peripheral"}]
renderer.update_entities(entity_p)
var node = renderer.entity_nodes[60]
assert_that(node.modulate.a).is_equal_approx(Constants.PERIPHERAL_ALPHA, 0.01)
# Update to Forward
var entity_f := [{"entity_id": 60, "x": 4.0, "y": 4.0, "z": 0,
"kind": {"variant": "Npc", "data": null}, "visibility": "Forward"}]
renderer.update_entities(entity_f)
assert_that(node.modulate.a).override_failure_message(
"Forward visibility should set alpha to 1.0"
).is_equal_approx(1.0, 0.01)
renderer.queue_free()
func test_entity_terrain_uses_object_color() -> void:
# P2-E03: Terrain entity kind maps to ENTITY_COLOR_OBJECT.
var renderer := _make_entity_renderer()
var entity := [{"entity_id": 70, "x": 2.0, "y": 2.0, "z": 0,
"kind": {"variant": "Terrain", "data": null}, "visibility": "Forward"}]
renderer.update_entities(entity)
var node = renderer.entity_nodes[70] as ColorRect
assert_that(node.color).override_failure_message(
"Terrain kind should use ENTITY_COLOR_OBJECT"
).is_equal(Constants.ENTITY_COLOR_OBJECT)
renderer.queue_free()
func test_entity_player_color_regardless_of_sector() -> void:
# P2-E04: Player uses ENTITY_COLOR_PLAYER even in Peripheral sector.
GameState.player_entity_id = 80
var renderer := _make_entity_renderer()
var entity := [{"entity_id": 80, "x": 5.0, "y": 5.0, "z": 0,
"kind": {"variant": "Player", "data": null}, "visibility": "Peripheral"}]
renderer.update_entities(entity)
var node = renderer.entity_nodes[80] as ColorRect
assert_that(node.color).override_failure_message(
"Player color must be constant regardless of visibility sector"
).is_equal(Constants.ENTITY_COLOR_PLAYER)
# Alpha should still be dimmed for Peripheral
assert_that(node.modulate.a).is_equal_approx(Constants.PERIPHERAL_ALPHA, 0.01)
renderer.queue_free()
# -- UI (7) --------------------------------------------------------------------
func test_monologue_display_visible_hidden() -> void:
# P2-U01: MonologueDisplay starts hidden, becomes visible after show_monologue.
# Note: mono.is_visible is a custom bool property on MonologueDisplay
# (monologue_display.gd:11), not the built-in CanvasItem.is_visible() method.
# The monologue uses tween alpha for visual hide/show, so the built-in
# .visible stays true — we test the script's own state tracking.
var inst := _make_scene()
var mono = inst.get_node("UILayer/MonologueDisplay")
assert_that(mono.is_visible).override_failure_message(
"Monologue should start hidden"
).is_false()
mono.show_monologue("Test thought.", 3.0)
assert_that(mono.is_visible).override_failure_message(
"Monologue should be visible after show_monologue"
).is_true()
assert_that(mono.text_label.text).is_equal("Test thought.")
func test_interaction_list_shows_nearest_verb() -> void:
# P2-U02: When nearby_interactions are present, interaction list shows verbs.
var inst := _make_scene()
var ilist = inst.get_node("InsertOverlay/InteractionList")
GameState.nearby_interactions = [{
"entity_id": 2,
"entity_type": "Npc",
"distance": 1,
"verbs": [
{"kind": "Talk", "label": "Talk", "priority": 1, "available": true},
{"kind": "Observe", "label": "Observe", "priority": 2, "available": true},
],
}]
ilist.update_from_state()
assert_that(ilist.get_visible_verb_count()).override_failure_message(
"Interaction list should show 2 verbs"
).is_equal(2)
assert_that(ilist.is_showing()).is_true()
# Cleanup
GameState.nearby_interactions = []
func test_inventory_grid_shows_carried_items() -> void:
# P2-U03: Inventory grid becomes visible when player has items.
var inst := _make_scene()
var inv = inst.get_node("UILayer/InventoryGrid")
# Initially empty — hidden
GameState.player_inventory = []
inv.update_from_state()
assert_that(inv.visible).override_failure_message(
"Inventory should be hidden when empty"
).is_false()
# Add items
GameState.player_inventory = [
{"item_id": 1, "name": "Keycard", "slot": 0},
{"item_id": 2, "name": "Datapad", "slot": 1},
]
inv.update_from_state()
assert_that(inv.visible).override_failure_message(
"Inventory should be visible with items"
).is_true()
assert_that(inv.get_slot_count()).is_equal(2)
# Cleanup
GameState.player_inventory = []
func test_dialogue_overlay_active_on_show() -> void:
# P2-U04: Dialogue box reports active after show_dialogue.
var inst := _make_scene()
var dlg = inst.get_node("InsertOverlay/DialogueBox")
assert_that(dlg.is_dialogue_active()).override_failure_message(
"Dialogue should start inactive"
).is_false()
dlg.show_dialogue("Kael", "How's it going?", [
{"text": "Fine.", "response_id": "r1", "priority": 1, "confrontation": false},
])
assert_that(dlg.is_dialogue_active()).override_failure_message(
"Dialogue should be active after show_dialogue"
).is_true()
func test_game_state_tick_rate_paused() -> void:
# P2-U05: TickRate::Paused is stored in game_time and queryable.
GameState.apply_snapshot({
"tick": 1,
"game_time": {
"day": 0,
"time_of_day": 100,
"day_phase": "Morning",
"tick_rate": "Paused",
},
})
assert_that(GameState.game_time.tick_rate).override_failure_message(
"Paused tick rate must be stored in game_time"
).is_equal("Paused")
func test_fog_entities_blob_count_matches_recognitions() -> void:
# P2-U06: FogEntities tracks one blob per pending recognition.
var fog_entities := _make_fog_entities()
var saved := GameState.pending_recognitions
GameState.pending_recognitions = [
{"entity_id": 101, "x": 5.0, "y": 5.0, "z": 0, "remaining_ticks": 3, "total_delay_ticks": 6},
{"entity_id": 102, "x": 8.0, "y": 8.0, "z": 0, "remaining_ticks": 1, "total_delay_ticks": 6},
]
fog_entities.update_from_state()
assert_that(fog_entities._entities.size()).override_failure_message(
"FogEntities should track 2 blob entities"
).is_equal(2)
assert_that(fog_entities._entities.has(101)).is_true()
assert_that(fog_entities._entities.has(102)).is_true()
GameState.pending_recognitions = saved
fog_entities.queue_free()
func test_fog_overlay_z_index_covers_world() -> void:
# P2-U07: FogOverlay renders above world content (z:900), ensuring
# occluded tiles are visually covered.
var inst := _make_scene()
var fog_overlay = inst.get_node("World/FogOverlay")
assert_that(fog_overlay.z_index).override_failure_message(
"FogOverlay z_index must be %d (Z_FOG)" % Constants.Z_FOG
).is_equal(Constants.Z_FOG)
# FogEntities at z:950 — above fog, below insert overlay
var fog_entities = inst.get_node("World/FogEntities")
assert_that(fog_entities.z_index).override_failure_message(
"FogEntities z_index must be %d (Z_FOG_ENTITIES)" % Constants.Z_FOG_ENTITIES
).is_equal(Constants.Z_FOG_ENTITIES)
+1
View File
@@ -0,0 +1 @@
uid://ci76ggq7n33w7
+341
View File
@@ -0,0 +1,341 @@
## P3 client tests: z-layer ordering (4), entity lerp (3), Tyre additions (5).
## Validates scene tree draw order, framerate-independent entity interpolation,
## and recognition transition timing.
## Spec ref: sprint-9/client.md #493.
class_name TestClientP3
extends GdUnitTestSuite
var EntityRendererScript = load("res://scripts/rendering/entity_renderer.gd")
var FogEntitiesScript = load("res://scripts/rendering/fog_entities.gd")
var _instance: Node = null
func before_test() -> void:
SimBridge.reset_test_state()
SimBridge._last_snapshot = null
GameState.current_tick = 0
GameState.player_position = Vector2.ZERO
GameState.visible_entities = []
GameState.visible_tiles = []
GameState.visible_positions = {}
GameState.current_monologue = null
GameState.current_dialogue = null
GameState.game_time = {}
GameState.pending_recognitions = []
GameState.nearby_interactions = []
GameState.player_inventory = []
GameState.player_stance = "Walk"
GameState.player_facing = "North"
GameState.dialogue_active = false
func after_test() -> void:
if _instance and is_instance_valid(_instance):
_instance.queue_free()
_instance = null
# -- Helpers -------------------------------------------------------------------
func _make_scene() -> Node:
var scene := load("res://scenes/main.tscn")
_instance = scene.instantiate()
auto_free(_instance)
add_child(_instance)
return _instance
func _make_entity_renderer() -> Node2D:
var renderer = Node2D.new()
renderer.set_script(EntityRendererScript)
add_child(renderer)
return renderer
func _make_fog_entities() -> Node2D:
var node = Node2D.new()
node.set_script(FogEntitiesScript)
add_child(node)
return node
# -- Z-layer ordering (4) -----------------------------------------------------
func test_z_floor_below_ysort() -> void:
# P3-Z01: FloorTiles (z:0) renders below YSortGroup (z:100).
var inst := _make_scene()
var floor_tiles = inst.get_node("World/FogGroup/FloorTiles")
var ysort = inst.get_node("World/FogGroup/YSortGroup")
assert_that(floor_tiles.z_index).override_failure_message(
"FloorTiles z_index must be Z_FLOOR (%d)" % Constants.Z_FLOOR
).is_equal(Constants.Z_FLOOR)
assert_that(ysort.z_index).override_failure_message(
"YSortGroup z_index must be Z_YSORT (%d)" % Constants.Z_YSORT
).is_equal(Constants.Z_YSORT)
assert_that(floor_tiles.z_index < ysort.z_index).is_true()
func test_z_entities_inside_ysort_at_zero() -> void:
# P3-Z02: Entities node lives inside YSortGroup with z_index = 0.
# Y-sort contract: children of YSortGroup must use z_index = 0.
var inst := _make_scene()
var entities = inst.get_node("World/FogGroup/YSortGroup/Entities")
assert_that(entities.z_index).override_failure_message(
"Entities z_index must be 0 inside YSortGroup (y-sort contract)"
).is_equal(0)
assert_that(entities.get_parent().y_sort_enabled).override_failure_message(
"Entities parent must have y_sort_enabled"
).is_true()
func test_z_fog_above_world_content() -> void:
# P3-Z03: FogOverlay (z:900) renders above all world content including
# YSortGroup (z:100) and Overhead (z:300).
# Spec #34: FogOverlay z_index == Z_FOG (900).
# Spec #35: FogEntities z_index == Z_FOG_ENTITIES (950).
var inst := _make_scene()
var fog = inst.get_node("World/FogOverlay")
var fog_entities = inst.get_node("World/FogEntities")
var fog_group = inst.get_node("World/FogGroup")
var overhead = inst.get_node("World/FogGroup/Overhead")
# FogOverlay must be a sibling of FogGroup (both children of World),
# not a child of FogGroup — fog renders OVER the composited group.
assert_that(fog.get_parent()).override_failure_message(
"FogOverlay must be sibling of FogGroup (both under World)"
).is_equal(fog_group.get_parent())
assert_that(fog.z_index).override_failure_message(
"FogOverlay z_index must be Z_FOG (%d) per D-049" % Constants.Z_FOG
).is_equal(Constants.Z_FOG)
assert_that(fog_entities.z_index).override_failure_message(
"FogEntities z_index must be Z_FOG_ENTITIES (%d) per D-049/D-059" % Constants.Z_FOG_ENTITIES
).is_equal(Constants.Z_FOG_ENTITIES)
assert_that(fog.z_index > overhead.z_index).override_failure_message(
"FogOverlay (z:%d) must render above Overhead (z:%d)" % [fog.z_index, overhead.z_index]
).is_true()
assert_that(fog_entities.z_index > fog.z_index).override_failure_message(
"FogEntities (z:%d) must render above FogOverlay (z:%d)" % [fog_entities.z_index, fog.z_index]
).is_true()
func test_z_ui_layer_above_world() -> void:
# P3-Z04: UILayer (CanvasLayer 20) renders above InsertOverlay (CanvasLayer 10)
# and both render above world content.
var inst := _make_scene()
var ui_layer = inst.get_node("UILayer") as CanvasLayer
var insert_layer = inst.get_node("InsertOverlay") as CanvasLayer
var modal_layer = inst.get_node("ModalLayer") as CanvasLayer
assert_that(insert_layer.layer).override_failure_message(
"InsertOverlay must be CanvasLayer %d" % Constants.CANVAS_INSERT
).is_equal(Constants.CANVAS_INSERT)
assert_that(ui_layer.layer).override_failure_message(
"UILayer must be CanvasLayer %d" % Constants.CANVAS_UI
).is_equal(Constants.CANVAS_UI)
assert_that(modal_layer.layer).override_failure_message(
"ModalLayer must be CanvasLayer %d" % Constants.CANVAS_MODAL
).is_equal(Constants.CANVAS_MODAL)
assert_that(ui_layer.layer > insert_layer.layer).override_failure_message(
"UILayer must render above InsertOverlay"
).is_true()
assert_that(modal_layer.layer > ui_layer.layer).override_failure_message(
"ModalLayer must render above UILayer"
).is_true()
# -- Entity lerp (3) ----------------------------------------------------------
func test_entity_snap_on_first_appear() -> void:
# P3-L01: Entity spawns at its position immediately — no lerp on first appear.
var renderer := _make_entity_renderer()
var entity := [{"entity_id": 10, "x": 8.0, "y": 6.0, "z": 0,
"kind": {"variant": "Npc", "data": null}}]
renderer.update_entities(entity)
var node = renderer.entity_nodes[10]
var expected := Vector2(
floorf(8.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET,
floorf(6.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET
)
assert_that(node.position).override_failure_message(
"Entity should snap to position on first appear (no lerp)"
).is_equal(expected)
renderer.queue_free()
func test_entity_lerp_moves_toward_target() -> void:
# P3-L02: After updating target position, entity moves toward it over time.
var renderer := _make_entity_renderer()
# Spawn at (5, 5)
var entity := [{"entity_id": 11, "x": 5.0, "y": 5.0, "z": 0,
"kind": {"variant": "Npc", "data": null}}]
renderer.update_entities(entity)
var node: ColorRect = renderer.entity_nodes[11]
var start_pos: Vector2 = node.position
# Move target to (6, 5)
var entity_moved := [{"entity_id": 11, "x": 6.0, "y": 5.0, "z": 0,
"kind": {"variant": "Npc", "data": null}}]
renderer.update_entities(entity_moved)
# Process several frames — entity should move toward target
renderer._process(0.016)
renderer._process(0.016)
var after_pos: Vector2 = node.position
var target := Vector2(
floorf(6.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET,
floorf(5.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET
)
# Position should have moved toward target (x increased)
assert_that(after_pos.x > start_pos.x).override_failure_message(
"Entity x should move toward target after _process"
).is_true()
# But should not have snapped — still in transit
assert_that(after_pos.x < target.x).override_failure_message(
"Entity should still be in transit after 2 frames"
).is_true()
renderer.queue_free()
func test_entity_lerp_converges_within_300ms() -> void:
# P3-L03: At LERP_SPEED=12.0, entity converges within ~0.3s.
# At 12.0: weight = 1.0 - exp(-12.0 * 0.3) ≈ 0.973 — 97% there.
var renderer := _make_entity_renderer()
# Spawn at (5, 5)
var entity := [{"entity_id": 12, "x": 5.0, "y": 5.0, "z": 0,
"kind": {"variant": "Npc", "data": null}}]
renderer.update_entities(entity)
# Move target to (7, 5) — 2 tiles
var entity_moved := [{"entity_id": 12, "x": 7.0, "y": 5.0, "z": 0,
"kind": {"variant": "Npc", "data": null}}]
renderer.update_entities(entity_moved)
var target := Vector2(
floorf(7.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET,
floorf(5.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET
)
# Simulate 0.3s at 60fps (18 frames × 0.016s ≈ 0.288s)
for i in 20:
renderer._process(0.016)
var final_node: ColorRect = renderer.entity_nodes[12]
var final_pos: Vector2 = final_node.position
# Should be within 5% of target (97% convergence at 0.3s)
var dist: float = final_pos.distance_to(target)
var total_dist: float = 2.0 * Constants.TILE_SIZE
assert_that(dist / total_dist < 0.05).override_failure_message(
"Entity should be within 5%% of target after 0.3s (dist: %.1f / %.1f)" % [dist, total_dist]
).is_true()
renderer.queue_free()
# -- Tyre additions (5) -------------------------------------------------------
func test_recognition_transition_progress() -> void:
# P3-T01: Recognition progress calculated correctly from remaining/total ticks.
# Grey blob → colored entity over total_delay_ticks.
var fog_entities := _make_fog_entities()
var saved := GameState.pending_recognitions
# 3 remaining out of 10 total → progress 0.7
GameState.pending_recognitions = [{
"entity_id": 200, "x": 5.0, "y": 5.0, "z": 0,
"remaining_ticks": 3, "total_delay_ticks": 10,
}]
fog_entities.update_from_state()
var blob: Dictionary = fog_entities._entities[200]
assert_that(blob.progress).override_failure_message(
"Progress should be 0.7 (1.0 - 3/10)"
).is_equal_approx(0.7, 0.01)
# 0 remaining → fully recognized (progress 1.0)
GameState.pending_recognitions = [{
"entity_id": 200, "x": 5.0, "y": 5.0, "z": 0,
"remaining_ticks": 0, "total_delay_ticks": 10,
}]
fog_entities.update_from_state()
assert_that(fog_entities._entities[200].progress).override_failure_message(
"Zero remaining should be progress 1.0"
).is_equal_approx(1.0, 0.01)
GameState.pending_recognitions = saved
fog_entities.queue_free()
func test_facing_indicator_rotation_matches_input_mapper_angle() -> void:
# P3-T02: D-054 — Facing indicator uses InputMapper.facing_angle (client-side float).
# Indicator rotation = facing_angle + PI/2 (0=North basis).
GameState.player_entity_id = 1
var renderer := _make_entity_renderer()
var entity := [{"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0,
"kind": {"variant": "Player", "data": null}, "visibility": "Forward"}]
renderer.update_entities(entity)
var indicator = renderer.entity_nodes[1].get_node("FacingIndicator")
# {facing_angle → expected indicator rotation}
var angles := {
-PI / 2.0: 0.0, # North
0.0: PI / 2.0, # East
PI / 2.0: PI, # South
PI: -PI / 2.0, # West (3PI/2 normalized to -PI/2 by Godot)
}
for angle in angles:
InputMapper.facing_angle = angle
renderer.update_entities(entity)
assert_that(indicator.rotation).override_failure_message(
"angle %.3f: expected rotation %.3f, got %.3f" % [angle, angles[angle], indicator.rotation]
).is_equal_approx(angles[angle], 0.001)
InputMapper.facing_angle = -PI / 2.0 # Reset to default
renderer.queue_free()
func test_lerp_weight_increases_with_delta() -> void:
# P3-T03: Sprint snappiness — larger delta → larger lerp weight → faster arrival.
# Exponential smoothing: weight = 1.0 - exp(-LERP_SPEED * delta).
# Higher delta (or higher lerp multiplier) means more progress per frame.
var renderer := _make_entity_renderer()
# Spawn entity, then move target
var entity := [{"entity_id": 20, "x": 5.0, "y": 5.0, "z": 0,
"kind": {"variant": "Npc", "data": null}}]
renderer.update_entities(entity)
var entity_moved := [{"entity_id": 20, "x": 8.0, "y": 5.0, "z": 0,
"kind": {"variant": "Npc", "data": null}}]
renderer.update_entities(entity_moved)
# Small delta step
var small_node: ColorRect = renderer.entity_nodes[20]
var small_start: float = small_node.position.x
renderer._process(0.008)
var small_progress: float = small_node.position.x - small_start
# Reset position for large delta test
small_node.position = Vector2(
floorf(5.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET,
floorf(5.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET
)
# Large delta step
var large_start: float = small_node.position.x
renderer._process(0.032)
var large_progress: float = small_node.position.x - large_start
assert_that(large_progress > small_progress).override_failure_message(
"Larger delta should produce more lerp progress (%.2f vs %.2f)" % [large_progress, small_progress]
).is_true()
renderer.queue_free()
func test_recognition_blob_removed_when_absent() -> void:
# P3-T04: When a pending recognition disappears from state, FogEntities
# removes the blob.
var fog_entities := _make_fog_entities()
var saved := GameState.pending_recognitions
# Add entity
GameState.pending_recognitions = [{
"entity_id": 300, "x": 10.0, "y": 10.0, "z": 0,
"remaining_ticks": 5, "total_delay_ticks": 10,
}]
fog_entities.update_from_state()
assert_that(fog_entities._entities.size()).is_equal(1)
# Remove from state
GameState.pending_recognitions = []
fog_entities.update_from_state()
assert_that(fog_entities._entities.size()).override_failure_message(
"Blob should be removed when absent from pending_recognitions"
).is_equal(0)
GameState.pending_recognitions = saved
fog_entities.queue_free()
func test_entity_renderer_lerp_speed_constant() -> void:
# P3-T05: LERP_SPEED is tuned at 12.0 — exponential smoothing constant
# for entity visual interpolation. Pin value to prevent accidental changes.
assert_that(EntityRenderer.LERP_SPEED).override_failure_message(
"LERP_SPEED must be 12.0 (entity movement feel constant)"
).is_equal(12.0)
+1
View File
@@ -0,0 +1 @@
uid://bas5l8rnvsjly
+366
View File
@@ -0,0 +1,366 @@
## #521: Confrontation D-033 color shift — QA test suite
## Spec refs: D-033 (entity color = relationship), D-063 (confrontation same box)
## Sprint Completion Proof (joint.md):
## - Entity tint fades 0.5-1s to new relationship color on confrontation delivery
## - Cursor hover tint also updates to match relationship
## - Palette matches D-033 exactly
##
## Tests are structured in layers:
## 1. D-033 color palette constants — always pass (no implementation dependency)
## 2. Relationship-to-color mapping — tests the lookup function
## 3. Entity renderer relationship coloring — tests that entities USE relationship
## 4. Tween on relationship change — tests fade behavior (0.5-1s)
## 5. Protocol/GameState passthrough — tests data pipeline integrity
class_name TestColorShift
extends GdUnitTestSuite
var EntityRendererScript: GDScript = load("res://scripts/rendering/entity_renderer.gd")
var ConstantsScript: GDScript = load("res://scripts/constants.gd")
# -- Test data -----------------------------------------------------------------
# Entity with relationship field (v4 protocol format)
func _make_entity(entity_id: int, kind: String, relationship: String, x: float = 5.0, y: float = 5.0) -> Dictionary:
return {
"entity_id": entity_id,
"x": x, "y": y, "z": 0,
"kind": {"variant": kind, "data": null},
"visibility": "Forward",
"relationship": relationship,
"observation": "Visible",
}
func _make_entity_renderer() -> Node2D:
var renderer: Node2D = Node2D.new()
renderer.set_script(EntityRendererScript)
add_child(renderer)
return renderer
func before_test() -> void:
GameState.player_entity_id = 1
# ==============================================================================
# Layer 1: D-033 Color Palette Constants
# These tests verify the palette is defined correctly. No implementation needed.
# ==============================================================================
func test_d033_unknown_teal() -> void:
assert_that(Constants.ENTITY_COLOR_UNKNOWN).is_equal(Color("#4a9ebb"))
func test_d033_friendly_green() -> void:
assert_that(Constants.ENTITY_COLOR_FRIENDLY).is_equal(Color("#6bc9a6"))
func test_d033_poi_amber() -> void:
assert_that(Constants.ENTITY_COLOR_POI).is_equal(Color("#e8c547"))
func test_d033_hostile_red() -> void:
assert_that(Constants.ENTITY_COLOR_HOSTILE).is_equal(Color("#d45d5d"))
func test_d033_object_grey() -> void:
assert_that(Constants.ENTITY_COLOR_OBJECT).is_equal(Color("#8b8ba0"))
func test_d033_player_cool_white() -> void:
assert_that(Constants.ENTITY_COLOR_PLAYER).is_equal(Color("#e0e8ff"))
func test_d033_palette_all_distinct() -> void:
# All 6 D-033 colors must be distinct from each other
var colors: Array[Color] = [
Constants.ENTITY_COLOR_UNKNOWN,
Constants.ENTITY_COLOR_FRIENDLY,
Constants.ENTITY_COLOR_POI,
Constants.ENTITY_COLOR_HOSTILE,
Constants.ENTITY_COLOR_OBJECT,
Constants.ENTITY_COLOR_PLAYER,
]
for i in range(colors.size()):
for j in range(i + 1, colors.size()):
assert_that(colors[i] != colors[j]).is_true()
# ==============================================================================
# Layer 2: Relationship-to-Color Mapping
# Tests the lookup function that maps relationship strings to D-033 colors.
# Depends on #521 adding color_for_relationship() to Constants.
# Uses ConstantsScript method list to skip gracefully if not yet implemented.
# ==============================================================================
func _has_color_for_relationship() -> bool:
# Check if the Constants script has a color_for_relationship method.
for method in ConstantsScript.get_script_method_list():
if method.name == "color_for_relationship":
return true
return false
func test_relationship_color_unknown() -> void:
if not _has_color_for_relationship():
push_warning("TestColorShift: color_for_relationship not implemented yet — awaiting #521")
return
var color: Color = Constants.color_for_relationship("Unknown")
assert_that(color).is_equal(Constants.ENTITY_COLOR_UNKNOWN)
func test_relationship_color_friendly() -> void:
if not _has_color_for_relationship():
return
var color: Color = Constants.color_for_relationship("Friendly")
assert_that(color).is_equal(Constants.ENTITY_COLOR_FRIENDLY)
func test_relationship_color_person_of_interest() -> void:
if not _has_color_for_relationship():
return
var color: Color = Constants.color_for_relationship("PersonOfInterest")
assert_that(color).is_equal(Constants.ENTITY_COLOR_POI)
func test_relationship_color_hostile() -> void:
if not _has_color_for_relationship():
return
var color: Color = Constants.color_for_relationship("Hostile")
assert_that(color).is_equal(Constants.ENTITY_COLOR_HOSTILE)
func test_relationship_color_fallback() -> void:
if not _has_color_for_relationship():
return
var color: Color = Constants.color_for_relationship("SomethingWeird")
assert_that(color).is_equal(Constants.ENTITY_COLOR_UNKNOWN)
# ==============================================================================
# Layer 3: Entity Renderer — Relationship-Based Coloring
# Tests that entity_renderer uses the relationship field for NPC colors.
# Player and Object entities should remain unaffected by relationship field.
# ==============================================================================
func test_npc_uses_relationship_color_unknown() -> void:
var renderer: Node2D = _make_entity_renderer()
var entities: Array = [_make_entity(2, "Npc", "Unknown")]
renderer.update_entities(entities)
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
# Unknown -> teal (both Phase 1 and Phase 2 produce the same result)
assert_that(node.color).is_equal(Constants.ENTITY_COLOR_UNKNOWN)
renderer.queue_free()
func test_npc_uses_relationship_color_friendly() -> void:
var renderer: Node2D = _make_entity_renderer()
var entities: Array = [_make_entity(2, "Npc", "Friendly")]
renderer.update_entities(entities)
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
if not _entity_uses_relationship(renderer):
push_warning("TestColorShift: entity renderer not yet using relationship for color — awaiting #521")
renderer.queue_free()
return
assert_that(node.color).is_equal(Constants.ENTITY_COLOR_FRIENDLY)
renderer.queue_free()
func test_npc_uses_relationship_color_poi() -> void:
var renderer: Node2D = _make_entity_renderer()
var entities: Array = [_make_entity(2, "Npc", "PersonOfInterest")]
renderer.update_entities(entities)
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
if not _entity_uses_relationship(renderer):
renderer.queue_free()
return
assert_that(node.color).is_equal(Constants.ENTITY_COLOR_POI)
renderer.queue_free()
func test_npc_uses_relationship_color_hostile() -> void:
var renderer: Node2D = _make_entity_renderer()
var entities: Array = [_make_entity(2, "Npc", "Hostile")]
renderer.update_entities(entities)
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
if not _entity_uses_relationship(renderer):
renderer.queue_free()
return
assert_that(node.color).is_equal(Constants.ENTITY_COLOR_HOSTILE)
renderer.queue_free()
func test_player_color_ignores_relationship() -> void:
# Player entity always uses ENTITY_COLOR_PLAYER regardless of relationship
var renderer: Node2D = _make_entity_renderer()
var entities: Array = [_make_entity(1, "Player", "Hostile")]
renderer.update_entities(entities)
var node: ColorRect = renderer.entity_nodes[1] as ColorRect
assert_that(node.color).is_equal(Constants.ENTITY_COLOR_PLAYER)
renderer.queue_free()
func test_object_color_ignores_relationship() -> void:
# Object entities always use ENTITY_COLOR_OBJECT
var renderer: Node2D = _make_entity_renderer()
var entities: Array = [_make_entity(3, "Object", "Friendly")]
renderer.update_entities(entities)
var node: ColorRect = renderer.entity_nodes[3] as ColorRect
assert_that(node.color).is_equal(Constants.ENTITY_COLOR_OBJECT)
renderer.queue_free()
# ==============================================================================
# Layer 4: Tween on Relationship Change
# Tests that color transitions use a 0.5-1s fade, not an instant flip.
# D-033: "Color shifts smoothly (0.5s fade) when relationship state changes."
# D-063: "entity D-033 color may fade" on confrontation delivery.
# ==============================================================================
func test_color_shift_not_instant() -> void:
var renderer: Node2D = _make_entity_renderer()
var entities_before: Array = [_make_entity(2, "Npc", "Friendly")]
renderer.update_entities(entities_before)
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
if not _entity_uses_relationship(renderer):
renderer.queue_free()
return
# Change relationship to Hostile
var entities_after: Array = [_make_entity(2, "Npc", "Hostile")]
renderer.update_entities(entities_after)
# Immediately after update, color should NOT yet be the target
var color_after_immediate: Color = node.color
if not _renderer_has_tween_support(renderer):
push_warning("TestColorShift: tween on relationship change not implemented yet — awaiting #521")
renderer.queue_free()
return
# The color should NOT be exactly the target yet (tween in progress)
assert_that(color_after_immediate != Constants.ENTITY_COLOR_HOSTILE).is_true()
renderer.queue_free()
func test_color_shift_reaches_target() -> void:
var renderer: Node2D = _make_entity_renderer()
if not _entity_uses_relationship(renderer):
renderer.queue_free()
return
if not _renderer_has_tween_support(renderer):
renderer.queue_free()
return
renderer.update_entities([_make_entity(2, "Npc", "Friendly")])
renderer.update_entities([_make_entity(2, "Npc", "Hostile")])
# Simulate time passing: ~1.5 seconds of frames
var elapsed: float = 0.0
while elapsed < 1.5:
renderer._process(1.0 / 60.0)
elapsed += 1.0 / 60.0
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
assert_that(node.color.is_equal_approx(Constants.ENTITY_COLOR_HOSTILE)).is_true()
renderer.queue_free()
func test_color_shift_mid_transition_retrigger() -> void:
# Rapid relationship changes: Unknown → Friendly → Hostile in quick succession.
# The second change should preempt the first tween and converge to Hostile.
var renderer: Node2D = _make_entity_renderer()
if not _entity_uses_relationship(renderer):
renderer.queue_free()
return
if not _renderer_has_tween_support(renderer):
renderer.queue_free()
return
renderer.update_entities([_make_entity(2, "Npc", "Unknown")])
# First change: Unknown → Friendly
renderer.update_entities([_make_entity(2, "Npc", "Friendly")])
# Advance partway (0.1s of a 0.5s tween)
for i in range(6):
renderer._process(1.0 / 60.0)
# Second change mid-tween: Friendly → Hostile (preempts first)
renderer.update_entities([_make_entity(2, "Npc", "Hostile")])
# Advance past full duration
var elapsed: float = 0.0
while elapsed < 1.0:
renderer._process(1.0 / 60.0)
elapsed += 1.0 / 60.0
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
assert_that(node.color.is_equal_approx(Constants.ENTITY_COLOR_HOSTILE)).is_true()
renderer.queue_free()
func test_color_shift_same_relationship_no_tween() -> void:
var renderer: Node2D = _make_entity_renderer()
if not _entity_uses_relationship(renderer):
renderer.queue_free()
return
var entities: Array = [_make_entity(2, "Npc", "Unknown")]
renderer.update_entities(entities)
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
var color_first: Color = node.color
renderer.update_entities(entities)
var color_second: Color = node.color
assert_that(color_first).is_equal(color_second)
renderer.queue_free()
# ==============================================================================
# Layer 5: Protocol — Relationship Field Round-Trip
# ==============================================================================
func test_protocol_entity_relationship_decoded() -> void:
var raw: Dictionary = {
"entity_id": 5,
"x": 10.0, "y": 10.0, "z": 0,
"kind": "Npc",
"relationship": "PersonOfInterest",
"visibility": "Forward",
"observation": "Visible",
}
var decoded: Variant = Protocol._decode_entity(raw)
assert_that(decoded).is_not_null()
assert_that(decoded.relationship).is_equal("PersonOfInterest")
func test_protocol_entity_relationship_defaults_unknown() -> void:
var raw: Dictionary = {
"entity_id": 5,
"x": 10.0, "y": 10.0, "z": 0,
"kind": "Npc",
}
var decoded: Variant = Protocol._decode_entity(raw)
assert_that(decoded).is_not_null()
assert_that(decoded.relationship).is_equal("Unknown")
func test_protocol_entity_all_relationship_values() -> void:
var relationships: Array[String] = ["Unknown", "Friendly", "PersonOfInterest", "Hostile"]
for rel in relationships:
var raw: Dictionary = {
"entity_id": 5,
"x": 10.0, "y": 10.0, "z": 0,
"kind": "Npc",
"relationship": rel,
}
var decoded: Variant = Protocol._decode_entity(raw)
assert_that(decoded).is_not_null()
assert_that(decoded.relationship).is_equal(rel)
# ==============================================================================
# Layer 6: GameState — Relationship Data Passthrough
# ==============================================================================
func test_game_state_preserves_relationship() -> void:
var snapshot: Dictionary = {
"tick": 1,
"entities": [_make_entity(2, "Npc", "Friendly")],
}
GameState.apply_snapshot(snapshot)
assert_that(GameState.visible_entities.size()).is_equal(1)
assert_that(GameState.visible_entities[0].relationship).is_equal("Friendly")
func test_game_state_relationship_changes_between_snapshots() -> void:
GameState.apply_snapshot({"tick": 1, "entities": [_make_entity(2, "Npc", "Friendly")]})
assert_that(GameState.visible_entities[0].relationship).is_equal("Friendly")
GameState.apply_snapshot({"tick": 2, "entities": [_make_entity(2, "Npc", "PersonOfInterest")]})
assert_that(GameState.visible_entities[0].relationship).is_equal("PersonOfInterest")
# ==============================================================================
# Helpers
# ==============================================================================
func _entity_uses_relationship(renderer: Node2D) -> bool:
var friendly: Dictionary = _make_entity(10, "Npc", "Friendly", 3.0, 3.0)
var hostile: Dictionary = _make_entity(11, "Npc", "Hostile", 5.0, 5.0)
renderer.update_entities([friendly, hostile])
if not renderer.entity_nodes.has(10) or not renderer.entity_nodes.has(11):
return false
var f_node: ColorRect = renderer.entity_nodes[10] as ColorRect
var h_node: ColorRect = renderer.entity_nodes[11] as ColorRect
var f_color: Color = f_node.color
var h_color: Color = h_node.color
var uses_rel: bool = not f_color.is_equal_approx(h_color)
renderer.update_entities([])
return uses_rel
func _renderer_has_tween_support(renderer: Node2D) -> bool:
return renderer.get("_entity_tweens") != null
+1
View File
@@ -0,0 +1 @@
uid://mg1wnv1ydc6m
+1
View File
@@ -0,0 +1 @@
uid://foiy3bk1jko7
+288
View File
@@ -0,0 +1,288 @@
## #501: Hub teleport client UX — QA test suite
## Spec refs: D-020 (protocol), D-030 (testability)
## Sprint Completion Proof (joint.md):
## - Home key sends TeleportToHub in Gauntlet mode
## - 0.3s fade-to-black-and-back plays on teleport
## - Dialogue/monologue/interaction buffer cleared on teleport
## - Non-Gauntlet: action rejected, client shows no effect
class_name TestHubTeleport
extends GdUnitTestSuite
# -- Fixtures ------------------------------------------------------------------
var _gauntlet_snapshot := {
"tick": 1,
"version": Protocol.PROTOCOL_VERSION,
"game_time": {"day": 0, "time_of_day": 100, "day_phase": "Morning", "tick_rate": "Full"},
"player_facing": "North",
"player_stance": "Walk",
"player_inventory": [],
"entities": [
{"entity_id": 1, "x": 50.0, "y": 50.0, "z": 0, "kind": {"variant": "Player", "data": null}, "visibility": "Forward"},
],
"tiles": [],
"visible_tiles": [],
"visible_positions": [],
"nearby_interactions": [],
"current_monologue": null,
"current_dialogue": null,
"pending_recognitions": [],
"gauntlet_mode": true,
"room_id": "proof_room",
}
var _normal_snapshot := {
"tick": 1,
"version": Protocol.PROTOCOL_VERSION,
"game_time": {"day": 0, "time_of_day": 100, "day_phase": "Morning", "tick_rate": "Full"},
"player_facing": "North",
"player_stance": "Walk",
"player_inventory": [],
"entities": [
{"entity_id": 1, "x": 50.0, "y": 50.0, "z": 0, "kind": {"variant": "Player", "data": null}, "visibility": "Forward"},
],
"tiles": [],
"visible_tiles": [],
"visible_positions": [],
"nearby_interactions": [],
"current_monologue": null,
"current_dialogue": null,
"pending_recognitions": [],
}
func before_test() -> void:
GameState.gauntlet_mode = false
GameState.current_monologue = null
GameState.current_dialogue = null
GameState.dialogue_active = false
GameState.room_id = null
InputMapper.input_queue.clear()
SimBridge.reset_test_state()
# -- InputMapper: TELEPORT_HUB action enum ------------------------------------
func test_teleport_hub_action_exists() -> void:
# Verify the enum value exists and is distinct
var action: int = InputMapper.Action.TELEPORT_HUB
assert_that(action).is_not_equal(InputMapper.Action.INTERACT)
assert_that(action).is_not_equal(InputMapper.Action.MOVE_NORTH)
# -- InputMapper: Gauntlet mode guard -----------------------------------------
func test_teleport_hub_blocked_outside_gauntlet() -> void:
# Non-gauntlet: Home key input should NOT queue TELEPORT_HUB
GameState.gauntlet_mode = false
InputMapper.input_queue.clear()
var event := InputEventKey.new()
event.physical_keycode = KEY_HOME
event.pressed = true
InputMapper._unhandled_input(event)
var has_teleport := false
for entry in InputMapper.input_queue:
if entry.action == InputMapper.Action.TELEPORT_HUB:
has_teleport = true
assert_that(has_teleport).is_false()
func test_teleport_hub_allowed_in_gauntlet_mode() -> void:
# Gauntlet mode: Home key SHOULD queue TELEPORT_HUB
GameState.gauntlet_mode = true
InputMapper.input_queue.clear()
var event := InputEventKey.new()
event.physical_keycode = KEY_HOME
event.pressed = true
InputMapper._unhandled_input(event)
var has_teleport := false
for entry in InputMapper.input_queue:
if entry.action == InputMapper.Action.TELEPORT_HUB:
has_teleport = true
assert_that(has_teleport).is_true()
# -- GameState: gauntlet_mode from snapshot ------------------------------------
func test_gauntlet_mode_set_from_snapshot() -> void:
GameState.apply_snapshot(_gauntlet_snapshot)
assert_that(GameState.gauntlet_mode).is_true()
assert_that(GameState.room_id).is_equal("proof_room")
func test_gauntlet_mode_false_when_absent() -> void:
GameState.apply_snapshot(_normal_snapshot)
assert_that(GameState.gauntlet_mode).is_false()
assert_that(GameState.room_id).is_null()
func test_gauntlet_mode_transitions_off() -> void:
# Gauntlet on → off: mode should clear
GameState.apply_snapshot(_gauntlet_snapshot)
assert_that(GameState.gauntlet_mode).is_true()
GameState.apply_snapshot(_normal_snapshot)
assert_that(GameState.gauntlet_mode).is_false()
# -- SimBridge: wire format encoding -------------------------------------------
func test_teleport_hub_wire_name() -> void:
# TELEPORT_HUB must encode to "TeleportToHub" on the wire (matching Rust PlayerAction)
var wire_name := SimBridge._action_enum_to_wire(InputMapper.Action.TELEPORT_HUB)
assert_that(wire_name).is_equal("TeleportToHub")
func test_teleport_hub_wire_not_empty() -> void:
# Wire name must not be empty (empty = client-only, not sent to server)
var wire_name := SimBridge._action_enum_to_wire(InputMapper.Action.TELEPORT_HUB)
assert_that(wire_name.is_empty()).is_false()
func test_teleport_hub_encode_roundtrip() -> void:
# Verify MessagePack encode→decode roundtrip for TeleportToHub
var encoded: PackedByteArray = Protocol.encode_player_input(42, "TeleportToHub")
assert_that(encoded.size()).is_greater(0)
var decoded: Variant = Protocol.decode_player_input(encoded)
assert_that(decoded).is_not_null()
assert_that(decoded.tick).is_equal(42)
assert_that(decoded.action.variant).is_equal("TeleportToHub")
assert_that(decoded.action.data).is_null()
# -- SimBridge: test mode teleport behavior ------------------------------------
func test_test_mode_teleport_resets_position() -> void:
# In test mode, TeleportToHub should reset player to hub spawn (10, 10)
SimBridge.reset_test_state()
# Move player away first
SimBridge._test_player_pos = Vector2i(50, 50)
SimBridge._test_input_queue.append("TeleportToHub")
var snap: Dictionary = SimBridge._test_snapshot()
# Player should be back at hub spawn
var player_entity: Dictionary = snap.entities[0]
assert_that(player_entity.x).is_equal(10.0)
assert_that(player_entity.y).is_equal(10.0)
func test_test_mode_teleport_clears_dialogue() -> void:
# TeleportToHub in test mode should clear dialogue state
SimBridge.reset_test_state()
SimBridge._test_in_dialogue = true
SimBridge._test_input_queue.append("TeleportToHub")
SimBridge._test_snapshot()
assert_that(SimBridge._test_in_dialogue).is_false()
# -- Teleport detection -------------------------------------------------------
# Threshold constant lives on the main scene node (TELEPORT_DISTANCE_THRESHOLD = 5.0).
# These tests verify the distance math against that threshold.
const _THRESHOLD: float = 5.0 # Mirror of main.gd TELEPORT_DISTANCE_THRESHOLD
func test_detect_teleport_large_jump() -> void:
# Position jump > threshold should be detected as teleport
var old_pos := Vector2(10.0, 10.0)
var new_pos := Vector2(50.0, 50.0)
assert_that(old_pos.distance_to(new_pos) > _THRESHOLD).is_true()
func test_detect_teleport_normal_movement() -> void:
# Normal 1-tile movement should NOT be detected as teleport
var old_pos := Vector2(10.0, 10.0)
var new_pos := Vector2(11.0, 10.0)
assert_that(old_pos.distance_to(new_pos) > _THRESHOLD).is_false()
func test_detect_teleport_diagonal_movement() -> void:
# Diagonal movement (1,1) — distance ~1.41, not a teleport
var old_pos := Vector2(10.0, 10.0)
var new_pos := Vector2(11.0, 11.0)
assert_that(old_pos.distance_to(new_pos) > _THRESHOLD).is_false()
func test_detect_teleport_boundary_exactly_threshold() -> void:
# Exactly threshold — should NOT trigger (> not >=)
var old_pos := Vector2(10.0, 10.0)
var new_pos := Vector2(15.0, 10.0)
assert_that(old_pos.distance_to(new_pos) > _THRESHOLD).is_false()
func test_detect_teleport_boundary_just_over() -> void:
# Just over threshold — should trigger
var old_pos := Vector2(10.0, 10.0)
var new_pos := Vector2(15.1, 10.0)
assert_that(old_pos.distance_to(new_pos) > _THRESHOLD).is_true()
# -- Buffer clearing on teleport -----------------------------------------------
func test_teleport_clears_dialogue_in_test_mode() -> void:
# TeleportToHub in test mode should clear dialogue state via the pipeline
SimBridge.reset_test_state()
SimBridge._test_in_dialogue = true
SimBridge._test_input_queue.append("TeleportToHub")
var snap: Dictionary = SimBridge._test_snapshot()
# Dialogue should be cleared by teleport
assert_that(SimBridge._test_in_dialogue).is_false()
assert_that(snap.current_dialogue).is_null()
func test_teleport_resets_position_in_test_mode() -> void:
# TeleportToHub must reset to hub spawn and clear dialogue (integration)
SimBridge.reset_test_state()
SimBridge._test_player_pos = Vector2i(50, 50)
SimBridge._test_in_dialogue = true
SimBridge._test_input_queue.append("TeleportToHub")
var snap: Dictionary = SimBridge._test_snapshot()
var player: Dictionary = snap.entities[0]
assert_that(player.x).is_equal(10.0)
assert_that(player.y).is_equal(10.0)
assert_that(SimBridge._test_in_dialogue).is_false()
# -- Send input integration (test mode) ----------------------------------------
func test_send_teleport_hub_in_test_mode() -> void:
# Verify send_input accepts TELEPORT_HUB in test mode
SimBridge.reset_test_state()
SimBridge.state = SimBridge.ConnectionState.CONNECTED
var err := SimBridge.send_input({
"action": InputMapper.Action.TELEPORT_HUB,
"timestamp_msec": 12345,
})
assert_that(err).is_equal(OK)
func test_send_teleport_hub_queues_wire_action() -> void:
# Verify TELEPORT_HUB is queued as "TeleportToHub" in test mode
SimBridge.reset_test_state()
SimBridge.state = SimBridge.ConnectionState.CONNECTED
SimBridge.send_input({
"action": InputMapper.Action.TELEPORT_HUB,
"timestamp_msec": 12345,
})
assert_that(SimBridge._test_input_queue.has("TeleportToHub")).is_true()
func test_gauntlet_mode_from_test_snapshot() -> void:
# Verify test snapshot includes gauntlet_mode field
SimBridge.reset_test_state()
SimBridge._test_gauntlet_mode = true
var snap: Dictionary = SimBridge._test_snapshot()
assert_that(snap.gauntlet_mode).is_true()
SimBridge._test_gauntlet_mode = false
snap = SimBridge._test_snapshot()
assert_that(snap.gauntlet_mode).is_false()
# -- Live mode outbound encoding -----------------------------------------------
func test_teleport_hub_outbound_entry() -> void:
# In live mode, TELEPORT_HUB should produce a valid outbound buffer entry
# (We can't test full live mode in unit tests, but we test the encode path)
var encoded: PackedByteArray = Protocol.encode_player_input(100, "TeleportToHub")
assert_that(encoded.size()).is_greater(0)
# Decode and verify
var decoded: Variant = Protocol.decode_player_input(encoded)
assert_that(decoded.action.variant).is_equal("TeleportToHub")
+119
View File
@@ -0,0 +1,119 @@
## D-054 facing and movement tests — _angle_to_octant, _snap_to_octant_dir,
## _wasd_to_world_dir coverage. All functions are static or use only facing_angle.
##
## Spec ref: D-054 (mouse-relative facing), Sprint 10 Completion Proof.
class_name TestInputMapperFacing
extends GdUnitTestSuite
# -- _angle_to_octant ----------------------------------------------------------
func test_angle_to_octant_east() -> void:
assert_str(InputMapper._angle_to_octant(0.0)).is_equal("East")
func test_angle_to_octant_north() -> void:
assert_str(InputMapper._angle_to_octant(-PI / 2.0)).is_equal("North")
func test_angle_to_octant_south() -> void:
assert_str(InputMapper._angle_to_octant(PI / 2.0)).is_equal("South")
func test_angle_to_octant_west() -> void:
assert_str(InputMapper._angle_to_octant(PI)).is_equal("West")
func test_angle_to_octant_northeast() -> void:
assert_str(InputMapper._angle_to_octant(-PI / 4.0)).is_equal("Northeast")
func test_angle_to_octant_southeast() -> void:
assert_str(InputMapper._angle_to_octant(PI / 4.0)).is_equal("Southeast")
func test_angle_to_octant_southwest() -> void:
assert_str(InputMapper._angle_to_octant(3.0 * PI / 4.0)).is_equal("Southwest")
func test_angle_to_octant_northwest() -> void:
assert_str(InputMapper._angle_to_octant(-3.0 * PI / 4.0)).is_equal("Northwest")
# -- _snap_to_octant_dir -------------------------------------------------------
func test_snap_east() -> void:
assert_object(InputMapper._snap_to_octant_dir(Vector2(1.0, 0.0))).is_equal(Vector2i(1, 0))
func test_snap_north() -> void:
assert_object(InputMapper._snap_to_octant_dir(Vector2(0.0, -1.0))).is_equal(Vector2i(0, -1))
func test_snap_south() -> void:
assert_object(InputMapper._snap_to_octant_dir(Vector2(0.0, 1.0))).is_equal(Vector2i(0, 1))
func test_snap_west() -> void:
assert_object(InputMapper._snap_to_octant_dir(Vector2(-1.0, 0.0))).is_equal(Vector2i(-1, 0))
func test_snap_northeast() -> void:
assert_object(InputMapper._snap_to_octant_dir(Vector2(0.7, -0.7))).is_equal(Vector2i(1, -1))
func test_snap_southwest() -> void:
assert_object(InputMapper._snap_to_octant_dir(Vector2(-0.7, 0.7))).is_equal(Vector2i(-1, 1))
func test_snap_zero_returns_zero() -> void:
assert_object(InputMapper._snap_to_octant_dir(Vector2.ZERO)).is_equal(Vector2i.ZERO)
func test_snap_tiny_returns_zero() -> void:
assert_object(InputMapper._snap_to_octant_dir(Vector2(0.001, 0.0))).is_equal(Vector2i.ZERO)
func test_snap_diagonal_bias() -> void:
# Slightly more east than north — should snap to northeast
assert_object(InputMapper._snap_to_octant_dir(Vector2(0.8, -0.6))).is_equal(Vector2i(1, -1))
# -- _wasd_to_world_dir --------------------------------------------------------
func test_wasd_forward_facing_east() -> void:
# W pressed, facing east → move east
InputMapper.facing_angle = 0.0 # East
var result := InputMapper._wasd_to_world_dir(Vector2i(0, -1))
assert_object(result).is_equal(Vector2i(1, 0))
func test_wasd_forward_facing_north() -> void:
# W pressed, facing north → move north
InputMapper.facing_angle = -PI / 2.0 # North
var result := InputMapper._wasd_to_world_dir(Vector2i(0, -1))
assert_object(result).is_equal(Vector2i(0, -1))
func test_wasd_backward_facing_north() -> void:
# S pressed, facing north → move south
InputMapper.facing_angle = -PI / 2.0 # North
var result := InputMapper._wasd_to_world_dir(Vector2i(0, 1))
assert_object(result).is_equal(Vector2i(0, 1))
func test_wasd_strafe_right_facing_north() -> void:
# D pressed, facing north → move east
InputMapper.facing_angle = -PI / 2.0 # North
var result := InputMapper._wasd_to_world_dir(Vector2i(1, 0))
assert_object(result).is_equal(Vector2i(1, 0))
func test_wasd_strafe_left_facing_north() -> void:
# A pressed, facing north → move west
InputMapper.facing_angle = -PI / 2.0 # North
var result := InputMapper._wasd_to_world_dir(Vector2i(-1, 0))
assert_object(result).is_equal(Vector2i(-1, 0))
func test_wasd_forward_facing_south() -> void:
# W pressed, facing south → move south
InputMapper.facing_angle = PI / 2.0 # South
var result := InputMapper._wasd_to_world_dir(Vector2i(0, -1))
assert_object(result).is_equal(Vector2i(0, 1))
func test_wasd_diagonal_forward_right_facing_east() -> void:
# W+D pressed, facing east → move southeast
InputMapper.facing_angle = 0.0 # East
var result := InputMapper._wasd_to_world_dir(Vector2i(1, -1))
assert_object(result).is_equal(Vector2i(1, 1))
func test_wasd_strafe_right_facing_west() -> void:
# D pressed, facing west → move north
InputMapper.facing_angle = PI # West
var result := InputMapper._wasd_to_world_dir(Vector2i(1, 0))
assert_object(result).is_equal(Vector2i(0, -1))
func after_test() -> void:
InputMapper.reset_facing_state()
@@ -0,0 +1 @@
uid://w7k3k1584ir7
+7 -3
View File
@@ -195,15 +195,19 @@ func test_prompt_hidden_initially() -> void:
# -- Input encoding: Interact --
func test_interact_encodes_as_unit_variant() -> void:
func test_interact_encodes_as_struct_variant() -> void:
# Interact is always a struct variant: {"Interact": {"target_entity_id": null, "verb": null}}
var inputs: Array = [{"tick": 100, "action_name": "Interact"}]
var bytes := Protocol.encode_player_inputs(inputs)
var raw = Messagepack.decode(bytes)
assert_that(raw.status == null).is_true()
assert_that(raw.value[0]["action"]).is_equal("Interact")
assert_that(raw.value[0]["action"] is Dictionary).is_true()
assert_that(raw.value[0]["action"].has("Interact")).is_true()
var interact_data: Dictionary = raw.value[0]["action"]["Interact"]
assert_that(interact_data.has("target_entity_id")).is_true()
assert_that(interact_data.has("verb")).is_true()
func test_interact_with_data_encodes_as_data_variant() -> void:
# Future: once server accepts Interact(InteractData)
var inputs: Array = [{
"tick": 100,
"action_name": "Interact",
+3 -1
View File
@@ -142,7 +142,9 @@ func test_framed_protocol_batch_input_roundtrip() -> void:
assert_that(raw.value is Array).is_true()
assert_that(raw.value.size()).is_equal(2)
assert_that(raw.value[0]["action"]).is_equal("MoveNorth")
assert_that(raw.value[1]["action"]).is_equal("Interact")
# Interact is a struct variant: {"Interact": {"target_entity_id": null, "verb": null}}
assert_that(raw.value[1]["action"] is Dictionary).is_true()
assert_that(raw.value[1]["action"].has("Interact")).is_true()
# -- Diagonal movement wire mapping --------------------------------------------
+6 -12
View File
@@ -85,13 +85,8 @@ func test_encode_uint32() -> void:
func test_encode_int64_positive() -> void:
# BV-P24 to BV-P25: int 64 range (2^32 to 2^63-1)
# KNOWN-DEFECT: The encoder's int_64 branch condition `-(1 << 63) <= v < (1 << 63)`
# evaluates to `MIN_INT64 <= v < MIN_INT64` due to overflow, making it dead code.
# Values that should be int_64 (0xd3) are instead encoded as uint_64 (0xcf).
# Roundtrip still works because put_u64/get_u64 preserve the bit pattern.
# This test documents ACTUAL behavior. Fix tracked in backlog.
# See: messagepack.gd int_64 branch — use explicit constant instead of `1 << 63`.
# BV-P24 to BV-P25: positive values > uint_32 max → uint_64 (0xcf)
# Positive values correctly use uint_64 encoding for Rust interop.
_assert_encodes_to(4294967296, PackedByteArray([
0xcf, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00
]), "BV-P24")
@@ -138,16 +133,15 @@ func test_encode_int32_negative() -> void:
func test_encode_int64_negative() -> void:
# BV-N15 to BV-N16: int 64 range (< -2147483648)
# KNOWN-DEFECT: Same int_64 branch issue as positive int_64 — encoded as uint_64 (0xcf).
# Bit pattern is preserved: put_u64(negative) writes two's complement,
# get_u64() reads it back and Variant stores as int64 with same bit pattern.
# Negative values beyond int_32 now correctly encode as int_64 (0xd3).
# Fixed: encoder's int_64 branch used `-(1 << 63)` which overflowed to dead code.
_assert_encodes_to(-2147483649, PackedByteArray([
0xcf, 0xff, 0xff, 0xff, 0xff, 0x7f, 0xff, 0xff, 0xff
0xd3, 0xff, 0xff, 0xff, 0xff, 0x7f, 0xff, 0xff, 0xff
]), "BV-N15")
# MIN_INT64: -9223372036854775808
var min_int64: int = -9223372036854775807 - 1
_assert_encodes_to(min_int64, PackedByteArray([
0xcf, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
0xd3, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
]), "BV-N16")
@@ -0,0 +1 @@
uid://bka1i18dfqay1
+7 -3
View File
@@ -119,15 +119,19 @@ func test_monologue_not_duplicated_after_consumption() -> void:
})
SimBridge.receive_bytes(bytes_with_mono)
# Client polls and consumes the monologue
var snapshot = SimBridge.poll_snapshot()
# Client polls and consumes the monologue.
# In live mode, poll_snapshot() returns _last_snapshot and sets it to null.
# In test mode, poll_snapshot() returns _test_snapshot() instead, so we
# simulate the live-mode consumption path directly.
var snapshot = SimBridge._last_snapshot
SimBridge._last_snapshot = null
assert_that(snapshot).is_not_null()
GameState.apply_snapshot(snapshot)
# apply_snapshot sets current_monologue, then main._consume_monologue() clears it.
# Simulate consumption:
GameState.current_monologue = null
# Next snapshot arrives without monologue — _last_snapshot is null after poll,
# Next snapshot arrives without monologue — _last_snapshot was null after poll,
# so no carry-forward should happen.
var bytes_next := _make_snapshot_bytes({"tick": 2})
SimBridge.receive_bytes(bytes_next)
+1
View File
@@ -0,0 +1 @@
uid://br7kv0dyk3w2k
+6 -2
View File
@@ -244,7 +244,9 @@ func test_encode_player_inputs_multiple() -> void:
assert_that(raw.status).is_null()
assert_that(raw.value.size()).is_equal(3)
assert_that(raw.value[0]["action"]).is_equal("MoveNorth")
assert_that(raw.value[1]["action"]).is_equal("Interact")
# Interact is a struct variant: {"Interact": {"target_entity_id": null, "verb": null}}
assert_that(raw.value[1]["action"] is Dictionary).is_true()
assert_that(raw.value[1]["action"].has("Interact")).is_true()
assert_that(raw.value[2]["action"]).is_equal("MoveSouthwest")
@@ -358,7 +360,9 @@ func test_decode_batch_input_fixture() -> void:
assert_that(raw.value[0]["tick"]).is_equal(0)
assert_that(raw.value[0]["action"]).is_equal("MoveNorth")
assert_that(raw.value[1]["tick"]).is_equal(0)
assert_that(raw.value[1]["action"]).is_equal("Interact")
# Interact is a struct variant: {"Interact": {"target_entity_id": null, "verb": null}}
assert_that(raw.value[1]["action"] is Dictionary).is_true()
assert_that(raw.value[1]["action"].has("Interact")).is_true()
# -- Diagonal movement fixtures (D-030 Layer 1 cross-language) -----------------
+7 -7
View File
@@ -26,17 +26,17 @@ func _load_fixture(name: String) -> PackedByteArray:
# -- Protocol version upgrade -------------------------------------------------
func test_protocol_version_is_7() -> void:
assert_that(Protocol.PROTOCOL_VERSION).is_equal(7)
func test_protocol_version_is_8() -> void:
assert_that(Protocol.PROTOCOL_VERSION).is_equal(8)
func test_fixtures_at_protocol_version_7() -> void:
# All regenerated fixtures should be at v7
func test_fixtures_at_protocol_version_8() -> void:
# All regenerated fixtures should be at v8
for fixture_name in ["snapshot_one_npc", "snapshot_empty", "snapshot_player", "snapshot_multi_entity"]:
var bytes = _load_fixture(fixture_name)
var snapshot = Protocol.decode_snapshot(bytes)
assert_that(snapshot).is_not_null()
assert_that(snapshot.version).is_equal(7)
assert_that(snapshot.version).is_equal(8)
func test_rejects_version_6() -> void:
@@ -282,10 +282,10 @@ func test_sim_bridge_test_snapshot_has_player_inventory() -> void:
assert_that(snap.player_inventory is Array).is_true()
func test_sim_bridge_test_snapshot_version_7() -> void:
func test_sim_bridge_test_snapshot_version_8() -> void:
SimBridge.reset_test_state()
var snap = SimBridge._test_snapshot()
assert_that(snap.version).is_equal(7)
assert_that(snap.version).is_equal(8)
# -- Fixture: v6 snapshots include new fields ----------------------------------
+1
View File
@@ -0,0 +1 @@
uid://cefjgd1iqi7qn
+1
View File
@@ -0,0 +1 @@
uid://bjd6g15syl2qy
+17 -12
View File
@@ -298,25 +298,30 @@ func test_entity_renderer_player_has_facing_indicator() -> void:
renderer.queue_free()
func test_entity_renderer_facing_indicator_rotation_accuracy() -> void:
# D-054: Facing indicator now reads InputMapper.facing_angle (float), not
# GameState.player_facing (string). Indicator rotation = facing_angle + PI/2.
GameState.player_entity_id = 1
var renderer := _make_entity_renderer()
var directions := {
"North": 0.0,
"Northeast": PI / 4.0,
"East": PI / 2.0,
"Southeast": 3.0 * PI / 4.0,
"South": PI,
"Southwest": 5.0 * PI / 4.0,
"West": 3.0 * PI / 2.0,
"Northwest": 7.0 * PI / 4.0,
# {facing_angle → expected indicator rotation}
# Indicator 0 = North (up). facing_angle 0 = East. So rotation = angle + PI/2.
var angles := {
-PI / 2.0: 0.0, # North
-PI / 4.0: PI / 4.0, # Northeast
0.0: PI / 2.0, # East
PI / 4.0: 3.0 * PI / 4.0, # Southeast
PI / 2.0: PI, # South
3.0 * PI / 4.0: -3.0 * PI / 4.0, # Southwest (Godot normalizes to (-PI, PI])
PI: -PI / 2.0, # West (3PI/2 normalized to -PI/2)
-3.0 * PI / 4.0: -PI / 4.0, # Northwest (-3PI/4 + PI/2 = -PI/4)
}
renderer.update_entities(_test_entities_v2)
var player_node = renderer.entity_nodes[1]
var indicator = player_node.get_node_or_null("FacingIndicator")
for dir_name in directions:
GameState.player_facing = dir_name
for angle in angles:
InputMapper.facing_angle = angle
renderer.update_entities(_test_entities_v2)
assert_that(indicator.rotation).is_equal_approx(directions[dir_name], 0.001)
assert_that(indicator.rotation).is_equal_approx(angles[angle], 0.001)
InputMapper.facing_angle = -PI / 2.0 # Reset to default (North)
renderer.queue_free()
func test_entity_renderer_npc_has_no_facing_indicator() -> void:
+206
View File
@@ -0,0 +1,206 @@
extends Control
## #495: WRONG button (F12) MVP — bug report capture dialog.
## On F12: pause sim, show one-line prompt, save snapshot + render + description, unpause.
## Output: user://bug-reports/gauntlet-t{tick}-{timestamp}/
signal capture_completed
signal capture_cancelled
const BG_COLOR := Color(0.05, 0.05, 0.08, 0.85)
const BORDER_COLOR := Color("#4a9ebb")
const TEXT_COLOR := Color("#c8d0e0")
const FONT_SIZE := 14
const LABEL_FONT_SIZE := 13
const BOX_WIDTH := 500
const BOX_HEIGHT := 120
const PADDING := 16
var _line_edit: LineEdit = null
var _active: bool = false
func _ready() -> void:
visible = false
mouse_filter = Control.MOUSE_FILTER_STOP
func start_capture() -> void:
if _active:
return
_active = true
visible = true
# Pause the simulation
SimBridge.send_input({
"action": InputMapper.Action.PAUSE,
"timestamp_msec": Time.get_ticks_msec(),
})
# Create the LineEdit dynamically
_line_edit = LineEdit.new()
_line_edit.placeholder_text = "Describe the issue..."
_line_edit.size = Vector2(BOX_WIDTH - PADDING * 2, 30)
_line_edit.position = Vector2(
(get_viewport_rect().size.x - BOX_WIDTH) / 2.0 + PADDING,
(get_viewport_rect().size.y - BOX_HEIGHT) / 2.0 + 50
)
_line_edit.add_theme_font_size_override("font_size", FONT_SIZE)
_line_edit.text_submitted.connect(_on_text_submitted)
add_child(_line_edit)
_line_edit.grab_focus()
func _on_text_submitted(text: String) -> void:
_save_report(text)
_close()
capture_completed.emit()
func _unhandled_input(event: InputEvent) -> void:
if not _active:
return
if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE:
_close()
capture_cancelled.emit()
get_viewport().set_input_as_handled()
func _close() -> void:
_active = false
visible = false
if _line_edit:
_line_edit.queue_free()
_line_edit = null
# Unpause the simulation
SimBridge.send_input({
"action": InputMapper.Action.UNPAUSE,
"timestamp_msec": Time.get_ticks_msec(),
})
func _save_report(description: String) -> void:
var tick := GameState.current_tick
var timestamp := Time.get_datetime_string_from_system().replace(":", "-").replace("T", "_")
var dir_name := "gauntlet-t%d-%s" % [tick, timestamp]
var base_path := "user://bug-reports/" + dir_name
# Ensure directory exists
var dir_err := DirAccess.make_dir_recursive_absolute(base_path)
if dir_err != OK:
push_error("BugReport: failed to create directory %s (error %d)" % [base_path, dir_err])
return
var files_saved := 0
# 1. snapshot.json — full current snapshot as JSON
var snapshot_path := base_path + "/snapshot.json"
var snapshot_file := FileAccess.open(snapshot_path, FileAccess.WRITE)
if snapshot_file:
snapshot_file.store_string(JSON.stringify(GameState.current_snapshot, "\t"))
snapshot_file.close()
files_saved += 1
else:
push_error("BugReport: failed to write %s" % snapshot_path)
# 2. render.txt — simplified client-side text render of snapshot
var render_path := base_path + "/render.txt"
var render_file := FileAccess.open(render_path, FileAccess.WRITE)
if render_file:
render_file.store_string(_render_snapshot_text())
render_file.close()
files_saved += 1
else:
push_error("BugReport: failed to write %s" % render_path)
# 3. description.txt — tester description + metadata
var desc_path := base_path + "/description.txt"
var desc_file := FileAccess.open(desc_path, FileAccess.WRITE)
if desc_file:
desc_file.store_string("Description: %s\n" % description)
desc_file.store_string("Tick: %d\n" % tick)
desc_file.store_string("Room: %s\n" % str(GameState.room_id if GameState.room_id else "none"))
desc_file.store_string("Stance: %s\n" % GameState.player_stance)
desc_file.store_string("Facing: %s\n" % GameState.player_facing)
desc_file.store_string("Position: %s\n" % str(GameState.player_position))
desc_file.store_string("Timestamp: %s\n" % Time.get_datetime_string_from_system())
desc_file.close()
files_saved += 1
else:
push_error("BugReport: failed to write %s" % desc_path)
push_warning("BugReport: saved %d/3 files to %s" % [files_saved, base_path])
## Simplified client-side text render of the current snapshot.
## MVP version — full fidelity via server's format_snapshot_text() is a stretch goal.
func _render_snapshot_text() -> String:
var lines: PackedStringArray = []
lines.append("=== Snapshot t%d ===" % GameState.current_tick)
lines.append("Player: %s facing %s (%s)" % [
str(GameState.player_position), GameState.player_facing, GameState.player_stance])
if GameState.game_time.size() > 0:
lines.append("Time: day %s, %s, %s" % [
str(GameState.game_time.get("day", "?")),
str(GameState.game_time.get("day_phase", "?")),
str(GameState.game_time.get("tick_rate", "?"))])
lines.append("")
lines.append("Entities (%d):" % GameState.visible_entities.size())
for entity in GameState.visible_entities:
var kind_str: String = ""
if entity.has("kind") and entity.kind is Dictionary:
kind_str = entity.kind.get("variant", "?")
elif entity.has("kind") and entity.kind is String:
kind_str = entity.kind
var vis: String = entity.get("visibility", "?")
lines.append(" #%s %s at (%s, %s) [%s]" % [
str(entity.get("entity_id", "?")),
kind_str,
str(entity.get("x", "?")),
str(entity.get("y", "?")),
vis])
lines.append("")
lines.append("Visible tiles: %d" % GameState.visible_tiles.size())
if GameState.current_monologue != null:
lines.append("Monologue: %s" % str(GameState.current_monologue.get("text", "")))
if GameState.current_dialogue != null:
lines.append("Dialogue: %s says '%s'" % [
str(GameState.current_dialogue.get("npc_name", "?")),
str(GameState.current_dialogue.get("speech", ""))])
return "\n".join(lines)
func _draw() -> void:
if not _active:
return
var viewport_size := get_viewport_rect().size
# Full-screen dim
draw_rect(Rect2(Vector2.ZERO, viewport_size), BG_COLOR)
# Center box
var box_pos := Vector2(
(viewport_size.x - BOX_WIDTH) / 2.0,
(viewport_size.y - BOX_HEIGHT) / 2.0
)
var box_rect := Rect2(box_pos, Vector2(BOX_WIDTH, BOX_HEIGHT))
draw_rect(box_rect, Color(0.08, 0.08, 0.12, 0.95))
draw_rect(box_rect, BORDER_COLOR, false, 1.0)
# Title
var font := ThemeDB.fallback_font
draw_string(font,
box_pos + Vector2(PADDING, 24),
"WRONG — Describe the issue (Enter to save, Esc to cancel):",
HORIZONTAL_ALIGNMENT_LEFT, -1, LABEL_FONT_SIZE, TEXT_COLOR)
# -- Public API ---------------------------------------------------------------
func is_active() -> bool:
return _active
+1
View File
@@ -0,0 +1 @@
uid://dwdssbevd8gqq
+14
View File
@@ -0,0 +1,14 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://ui/bug_report_dialog.gd" id="1_bugreport"]
; #495: WRONG button (F12) — bug report capture dialog, ModalLayer
[node name="BugReportDialog" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
script = ExtResource("1_bugreport")
+134
View File
@@ -0,0 +1,134 @@
extends Control
## #503: Auto-checklist HUD overlay — shows condition progress in Gauntlet mode.
## Renders below the GauntletHUD timer. Each condition shows a check/dash + description.
## Only visible in gauntlet_mode. Latched conditions stay checked.
##
## Spec ref: D-030 (testability), #503, Sprint 10 Completion Proof.
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
const UNMET_COLOR := Color("#8890a0") # Dim grey — condition pending
const HEADER_COLOR := Color("#c8d0e0") # Insert text color — header/summary
const COMPLETE_COLOR := Color("#e8c547") # Amber — all conditions met
const FONT_SIZE := 11
const LINE_HEIGHT := 16
const PADDING := Vector2(8, 6)
const MAX_DESC_CHARS := 52 # Truncate long descriptions
var _evaluator = null # ChecklistEvaluator instance
var _last_room_id: Variant = null
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()
_cached_font = get_theme_default_font()
func update_from_state() -> void:
if not GameState.gauntlet_mode:
if visible:
visible = false
return
if not visible:
visible = true
var room_id: Variant = GameState.room_id
if room_id == null:
if _evaluator.is_loaded():
_evaluator.reset()
_last_room_id = null
queue_redraw()
return
# Load checklist on room change
if room_id != _last_room_id:
_evaluator.load_room(str(room_id))
_last_room_id = room_id
# Evaluate conditions against current snapshot
_evaluator.evaluate()
queue_redraw()
func _draw() -> void:
if _evaluator == null or not _evaluator.is_loaded():
return
var font: Font = _cached_font if _cached_font else get_theme_default_font()
var results: Array = _evaluator.get_results()
if results.is_empty():
return
var met_count: int = _evaluator.get_met_count()
var total_count: int = _evaluator.get_total_count()
var all_complete: bool = _evaluator.is_complete()
# Header line: "CHECK: 5/8"
var header_text := "CHECK: %d/%d" % [met_count, total_count]
var header_color: Color = COMPLETE_COLOR if all_complete else HEADER_COLOR
# Calculate box height: header + one line per condition + padding
var line_count: int = 1 + results.size()
var box_height: float = PADDING.y * 2 + line_count * LINE_HEIGHT
# Calculate box width from longest line
var max_width: float = font.get_string_size(header_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE).x
for r in results:
var desc: String = r.get("description", r.get("id", ""))
if desc.length() > MAX_DESC_CHARS:
desc = desc.substr(0, MAX_DESC_CHARS - 1) + "..."
var prefix: String = "[x] " if r.get("met", false) else "[ ] "
var line_width: float = font.get_string_size(prefix + desc, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE).x
if line_width > max_width:
max_width = line_width
var box_width: float = max_width + PADDING.x * 2
# Background
draw_rect(Rect2(Vector2.ZERO, Vector2(box_width, box_height)), BG_COLOR)
# Header
var y: float = PADDING.y + FONT_SIZE
draw_string(font, Vector2(PADDING.x, y), header_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, header_color)
# Condition lines
for r in results:
y += LINE_HEIGHT
var is_met: bool = r.get("met", false)
var prefix: String = "[x] " if is_met else "[ ] "
var desc: String = r.get("description", r.get("id", ""))
if desc.length() > MAX_DESC_CHARS:
desc = desc.substr(0, MAX_DESC_CHARS - 1) + "..."
var color: Color = MET_COLOR if is_met else UNMET_COLOR
draw_string(font, Vector2(PADDING.x, y), prefix + desc, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, color)
# -- Public API ---------------------------------------------------------------
func get_evaluator():
return _evaluator
func get_met_count() -> int:
if _evaluator == null:
return 0
return _evaluator.get_met_count()
func get_total_count() -> int:
if _evaluator == null:
return 0
return _evaluator.get_total_count()
func is_complete() -> bool:
if _evaluator == null:
return false
return _evaluator.is_complete()
+17
View File
@@ -0,0 +1,17 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://ui/checklist_overlay.gd" id="1_checklist"]
; #503: Auto-checklist overlay — below GauntletHUD timer, right-aligned
[node name="ChecklistOverlay" type="Control"]
layout_mode = 3
anchors_preset = 1
anchor_left = 1.0
anchor_right = 1.0
offset_left = -420.0
offset_top = 78.0
offset_right = -16.0
offset_bottom = 400.0
grow_horizontal = 0
mouse_filter = 2
script = ExtResource("1_checklist")
+1
View File
@@ -0,0 +1 @@
uid://btwe5f7tmm3o4
+182
View File
@@ -0,0 +1,182 @@
extends Control
## #496: Gauntlet HUD — room timer + personal bests.
## Shows TIMER: MM:SS (PB: MM:SS) in top-right, below StanceIndicator.
## Hidden in non-gauntlet mode. Stats persisted to user://dev/gauntlet-stats.json.
const BG_COLOR := Color(0.05, 0.05, 0.08, 0.5)
const TIMER_COLOR := Color("#c8d0e0") # Default insert text
const PB_COLOR := Color("#6bc9a6") # Friendly green — personal best
const NEW_PB_COLOR := Color("#e8c547") # Amber flash on new PB
const FONT_SIZE := 13
const PADDING := Vector2(10, 6)
const STATS_PATH := "user://dev/gauntlet-stats.json"
var _timer_seconds: float = 0.0
var _timer_running: bool = false
var _current_room_id: Variant = null
var _personal_bests: Dictionary = {} # room_id -> float (seconds)
var _session_rooms: Dictionary = {} # room_id -> {attempts: int, best: float}
var _new_pb_flash: float = 0.0 # Countdown for PB flash effect
func _ready() -> void:
mouse_filter = Control.MOUSE_FILTER_IGNORE
visible = false
_load_stats()
func _process(delta: float) -> void:
if not visible:
return
if _timer_running:
_timer_seconds += delta
queue_redraw()
if _new_pb_flash > 0.0:
_new_pb_flash -= delta
queue_redraw()
func update_from_state() -> void:
if not GameState.gauntlet_mode:
if visible:
visible = false
return
if not visible:
visible = true
queue_redraw()
var new_room_id: Variant = GameState.room_id
if new_room_id == null:
# Gauntlet mode but no room yet — stop timer, clear tracked room
# so re-entry to the same room after null triggers a restart.
_timer_running = false
_current_room_id = null
return
if new_room_id != _current_room_id:
_on_room_change(str(new_room_id))
func _on_room_change(new_room_id: String) -> void:
# Record completion of previous room
if _current_room_id != null and _timer_running:
_record_room_completion(_current_room_id, _timer_seconds)
# Start timer for new room
_current_room_id = new_room_id
_timer_seconds = 0.0
_timer_running = true
# Track session stats
if not _session_rooms.has(new_room_id):
_session_rooms[new_room_id] = {"attempts": 0, "best": INF}
_session_rooms[new_room_id]["attempts"] += 1
queue_redraw()
func _record_room_completion(completed_room_id: String, seconds: float) -> void:
var old_pb: float = _personal_bests.get(completed_room_id, INF)
if seconds < old_pb:
_personal_bests[completed_room_id] = seconds
_new_pb_flash = 2.0 # Flash for 2 seconds
_save_stats()
# Update session tracking
if _session_rooms.has(completed_room_id):
var entry: Dictionary = _session_rooms[completed_room_id]
if seconds < entry["best"]:
entry["best"] = seconds
func _draw() -> void:
var font := ThemeDB.fallback_font
var timer_text := "TIMER: " + _format_time(_timer_seconds)
var pb_text := ""
if _current_room_id != null and _personal_bests.has(_current_room_id):
pb_text = " (PB: " + _format_time(_personal_bests[_current_room_id]) + ")"
var full_text := timer_text + pb_text
var text_size := font.get_string_size(full_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE)
var box_size := text_size + PADDING * 2
# Background
draw_rect(Rect2(Vector2.ZERO, box_size), BG_COLOR)
# Timer text
var y_offset := PADDING.y + text_size.y
var timer_size := font.get_string_size(timer_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE)
draw_string(font, Vector2(PADDING.x, y_offset), timer_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, TIMER_COLOR)
# 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)
static func _format_time(seconds: float) -> String:
var total_secs := int(seconds)
var mins := total_secs / 60
var secs := total_secs % 60
return "%02d:%02d" % [mins, secs]
func _load_stats() -> void:
if not FileAccess.file_exists(STATS_PATH):
return
var file := FileAccess.open(STATS_PATH, FileAccess.READ)
if file == null:
return
var json := JSON.new()
var err := json.parse(file.get_as_text())
file.close()
if err == OK and json.data is Dictionary:
_personal_bests = json.data
func _save_stats() -> void:
var file := FileAccess.open(STATS_PATH, FileAccess.WRITE)
if file == null:
push_warning("GauntletHUD: cannot write stats to %s" % STATS_PATH)
return
file.store_string(JSON.stringify(_personal_bests, "\t"))
file.close()
func print_session_summary() -> void:
if _session_rooms.is_empty():
return
print("=== Gauntlet Session Summary ===")
for room_id in _session_rooms:
var entry: Dictionary = _session_rooms[room_id]
var best_str := _format_time(entry["best"]) if entry["best"] != INF else "--:--"
var pb_str := _format_time(_personal_bests[room_id]) if _personal_bests.has(room_id) else "--:--"
print(" Room %s: %d attempts, session best %s, all-time PB %s" % [
room_id, entry["attempts"], best_str, pb_str])
print("================================")
# Record current room if timer is running (called on disconnect)
func finalize() -> void:
if _current_room_id != null and _timer_running:
_record_room_completion(_current_room_id, _timer_seconds)
_timer_running = false
print_session_summary()
# -- Public API ---------------------------------------------------------------
func get_timer_seconds() -> float:
return _timer_seconds
func is_timer_running() -> bool:
return _timer_running
func get_current_room_id() -> Variant:
return _current_room_id
func get_personal_best(for_room_id: String) -> float:
return _personal_bests.get(for_room_id, INF)
+1
View File
@@ -0,0 +1 @@
uid://2dtu0xe0odtf
+17
View File
@@ -0,0 +1,17 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://ui/gauntlet_hud.gd" id="1_gauntlet"]
; #496: Gauntlet HUD — room timer + personal bests, top-right below StanceIndicator
[node name="GauntletHUD" type="Control"]
layout_mode = 3
anchors_preset = 1
anchor_left = 1.0
anchor_right = 1.0
offset_left = -160.0
offset_top = 48.0
offset_right = -16.0
offset_bottom = 74.0
grow_horizontal = 0
mouse_filter = 2
script = ExtResource("1_gauntlet")
+2 -2
View File
@@ -16,8 +16,8 @@ const FADE_IN := 0.12
const FADE_OUT := 0.10
const LABEL_HEIGHT := 22
const LABEL_GAP := 2
const INSERT_FG := Color("#c8d0e0")
const INSERT_DIM := Color("#8b8ba0")
const INSERT_FG := Constants.IMPLANT_TEXT_COLOR
const INSERT_DIM := Constants.IMPLANT_TEXT_DIM
const INSERT_BG := Color(0.05, 0.05, 0.08, 0.7)
var _showing: bool = false
+1
View File
@@ -0,0 +1 @@
uid://wkkt2nhmygtm
+1
View File
@@ -0,0 +1 @@
uid://dhdp146dcog3x
+1
View File
@@ -0,0 +1 @@
uid://7y327ugttryy
+34 -15
View File
@@ -4,7 +4,9 @@ extends Control
## Insert-styled: geometric lines, thin spokes, nearly transparent.
## Renders on InsertOverlay (CanvasLayer 10).
## Drag-release for power users, click-click for newcomers.
## Insert spoke sends Pause on activate, Pause again on close (toggle).
## Insert spoke sends PauseSimulation on activate (#518/D-058).
## Selecting any non-Insert spoke, cancelling, or pressing Escape calls
## deactivate_insert() which sends ResumeSimulation. Pause/resume are idempotent.
signal spoke_selected(spoke_name: String)
@@ -35,6 +37,7 @@ var _origin: Vector2 = Vector2.ZERO
var _hovered_spoke: int = Spoke.NONE
var _drag_mode: bool = false
var _insert_active: bool = false
var _cached_font: Font = null
func _ready() -> void:
@@ -43,6 +46,7 @@ func _ready() -> void:
size = custom_minimum_size
visible = false
mouse_filter = Control.MOUSE_FILTER_STOP
_cached_font = get_theme_default_font()
func _input(event: InputEvent) -> void:
@@ -83,6 +87,15 @@ func _close_menu() -> void:
visible = false
# Handle Escape key to dismiss menu and deactivate insert if active.
func _unhandled_input(event: InputEvent) -> void:
if _open and event.is_action_pressed("ui_cancel"):
if _insert_active:
deactivate_insert()
_close_menu()
get_viewport().set_input_as_handled()
func _update_hover(mouse_pos: Vector2) -> void:
var delta := mouse_pos - _origin
var dist := delta.length()
@@ -115,29 +128,36 @@ func _confirm_selection() -> void:
if _hovered_spoke == Spoke.INSERT:
_activate_insert()
elif _insert_active:
# Selecting any non-Insert spoke closes the insert and resumes sim
deactivate_insert()
elif _insert_active:
# No spoke selected (cancelled) while insert active — close insert
deactivate_insert()
_close_menu()
func _activate_insert() -> void:
# TODO(v7): replace PAUSE toggle with dedicated ToggleInsert action in protocol
# #518/D-058: Send PauseSimulation when insert opens. Idempotent —
# if already paused (e.g. Gauntlet interlude), server ignores duplicate.
if not _insert_active:
_insert_active = true
_send_pause()
func _send_pause() -> void:
SimBridge.send_input({
"action": InputMapper.Action.PAUSE,
"timestamp_msec": Time.get_ticks_msec(),
})
SimBridge.send_input({
"action": InputMapper.Action.PAUSE,
"timestamp_msec": Time.get_ticks_msec(),
})
func deactivate_insert() -> void:
# Called when closing insert view — send Pause again (toggle)
# #518/D-058: Send ResumeSimulation when insert closes.
if _insert_active:
_insert_active = false
_send_pause()
SimBridge.send_input({
"action": InputMapper.Action.UNPAUSE,
"timestamp_msec": Time.get_ticks_msec(),
})
func _draw() -> void:
@@ -174,10 +194,9 @@ func _draw() -> void:
# Label
var label: String = SPOKE_NAMES.get(spoke, "")
var font := ThemeDB.fallback_font
var text_size := font.get_string_size(label, HORIZONTAL_ALIGNMENT_CENTER, -1, 11)
var text_size := _cached_font.get_string_size(label, HORIZONTAL_ALIGNMENT_CENTER, -1, 11)
var label_pos := icon_center + Vector2(-text_size.x / 2.0, ICON_SIZE + 14.0)
draw_string(font, label_pos, label, HORIZONTAL_ALIGNMENT_LEFT, -1, 11, color)
draw_string(_cached_font, label_pos, label, HORIZONTAL_ALIGNMENT_LEFT, -1, 11, color)
func _draw_spoke_icon(spoke: int, center: Vector2, color: Color) -> void:
+1
View File
@@ -0,0 +1 @@
uid://ct55qobqip5nn
+135
View File
@@ -0,0 +1,135 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "checklist.schema.json",
"title": "Gauntlet QA Checklist",
"description": "Per-room QA checklist with conditions evaluable from ObserverSnapshot (D-030). Feeds into #503 auto-checklist progress tracking.",
"type": "object",
"required": ["conditions"],
"additionalProperties": false,
"properties": {
"room_id": {
"type": "string",
"pattern": "^[a-z][a-z0-9_]*$",
"description": "Room identifier matching constants.rs room name"
},
"room_name": {
"type": "string",
"minLength": 1,
"description": "Human-readable room name"
},
"scope": {
"type": "string",
"enum": ["per_room", "cross_room"],
"description": "Checklist scope: per_room (default) or cross_room"
},
"description": {
"type": "string"
},
"conditions": {
"type": "array",
"items": {
"oneOf": [
{ "$ref": "#/$defs/player_near" },
{ "$ref": "#/$defs/player_facing" },
{ "$ref": "#/$defs/entity_present" },
{ "$ref": "#/$defs/entity_absent" },
{ "$ref": "#/$defs/expected_monologue" },
{ "$ref": "#/$defs/expected_dialogue" },
{ "$ref": "#/$defs/expected_interaction_verb" }
]
},
"minItems": 1
}
},
"$defs": {
"condition_id": {
"type": "string",
"pattern": "^[a-z][a-z0-9_-]*$",
"description": "Globally unique condition identifier across all checklists. Use room_id prefix (e.g., inv-item-present, occ-wall-visible)."
},
"player_near": {
"type": "object",
"additionalProperties": false,
"required": ["id", "description", "condition_type", "x", "y", "radius"],
"properties": {
"id": { "$ref": "#/$defs/condition_id" },
"description": { "type": "string" },
"condition_type": { "const": "player_near" },
"x": { "type": "integer", "description": "Target X coordinate (absolute sim tiles)" },
"y": { "type": "integer", "description": "Target Y coordinate (absolute sim tiles)" },
"radius": { "type": "number", "minimum": 0, "description": "Proximity radius in tiles" }
}
},
"player_facing": {
"type": "object",
"additionalProperties": false,
"required": ["id", "description", "condition_type", "direction"],
"properties": {
"id": { "$ref": "#/$defs/condition_id" },
"description": { "type": "string" },
"condition_type": { "const": "player_facing" },
"direction": {
"type": "string",
"enum": ["North", "South", "East", "West"],
"description": "Expected player facing direction"
}
}
},
"entity_present": {
"type": "object",
"additionalProperties": false,
"required": ["id", "description", "condition_type", "entity_id"],
"properties": {
"id": { "$ref": "#/$defs/condition_id" },
"description": { "type": "string" },
"condition_type": { "const": "entity_present" },
"entity_id": { "type": "integer", "minimum": 0, "description": "StableId of expected entity" }
}
},
"entity_absent": {
"type": "object",
"additionalProperties": false,
"required": ["id", "description", "condition_type", "entity_id"],
"properties": {
"id": { "$ref": "#/$defs/condition_id" },
"description": { "type": "string" },
"condition_type": { "const": "entity_absent" },
"entity_id": { "type": "integer", "minimum": 0, "description": "StableId of entity that should NOT be visible" }
}
},
"expected_monologue": {
"type": "object",
"additionalProperties": false,
"required": ["id", "description", "condition_type", "contains"],
"properties": {
"id": { "$ref": "#/$defs/condition_id" },
"description": { "type": "string" },
"condition_type": { "const": "expected_monologue" },
"contains": { "type": "string", "minLength": 1, "description": "Substring expected in current_monologue text" }
}
},
"expected_dialogue": {
"type": "object",
"additionalProperties": false,
"required": ["id", "description", "condition_type", "contains"],
"properties": {
"id": { "$ref": "#/$defs/condition_id" },
"description": { "type": "string" },
"condition_type": { "const": "expected_dialogue" },
"contains": { "type": "string", "minLength": 1, "description": "Substring expected in dialogue_response text" }
}
},
"expected_interaction_verb": {
"type": "object",
"additionalProperties": false,
"required": ["id", "description", "condition_type", "entity_id", "verb"],
"properties": {
"id": { "$ref": "#/$defs/condition_id" },
"description": { "type": "string" },
"condition_type": { "const": "expected_interaction_verb" },
"entity_id": { "type": "integer", "minimum": 0, "description": "StableId of target entity" },
"verb": { "type": "string", "minLength": 1, "description": "Expected interaction verb (e.g., Talk, Pickup, Examine)" }
}
}
}
}

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