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:
@@ -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