fix(client): clear the test debt — 2 production bugs, suite fully green (T-973 et al.)
Production fixes surfaced by honest test triage: - hud_groups.gd: _set_group_z crashed on freed HUD nodes — the typed loop variable errors before the is_instance_valid guard runs; prune first - fog_state.gd: _resize cleared _prev_visible (world-space keys survive resizes), so pre-resize tiles never decayed VISIBLE→EXPLORED (D-059) Test debt (T-928/929/934/935/936/937/938/939, T-864, T-973): lambda local-capture bugs rewritten with array captures (now assert exact emission counts), e2e suites updated to the current handshake + StartupMessage protocol and stream-aware reads against the live binary, fog perf test measures steady state, chime test pins the shipped 800ms catalog asset (D-067 amended separately), monologue gdUnit4 API typo, battery-warning tests follow the MetaScreen on_open lifecycle. 3 sprint2 proof tests revived (corner_reveal had passed from the wrong tile — NPC3 blocks (18,14); route corrected). Soft-skips converted to real do_skip reporting. T-1068: 7 orphan .gd.uid deleted, _format_pop/_format_radius deduped into atlas_format.gd (preload, no class_name — headless cache). Suite: 1264 cases/20 failures → 1268/0, independently re-verified (2536/2536, exit 0). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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 <stem>).
|
||||
|
||||
Re-homed under maintenance (test-failure debt / suite hygiene) per cascade refocus 2026-06-12.', 'in_progress', 'medium', NULL, 'client', NULL, '2026-05-24 16:49:54', '2026-06-12 12:02:03', NULL, 'a534447428c3a4f0eebc5b38053250cb', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNSRWK87PCWGZ6GWT2TBM', 'bug', '06FBPPMZNNEV052DBYYY3A897C', 'test_game_state_sprint20: 1 failure — SnapshotHandler or stationary_ticks path', '1 failing test case in test_game_state_sprint20.gd. before_each resets SnapshotHandler._prev_player_position. If this static var was renamed or moved, the before_each hook would error. Alternatively a stationary_ticks path changed in SnapshotHandler. Verify SnapshotHandler still has static var _prev_player_position at that exact name. Sprint 38 triage #871.
|
||||
|
||||
Re-homed under maintenance from done umbrella T-871 per cascade refocus 2026-06-12.', 'in_progress', 'low', NULL, 'client', NULL, '2026-05-02 08:12:38', '2026-06-12 12:02:03', NULL, 'b5bf0ac7b0e764bae1e17c69fd77f5ad', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBPTXRRDGJ5GFPGHZV3WS0D0', 'task', '06FBPPMZNNEV052DBYYY3A897C', 'Wire tooling tests into a gate: make test-tooling + pre-push on tooling changes', '(description follows in first append)
|
||||
|
||||
Filed 2026-06-12 from the fable-ous.md audit (S-18; citations adversarially verified).
|
||||
22,340 lines of tooling Python (including the systems.db generator) are verified only by a 4-rule ruff subset (pyproject.toml:26-31: E9/F401/F811/F821); zero pytest anywhere; the one real test (tooling/planet-gen/test_sim_determinism.py, guarding the expensive 271-body bake, exit codes 0/1/2) is wired into nothing.
|
||||
Fix: make test-tooling = test_sim_determinism.py + import_economics --dry-run against the committed DB; call from pre-push when TOOLING_CHANGED > 0; widen the ruff selection.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-12 10:40:59', '2026-06-12 12:02:03', NULL, 'c1ce9016e14e93003263e30033e95d1a', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNSRWBJ1XC9V5ZQHSXAXG', 'bug', '06FBPPMZNNEV052DBYYY3A897C', 'Rewrite P2-U01 monologue_display_visible_hidden test against current _visible array API', 'test_monologue_display_visible_hidden in test_client_p2.gd accessed mono.is_visible (a custom bool property). MonologueDisplay was refactored to use _visible: Array[Dictionary] instead. Test skipped (skip_test_) pending rewrite. New test should check _visible.size() > 0 after show_monologue, and == 0 when idle.
|
||||
|
||||
Re-homed under maintenance (test debt) per cascade refocus 2026-06-12.', 'in_progress', 'low', NULL, 'client', NULL, '2026-04-20 11:17:20', '2026-06-12 12:02:03', NULL, 'e0f58b478b902e102815af03c820a0fe', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBPTXSA4WFXPD21YK7N8SF60', 'task', '06FBPPMZNNEV052DBYYY3A897C', 'Fix 2,131 broken heightmap.png body-page links — conditional scaffold link + regenerate', '(description follows in first append)
|
||||
|
||||
Filed 2026-06-12 from the fable-ous.md audit (S-53; counts independently reproduced by verifier).
|
||||
The body-page scaffold links  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);
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -144,6 +144,10 @@ func _apply_z(node: CanvasItem, group: String) -> void:
|
||||
func _set_group_z(group: String, z: int) -> void:
|
||||
if not _groups.has(group):
|
||||
return
|
||||
for node: CanvasItem in _groups[group]:
|
||||
if is_instance_valid(node):
|
||||
node.z_index = z
|
||||
# Prune freed nodes first: a typed loop variable (`for node: CanvasItem`)
|
||||
# errors on assignment of a freed instance BEFORE any is_instance_valid
|
||||
# guard can run. Nodes freed without unregister() must not crash the manager.
|
||||
var alive: Array = _groups[group].filter(func(n): return is_instance_valid(n))
|
||||
_groups[group] = alive
|
||||
for node: CanvasItem in alive:
|
||||
node.z_index = z
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
uid://cfyv4qt7yybib
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
## Sprint 2 Proof: Fog of Perception (#357)
|
||||
## Verifies all 7 acceptance criteria through the full server pipeline.
|
||||
## Verifies the original acceptance criteria through the full server pipeline.
|
||||
##
|
||||
## SUITE DISABLED (sprint-36): Sprint 2 ACs are long satisfied.
|
||||
## The room coordinates and player spawn positions below are hardcoded from
|
||||
## the Sprint 2 room layout, which has evolved (protocol is now v23; Gauntlet
|
||||
## room layout is different). Live server testing via the Gauntlet infrastructure
|
||||
## supersedes these tests. Rewrite against the current Gauntlet rooms if
|
||||
## per-AC regression coverage is needed again.
|
||||
## REVIVED (T-1068): disabled in sprint-36, re-enabled against the current
|
||||
## protocol (handshake + StartupMessage exchange per #555/#175, streamed
|
||||
## snapshots). The proof room layout below is still what main.rs
|
||||
## setup_proof_room() spawns for non-test-mode servers.
|
||||
##
|
||||
## Server proof room layout (Sprint 2 — stale):
|
||||
## Server proof room layout (setup_proof_room in server/src/main.rs):
|
||||
## (16,13) = NPC1 (16,14) = WALL (16,16) = Player start
|
||||
## (14,18) = NPC2 (18,14) = NPC3
|
||||
## Player facing North → NPC1 blocked by wall.
|
||||
@@ -57,76 +55,170 @@ func after_test() -> void:
|
||||
_server_pid = -1
|
||||
|
||||
|
||||
## Send a batch input and receive the snapshot response.
|
||||
func _send_and_receive(action_name: String, tick: int = 0) -> Variant:
|
||||
## Find the player entity in a snapshot by kind.
|
||||
static func _find_player(snapshot: Dictionary) -> Dictionary:
|
||||
for entity in snapshot.entities:
|
||||
if entity.kind.variant == "Player":
|
||||
return entity
|
||||
return {}
|
||||
|
||||
|
||||
## Send a batch input and receive a snapshot that reflects it.
|
||||
## The server free-runs at ~20 ticks/sec and STREAMS a snapshot every tick —
|
||||
## it is not request/response. Drain stale queued snapshots first, then:
|
||||
## - expect_position != null (Vector2, render coords): wait until the player
|
||||
## reaches that position and assert it. Deterministic under load — tick
|
||||
## margins race the free-running server when the test process is descheduled
|
||||
## between drain and send.
|
||||
## - expect_position == null: wait for a snapshot at least 2 ticks past the
|
||||
## drain point (enough for no-op actions).
|
||||
func _send_and_receive(
|
||||
action_name: String, tick: int = 0, expect_position: Variant = null
|
||||
) -> Variant:
|
||||
# Drain queued stale snapshots, remembering the newest tick seen.
|
||||
var last_tick: int = -1
|
||||
_bridge.poll()
|
||||
var pending := _bridge.poll_message()
|
||||
while pending.size() > 0:
|
||||
var stale: Variant = Protocol.decode_snapshot(pending)
|
||||
if stale != null:
|
||||
last_tick = stale.tick
|
||||
_bridge.poll()
|
||||
pending = _bridge.poll_message()
|
||||
|
||||
var inputs: Array = [{"tick": tick, "action_name": action_name}]
|
||||
var encoded := Protocol.encode_player_inputs(inputs)
|
||||
assert_that(encoded.size()).is_greater(0)
|
||||
var send_err := _bridge.send_message(encoded)
|
||||
assert_that(send_err).is_equal(OK)
|
||||
|
||||
var snapshot_bytes := PackedByteArray()
|
||||
# Wait for a snapshot that reflects the processed input.
|
||||
var min_tick: int = last_tick + 2
|
||||
var snapshot: Variant = null
|
||||
var elapsed := 0.0
|
||||
while elapsed < RESPONSE_TIMEOUT:
|
||||
_bridge.poll()
|
||||
snapshot_bytes = _bridge.poll_message()
|
||||
if snapshot_bytes.size() > 0:
|
||||
break
|
||||
var msg := _bridge.poll_message()
|
||||
if msg.size() > 0:
|
||||
var decoded: Variant = Protocol.decode_snapshot(msg)
|
||||
if decoded == null:
|
||||
continue # undecodable frame — keep draining
|
||||
if expect_position != null:
|
||||
snapshot = decoded # keep latest so a timeout reports actual state
|
||||
var player := _find_player(decoded)
|
||||
if (
|
||||
not player.is_empty()
|
||||
and is_equal_approx(player.x, expect_position.x)
|
||||
and is_equal_approx(player.y, expect_position.y)
|
||||
):
|
||||
break
|
||||
elif decoded.tick >= min_tick:
|
||||
snapshot = decoded
|
||||
break
|
||||
continue
|
||||
await get_tree().create_timer(0.05).timeout
|
||||
elapsed += 0.05
|
||||
|
||||
assert_that(snapshot_bytes.size()).is_greater(0)
|
||||
var snapshot: Variant = Protocol.decode_snapshot(snapshot_bytes)
|
||||
assert_that(snapshot).is_not_null()
|
||||
assert_that(snapshot).override_failure_message(
|
||||
"no snapshot received within %.1fs after '%s'" % [RESPONSE_TIMEOUT, action_name]
|
||||
).is_not_null()
|
||||
if expect_position != null and snapshot != null:
|
||||
var player := _find_player(snapshot)
|
||||
var actual := Vector2(player.x, player.y) if not player.is_empty() else Vector2.INF
|
||||
assert_that(actual.is_equal_approx(expect_position)).override_failure_message(
|
||||
"player must reach %s after '%s' — last seen %s" % [expect_position, action_name, actual]
|
||||
).is_true()
|
||||
return snapshot
|
||||
|
||||
|
||||
## Connect to server, returning true on success.
|
||||
## Protocol handshake (#555, #175): the server sends a HandshakeMessage as the
|
||||
## first framed message; the client validates it and replies with a
|
||||
## StartupMessage carrying world_seed before any input is accepted.
|
||||
## Mirrors sim_bridge.gd's HANDSHAKING state.
|
||||
func _do_handshake(world_seed: int = 42) -> bool:
|
||||
var msg := PackedByteArray()
|
||||
var elapsed := 0.0
|
||||
while elapsed < CONNECT_TIMEOUT:
|
||||
_bridge.poll()
|
||||
msg = _bridge.poll_message()
|
||||
if msg.size() > 0:
|
||||
break
|
||||
await get_tree().create_timer(0.05).timeout
|
||||
elapsed += 0.05
|
||||
if msg.is_empty():
|
||||
push_warning("No HandshakeMessage received within %.1fs" % CONNECT_TIMEOUT)
|
||||
return false
|
||||
var MP = load("res://addons/messagepack/messagepack.gd")
|
||||
var decoded: Variant = MP.decode(msg)
|
||||
if decoded.status != null or not (decoded.value is Dictionary):
|
||||
push_warning("Malformed HandshakeMessage")
|
||||
return false
|
||||
var startup_bytes := Protocol.encode_startup_message(world_seed)
|
||||
if startup_bytes.is_empty():
|
||||
return false
|
||||
return _bridge.send_message(startup_bytes) == OK
|
||||
|
||||
|
||||
## Spawn the server, connect, and complete the protocol handshake.
|
||||
## Binary absence is handled by per-test `_do_skip` — by the time this runs the
|
||||
## binary exists, so any failure here is a real failure (asserted loudly).
|
||||
func _connect_to_server() -> bool:
|
||||
var server_path := _server_binary_path()
|
||||
if not FileAccess.file_exists(server_path):
|
||||
push_warning("Sprint 2 proof skipped: server binary not found at %s" % server_path)
|
||||
return false
|
||||
|
||||
var spawned := await _spawn_server(server_path)
|
||||
assert_bool(spawned).override_failure_message(
|
||||
"server spawn failed after %d port attempts" % MAX_PORT_ATTEMPTS
|
||||
).is_true()
|
||||
if not spawned:
|
||||
return false
|
||||
|
||||
_bridge = LocalBridge.new()
|
||||
var connected := false
|
||||
var elapsed := 0.0
|
||||
while elapsed < CONNECT_TIMEOUT:
|
||||
if _server_pid > 0 and not OS.is_process_running(_server_pid):
|
||||
push_warning("Server process died during connection")
|
||||
return false
|
||||
break
|
||||
if _bridge.get_status() == StreamPeerTCP.STATUS_NONE:
|
||||
_bridge.connect_to_server("127.0.0.1", _test_port)
|
||||
_bridge.poll()
|
||||
if _bridge.get_status() == StreamPeerTCP.STATUS_CONNECTED:
|
||||
return true
|
||||
connected = true
|
||||
break
|
||||
if _bridge.get_status() == StreamPeerTCP.STATUS_ERROR:
|
||||
_bridge.disconnect_from_server()
|
||||
_bridge.reset()
|
||||
await get_tree().create_timer(0.1).timeout
|
||||
elapsed += 0.1
|
||||
|
||||
return false
|
||||
assert_bool(connected).override_failure_message(
|
||||
"TCP connect to spawned server failed within %.1fs" % CONNECT_TIMEOUT
|
||||
).is_true()
|
||||
if not connected:
|
||||
return false
|
||||
|
||||
# Handshake + startup exchange (#555, #175) — required before input is accepted
|
||||
var handshake_ok := await _do_handshake()
|
||||
assert_bool(handshake_ok).override_failure_message(
|
||||
"protocol handshake + StartupMessage exchange must complete (#555, #175)"
|
||||
).is_true()
|
||||
return handshake_ok
|
||||
|
||||
|
||||
# -- AC#1, AC#2, AC#3, AC#5: Movement, camera, tiles, fog -------------------------
|
||||
|
||||
func skip_test_proof_player_moves_and_v2_snapshot() -> void:
|
||||
func test_proof_player_moves_and_v2_snapshot(
|
||||
_do_skip := not FileAccess.file_exists(_server_binary_path()),
|
||||
_skip_reason := "server binary not built — run `cargo build` in server/"
|
||||
) -> void:
|
||||
var ok := await _connect_to_server()
|
||||
if not ok:
|
||||
return
|
||||
|
||||
var snapshot: Dictionary = await _send_and_receive("MoveNorth")
|
||||
var snapshot: Dictionary = await _send_and_receive("MoveNorth", 0, Vector2(16.5, 15.5))
|
||||
|
||||
# AC#1: Player moved from (16,16) to (16,15)
|
||||
var player: Dictionary = {}
|
||||
for entity in snapshot.entities:
|
||||
if entity.kind.variant == "Player":
|
||||
player = entity
|
||||
break
|
||||
var player := _find_player(snapshot)
|
||||
assert_that(player.size()).is_greater(0)
|
||||
assert_float(player.x).is_equal_approx(16.5, 0.001)
|
||||
assert_float(player.y).is_equal_approx(15.5, 0.001)
|
||||
@@ -144,14 +236,17 @@ func skip_test_proof_player_moves_and_v2_snapshot() -> void:
|
||||
|
||||
# -- AC#6: Wall hides entity -------------------------------------------------------
|
||||
|
||||
func skip_test_proof_wall_hides_entity() -> void:
|
||||
func test_proof_wall_hides_entity(
|
||||
_do_skip := not FileAccess.file_exists(_server_binary_path()),
|
||||
_skip_reason := "server binary not built — run `cargo build` in server/"
|
||||
) -> void:
|
||||
var ok := await _connect_to_server()
|
||||
if not ok:
|
||||
return
|
||||
|
||||
# After MoveNorth: player at (16,15) facing North.
|
||||
# Wall at (16,14) blocks LOS to NPC1 at (16,13).
|
||||
var snapshot: Dictionary = await _send_and_receive("MoveNorth")
|
||||
var snapshot: Dictionary = await _send_and_receive("MoveNorth", 0, Vector2(16.5, 15.5))
|
||||
|
||||
# NPC1 at (16,13) should be hidden — wall at (16,14) blocks LOS.
|
||||
# Other NPCs (NPC2 at (14,18), NPC3 at (18,14)) may be visible.
|
||||
@@ -166,24 +261,33 @@ func skip_test_proof_wall_hides_entity() -> void:
|
||||
|
||||
# -- AC#4, AC#7: Entity appears via LOS / corner reveal ----------------------------
|
||||
|
||||
func skip_test_proof_corner_reveal() -> void:
|
||||
func test_proof_corner_reveal(
|
||||
_do_skip := not FileAccess.file_exists(_server_binary_path()),
|
||||
_skip_reason := "server binary not built — run `cargo build` in server/"
|
||||
) -> void:
|
||||
var ok := await _connect_to_server()
|
||||
if not ok:
|
||||
return
|
||||
|
||||
# Step 1: Move East twice to get beside the wall
|
||||
# (16,16) → MoveEast → (17,16) → MoveEast → (18,16)
|
||||
await _send_and_receive("MoveEast", 0)
|
||||
await _send_and_receive("MoveEast", 1)
|
||||
# Step 1: Move East three times — column x=18 is blocked at (18,14) by
|
||||
# NPC3 (entities are unwalkable), so route around via x=19.
|
||||
# (16,16) → (17,16) → (18,16) → (19,16)
|
||||
await _send_and_receive("MoveEast", 0, Vector2(17.5, 16.5))
|
||||
await _send_and_receive("MoveEast", 1, Vector2(18.5, 16.5))
|
||||
await _send_and_receive("MoveEast", 2, Vector2(19.5, 16.5))
|
||||
|
||||
# Step 2: Move North past the wall line (y=14)
|
||||
# (18,16) → MoveNorth → (18,15) → MoveNorth → (18,14) → MoveNorth → (18,13)
|
||||
await _send_and_receive("MoveNorth", 2)
|
||||
await _send_and_receive("MoveNorth", 3)
|
||||
var snapshot: Dictionary = await _send_and_receive("MoveNorth", 4)
|
||||
# (19,16) → (19,15) → (19,14) → (19,13)
|
||||
await _send_and_receive("MoveNorth", 3, Vector2(19.5, 15.5))
|
||||
await _send_and_receive("MoveNorth", 4, Vector2(19.5, 14.5))
|
||||
await _send_and_receive("MoveNorth", 5, Vector2(19.5, 13.5))
|
||||
|
||||
# Player at (18,13) facing North. NPC1 at (16,13) is 2 tiles west —
|
||||
# within peripheral cone, no wall between. NPC1 should be visible.
|
||||
# Step 3: Move West onto (18,13) — now facing West, looking straight at
|
||||
# NPC1 at (16,13) two tiles ahead with no wall between ((17,13) is open).
|
||||
var snapshot: Dictionary = await _send_and_receive("MoveWest", 6, Vector2(18.5, 13.5))
|
||||
|
||||
# Player at (18,13) facing West. NPC1 at (16,13) is 2 tiles dead ahead —
|
||||
# inside the vision cone, no wall between. NPC1 should be visible.
|
||||
var npc1_found := false
|
||||
for entity in snapshot.entities:
|
||||
if entity.kind.variant == "Npc":
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
uid://dwdssbevd8gqq
|
||||
@@ -1 +0,0 @@
|
||||
uid://char_select_sr
|
||||
@@ -1 +0,0 @@
|
||||
uid://c8pvt3xr7kmd2
|
||||
@@ -0,0 +1,28 @@
|
||||
extends RefCounted
|
||||
## Shared formatting helpers for Atlas screens (T-1068).
|
||||
## Deduplicated from planet_screen.gd / system_screen.gd.
|
||||
## Consumed via explicit preload (no class_name — avoids the global class
|
||||
## cache dependency that breaks headless test runs on fresh checkouts):
|
||||
## const AtlasFormat := preload("res://ui/implant/apps/atlas/atlas_format.gd")
|
||||
|
||||
|
||||
## Thousands-separated integer, e.g. 1234567 -> "1,234,567".
|
||||
static func format_pop(pop: int) -> String:
|
||||
if pop <= 0:
|
||||
return "0"
|
||||
var s: String = str(pop)
|
||||
var result: String = ""
|
||||
var count: int = 0
|
||||
for i: int in range(s.length() - 1, -1, -1):
|
||||
if count > 0 and count % 3 == 0:
|
||||
result = "," + result
|
||||
result = s[i] + result
|
||||
count += 1
|
||||
return result
|
||||
|
||||
|
||||
## Body radius in km — thousands-separated above 10,000 km, plain below.
|
||||
static func format_radius(km: float) -> String:
|
||||
if km >= 10000.0:
|
||||
return "%s km" % format_pop(int(km))
|
||||
return "%.0f km" % km
|
||||
@@ -3,6 +3,8 @@ extends Control
|
||||
## Body entry screen for AtlasApp (#844, D-191).
|
||||
## Shows body detail panel. Heightmap viewer is in RegionalScreen.
|
||||
|
||||
const AtlasFormat := preload("res://ui/implant/apps/atlas/atlas_format.gd")
|
||||
|
||||
const PANEL_WIDTH: float = 320.0
|
||||
const PANEL_MARGIN: float = 16.0
|
||||
const COLOR_BG: Color = Color("#0d1117")
|
||||
@@ -85,7 +87,7 @@ func _rebuild_body_panel() -> void:
|
||||
|
||||
if inhabited:
|
||||
_body_panel.add_component(ImplantSeparator.new())
|
||||
_body_panel.add_component(ImplantDataRow.new("population " + _format_pop(pop)))
|
||||
_body_panel.add_component(ImplantDataRow.new("population " + AtlasFormat.format_pop(pop)))
|
||||
|
||||
_body_panel.add_component(ImplantSeparator.new())
|
||||
|
||||
@@ -95,22 +97,3 @@ func _rebuild_body_panel() -> void:
|
||||
_body_panel.add_component(ImplantTextBlock.new("atlas data pending (#839)"))
|
||||
|
||||
_body_panel.add_component(ImplantTextBlock.new("esc back to orbital view"))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Helpers
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func _format_pop(pop: int) -> String:
|
||||
if pop <= 0:
|
||||
return "0"
|
||||
var s: String = str(pop)
|
||||
var result: String = ""
|
||||
var count: int = 0
|
||||
for i: int in range(s.length() - 1, -1, -1):
|
||||
if count > 0 and count % 3 == 0:
|
||||
result = "," + result
|
||||
result = s[i] + result
|
||||
count += 1
|
||||
return result
|
||||
|
||||
@@ -6,6 +6,8 @@ extends Control
|
||||
|
||||
signal body_selected(body: Dictionary)
|
||||
|
||||
const AtlasFormat := preload("res://ui/implant/apps/atlas/atlas_format.gd")
|
||||
|
||||
const STAR_RADIUS: float = 140.0
|
||||
const STAR_X_OFFSET: float = 0.0
|
||||
const BODY_LEFT_MARGIN: float = 300.0
|
||||
@@ -667,11 +669,15 @@ func _rebuild_body_panel() -> void:
|
||||
_body_panel.add_component(ImplantDataRow.new(type_line))
|
||||
_body_panel.add_component(ImplantDataRow.new("atmosphere " + atmo))
|
||||
if radius_km > 0:
|
||||
_body_panel.add_component(ImplantDataRow.new("radius " + _format_radius(radius_km)))
|
||||
_body_panel.add_component(
|
||||
ImplantDataRow.new("radius " + AtlasFormat.format_radius(radius_km))
|
||||
)
|
||||
|
||||
if inhabited:
|
||||
_body_panel.add_component(ImplantSeparator.new())
|
||||
_body_panel.add_component(ImplantDataRow.new("population " + _format_pop(pop)))
|
||||
_body_panel.add_component(
|
||||
ImplantDataRow.new("population " + AtlasFormat.format_pop(pop))
|
||||
)
|
||||
|
||||
_body_panel.add_component(ImplantSeparator.new())
|
||||
var has_heightmap: bool = b.get("terrain_reference") != null
|
||||
@@ -680,23 +686,3 @@ func _rebuild_body_panel() -> void:
|
||||
_body_panel.add_component(ImplantTextBlock.new("esc close"))
|
||||
|
||||
_dirty = true
|
||||
|
||||
|
||||
static func _format_pop(pop: int) -> String:
|
||||
if pop <= 0:
|
||||
return "0"
|
||||
var s: String = str(pop)
|
||||
var result: String = ""
|
||||
var count: int = 0
|
||||
for i: int in range(s.length() - 1, -1, -1):
|
||||
if count > 0 and count % 3 == 0:
|
||||
result = "," + result
|
||||
result = s[i] + result
|
||||
count += 1
|
||||
return result
|
||||
|
||||
|
||||
static func _format_radius(km: float) -> String:
|
||||
if km >= 10000.0:
|
||||
return "%s km" % _format_pop(int(km))
|
||||
return "%.0f km" % km
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
uid://c6wtn7qk3mv2x
|
||||
@@ -1 +0,0 @@
|
||||
uid://8jhxbtbelw0y
|
||||
@@ -1 +0,0 @@
|
||||
uid://cpjq8yfsnpr5m
|
||||
Reference in New Issue
Block a user