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

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 16:22:01 +02:00
co-authored by Claude Fable 5
parent b7a8cd1876
commit c64231e8ee
25 changed files with 603 additions and 269 deletions
+5 -1
View File
@@ -131,7 +131,11 @@ func _resize(bounds: Rect2i) -> void:
_tint_image = Image.create_from_data(_width, _height, false, Image.FORMAT_RGB8, _tint_bytes)
zone_tint_texture = ImageTexture.create_from_image(_tint_image)
_prev_visible.clear()
# NOTE: _prev_visible is deliberately NOT cleared here. Its keys are
# world-space coordinates, which stay valid across resizes. Clearing it
# meant tiles visible before a resize never decayed EXP_VISIBLE→EXP_EXPLORED
# when leaving LOS (D-059 violation — they rendered as currently-visible
# forever instead of deep fog).
func update_from_state() -> void:
+7 -3
View File
@@ -144,6 +144,10 @@ func _apply_z(node: CanvasItem, group: String) -> void:
func _set_group_z(group: String, z: int) -> void:
if not _groups.has(group):
return
for node: CanvasItem in _groups[group]:
if is_instance_valid(node):
node.z_index = z
# Prune freed nodes first: a typed loop variable (`for node: CanvasItem`)
# errors on assignment of a freed instance BEFORE any is_instance_valid
# guard can run. Nodes freed without unregister() must not crash the manager.
var alive: Array = _groups[group].filter(func(n): return is_instance_valid(n))
_groups[group] = alive
for node: CanvasItem in alive:
node.z_index = z
@@ -1 +0,0 @@
uid://cfyv4qt7yybib
+6 -6
View File
@@ -872,9 +872,9 @@ func test_settings_dialog_warning_label_shown_when_battery_suspended() -> void:
var dialog := scene.instantiate()
auto_free(dialog)
add_child(dialog)
if not dialog.has_method("get_battery_warning_visible"):
push_warning("TestAiDialogueSprint26: get_battery_warning_visible() not yet implemented — skipped")
return
# MetaScreen lifecycle (f24d08f75): the UI (including the warning label)
# is built in on_open(), not _ready() — open the dialog first.
dialog.open()
dialog.set_ai_inference_suspended(true)
assert_bool(dialog.get_battery_warning_visible()).override_failure_message(
"Battery warning label must be visible when inference is suspended (toggle is on but paused)"
@@ -890,9 +890,9 @@ func test_settings_dialog_warning_label_hidden_when_not_suspended() -> void:
var dialog := scene.instantiate()
auto_free(dialog)
add_child(dialog)
if not dialog.has_method("get_battery_warning_visible"):
push_warning("TestAiDialogueSprint26: get_battery_warning_visible() not yet implemented — skipped")
return
# MetaScreen lifecycle (f24d08f75): build the UI so this asserts the real
# label state, not the null-label fallback.
dialog.open()
dialog.set_ai_inference_suspended(false)
assert_bool(dialog.get_battery_warning_visible()).override_failure_message(
"Battery warning label must be hidden when inference is not suspended"
+18 -36
View File
@@ -79,10 +79,8 @@ func test_d073_set_zone_unknown_zone_does_not_crash() -> void:
func test_d073_zone_asset_hub_key_matches_filename_convention() -> void:
## D-073 / #529: v0.1 zone-to-asset mapping per sprint brief.
## hub/workplace → amb_hub_layer (must match filename stem in res://audio/).
## Test verifies naming convention is documentable. Activates once #529 adds the map.
if not "ZONE_ASSETS" in AudioManager:
push_warning("TestAudioSprint13: ZONE_ASSETS not yet defined (#529 pending) — skip zone map test")
return
## Test verifies naming convention is documentable. (#529 shipped — the old
## "not yet defined" guard was removed in T-1068; absence now fails loudly.)
var zone_assets: Dictionary = AudioManager.ZONE_ASSETS
assert_that(zone_assets.has("hub")).is_true()
assert_that(zone_assets["hub"]).is_equal("amb_hub_layer")
@@ -90,9 +88,6 @@ func test_d073_zone_asset_hub_key_matches_filename_convention() -> void:
func test_d073_zone_asset_bar_key_matches_filename_convention() -> void:
## D-073 / #529: bar → amb_bar_layer
if not "ZONE_ASSETS" in AudioManager:
push_warning("TestAudioSprint13: ZONE_ASSETS not yet defined (#529 pending) — skip zone map test")
return
var zone_assets: Dictionary = AudioManager.ZONE_ASSETS
assert_that(zone_assets.has("bar")).is_true()
assert_that(zone_assets["bar"]).is_equal("amb_bar_layer")
@@ -100,9 +95,6 @@ func test_d073_zone_asset_bar_key_matches_filename_convention() -> void:
func test_d073_zone_asset_corridor_key_matches_filename_convention() -> void:
## D-073 / #529: smuggling corridor → amb_corridor_layer
if not "ZONE_ASSETS" in AudioManager:
push_warning("TestAudioSprint13: ZONE_ASSETS not yet defined (#529 pending) — skip zone map test")
return
var zone_assets: Dictionary = AudioManager.ZONE_ASSETS
assert_that(zone_assets.has("corridor")).is_true()
assert_that(zone_assets["corridor"]).is_equal("amb_corridor_layer")
@@ -139,20 +131,13 @@ func test_d073_game_state_zone_id_empty_when_field_absent() -> void:
func test_d073_crossfade_duration_in_1_5_to_2_0s_range() -> void:
## D-073: Crossfade tween duration must be 1.5-2.0s.
## Activates once #529 defines the duration constant.
if not "CROSSFADE_DURATION" in AudioManager:
push_warning("TestAudioSprint13: CROSSFADE_DURATION not yet defined (#529 pending) — skip duration test")
return
var duration: float = AudioManager.CROSSFADE_DURATION
assert_that(duration >= 1.5 and duration <= 2.0).is_true()
func test_d073_set_zone_same_zone_repeated_is_noop() -> void:
## D-073: Crossing back to the current zone should not restart a crossfade.
## (No audio pops when zone boundary is ambiguous.) Activates post-#529.
if not "ZONE_ASSETS" in AudioManager:
push_warning("TestAudioSprint13: set_zone body not yet implemented (#529) — skip no-op test")
return
## (No audio pops when zone boundary is ambiguous.)
AudioManager.set_zone("hub")
AudioManager.set_zone("hub")
## Expect exactly one or zero ambient players after same-zone calls (no stacked tweens).
@@ -164,9 +149,6 @@ func test_d073_rapid_zone_crossing_interruptible() -> void:
## D-073: Rapid back-and-forth zone crossing (interruptible crossfade).
## When _kill_zone_tweens() fires mid-fade, old player stays at intermediate
## volume — new tween starts from current position. No stacked tweens, no crash.
if not "ZONE_ASSETS" in AudioManager:
push_warning("TestAudioSprint13: ZONE_ASSETS not yet defined — skip rapid crossing test")
return
# Cross hub → bar → hub rapidly (simulates player walking back and forth)
AudioManager.set_zone("hub")
AudioManager.set_zone("bar") # Interrupts hub fade-in mid-tween
@@ -530,22 +512,22 @@ func test_d067_chime_fires_at_fog_entity_spawn_not_removal() -> void:
assert_that(GameState.pending_recognitions.size()).is_equal(0)
func test_d067_chime_duration_spec_is_300_to_400ms() -> void:
## D-067: Chime duration is 300-400ms per spec. The asset (.ogg) carries this duration.
## This test documents the spec range so asset authoring can be validated.
## When sfx_monologue_chime.ogg is present, its AudioStream.get_length() should
## return a value in this range.
const CHIME_MIN_DURATION := 0.3
const CHIME_MAX_DURATION := 0.4
if not AudioManager.has_asset(AudioManager.CHIME_RECOGNITION):
push_warning("TestAudioSprint13: sfx_monologue_chime asset absent — skip duration test")
return
func test_d067_chime_duration_matches_catalog_800ms(
_do_skip := not AudioManager.has_asset(AudioManager.CHIME_RECOGNITION),
_skip_reason := "sfx_monologue_chime asset absent"
) -> void:
## D-067 originally specified a 300-400ms chime. The production asset shipped
## in Sprint 10 (#327) is a deliberate 800ms manual synthesis — see
## docs/assets/audio/ui.md (UI-005) for the full synthesis spec. This test pins
## the shipped catalog duration so silent asset regressions are caught.
## NOTE: D-067's prose still says 300-400ms — flagged for governance amendment (T-938).
var stream: AudioStream = AudioManager._registry.get(AudioManager.CHIME_RECOGNITION)
if stream == null:
push_warning("TestAudioSprint13: could not retrieve chime stream from registry")
return
assert_that(stream.get_length() >= CHIME_MIN_DURATION
and stream.get_length() <= CHIME_MAX_DURATION).is_true()
assert_that(stream).override_failure_message(
"chime asset registered but stream missing from registry"
).is_not_null()
assert_float(stream.get_length()).override_failure_message(
"sfx_monologue_chime duration must match the asset catalog (800ms, ui.md UI-005)"
).is_equal_approx(0.8, 0.05)
# ==============================================================================
+20 -45
View File
@@ -67,23 +67,9 @@ func test_camera_zoom_default_2x() -> void:
assert_that(camera.zoom).is_equal(Vector2(2, 2))
func skip_test_camera_smoothing_convergence() -> void:
# P2-C02: STALE — #117 permanently disables Camera2D.position_smoothing_enabled
# in main.gd _ready() (manual lerp approach). Assertion is_true() no longer valid.
# TODO: rewrite against manual lerp behaviour once lerp test API is available.
var inst := _make_scene()
var camera: Camera2D = inst.get_node("Camera2D")
# First frame re-enables smoothing
inst._process(0.016)
assert_that(camera.position_smoothing_enabled).is_true()
# Several more frames — stationary player, camera converges
for i in 5:
inst._process(0.016)
var expected := GameState.player_position * Constants.TILE_SIZE
var dist := camera.global_position.distance_to(expected)
assert_that(dist < 0.1).override_failure_message(
"Camera should converge to player position (dist: %.4f)" % dist
).is_true()
# P2-C02 (skip_test_camera_smoothing_convergence) removed (T-1068): stale since
# #117 switched to manual lerp. Superseded by test_camera_anchor.gd
# (test_camera_smoothing_stays_off_with_manual_lerp, test_camera_lerps_toward_player_movement).
func test_camera_viewport_tracks_player_position() -> void:
@@ -97,23 +83,10 @@ func test_camera_viewport_tracks_player_position() -> void:
).is_equal(expected)
func skip_test_camera_follows_player_after_movement() -> void:
# P2-C04: STALE — #117 switched camera to manual lerp; after 1 frame the camera
# has not converged to player_position * TILE_SIZE. Exact equality assertion fails.
# TODO: rewrite to assert directional movement only (y > initial_pos.y) OR
# run enough frames for lerp convergence before asserting exact position.
var inst := _make_scene()
var camera: Camera2D = inst.get_node("Camera2D")
var initial_pos := camera.global_position
# Move player south via SimBridge test mode
SimBridge._test_input_queue.append("MoveSouth")
inst._process(0.016)
# Camera should have moved with player
assert_that(camera.global_position.y > initial_pos.y).override_failure_message(
"Camera Y should increase after moving south"
).is_true()
assert_that(camera.global_position).is_equal(
GameState.player_position * Constants.TILE_SIZE)
# P2-C04 (skip_test_camera_follows_player_after_movement) removed (T-1068): stale
# since #117 (manual lerp — exact-equality assertion invalid after one frame).
# Superseded by test_camera_anchor.gd (test_camera_tracks_player_after_process,
# test_camera_lerps_toward_player_movement).
func test_camera_no_panning_locked_to_player() -> void:
@@ -195,20 +168,22 @@ func test_entity_player_color_regardless_of_sector() -> void:
# -- UI (7) --------------------------------------------------------------------
func skip_test_monologue_display_visible_hidden() -> void:
# P2-U01: BROKEN — MonologueDisplay no longer has an `is_visible` bool property.
# Current API uses `_visible: Array[Dictionary]` (monologue_display.gd).
# TODO: rewrite against _visible array and/or a public visibility accessor.
func test_monologue_display_visible_hidden() -> void:
# P2-U01 (T-864): MonologueDisplay tracks shown lines in `_visible:
# Array[Dictionary]` — empty when idle, populated after show_monologue.
var inst := _make_scene()
var mono = inst.get_node("UILayer/MonologueDisplay")
assert_that(mono.is_visible).override_failure_message(
"Monologue should start hidden"
).is_false()
assert_int(mono._visible.size()).override_failure_message(
"Monologue must start with no visible lines"
).is_equal(0)
mono.show_monologue("Test thought.", 3.0)
assert_that(mono.is_visible).override_failure_message(
"Monologue should be visible after show_monologue"
).is_true()
assert_that(mono.text_label.text).is_equal("Test thought.")
assert_int(mono._visible.size()).override_failure_message(
"Monologue must have one visible line after show_monologue"
).is_equal(1)
var label: RichTextLabel = mono._visible[0].node.get_child(0)
assert_str(label.get_parsed_text()).override_failure_message(
"Visible monologue line must carry the shown text"
).is_equal("Test thought.")
func test_interaction_list_shows_nearest_verb() -> void:
+15 -10
View File
@@ -189,11 +189,11 @@ func test_d063_confrontation_signal_fires_on_confrontation_option() -> void:
var box := _make_dialogue_box()
if box == null: return
var signal_fired := false
var received_text := ""
# Array capture: GDScript lambdas capture locals by value — assignment
# inside the lambda never propagates out. Mutating an Array does.
var received_texts: Array = []
box.confrontation_monologue.connect(func(text: String, _dur: float):
signal_fired = true
received_text = text
received_texts.append(text)
)
# Show dialogue with one confrontation option
@@ -203,9 +203,12 @@ func test_d063_confrontation_signal_fires_on_confrontation_option() -> void:
# Press option 1 (index 0)
box._on_option_pressed(0)
assert_bool(signal_fired).override_failure_message(
assert_int(received_texts.size()).override_failure_message(
"D-063: confrontation_monologue signal must fire when confrontation option is selected"
).is_true()
).is_equal(1)
assert_str(received_texts[0]).override_failure_message(
"D-063: confrontation_monologue must carry the beat monologue text"
).is_not_empty()
box.queue_free()
@@ -214,18 +217,20 @@ func test_d063_non_confrontation_option_does_not_fire_beat_signal() -> void:
var box := _make_dialogue_box()
if box == null: return
var signal_fired := false
# Array capture — local-assignment captures never propagate out of a
# lambda, which made the old bool version of this test vacuous.
var fired: Array = []
box.confrontation_monologue.connect(func(_text: String, _dur: float):
signal_fired = true
fired.append(true)
)
var opts := _make_options(["A normal response."], [false])
box.show_dialogue("NPC", "Hello.", opts)
box._on_option_pressed(0)
assert_bool(signal_fired).override_failure_message(
assert_int(fired.size()).override_failure_message(
"D-063: confrontation_monologue must NOT fire for standard options"
).is_false()
).is_equal(0)
box.queue_free()
+29 -23
View File
@@ -32,12 +32,14 @@ func test_show_dialogue_emits_dialogue_state_changed_true() -> void:
var box := _make_dialogue_box()
if box == null: return
auto_free(box)
var received: Variant = null
box.dialogue_state_changed.connect(func(active: bool): received = active)
# Array capture: GDScript lambdas capture locals by value — assignment
# inside the lambda never propagates out. Mutating an Array does.
var received: Array = []
box.dialogue_state_changed.connect(func(active: bool): received.append(active))
box.show_dialogue("NPC", "Hello.", [])
assert_that(received).override_failure_message(
"show_dialogue() must emit dialogue_state_changed(true) (#558)"
).is_equal(true)
assert_array(received).override_failure_message(
"show_dialogue() must emit dialogue_state_changed(true) exactly once (#558)"
).is_equal([true])
func test_show_dialogue_does_not_mutate_game_state_directly() -> void:
@@ -59,12 +61,13 @@ func test_hide_dialogue_emits_dialogue_state_changed_false() -> void:
if box == null: return
auto_free(box)
box.show_dialogue("NPC", "Hello.", [])
var received: Variant = null
box.dialogue_state_changed.connect(func(active: bool): received = active)
# Array capture — see test_show_dialogue_emits_dialogue_state_changed_true.
var received: Array = []
box.dialogue_state_changed.connect(func(active: bool): received.append(active))
box.hide_dialogue()
assert_that(received).override_failure_message(
"hide_dialogue() must emit dialogue_state_changed(false) (#558)"
).is_equal(false)
assert_array(received).override_failure_message(
"hide_dialogue() must emit dialogue_state_changed(false) exactly once (#558)"
).is_equal([false])
# -- audio_dip_requested / audio_dip_cleared signals -------------------------
@@ -74,12 +77,13 @@ func test_show_dialogue_emits_audio_dip_requested_dialogue() -> void:
var box := _make_dialogue_box()
if box == null: return
auto_free(box)
var received_profile: Variant = null
box.audio_dip_requested.connect(func(profile: String): received_profile = profile)
# Array capture — see test_show_dialogue_emits_dialogue_state_changed_true.
var received_profiles: Array = []
box.audio_dip_requested.connect(func(profile: String): received_profiles.append(profile))
box.show_dialogue("NPC", "Hello.", [])
assert_that(received_profile).override_failure_message(
"show_dialogue() must emit audio_dip_requested('dialogue') (#558)"
).is_equal("dialogue")
assert_array(received_profiles).override_failure_message(
"show_dialogue() must emit audio_dip_requested('dialogue') exactly once (#558)"
).is_equal(["dialogue"])
func test_show_dialogue_does_not_call_audio_manager_directly() -> void:
@@ -102,12 +106,13 @@ func test_hide_dialogue_emits_audio_dip_cleared() -> void:
if box == null: return
auto_free(box)
box.show_dialogue("NPC", "Hello.", [])
var cleared := false
box.audio_dip_cleared.connect(func(): cleared = true)
# Array capture — see test_show_dialogue_emits_dialogue_state_changed_true.
var cleared: Array = []
box.audio_dip_cleared.connect(func(): cleared.append(true))
box.hide_dialogue()
assert_bool(cleared).override_failure_message(
"hide_dialogue() must emit audio_dip_cleared (#558)"
).is_true()
assert_int(cleared.size()).override_failure_message(
"hide_dialogue() must emit audio_dip_cleared exactly once (#558)"
).is_equal(1)
func test_audio_dip_cleared_count_on_conversation_end() -> void:
@@ -115,11 +120,12 @@ func test_audio_dip_cleared_count_on_conversation_end() -> void:
var box := _make_dialogue_box()
if box == null: return
auto_free(box)
var cleared_count := 0
box.audio_dip_cleared.connect(func(): cleared_count += 1)
# Array capture — see test_show_dialogue_emits_dialogue_state_changed_true.
var cleared: Array = []
box.audio_dip_cleared.connect(func(): cleared.append(true))
box.show_dialogue("NPC", "Speak.", [])
box.hide_dialogue()
assert_int(cleared_count).override_failure_message(
assert_int(cleared.size()).override_failure_message(
"audio_dip_cleared must fire at least once when conversation ends (#558)"
).is_greater_equal(1)
+38 -4
View File
@@ -52,13 +52,41 @@ func after_test() -> void:
_server_pid = -1
## Protocol handshake (#555, #175): the server sends a HandshakeMessage as the
## first framed message; the client validates it and replies with a
## StartupMessage carrying world_seed before any input is accepted.
## Mirrors sim_bridge.gd's HANDSHAKING state.
func _do_handshake(world_seed: int = 42) -> bool:
var msg := PackedByteArray()
var elapsed := 0.0
while elapsed < CONNECT_TIMEOUT:
_bridge.poll()
msg = _bridge.poll_message()
if msg.size() > 0:
break
await get_tree().create_timer(0.05).timeout
elapsed += 0.05
if msg.is_empty():
push_warning("No HandshakeMessage received within %.1fs" % CONNECT_TIMEOUT)
return false
var MP = load("res://addons/messagepack/messagepack.gd")
var decoded: Variant = MP.decode(msg)
if decoded.status != null or not (decoded.value is Dictionary):
push_warning("Malformed HandshakeMessage")
return false
var startup_bytes := Protocol.encode_startup_message(world_seed)
if startup_bytes.is_empty():
return false
return _bridge.send_message(startup_bytes) == OK
# -- E2E: full round-trip through server binary --------------------------------
func test_send_input_receive_snapshot() -> void:
func test_send_input_receive_snapshot(
_do_skip := not FileAccess.file_exists(_server_binary_path()),
_skip_reason := "server binary not built — run `cargo build` in server/"
) -> void:
var server_path := _server_binary_path()
if not FileAccess.file_exists(server_path):
push_warning("E2E test skipped: server binary not found at %s" % server_path)
return
# Spawn server with port rotation (retries if port is in use)
var spawned := await _spawn_server(server_path)
@@ -83,6 +111,12 @@ func test_send_input_receive_snapshot() -> void:
assert_bool(connected).is_true()
# Handshake + startup exchange (#555, #175) — required before input is accepted
var handshake_ok := await _do_handshake()
assert_bool(handshake_ok).override_failure_message(
"protocol handshake + StartupMessage exchange must complete (#555, #175)"
).is_true()
# Send batch input: MoveNorth at tick 0 (matching game_loop.rs test)
var inputs: Array = [{"tick": 0, "action_name": "MoveNorth"}]
var encoded := Protocol.encode_player_inputs(inputs)
+5 -12
View File
@@ -260,18 +260,11 @@ func test_list_hides_on_empty_verb_list() -> void:
list.queue_free()
# -------------------------------------------------------------------------
# Phase 2 placeholders — deferred pending server protocol change (no known_attributes in v13)
# -------------------------------------------------------------------------
func skip_test_npc_name_displayed_when_known() -> void:
pass
func skip_test_dialogue_tier_context_hint_for_friendly() -> void:
pass
func skip_test_dialogue_tier_context_hint_for_hostile() -> void:
pass
# Empty skip_test_ placeholders for NPC-name display and dialogue-tier context
# hints removed (T-1068). They contained no test body. The blocking protocol gap
# (no known_attributes in v13) is long lifted (player_knowledge shipped in v14,
# #264/D-041), but the interaction-list features they anticipated do not exist —
# that is later-phase NPC detail work which will arrive with its own tests.
# -------------------------------------------------------------------------
+23 -9
View File
@@ -8,6 +8,20 @@ class_name TestFogShader
extends GdUnitTestSuite
# Suite prerequisites — reported as a real gdUnit4 skip (visible in the JSON
# summary) instead of the old push_warning-then-return soft-skip (T-1068).
# The runtime null-guards in the helpers below remain as defensive code only.
func before(
_do_skip := not (
ResourceLoader.exists("res://scripts/autoloads/fog_state.gd")
and ResourceLoader.exists("res://scripts/rendering/fog_shader.gd")
and ResourceLoader.exists("res://shaders/fog.gdshader")
),
_skip_reason := "fog shader scripts missing (fog_state.gd / fog_shader.gd / fog.gdshader)"
) -> void:
pass
# -- Helpers -------------------------------------------------------------------
func _fog_state_exists() -> bool:
@@ -166,29 +180,29 @@ func test_visibility_texture_update_performance() -> void:
return
if not fog_state.has_method("update_from_state"):
return
# Simulate a realistic tile count (~400 visible tiles)
# Simulate a realistic tile count (~400 visible tiles).
# All tiles are Forward — the peripheral sector was removed in Sprint 22 (#569).
var positions := {}
var sectors := {}
for x in range(20):
for y in range(20):
var pos := Vector2i(x, y)
positions[pos] = true
sectors[pos] = "Forward" if y < 10 else "Peripheral"
positions[Vector2i(x, y)] = true
GameState.visible_positions = positions
GameState.visibility_sectors = sectors
# Measure update time
# Warm-up: the first update may trigger a grow-only bounds resize, which is
# a rare amortized event (8-tile padding), not part of the per-frame budget.
fog_state.update_from_state()
# Measure the steady-state per-frame update (same positions, no resize)
var start := Time.get_ticks_usec()
fog_state.update_from_state()
var elapsed_us := Time.get_ticks_usec() - start
var elapsed_ms := elapsed_us / 1000.0
# D-059: Visibility texture upload budget: 0.1ms
# Allow 2x margin for test environment overhead
# Allow margin for test environment overhead
assert_that(elapsed_ms).is_less(0.5)
GameState.visible_positions.clear()
GameState.visibility_sectors.clear()
func test_full_fog_update_under_1ms() -> void:
+120 -22
View File
@@ -48,38 +48,104 @@ func after_test() -> void:
_server_pid = -1
## Spawn the server, connect, and complete the protocol handshake.
## Binary absence is handled by per-test `_do_skip` — by the time this runs the
## binary exists, so any failure here is a real failure (asserted loudly).
func _connect_to_server() -> bool:
var server_path := _server_binary_path()
if not FileAccess.file_exists(server_path):
push_warning("Input roundtrip test skipped: server binary not found at %s" % server_path)
return false
var spawned := await _spawn_server(server_path)
assert_bool(spawned).override_failure_message(
"server spawn failed after %d port attempts" % MAX_PORT_ATTEMPTS
).is_true()
if not spawned:
return false
_bridge = LocalBridge.new()
var connected := false
var elapsed := 0.0
while elapsed < CONNECT_TIMEOUT:
if _server_pid > 0 and not OS.is_process_running(_server_pid):
push_warning("Server process died during connection")
return false
break
if _bridge.get_status() == StreamPeerTCP.STATUS_NONE:
_bridge.connect_to_server("127.0.0.1", _test_port)
_bridge.poll()
if _bridge.get_status() == StreamPeerTCP.STATUS_CONNECTED:
return true
connected = true
break
if _bridge.get_status() == StreamPeerTCP.STATUS_ERROR:
_bridge.disconnect_from_server()
_bridge.reset()
await get_tree().create_timer(0.1).timeout
elapsed += 0.1
return false
assert_bool(connected).override_failure_message(
"TCP connect to spawned server failed within %.1fs" % CONNECT_TIMEOUT
).is_true()
if not connected:
return false
# Handshake + startup exchange (#555, #175) — required before input is accepted
var handshake_ok := await _do_handshake()
assert_bool(handshake_ok).override_failure_message(
"protocol handshake + StartupMessage exchange must complete (#555, #175)"
).is_true()
return handshake_ok
## Send a batch input and receive the snapshot response.
func _send_and_receive(action_name: String, tick: int = 0, action_data: Variant = null) -> Variant:
## Protocol handshake (#555, #175): the server sends a HandshakeMessage as the
## first framed message; the client validates it and replies with a
## StartupMessage carrying world_seed before any input is accepted.
## Mirrors sim_bridge.gd's HANDSHAKING state.
func _do_handshake(world_seed: int = 42) -> bool:
var msg := PackedByteArray()
var elapsed := 0.0
while elapsed < CONNECT_TIMEOUT:
_bridge.poll()
msg = _bridge.poll_message()
if msg.size() > 0:
break
await get_tree().create_timer(0.05).timeout
elapsed += 0.05
if msg.is_empty():
push_warning("No HandshakeMessage received within %.1fs" % CONNECT_TIMEOUT)
return false
var MP = load("res://addons/messagepack/messagepack.gd")
var decoded: Variant = MP.decode(msg)
if decoded.status != null or not (decoded.value is Dictionary):
push_warning("Malformed HandshakeMessage")
return false
var startup_bytes := Protocol.encode_startup_message(world_seed)
if startup_bytes.is_empty():
return false
return _bridge.send_message(startup_bytes) == OK
## Send a batch input and receive a snapshot that reflects it.
## The server free-runs at ~20 ticks/sec and STREAMS a snapshot every tick —
## it is not request/response. Drain stale queued snapshots first, then:
## - expect_position != null (Vector2, render coords): wait until the player
## reaches that position and assert it. Deterministic under load — tick
## margins race the free-running server when the test process is descheduled
## between drain and send.
## - expect_position == null: wait for a snapshot at least 2 ticks past the
## drain point (enough for no-op actions like Interact).
func _send_and_receive(
action_name: String, tick: int = 0, action_data: Variant = null,
expect_position: Variant = null
) -> Variant:
# Drain queued stale snapshots, remembering the newest tick seen.
var last_tick: int = -1
_bridge.poll()
var pending := _bridge.poll_message()
while pending.size() > 0:
var stale: Variant = Protocol.decode_snapshot(pending)
if stale != null:
last_tick = stale.tick
_bridge.poll()
pending = _bridge.poll_message()
var input_entry := {"tick": tick, "action_name": action_name}
if action_data != null:
input_entry["action_data"] = action_data
@@ -89,19 +155,42 @@ func _send_and_receive(action_name: String, tick: int = 0, action_data: Variant
var send_err := _bridge.send_message(encoded)
assert_that(send_err).is_equal(OK)
var snapshot_bytes := PackedByteArray()
# Wait for a snapshot that reflects the processed input.
var min_tick: int = last_tick + 2
var snapshot: Variant = null
var elapsed := 0.0
while elapsed < RESPONSE_TIMEOUT:
_bridge.poll()
snapshot_bytes = _bridge.poll_message()
if snapshot_bytes.size() > 0:
break
var msg := _bridge.poll_message()
if msg.size() > 0:
var decoded: Variant = Protocol.decode_snapshot(msg)
if decoded == null:
continue # undecodable frame — keep draining
if expect_position != null:
snapshot = decoded # keep latest so a timeout reports actual state
var player := _find_player(decoded)
if (
not player.is_empty()
and is_equal_approx(player.x, expect_position.x)
and is_equal_approx(player.y, expect_position.y)
):
break
elif decoded.tick >= min_tick:
snapshot = decoded
break
continue
await get_tree().create_timer(0.05).timeout
elapsed += 0.05
assert_that(snapshot_bytes.size()).is_greater(0)
var snapshot: Variant = Protocol.decode_snapshot(snapshot_bytes)
assert_that(snapshot).is_not_null()
assert_that(snapshot).override_failure_message(
"no snapshot received within %.1fs after '%s'" % [RESPONSE_TIMEOUT, action_name]
).is_not_null()
if expect_position != null and snapshot != null:
var player := _find_player(snapshot)
var actual := Vector2(player.x, player.y) if not player.is_empty() else Vector2.INF
assert_that(actual.is_equal_approx(expect_position)).override_failure_message(
"player must reach %s after '%s' — last seen %s" % [expect_position, action_name, actual]
).is_true()
return snapshot
@@ -115,7 +204,10 @@ static func _find_player(snapshot: Dictionary) -> Dictionary:
# -- Movement roundtrip: send movement, verify position changes ----------------
func test_movement_roundtrip() -> void:
func test_movement_roundtrip(
_do_skip := not FileAccess.file_exists(_server_binary_path()),
_skip_reason := "server binary not built — run `cargo build` in server/"
) -> void:
var ok := await _connect_to_server()
if not ok:
return
@@ -132,14 +224,14 @@ func test_movement_roundtrip() -> void:
assert_float(start_y).is_equal_approx(16.5, 0.001)
# Send MoveNorth — player should move to (16, 15) → (16.5, 15.5)
var snap1: Dictionary = await _send_and_receive("MoveNorth", 1)
var snap1: Dictionary = await _send_and_receive("MoveNorth", 1, null, Vector2(16.5, 15.5))
var p1 := _find_player(snap1)
assert_that(p1.size()).is_greater(0)
assert_float(p1.x).is_equal_approx(16.5, 0.001)
assert_float(p1.y).is_equal_approx(15.5, 0.001)
# Send MoveEast — player should move to (17, 15) → (17.5, 15.5)
var snap2: Dictionary = await _send_and_receive("MoveEast", 2)
var snap2: Dictionary = await _send_and_receive("MoveEast", 2, null, Vector2(17.5, 15.5))
var p2 := _find_player(snap2)
assert_that(p2.size()).is_greater(0)
assert_float(p2.x).is_equal_approx(17.5, 0.001)
@@ -151,7 +243,10 @@ func test_movement_roundtrip() -> void:
# -- Interact roundtrip: server accepts without crashing -----------------------
func test_interact_roundtrip() -> void:
func test_interact_roundtrip(
_do_skip := not FileAccess.file_exists(_server_binary_path()),
_skip_reason := "server binary not built — run `cargo build` in server/"
) -> void:
var ok := await _connect_to_server()
if not ok:
return
@@ -175,13 +270,16 @@ func test_interact_roundtrip() -> void:
# -- Mixed sequence: movement then interact in one session ---------------------
func test_move_then_interact() -> void:
func test_move_then_interact(
_do_skip := not FileAccess.file_exists(_server_binary_path()),
_skip_reason := "server binary not built — run `cargo build` in server/"
) -> void:
var ok := await _connect_to_server()
if not ok:
return
# Move player first
var snap1: Dictionary = await _send_and_receive("MoveNorth", 0)
# Move player first — (16,16) → (16,15) → render (16.5, 15.5)
var snap1: Dictionary = await _send_and_receive("MoveNorth", 0, null, Vector2(16.5, 15.5))
var p1 := _find_player(snap1)
assert_that(p1.size()).is_greater(0)
var moved_x: float = p1.x
+14 -5
View File
@@ -8,6 +8,15 @@ class_name TestMonologueDisplay
extends GdUnitTestSuite
# Suite prerequisite — reported as a real gdUnit4 skip (visible in the JSON
# summary) instead of the old push_warning-then-return soft-skip (T-1068).
func before(
_do_skip := not ResourceLoader.exists("res://ui/monologue_display.tscn"),
_skip_reason := "monologue_display.tscn not found"
) -> void:
pass
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
@@ -222,7 +231,7 @@ func test_queue_never_exceeds_max_depth() -> void:
d._next_fade_in_msec = 0.0; d.show_monologue("C", 10.0)
for i in range(d.MAX_QUEUE + 20):
d.show_monologue("flood_%d" % i, 1.0, 2)
assert_int(d._queue.size()).is_less_or_equal(d.MAX_QUEUE)
assert_int(d._queue.size()).is_less_equal(d.MAX_QUEUE)
d.queue_free()
@@ -429,16 +438,16 @@ func test_canvas_ui_constant_is_20() -> void:
assert_that(Constants.CANVAS_UI).is_equal(20)
func test_monologue_display_parented_to_canvas_layer_20_in_main_scene() -> void:
func test_monologue_display_parented_to_canvas_layer_20_in_main_scene(
_do_skip := not ResourceLoader.exists("res://scenes/main.tscn"),
_skip_reason := "main.tscn not found"
) -> void:
## D-049: Structural verification — MonologueDisplay must be a direct child of
## UILayer (CanvasLayer, layer=20) in the live scene tree, not the world layer.
## Catches regressions where the node gets accidentally moved to InsertOverlay
## (layer=10) or MetaLayer (layer=30), or dropped into the world z-stack.
##
## Scene path verified: Game/UILayer/MonologueDisplay (main.tscn line 141).
if not ResourceLoader.exists("res://scenes/main.tscn"):
push_warning("TestMonologueDisplay: main.tscn not found — D-049 scene tree test skipped")
return
var scene: Node = load("res://scenes/main.tscn").instantiate()
auto_free(scene)
add_child(scene)
+148 -44
View File
@@ -1,14 +1,12 @@
## Sprint 2 Proof: Fog of Perception (#357)
## Verifies all 7 acceptance criteria through the full server pipeline.
## Verifies the original acceptance criteria through the full server pipeline.
##
## SUITE DISABLED (sprint-36): Sprint 2 ACs are long satisfied.
## The room coordinates and player spawn positions below are hardcoded from
## the Sprint 2 room layout, which has evolved (protocol is now v23; Gauntlet
## room layout is different). Live server testing via the Gauntlet infrastructure
## supersedes these tests. Rewrite against the current Gauntlet rooms if
## per-AC regression coverage is needed again.
## REVIVED (T-1068): disabled in sprint-36, re-enabled against the current
## protocol (handshake + StartupMessage exchange per #555/#175, streamed
## snapshots). The proof room layout below is still what main.rs
## setup_proof_room() spawns for non-test-mode servers.
##
## Server proof room layout (Sprint 2 — stale):
## Server proof room layout (setup_proof_room in server/src/main.rs):
## (16,13) = NPC1 (16,14) = WALL (16,16) = Player start
## (14,18) = NPC2 (18,14) = NPC3
## Player facing North → NPC1 blocked by wall.
@@ -57,76 +55,170 @@ func after_test() -> void:
_server_pid = -1
## Send a batch input and receive the snapshot response.
func _send_and_receive(action_name: String, tick: int = 0) -> Variant:
## Find the player entity in a snapshot by kind.
static func _find_player(snapshot: Dictionary) -> Dictionary:
for entity in snapshot.entities:
if entity.kind.variant == "Player":
return entity
return {}
## Send a batch input and receive a snapshot that reflects it.
## The server free-runs at ~20 ticks/sec and STREAMS a snapshot every tick —
## it is not request/response. Drain stale queued snapshots first, then:
## - expect_position != null (Vector2, render coords): wait until the player
## reaches that position and assert it. Deterministic under load — tick
## margins race the free-running server when the test process is descheduled
## between drain and send.
## - expect_position == null: wait for a snapshot at least 2 ticks past the
## drain point (enough for no-op actions).
func _send_and_receive(
action_name: String, tick: int = 0, expect_position: Variant = null
) -> Variant:
# Drain queued stale snapshots, remembering the newest tick seen.
var last_tick: int = -1
_bridge.poll()
var pending := _bridge.poll_message()
while pending.size() > 0:
var stale: Variant = Protocol.decode_snapshot(pending)
if stale != null:
last_tick = stale.tick
_bridge.poll()
pending = _bridge.poll_message()
var inputs: Array = [{"tick": tick, "action_name": action_name}]
var encoded := Protocol.encode_player_inputs(inputs)
assert_that(encoded.size()).is_greater(0)
var send_err := _bridge.send_message(encoded)
assert_that(send_err).is_equal(OK)
var snapshot_bytes := PackedByteArray()
# Wait for a snapshot that reflects the processed input.
var min_tick: int = last_tick + 2
var snapshot: Variant = null
var elapsed := 0.0
while elapsed < RESPONSE_TIMEOUT:
_bridge.poll()
snapshot_bytes = _bridge.poll_message()
if snapshot_bytes.size() > 0:
break
var msg := _bridge.poll_message()
if msg.size() > 0:
var decoded: Variant = Protocol.decode_snapshot(msg)
if decoded == null:
continue # undecodable frame — keep draining
if expect_position != null:
snapshot = decoded # keep latest so a timeout reports actual state
var player := _find_player(decoded)
if (
not player.is_empty()
and is_equal_approx(player.x, expect_position.x)
and is_equal_approx(player.y, expect_position.y)
):
break
elif decoded.tick >= min_tick:
snapshot = decoded
break
continue
await get_tree().create_timer(0.05).timeout
elapsed += 0.05
assert_that(snapshot_bytes.size()).is_greater(0)
var snapshot: Variant = Protocol.decode_snapshot(snapshot_bytes)
assert_that(snapshot).is_not_null()
assert_that(snapshot).override_failure_message(
"no snapshot received within %.1fs after '%s'" % [RESPONSE_TIMEOUT, action_name]
).is_not_null()
if expect_position != null and snapshot != null:
var player := _find_player(snapshot)
var actual := Vector2(player.x, player.y) if not player.is_empty() else Vector2.INF
assert_that(actual.is_equal_approx(expect_position)).override_failure_message(
"player must reach %s after '%s' — last seen %s" % [expect_position, action_name, actual]
).is_true()
return snapshot
## Connect to server, returning true on success.
## Protocol handshake (#555, #175): the server sends a HandshakeMessage as the
## first framed message; the client validates it and replies with a
## StartupMessage carrying world_seed before any input is accepted.
## Mirrors sim_bridge.gd's HANDSHAKING state.
func _do_handshake(world_seed: int = 42) -> bool:
var msg := PackedByteArray()
var elapsed := 0.0
while elapsed < CONNECT_TIMEOUT:
_bridge.poll()
msg = _bridge.poll_message()
if msg.size() > 0:
break
await get_tree().create_timer(0.05).timeout
elapsed += 0.05
if msg.is_empty():
push_warning("No HandshakeMessage received within %.1fs" % CONNECT_TIMEOUT)
return false
var MP = load("res://addons/messagepack/messagepack.gd")
var decoded: Variant = MP.decode(msg)
if decoded.status != null or not (decoded.value is Dictionary):
push_warning("Malformed HandshakeMessage")
return false
var startup_bytes := Protocol.encode_startup_message(world_seed)
if startup_bytes.is_empty():
return false
return _bridge.send_message(startup_bytes) == OK
## Spawn the server, connect, and complete the protocol handshake.
## Binary absence is handled by per-test `_do_skip` — by the time this runs the
## binary exists, so any failure here is a real failure (asserted loudly).
func _connect_to_server() -> bool:
var server_path := _server_binary_path()
if not FileAccess.file_exists(server_path):
push_warning("Sprint 2 proof skipped: server binary not found at %s" % server_path)
return false
var spawned := await _spawn_server(server_path)
assert_bool(spawned).override_failure_message(
"server spawn failed after %d port attempts" % MAX_PORT_ATTEMPTS
).is_true()
if not spawned:
return false
_bridge = LocalBridge.new()
var connected := false
var elapsed := 0.0
while elapsed < CONNECT_TIMEOUT:
if _server_pid > 0 and not OS.is_process_running(_server_pid):
push_warning("Server process died during connection")
return false
break
if _bridge.get_status() == StreamPeerTCP.STATUS_NONE:
_bridge.connect_to_server("127.0.0.1", _test_port)
_bridge.poll()
if _bridge.get_status() == StreamPeerTCP.STATUS_CONNECTED:
return true
connected = true
break
if _bridge.get_status() == StreamPeerTCP.STATUS_ERROR:
_bridge.disconnect_from_server()
_bridge.reset()
await get_tree().create_timer(0.1).timeout
elapsed += 0.1
return false
assert_bool(connected).override_failure_message(
"TCP connect to spawned server failed within %.1fs" % CONNECT_TIMEOUT
).is_true()
if not connected:
return false
# Handshake + startup exchange (#555, #175) — required before input is accepted
var handshake_ok := await _do_handshake()
assert_bool(handshake_ok).override_failure_message(
"protocol handshake + StartupMessage exchange must complete (#555, #175)"
).is_true()
return handshake_ok
# -- AC#1, AC#2, AC#3, AC#5: Movement, camera, tiles, fog -------------------------
func skip_test_proof_player_moves_and_v2_snapshot() -> void:
func test_proof_player_moves_and_v2_snapshot(
_do_skip := not FileAccess.file_exists(_server_binary_path()),
_skip_reason := "server binary not built — run `cargo build` in server/"
) -> void:
var ok := await _connect_to_server()
if not ok:
return
var snapshot: Dictionary = await _send_and_receive("MoveNorth")
var snapshot: Dictionary = await _send_and_receive("MoveNorth", 0, Vector2(16.5, 15.5))
# AC#1: Player moved from (16,16) to (16,15)
var player: Dictionary = {}
for entity in snapshot.entities:
if entity.kind.variant == "Player":
player = entity
break
var player := _find_player(snapshot)
assert_that(player.size()).is_greater(0)
assert_float(player.x).is_equal_approx(16.5, 0.001)
assert_float(player.y).is_equal_approx(15.5, 0.001)
@@ -144,14 +236,17 @@ func skip_test_proof_player_moves_and_v2_snapshot() -> void:
# -- AC#6: Wall hides entity -------------------------------------------------------
func skip_test_proof_wall_hides_entity() -> void:
func test_proof_wall_hides_entity(
_do_skip := not FileAccess.file_exists(_server_binary_path()),
_skip_reason := "server binary not built — run `cargo build` in server/"
) -> void:
var ok := await _connect_to_server()
if not ok:
return
# After MoveNorth: player at (16,15) facing North.
# Wall at (16,14) blocks LOS to NPC1 at (16,13).
var snapshot: Dictionary = await _send_and_receive("MoveNorth")
var snapshot: Dictionary = await _send_and_receive("MoveNorth", 0, Vector2(16.5, 15.5))
# NPC1 at (16,13) should be hidden — wall at (16,14) blocks LOS.
# Other NPCs (NPC2 at (14,18), NPC3 at (18,14)) may be visible.
@@ -166,24 +261,33 @@ func skip_test_proof_wall_hides_entity() -> void:
# -- AC#4, AC#7: Entity appears via LOS / corner reveal ----------------------------
func skip_test_proof_corner_reveal() -> void:
func test_proof_corner_reveal(
_do_skip := not FileAccess.file_exists(_server_binary_path()),
_skip_reason := "server binary not built — run `cargo build` in server/"
) -> void:
var ok := await _connect_to_server()
if not ok:
return
# Step 1: Move East twice to get beside the wall
# (16,16) → MoveEast → (17,16) → MoveEast → (18,16)
await _send_and_receive("MoveEast", 0)
await _send_and_receive("MoveEast", 1)
# Step 1: Move East three times — column x=18 is blocked at (18,14) by
# NPC3 (entities are unwalkable), so route around via x=19.
# (16,16) → (17,16) → (18,16) → (19,16)
await _send_and_receive("MoveEast", 0, Vector2(17.5, 16.5))
await _send_and_receive("MoveEast", 1, Vector2(18.5, 16.5))
await _send_and_receive("MoveEast", 2, Vector2(19.5, 16.5))
# Step 2: Move North past the wall line (y=14)
# (18,16) → MoveNorth → (18,15) → MoveNorth → (18,14) → MoveNorth → (18,13)
await _send_and_receive("MoveNorth", 2)
await _send_and_receive("MoveNorth", 3)
var snapshot: Dictionary = await _send_and_receive("MoveNorth", 4)
# (19,16) → (19,15) → (19,14) → (19,13)
await _send_and_receive("MoveNorth", 3, Vector2(19.5, 15.5))
await _send_and_receive("MoveNorth", 4, Vector2(19.5, 14.5))
await _send_and_receive("MoveNorth", 5, Vector2(19.5, 13.5))
# Player at (18,13) facing North. NPC1 at (16,13) is 2 tiles west —
# within peripheral cone, no wall between. NPC1 should be visible.
# Step 3: Move West onto (18,13) — now facing West, looking straight at
# NPC1 at (16,13) two tiles ahead with no wall between ((17,13) is open).
var snapshot: Dictionary = await _send_and_receive("MoveWest", 6, Vector2(18.5, 13.5))
# Player at (18,13) facing West. NPC1 at (16,13) is 2 tiles dead ahead —
# inside the vision cone, no wall between. NPC1 should be visible.
var npc1_found := false
for entity in snapshot.entities:
if entity.kind.variant == "Npc":
-1
View File
@@ -1 +0,0 @@
uid://dwdssbevd8gqq
-1
View File
@@ -1 +0,0 @@
uid://char_select_sr
-1
View File
@@ -1 +0,0 @@
uid://c8pvt3xr7kmd2
@@ -0,0 +1,28 @@
extends RefCounted
## Shared formatting helpers for Atlas screens (T-1068).
## Deduplicated from planet_screen.gd / system_screen.gd.
## Consumed via explicit preload (no class_name — avoids the global class
## cache dependency that breaks headless test runs on fresh checkouts):
## const AtlasFormat := preload("res://ui/implant/apps/atlas/atlas_format.gd")
## Thousands-separated integer, e.g. 1234567 -> "1,234,567".
static func format_pop(pop: int) -> String:
if pop <= 0:
return "0"
var s: String = str(pop)
var result: String = ""
var count: int = 0
for i: int in range(s.length() - 1, -1, -1):
if count > 0 and count % 3 == 0:
result = "," + result
result = s[i] + result
count += 1
return result
## Body radius in km — thousands-separated above 10,000 km, plain below.
static func format_radius(km: float) -> String:
if km >= 10000.0:
return "%s km" % format_pop(int(km))
return "%.0f km" % km
@@ -3,6 +3,8 @@ extends Control
## Body entry screen for AtlasApp (#844, D-191).
## Shows body detail panel. Heightmap viewer is in RegionalScreen.
const AtlasFormat := preload("res://ui/implant/apps/atlas/atlas_format.gd")
const PANEL_WIDTH: float = 320.0
const PANEL_MARGIN: float = 16.0
const COLOR_BG: Color = Color("#0d1117")
@@ -85,7 +87,7 @@ func _rebuild_body_panel() -> void:
if inhabited:
_body_panel.add_component(ImplantSeparator.new())
_body_panel.add_component(ImplantDataRow.new("population " + _format_pop(pop)))
_body_panel.add_component(ImplantDataRow.new("population " + AtlasFormat.format_pop(pop)))
_body_panel.add_component(ImplantSeparator.new())
@@ -95,22 +97,3 @@ func _rebuild_body_panel() -> void:
_body_panel.add_component(ImplantTextBlock.new("atlas data pending (#839)"))
_body_panel.add_component(ImplantTextBlock.new("esc back to orbital view"))
# =============================================================================
# Helpers
# =============================================================================
func _format_pop(pop: int) -> String:
if pop <= 0:
return "0"
var s: String = str(pop)
var result: String = ""
var count: int = 0
for i: int in range(s.length() - 1, -1, -1):
if count > 0 and count % 3 == 0:
result = "," + result
result = s[i] + result
count += 1
return result
@@ -6,6 +6,8 @@ extends Control
signal body_selected(body: Dictionary)
const AtlasFormat := preload("res://ui/implant/apps/atlas/atlas_format.gd")
const STAR_RADIUS: float = 140.0
const STAR_X_OFFSET: float = 0.0
const BODY_LEFT_MARGIN: float = 300.0
@@ -667,11 +669,15 @@ func _rebuild_body_panel() -> void:
_body_panel.add_component(ImplantDataRow.new(type_line))
_body_panel.add_component(ImplantDataRow.new("atmosphere " + atmo))
if radius_km > 0:
_body_panel.add_component(ImplantDataRow.new("radius " + _format_radius(radius_km)))
_body_panel.add_component(
ImplantDataRow.new("radius " + AtlasFormat.format_radius(radius_km))
)
if inhabited:
_body_panel.add_component(ImplantSeparator.new())
_body_panel.add_component(ImplantDataRow.new("population " + _format_pop(pop)))
_body_panel.add_component(
ImplantDataRow.new("population " + AtlasFormat.format_pop(pop))
)
_body_panel.add_component(ImplantSeparator.new())
var has_heightmap: bool = b.get("terrain_reference") != null
@@ -680,23 +686,3 @@ func _rebuild_body_panel() -> void:
_body_panel.add_component(ImplantTextBlock.new("esc close"))
_dirty = true
static func _format_pop(pop: int) -> String:
if pop <= 0:
return "0"
var s: String = str(pop)
var result: String = ""
var count: int = 0
for i: int in range(s.length() - 1, -1, -1):
if count > 0 and count % 3 == 0:
result = "," + result
result = s[i] + result
count += 1
return result
static func _format_radius(km: float) -> String:
if km >= 10000.0:
return "%s km" % _format_pop(int(km))
return "%.0f km" % km
-1
View File
@@ -1 +0,0 @@
uid://c6wtn7qk3mv2x
-1
View File
@@ -1 +0,0 @@
uid://8jhxbtbelw0y
-1
View File
@@ -1 +0,0 @@
uid://cpjq8yfsnpr5m