From c64231e8ee44ccfd55913f93447ff4f25529d353 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Fri, 12 Jun 2026 16:22:01 +0200 Subject: [PATCH] =?UTF-8?q?fix(client):=20clear=20the=20test=20debt=20?= =?UTF-8?q?=E2=80=94=202=20production=20bugs,=20suite=20fully=20green=20(T?= =?UTF-8?q?-973=20et=20al.)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .pql/changelog/ticket_idmap/2026-06.sql | 1 + .pql/changelog/tickets/2026-06.sql | 115 +++++++++++ client/scripts/autoloads/fog_state.gd | 6 +- client/scripts/autoloads/hud_groups.gd | 10 +- client/scripts/rendering/fog_renderer.gd.uid | 1 - client/tests/test_ai_dialogue_sprint26.gd | 12 +- client/tests/test_audio_sprint13.gd | 54 ++--- client/tests/test_client_p2.gd | 65 ++---- client/tests/test_dialogue_sprint18.gd | 25 ++- client/tests/test_dialogue_sprint20.gd | 52 ++--- client/tests/test_e2e_connection.gd | 42 +++- client/tests/test_etalk_overlay_sprint17.gd | 17 +- client/tests/test_fog_shader.gd | 32 ++- client/tests/test_input_roundtrip.gd | 142 +++++++++++-- client/tests/test_monologue_display.gd | 19 +- client/tests/test_sprint2_proof.gd | 192 ++++++++++++++---- client/ui/bug_report_dialog.gd.uid | 1 - client/ui/character_select.gd.uid | 1 - client/ui/debug_console.gd.uid | 1 - client/ui/implant/apps/atlas/atlas_format.gd | 28 +++ .../apps/atlas/screens/planet_screen.gd | 23 +-- .../apps/atlas/screens/system_screen.gd | 30 +-- client/ui/loading_screen.gd.uid | 1 - client/ui/main_menu.gd.uid | 1 - client/ui/settings_dialog.gd.uid | 1 - 25 files changed, 603 insertions(+), 269 deletions(-) delete mode 100644 client/scripts/rendering/fog_renderer.gd.uid delete mode 100644 client/ui/bug_report_dialog.gd.uid delete mode 100644 client/ui/character_select.gd.uid delete mode 100644 client/ui/debug_console.gd.uid create mode 100644 client/ui/implant/apps/atlas/atlas_format.gd delete mode 100644 client/ui/loading_screen.gd.uid delete mode 100644 client/ui/main_menu.gd.uid delete mode 100644 client/ui/settings_dialog.gd.uid diff --git a/.pql/changelog/ticket_idmap/2026-06.sql b/.pql/changelog/ticket_idmap/2026-06.sql index 06eeccd1c..ba1c55cc0 100644 --- a/.pql/changelog/ticket_idmap/2026-06.sql +++ b/.pql/changelog/ticket_idmap/2026-06.sql @@ -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); diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index 6fcd673d5..3c0c4248f 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -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-19→03-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 ). + +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); diff --git a/client/scripts/autoloads/fog_state.gd b/client/scripts/autoloads/fog_state.gd index c1bf7fefb..23c9cdb84 100644 --- a/client/scripts/autoloads/fog_state.gd +++ b/client/scripts/autoloads/fog_state.gd @@ -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: diff --git a/client/scripts/autoloads/hud_groups.gd b/client/scripts/autoloads/hud_groups.gd index 07e6ec62e..7e35e2ac2 100644 --- a/client/scripts/autoloads/hud_groups.gd +++ b/client/scripts/autoloads/hud_groups.gd @@ -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 diff --git a/client/scripts/rendering/fog_renderer.gd.uid b/client/scripts/rendering/fog_renderer.gd.uid deleted file mode 100644 index 627d877d6..000000000 --- a/client/scripts/rendering/fog_renderer.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cfyv4qt7yybib diff --git a/client/tests/test_ai_dialogue_sprint26.gd b/client/tests/test_ai_dialogue_sprint26.gd index 516c65418..1b919625d 100644 --- a/client/tests/test_ai_dialogue_sprint26.gd +++ b/client/tests/test_ai_dialogue_sprint26.gd @@ -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" diff --git a/client/tests/test_audio_sprint13.gd b/client/tests/test_audio_sprint13.gd index 2f397172e..614492f1c 100644 --- a/client/tests/test_audio_sprint13.gd +++ b/client/tests/test_audio_sprint13.gd @@ -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) # ============================================================================== diff --git a/client/tests/test_client_p2.gd b/client/tests/test_client_p2.gd index 7626ea6ee..6768bb93d 100644 --- a/client/tests/test_client_p2.gd +++ b/client/tests/test_client_p2.gd @@ -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: diff --git a/client/tests/test_dialogue_sprint18.gd b/client/tests/test_dialogue_sprint18.gd index ce8fb55c9..2c16a42ad 100644 --- a/client/tests/test_dialogue_sprint18.gd +++ b/client/tests/test_dialogue_sprint18.gd @@ -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() diff --git a/client/tests/test_dialogue_sprint20.gd b/client/tests/test_dialogue_sprint20.gd index b3f580dae..76798d45a 100644 --- a/client/tests/test_dialogue_sprint20.gd +++ b/client/tests/test_dialogue_sprint20.gd @@ -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) diff --git a/client/tests/test_e2e_connection.gd b/client/tests/test_e2e_connection.gd index d3d9b9a59..c004b8df5 100644 --- a/client/tests/test_e2e_connection.gd +++ b/client/tests/test_e2e_connection.gd @@ -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) diff --git a/client/tests/test_etalk_overlay_sprint17.gd b/client/tests/test_etalk_overlay_sprint17.gd index 9446f662d..dc721c8ef 100644 --- a/client/tests/test_etalk_overlay_sprint17.gd +++ b/client/tests/test_etalk_overlay_sprint17.gd @@ -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. # ------------------------------------------------------------------------- diff --git a/client/tests/test_fog_shader.gd b/client/tests/test_fog_shader.gd index 88882bd30..be1b1448d 100644 --- a/client/tests/test_fog_shader.gd +++ b/client/tests/test_fog_shader.gd @@ -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: diff --git a/client/tests/test_input_roundtrip.gd b/client/tests/test_input_roundtrip.gd index ac58eeb74..71c8136cd 100644 --- a/client/tests/test_input_roundtrip.gd +++ b/client/tests/test_input_roundtrip.gd @@ -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 diff --git a/client/tests/test_monologue_display.gd b/client/tests/test_monologue_display.gd index 21787f56d..abd267ef9 100644 --- a/client/tests/test_monologue_display.gd +++ b/client/tests/test_monologue_display.gd @@ -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) diff --git a/client/tests/test_sprint2_proof.gd b/client/tests/test_sprint2_proof.gd index b27cedd7f..19dc96834 100644 --- a/client/tests/test_sprint2_proof.gd +++ b/client/tests/test_sprint2_proof.gd @@ -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": diff --git a/client/ui/bug_report_dialog.gd.uid b/client/ui/bug_report_dialog.gd.uid deleted file mode 100644 index f71d45cd5..000000000 --- a/client/ui/bug_report_dialog.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dwdssbevd8gqq diff --git a/client/ui/character_select.gd.uid b/client/ui/character_select.gd.uid deleted file mode 100644 index aed1f1607..000000000 --- a/client/ui/character_select.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://char_select_sr \ No newline at end of file diff --git a/client/ui/debug_console.gd.uid b/client/ui/debug_console.gd.uid deleted file mode 100644 index 5b1559a85..000000000 --- a/client/ui/debug_console.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://c8pvt3xr7kmd2 diff --git a/client/ui/implant/apps/atlas/atlas_format.gd b/client/ui/implant/apps/atlas/atlas_format.gd new file mode 100644 index 000000000..c4d1ca87a --- /dev/null +++ b/client/ui/implant/apps/atlas/atlas_format.gd @@ -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 diff --git a/client/ui/implant/apps/atlas/screens/planet_screen.gd b/client/ui/implant/apps/atlas/screens/planet_screen.gd index d8e50ac3a..f77c67d01 100644 --- a/client/ui/implant/apps/atlas/screens/planet_screen.gd +++ b/client/ui/implant/apps/atlas/screens/planet_screen.gd @@ -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 diff --git a/client/ui/implant/apps/atlas/screens/system_screen.gd b/client/ui/implant/apps/atlas/screens/system_screen.gd index 5391a3deb..1652f332f 100644 --- a/client/ui/implant/apps/atlas/screens/system_screen.gd +++ b/client/ui/implant/apps/atlas/screens/system_screen.gd @@ -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 diff --git a/client/ui/loading_screen.gd.uid b/client/ui/loading_screen.gd.uid deleted file mode 100644 index 29248a1a3..000000000 --- a/client/ui/loading_screen.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://c6wtn7qk3mv2x diff --git a/client/ui/main_menu.gd.uid b/client/ui/main_menu.gd.uid deleted file mode 100644 index e8143c986..000000000 --- a/client/ui/main_menu.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://8jhxbtbelw0y diff --git a/client/ui/settings_dialog.gd.uid b/client/ui/settings_dialog.gd.uid deleted file mode 100644 index 501d4b5ef..000000000 --- a/client/ui/settings_dialog.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cpjq8yfsnpr5m