fix(client): clear the test debt — 2 production bugs, suite fully green (T-973 et al.)

Production fixes surfaced by honest test triage:
- hud_groups.gd: _set_group_z crashed on freed HUD nodes — the typed loop
  variable errors before the is_instance_valid guard runs; prune first
- fog_state.gd: _resize cleared _prev_visible (world-space keys survive
  resizes), so pre-resize tiles never decayed VISIBLE→EXPLORED (D-059)

Test debt (T-928/929/934/935/936/937/938/939, T-864, T-973): lambda
local-capture bugs rewritten with array captures (now assert exact
emission counts), e2e suites updated to the current handshake +
StartupMessage protocol and stream-aware reads against the live binary,
fog perf test measures steady state, chime test pins the shipped 800ms
catalog asset (D-067 amended separately), monologue gdUnit4 API typo,
battery-warning tests follow the MetaScreen on_open lifecycle. 3 sprint2
proof tests revived (corner_reveal had passed from the wrong tile — NPC3
blocks (18,14); route corrected). Soft-skips converted to real do_skip
reporting. T-1068: 7 orphan .gd.uid deleted, _format_pop/_format_radius
deduped into atlas_format.gd (preload, no class_name — headless cache).

Suite: 1264 cases/20 failures → 1268/0, independently re-verified
(2536/2536, exit 0).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 16:22:01 +02:00
co-authored by Claude Fable 5
parent b7a8cd1876
commit c64231e8ee
25 changed files with 603 additions and 269 deletions
+1
View File
@@ -80,3 +80,4 @@ INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBPTXSA4WFXPD21YK7N8SF60', 'T-1070', '2026-06-12 10:40:59', '2026-06-12 10:40:59', NULL, '7a1bce4e8c3705623cf50834005ba19a', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBPTXSEC1F0RKJVH7JVPS8NW', 'T-1071', '2026-06-12 10:40:59', '2026-06-12 10:40:59', NULL, 'b3f82a325e1724e3d218fe54ba344f64', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBQ8WGMA3552756BQAAFPMER', 'T-1072', '2026-06-12 11:41:59', '2026-06-12 11:41:59', NULL, 'fefd6fa8565a07b57ff8bbd5cadfeb7e', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBRCMEKT72S0Q6NE68JH7EXC', 'T-1073', '2026-06-12 14:18:10', '2026-06-12 14:18:10', NULL, 'e2373f7a83c096537e649e733ccde52f', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
+115
View File
@@ -1704,3 +1704,118 @@ DRAIN (S-02, med): receive_bridge_inputs calls receive() exactly once per 50 ms
Extension constraint when touching this code: D-225 amendment 2026-06-12 (tagged envelope for the next inbound type).
Fixed 2026-06-12, commit 49a74e93 (+ review hardening). FrameAccumulator state machine holds partial prefix/payload across non-blocking receive() calls; drain loop with per-tick cap covers inputs + atlas requests. Review fix_required item resolved: EOF mid-frame now escalates to Disconnected (peer dead, frame can never complete) instead of per-tick Io error spam regression test added (2 prefix bytes then drop). Minors: comment reworded; corrupt-stream escalation filed as T-1072; split-frame test hardening noted on T-1072. Gate: 1488 tests green.', 'done', 'high', NULL, 'server', 'D-225', '2026-06-12 10:40:58', '2026-06-12 11:50:14', NULL, '5f314f655cd285f4e8ad7e481e254cb6', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNSRY3TXD5HDKHFVGDG18', 'bug', '06FBPPMZNNEV052DBYYY3A897C', 'test_implant_app_lifecycle: 1 failure — ImplantApp or HudGroups state API', '1 failing test case in test_implant_app_lifecycle.gd. Suite tests ImplantApp._internal_app_changed, nav stack, preserves_state, and HudGroups state. after_test resets HudGroups._active_app, _active_mode, _groups — if these private fields were renamed or removed, after_test would fail and bleed state. Or a specific on_open/on_close hook fires differently. Run --filter test_implant_app_lifecycle and check specific assertion. Sprint 38 triage #871.
Re-homed under maintenance from done umbrella T-871 per cascade refocus 2026-06-12.', 'in_progress', 'low', NULL, 'client', NULL, '2026-05-02 08:12:32', '2026-06-12 12:02:03', NULL, '1108dfbc854c17e0a1a07b458bd043b7', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBPTXS5YZ04F4CPPYNZS4Z2W', 'task', '06FBPPMZNNEV052DBYYY3A897C', 'Docs-drift sweep: one-generator reality, dead check-protocol target, DEVOPS hook sections, project.yaml version, stale code comments', '(description follows in first append)
Filed 2026-06-12 from the fable-ous.md audit (S-11, S-15, S-16, S-25, S-26, S-39; S-15/S-16 adversarially verified).
1. S-15: docs/DEVOPS.md:211-230 documents three generators, .claude/rules/asset-pipeline.md:22,27,36 documents two with a literal command for a file that does not exist (tooling/planet-gen/generate_atlas.py, retired #951/D-223); truth is one (import_economics Makefile:334-352, stamp registry). Fix both + the CLAUDE.md asset-pipeline snippet.
2. S-16: delete make check-protocol (Makefile:123-130 greps constants deleted under D-192 via T-874/T-875 empty==empty always passes); drop from build dep (:132) and help (:42).
3. S-25: rewrite DEVOPS.md hook/config sections from current sources (pre-push table omits cargo test/deny/clerk + advisory gdlint status; .config/ description never materialized; pre-commit row omits pql validate/export).
4. S-26: project.yaml says version 0.2.0 under the 0.{phase}.{n} scheme with Phase 4 active, and its description still narrates the v0.1 Sova slice bump to 0.4.n (or document the bump trigger) + refresh description.
5. S-39: update voxel.rs module doc (:41-48) and MorphologyFamily variant comments (:256-280) still claiming 7 families are unimplemented stubs (T-1029 landed them all); fix cascade.rs:219 TODO citing closed T-1028 (now tracked by T-1044).
6. S-11: comment sweep main.rs:126-127 claims a protocol_version handshake that D-192 removed; npc/relationships.rs:92 TODO(v0.2); chunk_streaming.rs:6,21-22 v0.3+ rephrase version targets as phase numbers.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-12 10:40:59', '2026-06-12 12:02:03', NULL, '1a233ede23af3311ea9bdfd18ebc312c', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBPTXS18RSVRHC7Y9TAX06HG', 'task', '06FBPPMZNNEV052DBYYY3A897C', 'Client hygiene batch: orphan .gd.uid files, time_display removal rider, _format_pop dedupe, skip_test_ triage', '(description follows in first append)
Filed 2026-06-12 from the fable-ous.md audit (S-21, S-22, S-24; low severity spot-check citations at pickup).
1. S-21 now: delete 7 tracked orphan .gd.uid files whose scripts are gone (client/scripts/rendering/fog_renderer.gd.uid; client/ui/{bug_report_dialog,character_select,debug_console,loading_screen,main_menu,settings_dialog}.gd.uid). Phase-5 rider: remove time_display.gd/.tscn + its 199-line test (superseded by the merged HUD row per hud.gd:4 / #786) with the Phase-5 rendering pass, not before (D-166).
2. S-22: deduplicate _format_pop byte-identical 12-line implementations in planet_screen.gd:105-116 and system_screen.gd:685-696 (plus _format_radius) into a shared atlas helper.
3. S-24: triage the 9 permanently disabled skip_test_ functions (test_sprint2_proof.gd:117,147,169; test_etalk_overlay_sprint17.gd:267-273; test_client_p2.gd:70,100,198) re-enable, delete, or attach a T-id; convert the push_warning-then-return soft-skip pattern (test_fog_shader, test_audio_sprint13, test_monologue_display, test_input_roundtrip:54) to gdUnit4 skip reporting so missing prerequisites are visible in the JSON summary, not silently green.', 'in_progress', 'low', NULL, 'client', NULL, '2026-06-12 10:40:59', '2026-06-12 12:02:03', NULL, '1a26321a1ab62ad9a15398ef27e4f2fd', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNSRXN360AQN3973AHS8M', 'bug', '06FBPPMZNNEV052DBYYY3A897C', 'test_dialogue_sprint20: 5 failures — AudioManager.get_active_dip or other API drift', '5 failing test cases in test_dialogue_sprint20.gd. The suite tests D-020 signal decoupling for dialogue_box. Likely failure: test_show_dialogue_does_not_call_audio_manager_directly compares AudioManager.get_active_dip() before/after show_dialogue — if AudioManager.get_active_dip() behavior changed or is not returning clean state, this would fail. Investigate by running the suite in isolation and reading specific assertion failures from the log. Sprint 38 triage #871.
Re-homed under maintenance from done umbrella T-871 per cascade refocus 2026-06-12.', 'in_progress', 'low', NULL, 'client', NULL, '2026-05-02 08:11:54', '2026-06-12 12:02:03', NULL, '1ca6eb573c01be2f53a2a6afc6c76c85', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNSRXCE9D9JYBZWVXMG68', 'bug', '06FBPPMZNNEV052DBYYY3A897C', 'test_ai_dialogue_sprint26: 1 failure — HardwareDetector autoload missing or settings_dialog API', '1 failing test case in test_ai_dialogue_sprint26.gd. The HardwareDetector tests skip if autoload not found. The GameState field tests (ai_enhanced_dialogue_enabled) look correct. The failing test is likely in the settings_dialog section: SETTINGS_DIALOG_SCENE preloaded at class level — if settings_dialog.tscn doesn''t exist or a method was renamed, class-level preload would fail. Check res://ui/settings_dialog.tscn exists and the relevant methods. Sprint 38 triage #871.
Re-homed under maintenance from done umbrella T-871 per cascade refocus 2026-06-12.', 'in_progress', 'low', NULL, 'client', NULL, '2026-05-02 08:13:02', '2026-06-12 12:02:03', NULL, '451dd5fd4a11eb2ce1c400f24331ea28', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNSRYF23MZ86S1XF1542G', 'bug', '06FBPPMZNNEV052DBYYY3A897C', 'test_fog_shader: 1 failure — stale VIS_PERIPHERAL or peripheral sector assertion', '1 failing test case in test_fog_shader.gd. The most likely failure is test_fog_state_forward_vs_peripheral: it asserts Forward > Peripheral visibility values, but Sprint 22 (#569) removed peripheral sector. Now all visible tiles write VIS_FORWARD regardless. The test checks the texture is non-null (not the actual byte values), so it should pass... unless the failure is in test_fog_state_exploration_persists which has a weak assertion. Run --filter test_fog_shader to identify the specific failure. Sprint 38 triage #871.
Re-homed under maintenance from done umbrella T-871 per cascade refocus 2026-06-12.', 'in_progress', 'low', NULL, 'client', NULL, '2026-05-02 08:12:45', '2026-06-12 12:02:03', NULL, '50fdf7d80f80cd66756b9ad34314fc6e', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBPTXSEC1F0RKJVH7JVPS8NW', 'task', '06FBPPMZNNEV052DBYYY3A897C', 'Wiki sweep: real link breaks, stale nav surfaces, GOVERNANCE/template reconcile, Sol legacy markers, triangles index', '(description follows in first append)
Filed 2026-06-12 from the fable-ous.md audit (S-54, S-55, S-56, S-59; S-54/S-55/S-56 adversarially verified, S-59 agent-reported).
1. S-54 (~26 real breaks): knowledge/concepts/ rename leftovers (3 contraband files, triangles/hub-power.md:420, authoring/monologue-guide.md ×7); pre-governance decisions/ anchors (monologue-guide.md:834-836); 9 phantom companion-system links in star-systems/catalog.md (index.md handles the same companions correctly as unlinked text); factions/cygni-b-combines.md absent (linked from stalownia-kowalski.md:111); concepts/entity-attributes.md:280 wrong relative path to an existing doc.
2. S-55: wiki/index.md:12 claims "10 fully written" vs ~301 reality; corporations/index.md links 38/155 (117 orphaned); zero nav link to economics/ (39 md + 20 TOML, the primary generator source); 25 station-level gttr-*.md orphaned from their system pages. Regenerate counts from the catalog (or let wiki_sync own them); autogenerate the corp index from frontmatter.
3. S-56: wiki/GOVERNANCE.md:39-54 prescribes a star-systems/{system}/{station}/{district} hand-authored tree that has zero instances, and _templates/star-system.md:1-18 mandates frontmatter that 0/302 system pages have rewrite both to the generated model (wiki_sync.py: DB owns structured fields, wiki owns prose) or decide system pages should carry frontmatter and have wiki_sync emit it.
4. S-59: convert the 4 pre-D-223 geometry-bearing Sol markers.json (GJ-0 bodies, authored coordinates/populations) to names-only, moving the authored data to the Sol special-case home; add wiki/triangles/index.md or de-link it from wiki/index.md:19.
Audit wiki survey (verified 2026-06-12):
# wiki/ directory map
| Directory | Files | Format | Machine-readable (feeds DB)? | Completeness |
|---|---|---|---|---|
| star-systems/ | 3,262 md + 2,398 json + 5,921 png | md (no frontmatter on system pages; YAML frontmatter on body pages), markers.json, png | YES body frontmatter (axial_tilt import_economics.py:1286), markers.json city names (:1451), gttr.md gttr_hook (populate_gttr_hook.py); system infobox/bodies tables generated FROM systems.db (wiki_sync.py) | 302 system dirs; 301 full prose (Supply Dependency 301, Silence Topic 288, Calibration Note 261); 2,625 body pages all with EMPTY Description; 302 gttr.md + 6 corridor + 24 station GTTRs; heightmap.png only 267 vs globe 3,256/reliefmap 2,398; GJ-280B = sole stub |
| economics/ | 39 md + 20 toml | TOML (source of truth) + md prose | YES commodities.toml, production_chains.toml, currency_zones.toml, specialization_vocabulary.toml (27), system_specialization.toml (240/301 pins), architecture_trait_catalog.toml (28 templates) + bias, brands/generated_brands/tier1/tier2 | Complete for current decisions (D-173..D-182, D-232, D-237; T-1016 done); not linked from wiki/index.md |
| corporations/ | 156 md | md + YAML frontmatter | YES frontmatter slug/title/headquarters/tags/scope corporations + corp_presence (import_economics.py:734-790, D-182 wiki-is-truth) | 155 canonical pages ~8-16KB; index.md lists only 38 (117 orphaned from nav) |
| factions/ | 9 md | md + frontmatter | No (prose; dominant_faction comes from economics TOML) | Complete: 6 major + 1 regional + 1 local worked example, all canonical, clean index |
| technology/ | 7 md | md | No | Complete (6 entries + index) |
| contraband/ | 4 md | md + frontmatter | No | Complete (3 entries + index); stale ../knowledge/ links |
| concepts/ | 4 md | md | No | Complete (fact-catalog, entity-attributes, relationship-states); renamed from knowledge/, inbound links broken |
| authoring/ | 12 md (incl. npc-patterns/ 3) | md | No (authoring guides) | Complete; monologue-guide has 9 stale links (knowledge/, pre-governance decisions/ paths) |
| triangles/ | 1 md | md + frontmatter | No | One worked example (hub-power.md); index.md missing (broken link from wiki root) |
| cultural-groups/ | 0 (.gitkeep) | | | EMPTY (template exists) |
| institutions/ | 0 (.gitkeep) | | | EMPTY (template exists; covered under factions/) |
| lore/ | 0 (.gitkeep) | | | EMPTY (template exists) |
| species/ | 0 (.gitkeep) | | | EMPTY (template exists) |
| _templates/ | 17 md | md | | Complete but drifted: star-system template generated reality; station/district/location/cultural-group/species/lore/institution templates have 0 instances |
| glossary.md / GOVERNANCE.md / index.md | 3 md | md | No | Glossary current (incl. TOML/DB terminology + anti-drift list); GOVERNANCE spatial hierarchy + status lifecycle stale; index.md counts stale (''10 fully written'' vs ~301) |
# Star-systems census
- Systems: 302 dirs (catalog claims 301; 220 named / 81 unnamed); 9 catalog rows link nonexistent companion dirs
- Bodies: 2,625 body pages (planets, moons, belts, oort clouds; no per-type subdirs flat bodies/ per system)
- Stations: 0 wiki pages; rows in systems.db rendered into ''Stations & Facilities'' tables per system index + 24 station GTTR prose files
- Markers: 2,394 names-only (D-223) + 4 legacy geometry (all GJ-0/Sol)
# Generation data flow (verified)
wiki/economics/*.toml + wiki/corporations/*.md frontmatter + wiki/star-systems body frontmatter + markers.json names → tooling/economy-db/import_economics.py → server/data/systems.db (stamped, tooling/check-systems-db-stamp) → client Atlas (gttr_excerpt popups, reach_screen.gd:178) + direct runtime load of wiki globe.png (system_screen.gd:482-492). wiki_sync.py: DB owns structured fields, wiki owns prose sections.
# docs/ health snapshot
- docs/architecture/ (19): current pql-migration.md (2026-06-06), atlas-naming-pipeline, implant-app-pattern; historical sprint-36/37 audits, v01-yaml-content-audit, eval/risk-godot-rust-bridge (2026-03-13)
- docs/design/ (60+): ~35 files frozen at 2026-02-1903-14, status:active but v0.1/v0.2-era (wow-moments, first-5-minutes, spatial-layout-*-v01, dueling content-structure docs); still-load-bearing: star-map.json (Topology source), gj-catalog-full.json, planetary-terrain-generation.md, cultural-diversity-audit.md (remediated, T-793 done), sova-station-profile.md (canon for the stub system)', 'in_progress', 'medium', NULL, 'content', NULL, '2026-06-12 10:40:59', '2026-06-12 12:02:03', NULL, '626d75380801ac5c7e413b14b01a46c1', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNSRY9FV0S9RE42WY7SHW', 'bug', '06FBPPMZNNEV052DBYYY3A897C', 'test_audio_sprint13: 1 failure — AudioManager.clear_dip or API drift', '1 failing test case in test_audio_sprint13.gd. before_test calls AudioManager.clear_dip(), stop_all_loops(), set_volume() for all buses. If any of these methods were renamed or a bus constant changed, setup would fail and cascade. Or a specific dip timing assertion is stale. Run --filter test_audio_sprint13 and read the specific failure. Sprint 38 triage #871.
Re-homed under maintenance from done umbrella T-871 per cascade refocus 2026-06-12.', 'in_progress', 'low', NULL, 'client', NULL, '2026-05-02 08:12:56', '2026-06-12 12:02:03', NULL, '63931d00ac6e6bdd529d0c4399790138', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBPTXRBRFW4MH5G8C0X12YGR', 'task', '06FBPPMZNNEV052DBYYY3A897C', 'Workers pool: add tests, fix panic-loss, correct the false "tested" doc claim', '(description follows in first append)
Filed 2026-06-12 from the fable-ous.md audit (S-05; verified zero tests exist for BackgroundWorkerPool anywhere).
workers/stubs.rs:7 claims the pool infrastructure is "real and tested" false. pool.rs:8-13 documents that a worker-thread panic silently loses in-flight requests (TODO #843); for ModalLock consumers that means the player waits forever. SimulationPlugin spawns 4 live stub threads every session (simulation/mod.rs:85-87) for Phase-5+ functionality.
Do now: correct the doc claim; unit tests (spawn/push/poll/shutdown + worker-panic). Before any ModalLock consumer ships (Phase 5): heartbeat/re-enqueue or downgrade ModalLock to an error path. Optional: defer thread spawn until a real handler registers.', 'in_progress', 'medium', NULL, 'server', NULL, '2026-06-12 10:40:59', '2026-06-12 12:02:03', NULL, '7fa711e5c4e77c895f44a9a00c091719', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBPTXRG23G1RAREVHQJ14BWC', 'task', '06FBPPMZNNEV052DBYYY3A897C', 'Server hygiene batch: exemption contracts, dead deps, generator_spike quarantine, save/load ordering, economy.rs tests', '(description follows in first append)
Filed 2026-06-12 from the fable-ous.md audit (S-06, S-07, S-08, S-09, S-12; low severity spot-check citations at pickup).
1. S-06: add the consumer contract ("every consumer must sort before state/wire use") to the three perception disallowed-types allows (shadowcast.rs:17, query.rs:11, interpretation.rs:9).
2. S-07: delete unused bytemuck (Cargo.toml:30, zero references); fix the stale rayon comment (says "not yet called"; used in atlas/gen_queue.rs:192-228).
3. S-08: delete or feature-gate bin/generator_spike.rs (1,153 lines reimplementing npc/generate.rs outside ECS, 3 TODO(integration) duplication admissions); extract the shared surname corpus copied between generate_brands/names.rs and generate_corporations/names.rs (already drifted; both feed the systems.db stamp); prune or justify the 24 allow(dead_code) in generate_corporations.
4. S-09: pin execute_save_load with a lower-bound constraint (currently only .before(compute_observer_snapshot), simulation/mod.rs:106-114 the scheduler may legally run it before Input, changing what a save captures); note the exclusive-system exception in tick_phases.rs rules.
5. S-12: unit tests for simulation/economy.rs signal derivation (382 lines, no cfg(test); fixtures set economy_snapshot: None) trend windowing, first-tick baseline, zero-demand stockpile edge.', 'in_progress', 'low', NULL, 'server', NULL, '2026-06-12 10:40:59', '2026-06-12 12:02:03', NULL, '8999c1bf5577cdfb4d7ff8320f72024b', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNSRZYDBRV5T9QVQAXGV0', 'bug', '06FBPPMZNNEV052DBYYY3A897C', 'test_e2e_connection: 1 failure — server binary path or TCP connection state', '1 failing test case in test_e2e_connection.gd. test_send_input_receive_snapshot skips if server binary not found. If it fails, either: (1) server binary exists but connection fails (port collision, timing), or (2) LocalBridge.reset() method was removed (the test calls _bridge.reset() but this may not exist on LocalBridge). Verify LocalBridge has a reset() method. Sprint 38 triage #871.
Re-homed under maintenance from done umbrella T-871 per cascade refocus 2026-06-12.', 'in_progress', 'low', NULL, 'client', NULL, '2026-05-02 08:12:51', '2026-06-12 12:02:03', NULL, '9981659bf076dde1fe66f5ab6e47e461', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNSRYEM4VSX74MS4NKQ1C', 'bug', '06FBPPMZNNEV052DBYYY3A897C', 'Client test suite — pre-existing failures under bare run-godot', 'Surfaced 2026-05-24 running the full client suite (tests/run-godot, no server) during #960. NOT caused by #960 — verified: test_input_roundtrip fails identically at the pre-change baseline, and the rest are unrelated subsystems. Two categories to triage:
CATEGORY 1 server-dependent (expected-fail under bare run-godot): test_e2e_connection (test_send_input_receive_snapshot), test_input_roundtrip (movement/interact/move_then_interact). They error with ''Nil snapshot'' / ''.tick on Nil'' because no sim server is spawned. They need the run-ipc-* harness or SR_LIVE=1. FIX OPTIONS: gate/skip these under bare run-godot (mark as requiring a server) OR have run-godot spawn the server for the e2e tier, so tests/run-godot
{"suite":"godot","total":2522,"passed":2482,"failed":40,"duration_ms":96836,"log":"/tmp/sr-run-godot.1737738.log"} is green without false negatives.
CATEGORY 2 genuine logic failures in subsystems unrelated to current work: test_audio_sprint13 (test_d067_chime_duration_spec_is_300_to_400ms, line 547 ''Expecting true but is false''), test_fog_sprint22 (exploration_data_preserved_across_resize), test_fog_shader (visibility_texture_update_performance), test_dialogue_sprint18/20 (signal/audio-dip), test_monologue_display (queue_never_exceeds_max_depth), test_implant_app_lifecycle (on_insert_deactivated_closes_insert_app), test_ai_dialogue_sprint26 (settings_dialog warning label). Triage each: fix or remove/xfail with a reason.
Goal: a green (or explicitly-gated) bare `make test-client` so client regressions are detectable. Until then, run targeted suites (run-godot --filter <stem>).
Re-homed under maintenance (test-failure debt / suite hygiene) per cascade refocus 2026-06-12.', 'in_progress', 'medium', NULL, 'client', NULL, '2026-05-24 16:49:54', '2026-06-12 12:02:03', NULL, 'a534447428c3a4f0eebc5b38053250cb', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNSRWK87PCWGZ6GWT2TBM', 'bug', '06FBPPMZNNEV052DBYYY3A897C', 'test_game_state_sprint20: 1 failure — SnapshotHandler or stationary_ticks path', '1 failing test case in test_game_state_sprint20.gd. before_each resets SnapshotHandler._prev_player_position. If this static var was renamed or moved, the before_each hook would error. Alternatively a stationary_ticks path changed in SnapshotHandler. Verify SnapshotHandler still has static var _prev_player_position at that exact name. Sprint 38 triage #871.
Re-homed under maintenance from done umbrella T-871 per cascade refocus 2026-06-12.', 'in_progress', 'low', NULL, 'client', NULL, '2026-05-02 08:12:38', '2026-06-12 12:02:03', NULL, 'b5bf0ac7b0e764bae1e17c69fd77f5ad', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBPTXRRDGJ5GFPGHZV3WS0D0', 'task', '06FBPPMZNNEV052DBYYY3A897C', 'Wire tooling tests into a gate: make test-tooling + pre-push on tooling changes', '(description follows in first append)
Filed 2026-06-12 from the fable-ous.md audit (S-18; citations adversarially verified).
22,340 lines of tooling Python (including the systems.db generator) are verified only by a 4-rule ruff subset (pyproject.toml:26-31: E9/F401/F811/F821); zero pytest anywhere; the one real test (tooling/planet-gen/test_sim_determinism.py, guarding the expensive 271-body bake, exit codes 0/1/2) is wired into nothing.
Fix: make test-tooling = test_sim_determinism.py + import_economics --dry-run against the committed DB; call from pre-push when TOOLING_CHANGED > 0; widen the ruff selection.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-12 10:40:59', '2026-06-12 12:02:03', NULL, 'c1ce9016e14e93003263e30033e95d1a', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNSRWBJ1XC9V5ZQHSXAXG', 'bug', '06FBPPMZNNEV052DBYYY3A897C', 'Rewrite P2-U01 monologue_display_visible_hidden test against current _visible array API', 'test_monologue_display_visible_hidden in test_client_p2.gd accessed mono.is_visible (a custom bool property). MonologueDisplay was refactored to use _visible: Array[Dictionary] instead. Test skipped (skip_test_) pending rewrite. New test should check _visible.size() > 0 after show_monologue, and == 0 when idle.
Re-homed under maintenance (test debt) per cascade refocus 2026-06-12.', 'in_progress', 'low', NULL, 'client', NULL, '2026-04-20 11:17:20', '2026-06-12 12:02:03', NULL, 'e0f58b478b902e102815af03c820a0fe', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBPTXSA4WFXPD21YK7N8SF60', 'task', '06FBPPMZNNEV052DBYYY3A897C', 'Fix 2,131 broken heightmap.png body-page links — conditional scaffold link + regenerate', '(description follows in first append)
Filed 2026-06-12 from the fable-ous.md audit (S-53; counts independently reproduced by verifier).
The body-page scaffold links ![Heightmap](heightmap.png) for all non-gas-giant bodies (scaffold_bodies.py:94-96) but heightmaps exist for only 267 bodies (vs 2,398 reliefmap.png / 3,256 globe.png) 2,131 of the wiki''s 2,180 broken links are this one pattern, rendered as dead images in the implant wiki view. Fix: make the link conditional in the scaffold and regenerate the Visual section of body pages (or backfill heightmaps where Phase 4 wants them anyway they are a generation input).', 'in_progress', 'medium', NULL, 'content', NULL, '2026-06-12 10:40:59', '2026-06-12 12:02:03', NULL, 'e52392e8a10513975b5bb0279ffcc98c', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNSRXMVYQJV2QDHH8H7P8', 'bug', '06FBPPMZNNEV052DBYYY3A897C', 'test_input_roundtrip: 3 failures — server binary missing or LocalBridge API change', '3 failing test cases in test_input_roundtrip.gd. All tests check _connect_to_server() which skips if server binary not found — but 3 still fail, suggesting either: (1) server binary is present but something in LocalBridge.send_message, poll, or Protocol.encode_player_inputs changed, or (2) test environment returns false from _connect_to_server but then asserts fail. Investigate by running with --filter test_input_roundtrip and checking the log for exact assertion sites. Sprint 38 triage #871.
Re-homed under maintenance from done umbrella T-871 per cascade refocus 2026-06-12.', 'in_progress', 'low', NULL, 'client', NULL, '2026-05-02 08:11:59', '2026-06-12 12:02:03', NULL, 'f437e0c45508a6e8a8761cde942648a5', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBRCMEKT72S0Q6NE68JH7EXC', 'task', '06FB0TNSRZXCHGS16BFHSSGSV4', 'Sol markers → D-223 names-only, migrated together with the Atlas legacy-geometry read path', 'Split from T-1071 (2026-06-12): converting the 4 legacy geometry-bearing GJ-0 markers.json (GJ0d, GJ0d-1, GJ0e, GJ0f-2) to the D-223 names-only schema is SAFE for generation (import_economics.py:1452 skips GJ 0 explicitly; all authored coordinates/populations verified present name-by-name in tooling/planet-gen/sol_markers/*.json + sol_name_fixes.py FIXES) but REGRESSES the client: atlas_viewer.gd:433 still loads markers.json per the legacy D-191 §8 geometry schema, so Sol bodies lose their city/river/mountain Atlas overlays. The conversion was implemented, verified, and then deliberately reverted to preserve working overlays. Land both halves together: (1) point the Sol overlay read at the proper Sol home (sol_markers/ or a baked path — Q-107 consolidation may decide this), (2) convert the 4 files, (3) also fix the residual debt: sol_import.py still WRITES legacy-schema markers and an unconditional heightmap link in its own _write_index_md, and import_economics.py:1485 carries a now-misleading comment about preserved geometry markers (editing it stales the systems.db stamp — bundle with a regen).', 'backlog', 'low', NULL, 'client', NULL, '2026-06-12 14:18:10', '2026-06-12 14:18:10', NULL, '78e79b505b63901bf719649afc634080', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
+5 -1
View File
@@ -131,7 +131,11 @@ func _resize(bounds: Rect2i) -> void:
_tint_image = Image.create_from_data(_width, _height, false, Image.FORMAT_RGB8, _tint_bytes)
zone_tint_texture = ImageTexture.create_from_image(_tint_image)
_prev_visible.clear()
# NOTE: _prev_visible is deliberately NOT cleared here. Its keys are
# world-space coordinates, which stay valid across resizes. Clearing it
# meant tiles visible before a resize never decayed EXP_VISIBLE→EXP_EXPLORED
# when leaving LOS (D-059 violation — they rendered as currently-visible
# forever instead of deep fog).
func update_from_state() -> void:
+7 -3
View File
@@ -144,6 +144,10 @@ func _apply_z(node: CanvasItem, group: String) -> void:
func _set_group_z(group: String, z: int) -> void:
if not _groups.has(group):
return
for node: CanvasItem in _groups[group]:
if is_instance_valid(node):
node.z_index = z
# Prune freed nodes first: a typed loop variable (`for node: CanvasItem`)
# errors on assignment of a freed instance BEFORE any is_instance_valid
# guard can run. Nodes freed without unregister() must not crash the manager.
var alive: Array = _groups[group].filter(func(n): return is_instance_valid(n))
_groups[group] = alive
for node: CanvasItem in alive:
node.z_index = z
@@ -1 +0,0 @@
uid://cfyv4qt7yybib
+6 -6
View File
@@ -872,9 +872,9 @@ func test_settings_dialog_warning_label_shown_when_battery_suspended() -> void:
var dialog := scene.instantiate()
auto_free(dialog)
add_child(dialog)
if not dialog.has_method("get_battery_warning_visible"):
push_warning("TestAiDialogueSprint26: get_battery_warning_visible() not yet implemented — skipped")
return
# MetaScreen lifecycle (f24d08f75): the UI (including the warning label)
# is built in on_open(), not _ready() — open the dialog first.
dialog.open()
dialog.set_ai_inference_suspended(true)
assert_bool(dialog.get_battery_warning_visible()).override_failure_message(
"Battery warning label must be visible when inference is suspended (toggle is on but paused)"
@@ -890,9 +890,9 @@ func test_settings_dialog_warning_label_hidden_when_not_suspended() -> void:
var dialog := scene.instantiate()
auto_free(dialog)
add_child(dialog)
if not dialog.has_method("get_battery_warning_visible"):
push_warning("TestAiDialogueSprint26: get_battery_warning_visible() not yet implemented — skipped")
return
# MetaScreen lifecycle (f24d08f75): build the UI so this asserts the real
# label state, not the null-label fallback.
dialog.open()
dialog.set_ai_inference_suspended(false)
assert_bool(dialog.get_battery_warning_visible()).override_failure_message(
"Battery warning label must be hidden when inference is not suspended"
+18 -36
View File
@@ -79,10 +79,8 @@ func test_d073_set_zone_unknown_zone_does_not_crash() -> void:
func test_d073_zone_asset_hub_key_matches_filename_convention() -> void:
## D-073 / #529: v0.1 zone-to-asset mapping per sprint brief.
## hub/workplace → amb_hub_layer (must match filename stem in res://audio/).
## Test verifies naming convention is documentable. Activates once #529 adds the map.
if not "ZONE_ASSETS" in AudioManager:
push_warning("TestAudioSprint13: ZONE_ASSETS not yet defined (#529 pending) — skip zone map test")
return
## Test verifies naming convention is documentable. (#529 shipped — the old
## "not yet defined" guard was removed in T-1068; absence now fails loudly.)
var zone_assets: Dictionary = AudioManager.ZONE_ASSETS
assert_that(zone_assets.has("hub")).is_true()
assert_that(zone_assets["hub"]).is_equal("amb_hub_layer")
@@ -90,9 +88,6 @@ func test_d073_zone_asset_hub_key_matches_filename_convention() -> void:
func test_d073_zone_asset_bar_key_matches_filename_convention() -> void:
## D-073 / #529: bar → amb_bar_layer
if not "ZONE_ASSETS" in AudioManager:
push_warning("TestAudioSprint13: ZONE_ASSETS not yet defined (#529 pending) — skip zone map test")
return
var zone_assets: Dictionary = AudioManager.ZONE_ASSETS
assert_that(zone_assets.has("bar")).is_true()
assert_that(zone_assets["bar"]).is_equal("amb_bar_layer")
@@ -100,9 +95,6 @@ func test_d073_zone_asset_bar_key_matches_filename_convention() -> void:
func test_d073_zone_asset_corridor_key_matches_filename_convention() -> void:
## D-073 / #529: smuggling corridor → amb_corridor_layer
if not "ZONE_ASSETS" in AudioManager:
push_warning("TestAudioSprint13: ZONE_ASSETS not yet defined (#529 pending) — skip zone map test")
return
var zone_assets: Dictionary = AudioManager.ZONE_ASSETS
assert_that(zone_assets.has("corridor")).is_true()
assert_that(zone_assets["corridor"]).is_equal("amb_corridor_layer")
@@ -139,20 +131,13 @@ func test_d073_game_state_zone_id_empty_when_field_absent() -> void:
func test_d073_crossfade_duration_in_1_5_to_2_0s_range() -> void:
## D-073: Crossfade tween duration must be 1.5-2.0s.
## Activates once #529 defines the duration constant.
if not "CROSSFADE_DURATION" in AudioManager:
push_warning("TestAudioSprint13: CROSSFADE_DURATION not yet defined (#529 pending) — skip duration test")
return
var duration: float = AudioManager.CROSSFADE_DURATION
assert_that(duration >= 1.5 and duration <= 2.0).is_true()
func test_d073_set_zone_same_zone_repeated_is_noop() -> void:
## D-073: Crossing back to the current zone should not restart a crossfade.
## (No audio pops when zone boundary is ambiguous.) Activates post-#529.
if not "ZONE_ASSETS" in AudioManager:
push_warning("TestAudioSprint13: set_zone body not yet implemented (#529) — skip no-op test")
return
## (No audio pops when zone boundary is ambiguous.)
AudioManager.set_zone("hub")
AudioManager.set_zone("hub")
## Expect exactly one or zero ambient players after same-zone calls (no stacked tweens).
@@ -164,9 +149,6 @@ func test_d073_rapid_zone_crossing_interruptible() -> void:
## D-073: Rapid back-and-forth zone crossing (interruptible crossfade).
## When _kill_zone_tweens() fires mid-fade, old player stays at intermediate
## volume — new tween starts from current position. No stacked tweens, no crash.
if not "ZONE_ASSETS" in AudioManager:
push_warning("TestAudioSprint13: ZONE_ASSETS not yet defined — skip rapid crossing test")
return
# Cross hub → bar → hub rapidly (simulates player walking back and forth)
AudioManager.set_zone("hub")
AudioManager.set_zone("bar") # Interrupts hub fade-in mid-tween
@@ -530,22 +512,22 @@ func test_d067_chime_fires_at_fog_entity_spawn_not_removal() -> void:
assert_that(GameState.pending_recognitions.size()).is_equal(0)
func test_d067_chime_duration_spec_is_300_to_400ms() -> void:
## D-067: Chime duration is 300-400ms per spec. The asset (.ogg) carries this duration.
## This test documents the spec range so asset authoring can be validated.
## When sfx_monologue_chime.ogg is present, its AudioStream.get_length() should
## return a value in this range.
const CHIME_MIN_DURATION := 0.3
const CHIME_MAX_DURATION := 0.4
if not AudioManager.has_asset(AudioManager.CHIME_RECOGNITION):
push_warning("TestAudioSprint13: sfx_monologue_chime asset absent — skip duration test")
return
func test_d067_chime_duration_matches_catalog_800ms(
_do_skip := not AudioManager.has_asset(AudioManager.CHIME_RECOGNITION),
_skip_reason := "sfx_monologue_chime asset absent"
) -> void:
## D-067 originally specified a 300-400ms chime. The production asset shipped
## in Sprint 10 (#327) is a deliberate 800ms manual synthesis — see
## docs/assets/audio/ui.md (UI-005) for the full synthesis spec. This test pins
## the shipped catalog duration so silent asset regressions are caught.
## NOTE: D-067's prose still says 300-400ms — flagged for governance amendment (T-938).
var stream: AudioStream = AudioManager._registry.get(AudioManager.CHIME_RECOGNITION)
if stream == null:
push_warning("TestAudioSprint13: could not retrieve chime stream from registry")
return
assert_that(stream.get_length() >= CHIME_MIN_DURATION
and stream.get_length() <= CHIME_MAX_DURATION).is_true()
assert_that(stream).override_failure_message(
"chime asset registered but stream missing from registry"
).is_not_null()
assert_float(stream.get_length()).override_failure_message(
"sfx_monologue_chime duration must match the asset catalog (800ms, ui.md UI-005)"
).is_equal_approx(0.8, 0.05)
# ==============================================================================
+20 -45
View File
@@ -67,23 +67,9 @@ func test_camera_zoom_default_2x() -> void:
assert_that(camera.zoom).is_equal(Vector2(2, 2))
func skip_test_camera_smoothing_convergence() -> void:
# P2-C02: STALE — #117 permanently disables Camera2D.position_smoothing_enabled
# in main.gd _ready() (manual lerp approach). Assertion is_true() no longer valid.
# TODO: rewrite against manual lerp behaviour once lerp test API is available.
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()
# P2-C02 (skip_test_camera_smoothing_convergence) removed (T-1068): stale since
# #117 switched to manual lerp. Superseded by test_camera_anchor.gd
# (test_camera_smoothing_stays_off_with_manual_lerp, test_camera_lerps_toward_player_movement).
func test_camera_viewport_tracks_player_position() -> void:
@@ -97,23 +83,10 @@ func test_camera_viewport_tracks_player_position() -> void:
).is_equal(expected)
func skip_test_camera_follows_player_after_movement() -> void:
# P2-C04: STALE — #117 switched camera to manual lerp; after 1 frame the camera
# has not converged to player_position * TILE_SIZE. Exact equality assertion fails.
# TODO: rewrite to assert directional movement only (y > initial_pos.y) OR
# run enough frames for lerp convergence before asserting exact 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)
# P2-C04 (skip_test_camera_follows_player_after_movement) removed (T-1068): stale
# since #117 (manual lerp — exact-equality assertion invalid after one frame).
# Superseded by test_camera_anchor.gd (test_camera_tracks_player_after_process,
# test_camera_lerps_toward_player_movement).
func test_camera_no_panning_locked_to_player() -> void:
@@ -195,20 +168,22 @@ func test_entity_player_color_regardless_of_sector() -> void:
# -- UI (7) --------------------------------------------------------------------
func skip_test_monologue_display_visible_hidden() -> void:
# P2-U01: BROKEN — MonologueDisplay no longer has an `is_visible` bool property.
# Current API uses `_visible: Array[Dictionary]` (monologue_display.gd).
# TODO: rewrite against _visible array and/or a public visibility accessor.
func test_monologue_display_visible_hidden() -> void:
# P2-U01 (T-864): MonologueDisplay tracks shown lines in `_visible:
# Array[Dictionary]` — empty when idle, populated after show_monologue.
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()
assert_int(mono._visible.size()).override_failure_message(
"Monologue must start with no visible lines"
).is_equal(0)
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.")
assert_int(mono._visible.size()).override_failure_message(
"Monologue must have one visible line after show_monologue"
).is_equal(1)
var label: RichTextLabel = mono._visible[0].node.get_child(0)
assert_str(label.get_parsed_text()).override_failure_message(
"Visible monologue line must carry the shown text"
).is_equal("Test thought.")
func test_interaction_list_shows_nearest_verb() -> void:
+15 -10
View File
@@ -189,11 +189,11 @@ func test_d063_confrontation_signal_fires_on_confrontation_option() -> void:
var box := _make_dialogue_box()
if box == null: return
var signal_fired := false
var received_text := ""
# Array capture: GDScript lambdas capture locals by value — assignment
# inside the lambda never propagates out. Mutating an Array does.
var received_texts: Array = []
box.confrontation_monologue.connect(func(text: String, _dur: float):
signal_fired = true
received_text = text
received_texts.append(text)
)
# Show dialogue with one confrontation option
@@ -203,9 +203,12 @@ func test_d063_confrontation_signal_fires_on_confrontation_option() -> void:
# Press option 1 (index 0)
box._on_option_pressed(0)
assert_bool(signal_fired).override_failure_message(
assert_int(received_texts.size()).override_failure_message(
"D-063: confrontation_monologue signal must fire when confrontation option is selected"
).is_true()
).is_equal(1)
assert_str(received_texts[0]).override_failure_message(
"D-063: confrontation_monologue must carry the beat monologue text"
).is_not_empty()
box.queue_free()
@@ -214,18 +217,20 @@ func test_d063_non_confrontation_option_does_not_fire_beat_signal() -> void:
var box := _make_dialogue_box()
if box == null: return
var signal_fired := false
# Array capture — local-assignment captures never propagate out of a
# lambda, which made the old bool version of this test vacuous.
var fired: Array = []
box.confrontation_monologue.connect(func(_text: String, _dur: float):
signal_fired = true
fired.append(true)
)
var opts := _make_options(["A normal response."], [false])
box.show_dialogue("NPC", "Hello.", opts)
box._on_option_pressed(0)
assert_bool(signal_fired).override_failure_message(
assert_int(fired.size()).override_failure_message(
"D-063: confrontation_monologue must NOT fire for standard options"
).is_false()
).is_equal(0)
box.queue_free()
+29 -23
View File
@@ -32,12 +32,14 @@ func test_show_dialogue_emits_dialogue_state_changed_true() -> void:
var box := _make_dialogue_box()
if box == null: return
auto_free(box)
var received: Variant = null
box.dialogue_state_changed.connect(func(active: bool): received = active)
# Array capture: GDScript lambdas capture locals by value — assignment
# inside the lambda never propagates out. Mutating an Array does.
var received: Array = []
box.dialogue_state_changed.connect(func(active: bool): received.append(active))
box.show_dialogue("NPC", "Hello.", [])
assert_that(received).override_failure_message(
"show_dialogue() must emit dialogue_state_changed(true) (#558)"
).is_equal(true)
assert_array(received).override_failure_message(
"show_dialogue() must emit dialogue_state_changed(true) exactly once (#558)"
).is_equal([true])
func test_show_dialogue_does_not_mutate_game_state_directly() -> void:
@@ -59,12 +61,13 @@ func test_hide_dialogue_emits_dialogue_state_changed_false() -> void:
if box == null: return
auto_free(box)
box.show_dialogue("NPC", "Hello.", [])
var received: Variant = null
box.dialogue_state_changed.connect(func(active: bool): received = active)
# Array capture — see test_show_dialogue_emits_dialogue_state_changed_true.
var received: Array = []
box.dialogue_state_changed.connect(func(active: bool): received.append(active))
box.hide_dialogue()
assert_that(received).override_failure_message(
"hide_dialogue() must emit dialogue_state_changed(false) (#558)"
).is_equal(false)
assert_array(received).override_failure_message(
"hide_dialogue() must emit dialogue_state_changed(false) exactly once (#558)"
).is_equal([false])
# -- audio_dip_requested / audio_dip_cleared signals -------------------------
@@ -74,12 +77,13 @@ func test_show_dialogue_emits_audio_dip_requested_dialogue() -> void:
var box := _make_dialogue_box()
if box == null: return
auto_free(box)
var received_profile: Variant = null
box.audio_dip_requested.connect(func(profile: String): received_profile = profile)
# Array capture — see test_show_dialogue_emits_dialogue_state_changed_true.
var received_profiles: Array = []
box.audio_dip_requested.connect(func(profile: String): received_profiles.append(profile))
box.show_dialogue("NPC", "Hello.", [])
assert_that(received_profile).override_failure_message(
"show_dialogue() must emit audio_dip_requested('dialogue') (#558)"
).is_equal("dialogue")
assert_array(received_profiles).override_failure_message(
"show_dialogue() must emit audio_dip_requested('dialogue') exactly once (#558)"
).is_equal(["dialogue"])
func test_show_dialogue_does_not_call_audio_manager_directly() -> void:
@@ -102,12 +106,13 @@ func test_hide_dialogue_emits_audio_dip_cleared() -> void:
if box == null: return
auto_free(box)
box.show_dialogue("NPC", "Hello.", [])
var cleared := false
box.audio_dip_cleared.connect(func(): cleared = true)
# Array capture — see test_show_dialogue_emits_dialogue_state_changed_true.
var cleared: Array = []
box.audio_dip_cleared.connect(func(): cleared.append(true))
box.hide_dialogue()
assert_bool(cleared).override_failure_message(
"hide_dialogue() must emit audio_dip_cleared (#558)"
).is_true()
assert_int(cleared.size()).override_failure_message(
"hide_dialogue() must emit audio_dip_cleared exactly once (#558)"
).is_equal(1)
func test_audio_dip_cleared_count_on_conversation_end() -> void:
@@ -115,11 +120,12 @@ func test_audio_dip_cleared_count_on_conversation_end() -> void:
var box := _make_dialogue_box()
if box == null: return
auto_free(box)
var cleared_count := 0
box.audio_dip_cleared.connect(func(): cleared_count += 1)
# Array capture — see test_show_dialogue_emits_dialogue_state_changed_true.
var cleared: Array = []
box.audio_dip_cleared.connect(func(): cleared.append(true))
box.show_dialogue("NPC", "Speak.", [])
box.hide_dialogue()
assert_int(cleared_count).override_failure_message(
assert_int(cleared.size()).override_failure_message(
"audio_dip_cleared must fire at least once when conversation ends (#558)"
).is_greater_equal(1)
+38 -4
View File
@@ -52,13 +52,41 @@ func after_test() -> void:
_server_pid = -1
## Protocol handshake (#555, #175): the server sends a HandshakeMessage as the
## first framed message; the client validates it and replies with a
## StartupMessage carrying world_seed before any input is accepted.
## Mirrors sim_bridge.gd's HANDSHAKING state.
func _do_handshake(world_seed: int = 42) -> bool:
var msg := PackedByteArray()
var elapsed := 0.0
while elapsed < CONNECT_TIMEOUT:
_bridge.poll()
msg = _bridge.poll_message()
if msg.size() > 0:
break
await get_tree().create_timer(0.05).timeout
elapsed += 0.05
if msg.is_empty():
push_warning("No HandshakeMessage received within %.1fs" % CONNECT_TIMEOUT)
return false
var MP = load("res://addons/messagepack/messagepack.gd")
var decoded: Variant = MP.decode(msg)
if decoded.status != null or not (decoded.value is Dictionary):
push_warning("Malformed HandshakeMessage")
return false
var startup_bytes := Protocol.encode_startup_message(world_seed)
if startup_bytes.is_empty():
return false
return _bridge.send_message(startup_bytes) == OK
# -- E2E: full round-trip through server binary --------------------------------
func test_send_input_receive_snapshot() -> void:
func test_send_input_receive_snapshot(
_do_skip := not FileAccess.file_exists(_server_binary_path()),
_skip_reason := "server binary not built — run `cargo build` in server/"
) -> void:
var server_path := _server_binary_path()
if not FileAccess.file_exists(server_path):
push_warning("E2E test skipped: server binary not found at %s" % server_path)
return
# Spawn server with port rotation (retries if port is in use)
var spawned := await _spawn_server(server_path)
@@ -83,6 +111,12 @@ func test_send_input_receive_snapshot() -> void:
assert_bool(connected).is_true()
# Handshake + startup exchange (#555, #175) — required before input is accepted
var handshake_ok := await _do_handshake()
assert_bool(handshake_ok).override_failure_message(
"protocol handshake + StartupMessage exchange must complete (#555, #175)"
).is_true()
# Send batch input: MoveNorth at tick 0 (matching game_loop.rs test)
var inputs: Array = [{"tick": 0, "action_name": "MoveNorth"}]
var encoded := Protocol.encode_player_inputs(inputs)
+5 -12
View File
@@ -260,18 +260,11 @@ func test_list_hides_on_empty_verb_list() -> void:
list.queue_free()
# -------------------------------------------------------------------------
# Phase 2 placeholders — deferred pending server protocol change (no known_attributes in v13)
# -------------------------------------------------------------------------
func skip_test_npc_name_displayed_when_known() -> void:
pass
func skip_test_dialogue_tier_context_hint_for_friendly() -> void:
pass
func skip_test_dialogue_tier_context_hint_for_hostile() -> void:
pass
# Empty skip_test_ placeholders for NPC-name display and dialogue-tier context
# hints removed (T-1068). They contained no test body. The blocking protocol gap
# (no known_attributes in v13) is long lifted (player_knowledge shipped in v14,
# #264/D-041), but the interaction-list features they anticipated do not exist —
# that is later-phase NPC detail work which will arrive with its own tests.
# -------------------------------------------------------------------------
+23 -9
View File
@@ -8,6 +8,20 @@ class_name TestFogShader
extends GdUnitTestSuite
# Suite prerequisites — reported as a real gdUnit4 skip (visible in the JSON
# summary) instead of the old push_warning-then-return soft-skip (T-1068).
# The runtime null-guards in the helpers below remain as defensive code only.
func before(
_do_skip := not (
ResourceLoader.exists("res://scripts/autoloads/fog_state.gd")
and ResourceLoader.exists("res://scripts/rendering/fog_shader.gd")
and ResourceLoader.exists("res://shaders/fog.gdshader")
),
_skip_reason := "fog shader scripts missing (fog_state.gd / fog_shader.gd / fog.gdshader)"
) -> void:
pass
# -- Helpers -------------------------------------------------------------------
func _fog_state_exists() -> bool:
@@ -166,29 +180,29 @@ func test_visibility_texture_update_performance() -> void:
return
if not fog_state.has_method("update_from_state"):
return
# Simulate a realistic tile count (~400 visible tiles)
# Simulate a realistic tile count (~400 visible tiles).
# All tiles are Forward — the peripheral sector was removed in Sprint 22 (#569).
var positions := {}
var sectors := {}
for x in range(20):
for y in range(20):
var pos := Vector2i(x, y)
positions[pos] = true
sectors[pos] = "Forward" if y < 10 else "Peripheral"
positions[Vector2i(x, y)] = true
GameState.visible_positions = positions
GameState.visibility_sectors = sectors
# Measure update time
# Warm-up: the first update may trigger a grow-only bounds resize, which is
# a rare amortized event (8-tile padding), not part of the per-frame budget.
fog_state.update_from_state()
# Measure the steady-state per-frame update (same positions, no resize)
var start := Time.get_ticks_usec()
fog_state.update_from_state()
var elapsed_us := Time.get_ticks_usec() - start
var elapsed_ms := elapsed_us / 1000.0
# D-059: Visibility texture upload budget: 0.1ms
# Allow 2x margin for test environment overhead
# Allow margin for test environment overhead
assert_that(elapsed_ms).is_less(0.5)
GameState.visible_positions.clear()
GameState.visibility_sectors.clear()
func test_full_fog_update_under_1ms() -> void:
+120 -22
View File
@@ -48,38 +48,104 @@ func after_test() -> void:
_server_pid = -1
## Spawn the server, connect, and complete the protocol handshake.
## Binary absence is handled by per-test `_do_skip` — by the time this runs the
## binary exists, so any failure here is a real failure (asserted loudly).
func _connect_to_server() -> bool:
var server_path := _server_binary_path()
if not FileAccess.file_exists(server_path):
push_warning("Input roundtrip test skipped: server binary not found at %s" % server_path)
return false
var spawned := await _spawn_server(server_path)
assert_bool(spawned).override_failure_message(
"server spawn failed after %d port attempts" % MAX_PORT_ATTEMPTS
).is_true()
if not spawned:
return false
_bridge = LocalBridge.new()
var connected := false
var elapsed := 0.0
while elapsed < CONNECT_TIMEOUT:
if _server_pid > 0 and not OS.is_process_running(_server_pid):
push_warning("Server process died during connection")
return false
break
if _bridge.get_status() == StreamPeerTCP.STATUS_NONE:
_bridge.connect_to_server("127.0.0.1", _test_port)
_bridge.poll()
if _bridge.get_status() == StreamPeerTCP.STATUS_CONNECTED:
return true
connected = true
break
if _bridge.get_status() == StreamPeerTCP.STATUS_ERROR:
_bridge.disconnect_from_server()
_bridge.reset()
await get_tree().create_timer(0.1).timeout
elapsed += 0.1
return false
assert_bool(connected).override_failure_message(
"TCP connect to spawned server failed within %.1fs" % CONNECT_TIMEOUT
).is_true()
if not connected:
return false
# Handshake + startup exchange (#555, #175) — required before input is accepted
var handshake_ok := await _do_handshake()
assert_bool(handshake_ok).override_failure_message(
"protocol handshake + StartupMessage exchange must complete (#555, #175)"
).is_true()
return handshake_ok
## Send a batch input and receive the snapshot response.
func _send_and_receive(action_name: String, tick: int = 0, action_data: Variant = null) -> Variant:
## Protocol handshake (#555, #175): the server sends a HandshakeMessage as the
## first framed message; the client validates it and replies with a
## StartupMessage carrying world_seed before any input is accepted.
## Mirrors sim_bridge.gd's HANDSHAKING state.
func _do_handshake(world_seed: int = 42) -> bool:
var msg := PackedByteArray()
var elapsed := 0.0
while elapsed < CONNECT_TIMEOUT:
_bridge.poll()
msg = _bridge.poll_message()
if msg.size() > 0:
break
await get_tree().create_timer(0.05).timeout
elapsed += 0.05
if msg.is_empty():
push_warning("No HandshakeMessage received within %.1fs" % CONNECT_TIMEOUT)
return false
var MP = load("res://addons/messagepack/messagepack.gd")
var decoded: Variant = MP.decode(msg)
if decoded.status != null or not (decoded.value is Dictionary):
push_warning("Malformed HandshakeMessage")
return false
var startup_bytes := Protocol.encode_startup_message(world_seed)
if startup_bytes.is_empty():
return false
return _bridge.send_message(startup_bytes) == OK
## Send a batch input and receive a snapshot that reflects it.
## The server free-runs at ~20 ticks/sec and STREAMS a snapshot every tick —
## it is not request/response. Drain stale queued snapshots first, then:
## - expect_position != null (Vector2, render coords): wait until the player
## reaches that position and assert it. Deterministic under load — tick
## margins race the free-running server when the test process is descheduled
## between drain and send.
## - expect_position == null: wait for a snapshot at least 2 ticks past the
## drain point (enough for no-op actions like Interact).
func _send_and_receive(
action_name: String, tick: int = 0, action_data: Variant = null,
expect_position: Variant = null
) -> Variant:
# Drain queued stale snapshots, remembering the newest tick seen.
var last_tick: int = -1
_bridge.poll()
var pending := _bridge.poll_message()
while pending.size() > 0:
var stale: Variant = Protocol.decode_snapshot(pending)
if stale != null:
last_tick = stale.tick
_bridge.poll()
pending = _bridge.poll_message()
var input_entry := {"tick": tick, "action_name": action_name}
if action_data != null:
input_entry["action_data"] = action_data
@@ -89,19 +155,42 @@ func _send_and_receive(action_name: String, tick: int = 0, action_data: Variant
var send_err := _bridge.send_message(encoded)
assert_that(send_err).is_equal(OK)
var snapshot_bytes := PackedByteArray()
# Wait for a snapshot that reflects the processed input.
var min_tick: int = last_tick + 2
var snapshot: Variant = null
var elapsed := 0.0
while elapsed < RESPONSE_TIMEOUT:
_bridge.poll()
snapshot_bytes = _bridge.poll_message()
if snapshot_bytes.size() > 0:
break
var msg := _bridge.poll_message()
if msg.size() > 0:
var decoded: Variant = Protocol.decode_snapshot(msg)
if decoded == null:
continue # undecodable frame — keep draining
if expect_position != null:
snapshot = decoded # keep latest so a timeout reports actual state
var player := _find_player(decoded)
if (
not player.is_empty()
and is_equal_approx(player.x, expect_position.x)
and is_equal_approx(player.y, expect_position.y)
):
break
elif decoded.tick >= min_tick:
snapshot = decoded
break
continue
await get_tree().create_timer(0.05).timeout
elapsed += 0.05
assert_that(snapshot_bytes.size()).is_greater(0)
var snapshot: Variant = Protocol.decode_snapshot(snapshot_bytes)
assert_that(snapshot).is_not_null()
assert_that(snapshot).override_failure_message(
"no snapshot received within %.1fs after '%s'" % [RESPONSE_TIMEOUT, action_name]
).is_not_null()
if expect_position != null and snapshot != null:
var player := _find_player(snapshot)
var actual := Vector2(player.x, player.y) if not player.is_empty() else Vector2.INF
assert_that(actual.is_equal_approx(expect_position)).override_failure_message(
"player must reach %s after '%s' — last seen %s" % [expect_position, action_name, actual]
).is_true()
return snapshot
@@ -115,7 +204,10 @@ static func _find_player(snapshot: Dictionary) -> Dictionary:
# -- Movement roundtrip: send movement, verify position changes ----------------
func test_movement_roundtrip() -> void:
func test_movement_roundtrip(
_do_skip := not FileAccess.file_exists(_server_binary_path()),
_skip_reason := "server binary not built — run `cargo build` in server/"
) -> void:
var ok := await _connect_to_server()
if not ok:
return
@@ -132,14 +224,14 @@ func test_movement_roundtrip() -> void:
assert_float(start_y).is_equal_approx(16.5, 0.001)
# Send MoveNorth — player should move to (16, 15) → (16.5, 15.5)
var snap1: Dictionary = await _send_and_receive("MoveNorth", 1)
var snap1: Dictionary = await _send_and_receive("MoveNorth", 1, null, Vector2(16.5, 15.5))
var p1 := _find_player(snap1)
assert_that(p1.size()).is_greater(0)
assert_float(p1.x).is_equal_approx(16.5, 0.001)
assert_float(p1.y).is_equal_approx(15.5, 0.001)
# Send MoveEast — player should move to (17, 15) → (17.5, 15.5)
var snap2: Dictionary = await _send_and_receive("MoveEast", 2)
var snap2: Dictionary = await _send_and_receive("MoveEast", 2, null, Vector2(17.5, 15.5))
var p2 := _find_player(snap2)
assert_that(p2.size()).is_greater(0)
assert_float(p2.x).is_equal_approx(17.5, 0.001)
@@ -151,7 +243,10 @@ func test_movement_roundtrip() -> void:
# -- Interact roundtrip: server accepts without crashing -----------------------
func test_interact_roundtrip() -> void:
func test_interact_roundtrip(
_do_skip := not FileAccess.file_exists(_server_binary_path()),
_skip_reason := "server binary not built — run `cargo build` in server/"
) -> void:
var ok := await _connect_to_server()
if not ok:
return
@@ -175,13 +270,16 @@ func test_interact_roundtrip() -> void:
# -- Mixed sequence: movement then interact in one session ---------------------
func test_move_then_interact() -> void:
func test_move_then_interact(
_do_skip := not FileAccess.file_exists(_server_binary_path()),
_skip_reason := "server binary not built — run `cargo build` in server/"
) -> void:
var ok := await _connect_to_server()
if not ok:
return
# Move player first
var snap1: Dictionary = await _send_and_receive("MoveNorth", 0)
# Move player first — (16,16) → (16,15) → render (16.5, 15.5)
var snap1: Dictionary = await _send_and_receive("MoveNorth", 0, null, Vector2(16.5, 15.5))
var p1 := _find_player(snap1)
assert_that(p1.size()).is_greater(0)
var moved_x: float = p1.x
+14 -5
View File
@@ -8,6 +8,15 @@ class_name TestMonologueDisplay
extends GdUnitTestSuite
# Suite prerequisite — reported as a real gdUnit4 skip (visible in the JSON
# summary) instead of the old push_warning-then-return soft-skip (T-1068).
func before(
_do_skip := not ResourceLoader.exists("res://ui/monologue_display.tscn"),
_skip_reason := "monologue_display.tscn not found"
) -> void:
pass
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
@@ -222,7 +231,7 @@ func test_queue_never_exceeds_max_depth() -> void:
d._next_fade_in_msec = 0.0; d.show_monologue("C", 10.0)
for i in range(d.MAX_QUEUE + 20):
d.show_monologue("flood_%d" % i, 1.0, 2)
assert_int(d._queue.size()).is_less_or_equal(d.MAX_QUEUE)
assert_int(d._queue.size()).is_less_equal(d.MAX_QUEUE)
d.queue_free()
@@ -429,16 +438,16 @@ func test_canvas_ui_constant_is_20() -> void:
assert_that(Constants.CANVAS_UI).is_equal(20)
func test_monologue_display_parented_to_canvas_layer_20_in_main_scene() -> void:
func test_monologue_display_parented_to_canvas_layer_20_in_main_scene(
_do_skip := not ResourceLoader.exists("res://scenes/main.tscn"),
_skip_reason := "main.tscn not found"
) -> void:
## D-049: Structural verification — MonologueDisplay must be a direct child of
## UILayer (CanvasLayer, layer=20) in the live scene tree, not the world layer.
## Catches regressions where the node gets accidentally moved to InsertOverlay
## (layer=10) or MetaLayer (layer=30), or dropped into the world z-stack.
##
## Scene path verified: Game/UILayer/MonologueDisplay (main.tscn line 141).
if not ResourceLoader.exists("res://scenes/main.tscn"):
push_warning("TestMonologueDisplay: main.tscn not found — D-049 scene tree test skipped")
return
var scene: Node = load("res://scenes/main.tscn").instantiate()
auto_free(scene)
add_child(scene)
+148 -44
View File
@@ -1,14 +1,12 @@
## Sprint 2 Proof: Fog of Perception (#357)
## Verifies all 7 acceptance criteria through the full server pipeline.
## Verifies the original acceptance criteria through the full server pipeline.
##
## SUITE DISABLED (sprint-36): Sprint 2 ACs are long satisfied.
## The room coordinates and player spawn positions below are hardcoded from
## the Sprint 2 room layout, which has evolved (protocol is now v23; Gauntlet
## room layout is different). Live server testing via the Gauntlet infrastructure
## supersedes these tests. Rewrite against the current Gauntlet rooms if
## per-AC regression coverage is needed again.
## REVIVED (T-1068): disabled in sprint-36, re-enabled against the current
## protocol (handshake + StartupMessage exchange per #555/#175, streamed
## snapshots). The proof room layout below is still what main.rs
## setup_proof_room() spawns for non-test-mode servers.
##
## Server proof room layout (Sprint 2 — stale):
## Server proof room layout (setup_proof_room in server/src/main.rs):
## (16,13) = NPC1 (16,14) = WALL (16,16) = Player start
## (14,18) = NPC2 (18,14) = NPC3
## Player facing North → NPC1 blocked by wall.
@@ -57,76 +55,170 @@ func after_test() -> void:
_server_pid = -1
## Send a batch input and receive the snapshot response.
func _send_and_receive(action_name: String, tick: int = 0) -> Variant:
## Find the player entity in a snapshot by kind.
static func _find_player(snapshot: Dictionary) -> Dictionary:
for entity in snapshot.entities:
if entity.kind.variant == "Player":
return entity
return {}
## Send a batch input and receive a snapshot that reflects it.
## The server free-runs at ~20 ticks/sec and STREAMS a snapshot every tick —
## it is not request/response. Drain stale queued snapshots first, then:
## - expect_position != null (Vector2, render coords): wait until the player
## reaches that position and assert it. Deterministic under load — tick
## margins race the free-running server when the test process is descheduled
## between drain and send.
## - expect_position == null: wait for a snapshot at least 2 ticks past the
## drain point (enough for no-op actions).
func _send_and_receive(
action_name: String, tick: int = 0, expect_position: Variant = null
) -> Variant:
# Drain queued stale snapshots, remembering the newest tick seen.
var last_tick: int = -1
_bridge.poll()
var pending := _bridge.poll_message()
while pending.size() > 0:
var stale: Variant = Protocol.decode_snapshot(pending)
if stale != null:
last_tick = stale.tick
_bridge.poll()
pending = _bridge.poll_message()
var inputs: Array = [{"tick": tick, "action_name": action_name}]
var encoded := Protocol.encode_player_inputs(inputs)
assert_that(encoded.size()).is_greater(0)
var send_err := _bridge.send_message(encoded)
assert_that(send_err).is_equal(OK)
var snapshot_bytes := PackedByteArray()
# Wait for a snapshot that reflects the processed input.
var min_tick: int = last_tick + 2
var snapshot: Variant = null
var elapsed := 0.0
while elapsed < RESPONSE_TIMEOUT:
_bridge.poll()
snapshot_bytes = _bridge.poll_message()
if snapshot_bytes.size() > 0:
break
var msg := _bridge.poll_message()
if msg.size() > 0:
var decoded: Variant = Protocol.decode_snapshot(msg)
if decoded == null:
continue # undecodable frame — keep draining
if expect_position != null:
snapshot = decoded # keep latest so a timeout reports actual state
var player := _find_player(decoded)
if (
not player.is_empty()
and is_equal_approx(player.x, expect_position.x)
and is_equal_approx(player.y, expect_position.y)
):
break
elif decoded.tick >= min_tick:
snapshot = decoded
break
continue
await get_tree().create_timer(0.05).timeout
elapsed += 0.05
assert_that(snapshot_bytes.size()).is_greater(0)
var snapshot: Variant = Protocol.decode_snapshot(snapshot_bytes)
assert_that(snapshot).is_not_null()
assert_that(snapshot).override_failure_message(
"no snapshot received within %.1fs after '%s'" % [RESPONSE_TIMEOUT, action_name]
).is_not_null()
if expect_position != null and snapshot != null:
var player := _find_player(snapshot)
var actual := Vector2(player.x, player.y) if not player.is_empty() else Vector2.INF
assert_that(actual.is_equal_approx(expect_position)).override_failure_message(
"player must reach %s after '%s' — last seen %s" % [expect_position, action_name, actual]
).is_true()
return snapshot
## Connect to server, returning true on success.
## Protocol handshake (#555, #175): the server sends a HandshakeMessage as the
## first framed message; the client validates it and replies with a
## StartupMessage carrying world_seed before any input is accepted.
## Mirrors sim_bridge.gd's HANDSHAKING state.
func _do_handshake(world_seed: int = 42) -> bool:
var msg := PackedByteArray()
var elapsed := 0.0
while elapsed < CONNECT_TIMEOUT:
_bridge.poll()
msg = _bridge.poll_message()
if msg.size() > 0:
break
await get_tree().create_timer(0.05).timeout
elapsed += 0.05
if msg.is_empty():
push_warning("No HandshakeMessage received within %.1fs" % CONNECT_TIMEOUT)
return false
var MP = load("res://addons/messagepack/messagepack.gd")
var decoded: Variant = MP.decode(msg)
if decoded.status != null or not (decoded.value is Dictionary):
push_warning("Malformed HandshakeMessage")
return false
var startup_bytes := Protocol.encode_startup_message(world_seed)
if startup_bytes.is_empty():
return false
return _bridge.send_message(startup_bytes) == OK
## Spawn the server, connect, and complete the protocol handshake.
## Binary absence is handled by per-test `_do_skip` — by the time this runs the
## binary exists, so any failure here is a real failure (asserted loudly).
func _connect_to_server() -> bool:
var server_path := _server_binary_path()
if not FileAccess.file_exists(server_path):
push_warning("Sprint 2 proof skipped: server binary not found at %s" % server_path)
return false
var spawned := await _spawn_server(server_path)
assert_bool(spawned).override_failure_message(
"server spawn failed after %d port attempts" % MAX_PORT_ATTEMPTS
).is_true()
if not spawned:
return false
_bridge = LocalBridge.new()
var connected := false
var elapsed := 0.0
while elapsed < CONNECT_TIMEOUT:
if _server_pid > 0 and not OS.is_process_running(_server_pid):
push_warning("Server process died during connection")
return false
break
if _bridge.get_status() == StreamPeerTCP.STATUS_NONE:
_bridge.connect_to_server("127.0.0.1", _test_port)
_bridge.poll()
if _bridge.get_status() == StreamPeerTCP.STATUS_CONNECTED:
return true
connected = true
break
if _bridge.get_status() == StreamPeerTCP.STATUS_ERROR:
_bridge.disconnect_from_server()
_bridge.reset()
await get_tree().create_timer(0.1).timeout
elapsed += 0.1
return false
assert_bool(connected).override_failure_message(
"TCP connect to spawned server failed within %.1fs" % CONNECT_TIMEOUT
).is_true()
if not connected:
return false
# Handshake + startup exchange (#555, #175) — required before input is accepted
var handshake_ok := await _do_handshake()
assert_bool(handshake_ok).override_failure_message(
"protocol handshake + StartupMessage exchange must complete (#555, #175)"
).is_true()
return handshake_ok
# -- AC#1, AC#2, AC#3, AC#5: Movement, camera, tiles, fog -------------------------
func skip_test_proof_player_moves_and_v2_snapshot() -> void:
func test_proof_player_moves_and_v2_snapshot(
_do_skip := not FileAccess.file_exists(_server_binary_path()),
_skip_reason := "server binary not built — run `cargo build` in server/"
) -> void:
var ok := await _connect_to_server()
if not ok:
return
var snapshot: Dictionary = await _send_and_receive("MoveNorth")
var snapshot: Dictionary = await _send_and_receive("MoveNorth", 0, Vector2(16.5, 15.5))
# AC#1: Player moved from (16,16) to (16,15)
var player: Dictionary = {}
for entity in snapshot.entities:
if entity.kind.variant == "Player":
player = entity
break
var player := _find_player(snapshot)
assert_that(player.size()).is_greater(0)
assert_float(player.x).is_equal_approx(16.5, 0.001)
assert_float(player.y).is_equal_approx(15.5, 0.001)
@@ -144,14 +236,17 @@ func skip_test_proof_player_moves_and_v2_snapshot() -> void:
# -- AC#6: Wall hides entity -------------------------------------------------------
func skip_test_proof_wall_hides_entity() -> void:
func test_proof_wall_hides_entity(
_do_skip := not FileAccess.file_exists(_server_binary_path()),
_skip_reason := "server binary not built — run `cargo build` in server/"
) -> void:
var ok := await _connect_to_server()
if not ok:
return
# After MoveNorth: player at (16,15) facing North.
# Wall at (16,14) blocks LOS to NPC1 at (16,13).
var snapshot: Dictionary = await _send_and_receive("MoveNorth")
var snapshot: Dictionary = await _send_and_receive("MoveNorth", 0, Vector2(16.5, 15.5))
# NPC1 at (16,13) should be hidden — wall at (16,14) blocks LOS.
# Other NPCs (NPC2 at (14,18), NPC3 at (18,14)) may be visible.
@@ -166,24 +261,33 @@ func skip_test_proof_wall_hides_entity() -> void:
# -- AC#4, AC#7: Entity appears via LOS / corner reveal ----------------------------
func skip_test_proof_corner_reveal() -> void:
func test_proof_corner_reveal(
_do_skip := not FileAccess.file_exists(_server_binary_path()),
_skip_reason := "server binary not built — run `cargo build` in server/"
) -> void:
var ok := await _connect_to_server()
if not ok:
return
# Step 1: Move East twice to get beside the wall
# (16,16) → MoveEast → (17,16) → MoveEast → (18,16)
await _send_and_receive("MoveEast", 0)
await _send_and_receive("MoveEast", 1)
# Step 1: Move East three times — column x=18 is blocked at (18,14) by
# NPC3 (entities are unwalkable), so route around via x=19.
# (16,16) → (17,16) → (18,16) → (19,16)
await _send_and_receive("MoveEast", 0, Vector2(17.5, 16.5))
await _send_and_receive("MoveEast", 1, Vector2(18.5, 16.5))
await _send_and_receive("MoveEast", 2, Vector2(19.5, 16.5))
# Step 2: Move North past the wall line (y=14)
# (18,16) → MoveNorth → (18,15) → MoveNorth → (18,14) → MoveNorth → (18,13)
await _send_and_receive("MoveNorth", 2)
await _send_and_receive("MoveNorth", 3)
var snapshot: Dictionary = await _send_and_receive("MoveNorth", 4)
# (19,16) → (19,15) → (19,14) → (19,13)
await _send_and_receive("MoveNorth", 3, Vector2(19.5, 15.5))
await _send_and_receive("MoveNorth", 4, Vector2(19.5, 14.5))
await _send_and_receive("MoveNorth", 5, Vector2(19.5, 13.5))
# Player at (18,13) facing North. NPC1 at (16,13) is 2 tiles west —
# within peripheral cone, no wall between. NPC1 should be visible.
# Step 3: Move West onto (18,13) — now facing West, looking straight at
# NPC1 at (16,13) two tiles ahead with no wall between ((17,13) is open).
var snapshot: Dictionary = await _send_and_receive("MoveWest", 6, Vector2(18.5, 13.5))
# Player at (18,13) facing West. NPC1 at (16,13) is 2 tiles dead ahead —
# inside the vision cone, no wall between. NPC1 should be visible.
var npc1_found := false
for entity in snapshot.entities:
if entity.kind.variant == "Npc":
-1
View File
@@ -1 +0,0 @@
uid://dwdssbevd8gqq
-1
View File
@@ -1 +0,0 @@
uid://char_select_sr
-1
View File
@@ -1 +0,0 @@
uid://c8pvt3xr7kmd2
@@ -0,0 +1,28 @@
extends RefCounted
## Shared formatting helpers for Atlas screens (T-1068).
## Deduplicated from planet_screen.gd / system_screen.gd.
## Consumed via explicit preload (no class_name — avoids the global class
## cache dependency that breaks headless test runs on fresh checkouts):
## const AtlasFormat := preload("res://ui/implant/apps/atlas/atlas_format.gd")
## Thousands-separated integer, e.g. 1234567 -> "1,234,567".
static func format_pop(pop: int) -> String:
if pop <= 0:
return "0"
var s: String = str(pop)
var result: String = ""
var count: int = 0
for i: int in range(s.length() - 1, -1, -1):
if count > 0 and count % 3 == 0:
result = "," + result
result = s[i] + result
count += 1
return result
## Body radius in km — thousands-separated above 10,000 km, plain below.
static func format_radius(km: float) -> String:
if km >= 10000.0:
return "%s km" % format_pop(int(km))
return "%.0f km" % km
@@ -3,6 +3,8 @@ extends Control
## Body entry screen for AtlasApp (#844, D-191).
## Shows body detail panel. Heightmap viewer is in RegionalScreen.
const AtlasFormat := preload("res://ui/implant/apps/atlas/atlas_format.gd")
const PANEL_WIDTH: float = 320.0
const PANEL_MARGIN: float = 16.0
const COLOR_BG: Color = Color("#0d1117")
@@ -85,7 +87,7 @@ func _rebuild_body_panel() -> void:
if inhabited:
_body_panel.add_component(ImplantSeparator.new())
_body_panel.add_component(ImplantDataRow.new("population " + _format_pop(pop)))
_body_panel.add_component(ImplantDataRow.new("population " + AtlasFormat.format_pop(pop)))
_body_panel.add_component(ImplantSeparator.new())
@@ -95,22 +97,3 @@ func _rebuild_body_panel() -> void:
_body_panel.add_component(ImplantTextBlock.new("atlas data pending (#839)"))
_body_panel.add_component(ImplantTextBlock.new("esc back to orbital view"))
# =============================================================================
# Helpers
# =============================================================================
func _format_pop(pop: int) -> String:
if pop <= 0:
return "0"
var s: String = str(pop)
var result: String = ""
var count: int = 0
for i: int in range(s.length() - 1, -1, -1):
if count > 0 and count % 3 == 0:
result = "," + result
result = s[i] + result
count += 1
return result
@@ -6,6 +6,8 @@ extends Control
signal body_selected(body: Dictionary)
const AtlasFormat := preload("res://ui/implant/apps/atlas/atlas_format.gd")
const STAR_RADIUS: float = 140.0
const STAR_X_OFFSET: float = 0.0
const BODY_LEFT_MARGIN: float = 300.0
@@ -667,11 +669,15 @@ func _rebuild_body_panel() -> void:
_body_panel.add_component(ImplantDataRow.new(type_line))
_body_panel.add_component(ImplantDataRow.new("atmosphere " + atmo))
if radius_km > 0:
_body_panel.add_component(ImplantDataRow.new("radius " + _format_radius(radius_km)))
_body_panel.add_component(
ImplantDataRow.new("radius " + AtlasFormat.format_radius(radius_km))
)
if inhabited:
_body_panel.add_component(ImplantSeparator.new())
_body_panel.add_component(ImplantDataRow.new("population " + _format_pop(pop)))
_body_panel.add_component(
ImplantDataRow.new("population " + AtlasFormat.format_pop(pop))
)
_body_panel.add_component(ImplantSeparator.new())
var has_heightmap: bool = b.get("terrain_reference") != null
@@ -680,23 +686,3 @@ func _rebuild_body_panel() -> void:
_body_panel.add_component(ImplantTextBlock.new("esc close"))
_dirty = true
static func _format_pop(pop: int) -> String:
if pop <= 0:
return "0"
var s: String = str(pop)
var result: String = ""
var count: int = 0
for i: int in range(s.length() - 1, -1, -1):
if count > 0 and count % 3 == 0:
result = "," + result
result = s[i] + result
count += 1
return result
static func _format_radius(km: float) -> String:
if km >= 10000.0:
return "%s km" % _format_pop(int(km))
return "%.0f km" % km
-1
View File
@@ -1 +0,0 @@
uid://c6wtn7qk3mv2x
-1
View File
@@ -1 +0,0 @@
uid://8jhxbtbelw0y
-1
View File
@@ -1 +0,0 @@
uid://cpjq8yfsnpr5m