# test_phase4b1_fixes.gd # Headless regression suite for the Phase 4b.1 fix pass (spec ยง7 D1 + Bug 2-5): # # Bug 1 (D1 - block-unit paint stride): terrain drag-painting quantizes to the # active template's ACTUAL footprint (the sanitized AABB, e.g. Ground = 192x32, # NOT the raw 200x32 template and NOT the 16-px grid): # * horizontal ground run tiles at the 192px stride (end-to-end, no # interior overlap, no gaps); # * vertical ground run tiles at the 32px stride; # * free diagonal tiles corner-to-corner (no interior overlap between # consecutive blocks); # * a second drag crossing an existing same-type block classifies skip # (Case B), including the transient in-drag `_drag_painted` set; # * a cell occupied by a conflicting prop classifies blocked and is skipped # while its empty neighbours still spawn. # # Bug 2 (persistent guide line): StagePlacementOverlay.clear_terrain_guide / # clear_action clear the visible flags AND request a redraw so the erased # line is actually removed (source-presence check: is_queued_for_redraw() # does not exist in Godot 4.4, so the redraw request is verified statically). # # Bug 4 (single-placement guide circles): a single click (anchor == target) # never shows the guide line on the placement overlay. # # Bug 3 (waypoint jitter): a waypoint on a nav-mesh target terminates through # the nav branch with exactly one `arrived`, position snapped to the final # root target and stable for 60+ physics frames; an off-mesh waypoint still # reaches arrival through direct mode. # # Bug 5 (cursor ghost regression): _spawn_ghost creates a cursor-following # ghost for a terrain placement id (block-unit snapped), drag begin frees # it, drag end re-arms it while the tool is still active, and # set_placement_mode("") frees it. # # Run with (either console): # & "C:\Godot4\Godot_v4.4-stable_win64_console.exe" --headless --script res://tests/test_phase4b1_fixes.gd --path . # & "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --script res://tests/test_phase4b1_fixes.gd --path . # # Prints PASS/FAIL per assertion and exits 0 on all PASS, 1 on any FAIL. extends SceneTree const STAGE_SCENE := preload("res://scenes/sandbox_stage.tscn") const STAGE_SELECTION := preload("res://scripts/stage_selection.gd") const TERRAIN_BLOCK := preload("res://scripts/terrain_block.gd") const OVERLAY_SCRIPT := preload("res://scripts/stage_placement_overlay.gd") const RIG := preload("res://scripts/stickman_rig.gd") # The Ground registry template is authored 200x32 but StageSpawner sanitizes # every terrain polygon onto the 16px TERRAIN_GRID_SIZE before placement, so the # ACTUAL spawned footprint (and therefore the D1 paint stride) is 192x32. const GROUND_STRIDE_X := 192.0 const GROUND_STRIDE_Y := 32.0 const GROUND_HALF_X := 96.0 const GROUND_HALF_Y := 16.0 const SETTLE_FRAMES := 60 const MAX_WALK_FRAMES := 360 var _checks := 0 var _failures := 0 var _arrived_count := 0 func _initialize() -> void: call_deferred("_run") func _run() -> void: print("") print("========================================================") print(" PHASE 4b.1 FIX REGRESSION TEST (headless)") print("========================================================") _test_bug1_horizontal_run() _test_bug1_vertical_run() _test_bug1_diagonal_run() _test_bug1_same_type_skip() _test_bug1_conflicting_prop() _test_bug2_overlay_clear() _test_bug4_single_click_guide() await _test_bug3_walk_termination() await _test_bug5_ghost_lifecycle() print("--------------------------------------------------------") if _failures == 0: print("RESULT: ALL PASSED (%d assertions, 0 failures)" % _checks) quit(0) else: print("RESULT: %d FAILURE(S) out of %d assertions" % [_failures, _checks]) quit(1) # --------------------------------------------------------------------------- # Bug 1 (D1): block-stride quantization # --------------------------------------------------------------------------- func _test_bug1_horizontal_run() -> void: print("") print("--- Bug 1: horizontal ground run tiles at the 192px stride ---") var stage := _new_stage() var world: Node2D = stage._world stage.set_placement_mode("ground") stage._begin_terrain_drag(stage._terrain_cell_center(Vector2i(0, 0))) stage._update_terrain_drag(stage._terrain_cell_center(Vector2i(2, 0))) var expected_cells: Array[Vector2i] = [Vector2i(0, 0), Vector2i(1, 0), Vector2i(2, 0)] _check(stage._drag_cells == expected_cells, "drag (0,0)->(2,0) path is 3 block cells (got %s)" % str(stage._drag_cells)) _check(stage._classify_cell(Vector2i(0, 0)) == 1 and stage._classify_cell(Vector2i(2, 0)) == 1, "empty horizontal cells classify empty (1)") var before := _terrain_count(world) stage._commit_terrain_drag() var after := _terrain_count(world) _check(after - before == 3, "horizontal run committed 3 blocks (got %d)" % (after - before)) var blocks := _terrain_blocks(world) blocks.sort_custom(func(a, b): return (a as TerrainBlock).position.x < (b as TerrainBlock).position.x) _check(blocks.size() == 3, "3 ground blocks present (got %d)" % blocks.size()) var xs: Array[float] = [] for b: TerrainBlock in blocks: xs.append(b.position.x) var expected_xs: Array[float] = [0.0, GROUND_STRIDE_X, 2.0 * GROUND_STRIDE_X] _check(xs == expected_xs, "block centers at x = 0/192/384 (got %s)" % str(xs)) # End-to-end adjacency: each consecutive pair has zero gap and NO strict # interior overlap (the edge/corner contact that used to double-stamp). var adj_ok := true var gap_ok := true for i: int in range(blocks.size() - 1): var a := _block_world_aabb(blocks[i]) var b := _block_world_aabb(blocks[i + 1]) if _strict_overlap(a, b): adj_ok = false if not is_equal_approx(b.position.x - a.end.x, 0.0): gap_ok = false _check(adj_ok, "horizontal consecutive blocks have no interior overlap") _check(gap_ok, "horizontal consecutive blocks abut edge-to-edge (zero gap)") var total_span := _block_world_aabb(blocks[2]).end.x - _block_world_aabb(blocks[0]).position.x _check(is_equal_approx(total_span, 3.0 * GROUND_STRIDE_X), "3-block run spans exactly %dpx (got %.1f)" % [3 * GROUND_STRIDE_X, total_span]) _free_stage(stage) func _test_bug1_vertical_run() -> void: print("") print("--- Bug 1: vertical ground run tiles at the 32px stride ---") var stage := _new_stage() var world: Node2D = stage._world stage.set_placement_mode("ground") stage._begin_terrain_drag(stage._terrain_cell_center(Vector2i(0, 0))) stage._update_terrain_drag(stage._terrain_cell_center(Vector2i(0, 3))) var expected_cells: Array[Vector2i] = [Vector2i(0, 0), Vector2i(0, 1), Vector2i(0, 2), Vector2i(0, 3)] _check(stage._drag_cells == expected_cells, "drag (0,0)->(0,3) path is 4 block cells (got %s)" % str(stage._drag_cells)) var before := _terrain_count(world) stage._commit_terrain_drag() var delta := _terrain_count(world) - before _check(delta == 4, "vertical run committed 4 blocks (got %d)" % delta) var blocks := _terrain_blocks(world) blocks.sort_custom(func(a, b): return (a as TerrainBlock).position.y < (b as TerrainBlock).position.y) var ys: Array[float] = [] for b: TerrainBlock in blocks: ys.append(b.position.y) var expected_ys: Array[float] = [0.0, 32.0, 64.0, 96.0] _check(ys == expected_ys, "block centers at y = 0/32/64/96 (got %s)" % str(ys)) var adj_ok := true var gap_ok := true for i: int in range(blocks.size() - 1): var a := _block_world_aabb(blocks[i]) var b := _block_world_aabb(blocks[i + 1]) if _strict_overlap(a, b): adj_ok = false if not is_equal_approx(b.position.y - a.end.y, 0.0): gap_ok = false _check(adj_ok, "vertical consecutive blocks have no interior overlap") _check(gap_ok, "vertical consecutive blocks abut edge-to-edge (zero gap)") _free_stage(stage) func _test_bug1_diagonal_run() -> void: print("") print("--- Bug 1: free diagonal tiles corner-to-corner (no interior overlap) ---") var stage := _new_stage() var world: Node2D = stage._world stage.set_placement_mode("ground") stage._begin_terrain_drag(stage._terrain_cell_center(Vector2i(0, 0))) stage._update_terrain_drag(stage._terrain_cell_center(Vector2i(2, 2))) var expected_cells: Array[Vector2i] = [Vector2i(0, 0), Vector2i(1, 1), Vector2i(2, 2)] _check(stage._drag_cells == expected_cells, "diagonal drag path is 3 staircase cells (got %s)" % str(stage._drag_cells)) var before := _terrain_count(world) stage._commit_terrain_drag() var delta := _terrain_count(world) - before _check(delta == 3, "diagonal run committed 3 blocks (got %d)" % delta) var blocks := _terrain_blocks(world) var no_overlap := true for i: int in range(blocks.size()): for j: int in range(i + 1, blocks.size()): if _strict_overlap(_block_world_aabb(blocks[i]), _block_world_aabb(blocks[j])): no_overlap = false _check(no_overlap, "diagonal blocks share at most a corner - no interior overlap anywhere") _free_stage(stage) func _test_bug1_same_type_skip() -> void: print("") print("--- Bug 1: second drag over an existing same-type block skips (Case B) ---") var stage := _new_stage() var world: Node2D = stage._world stage.set_placement_mode("ground") # First drag commits cells (0,0),(1,0),(2,0). stage._begin_terrain_drag(stage._terrain_cell_center(Vector2i(0, 0))) stage._update_terrain_drag(stage._terrain_cell_center(Vector2i(2, 0))) stage._commit_terrain_drag() _check(_terrain_count(world) == 3, "first drag committed 3 blocks") # Second drag starts ON the existing block at cell (2,0) and extends to (4,0). stage._begin_terrain_drag(stage._terrain_cell_center(Vector2i(2, 0))) stage._update_terrain_drag(stage._terrain_cell_center(Vector2i(4, 0))) _check(stage._classify_cell(Vector2i(2, 0)) == 2, "occupied same-type cell (2,0) classifies skip (2) (got %d)" % stage._classify_cell(Vector2i(2, 0))) _check(stage._classify_cell(Vector2i(3, 0)) == 1 and stage._classify_cell(Vector2i(4, 0)) == 1, "extension cells (3,0),(4,0) classify empty (1)") var before := _terrain_count(world) stage._commit_terrain_drag() var delta := _terrain_count(world) - before _check(delta == 2, "Case B second drag committed only the 2 empty cells (got %d)" % delta) # Transient in-drag self-overlap set participates in classification. stage.set_placement_mode("ground") stage._begin_terrain_drag(stage._terrain_cell_center(Vector2i(40, 0))) stage._drag_painted[Vector2i(40, 0)] = true _check(stage._classify_cell(Vector2i(40, 0)) == 2, "in-drag _drag_painted cell classifies same-type skip (2) (got %d)" % stage._classify_cell(Vector2i(40, 0))) stage._cancel_terrain_drag() _free_stage(stage) func _test_bug1_conflicting_prop() -> void: print("") print("--- Bug 1: conflicting prop cell classifies blocked and is skipped ---") var stage := _new_stage() var world: Node2D = stage._world stage.set_placement_mode("ground") # Crate sits at cell (3,0) center (600,0) (48x48 prop inside the 200x32 cell). var crate: Node2D = stage._spawner.spawn("prop", stage._terrain_cell_center(Vector2i(3, 0))) _check(crate != null, "crate prop spawns at cell (3,0)") stage._rebuild_grid_cells() _check(stage._classify_cell(Vector2i(3, 0)) == 3, "conflicting prop cell (3,0) classifies blocked (3) (got %d)" % stage._classify_cell(Vector2i(3, 0))) _check(stage._classify_cell(Vector2i(2, 0)) == 1, "empty neighbour cell (2,0) classifies empty (1)") var before := _terrain_count(world) stage._begin_terrain_drag(stage._terrain_cell_center(Vector2i(2, 0))) stage._update_terrain_drag(stage._terrain_cell_center(Vector2i(4, 0))) _check(stage._drag_cells.size() == 3, "conflict drag path has 3 cells (got %d)" % stage._drag_cells.size()) stage._commit_terrain_drag() var delta := _terrain_count(world) - before _check(delta == 2, "conflict drag committed only the 2 empty edge cells (got %d)" % delta) # The middle cell must not contain any freshly committed ground block. var blocked_cell_clean := true for b: TerrainBlock in _terrain_blocks(world): if _strict_overlap(_block_world_aabb(b), _crate_aabb(crate)): blocked_cell_clean = false _check(blocked_cell_clean, "no committed ground block overlaps the crate cell") _free_stage(stage) # --------------------------------------------------------------------------- # Bug 2 (overlay clear redraw) + Bug 4 (single-click guide suppression) # --------------------------------------------------------------------------- func _test_bug2_overlay_clear() -> void: print("") print("--- Bug 2: overlay clear requests a redraw + resets visible flags ---") # Source-presence check: clear_terrain_guide() and clear_action() must both # call queue_redraw() so the erased line is removed (Godot 4.4 exposes no # is_queued_for_redraw(), so this is the headless-observable regression). var file := FileAccess.open("res://scripts/stage_placement_overlay.gd", FileAccess.READ) _check(file != null, "stage_placement_overlay.gd is readable") if file != null: var text := file.get_as_text() file.close() _check(_func_body_contains(text, "clear_terrain_guide", "queue_redraw()"), "clear_terrain_guide() body contains queue_redraw()") _check(_func_body_contains(text, "clear_action", "queue_redraw()"), "clear_action() body contains queue_redraw()") # Behavioral flags: clearing hides the guide/trajectory state. var overlay: Node2D = OVERLAY_SCRIPT.new() root.add_child(overlay) overlay.set_process(false) overlay.set_terrain_guide(Vector2(0.0, 0.0), Vector2(400.0, 0.0)) _check(overlay.terrain_guide_visible, "set_terrain_guide marks the guide visible") overlay.clear_terrain_guide() _check(not overlay.terrain_guide_visible, "clear_terrain_guide hides the guide") overlay.set_action_trajectory(Vector2(0.0, 0.0), Vector2(300.0, 0.0), true) _check(overlay.action_visible, "set_action_trajectory marks the action visible") overlay.clear_action() _check(not overlay.action_visible, "clear_action hides the action trajectory") overlay.free() func _test_bug4_single_click_guide() -> void: print("") print("--- Bug 4: single-click drag never shows the guide line ---") var stage := _new_stage() var world: Node2D = stage._world var overlay = stage._placement_overlay _check(overlay != null, "placement overlay is built") stage.set_placement_mode("ground") _check(not overlay.terrain_guide_visible, "guide hidden before any drag") # Single click (anchor == target): _begin_terrain_drag -> _update_terrain_drag # with the same cell must NOT call set_terrain_guide. stage._begin_terrain_drag(stage._terrain_cell_center(Vector2i(20, 0))) _check(overlay.terrain_guide_visible == false, "single-click begin keeps guide hidden (anchor == target)") var before := _terrain_count(world) stage._commit_terrain_drag() _check(_terrain_count(world) - before == 1, "single click committed exactly 1 block") _check(not overlay.terrain_guide_visible, "guide still hidden after single-click commit") # Multi-cell drag DOES show the guide while dragging... stage._begin_terrain_drag(stage._terrain_cell_center(Vector2i(25, 0))) stage._update_terrain_drag(stage._terrain_cell_center(Vector2i(27, 0))) _check(overlay.terrain_guide_visible, "multi-cell drag shows the guide") # ...and retracting to the anchor again hides it (target == anchor). stage._update_terrain_drag(stage._terrain_cell_center(Vector2i(25, 0))) _check(not overlay.terrain_guide_visible, "retracting to the anchor hides the guide") # ...and commit clears it. stage._update_terrain_drag(stage._terrain_cell_center(Vector2i(27, 0))) stage._commit_terrain_drag() _check(not overlay.terrain_guide_visible, "commit clears the guide") _free_stage(stage) # --------------------------------------------------------------------------- # Bug 3: nav-mode termination (waypoint jitter fix) # --------------------------------------------------------------------------- func _test_bug3_walk_termination() -> void: print("") print("--- Bug 3: nav-mode termination + off-mesh direct still works ---") # On-mesh waypoint near the slab's right edge (the former oscillation zone): # the rig must terminate exactly once through the nav branch and stay put. await _walk_case("on-mesh (near slab edge)", Vector2(88.0, -8.0), "nav") # Off-mesh waypoint in open space: direct mode must still reach arrival. await _walk_case("off-mesh (open space)", Vector2(250.0, 0.0), "direct") func _on_arrived(_target: Vector2) -> void: _arrived_count += 1 func _walk_case(label: String, target_feet: Vector2, expected_mode: String) -> void: var stage := _new_stage() var ground = stage._spawner.spawn("ground", Vector2(0, 0)) _check(ground != null, "%s: ground spawns" % label) stage._rebake_navigation() var rig = stage._spawner.spawn("stickman", Vector2(0, 0)) _check(rig != null, "%s: stickman spawns" % label) if rig == null: _free_stage(stage) return _arrived_count = 0 rig.arrived.connect(_on_arrived) rig.walk_to(target_feet, 200.0) _check(not rig._walk_done, "%s: walk not done immediately" % label) var frames := 0 var mode_flipped := false while not rig._walk_done and frames < MAX_WALK_FRAMES: if rig._walk_mode_latched and rig._walk_mode != expected_mode: mode_flipped = true await physics_frame frames += 1 _check(rig._walk_done, "%s: walk finished within %d frames (used %d)" % [label, MAX_WALK_FRAMES, frames]) _check(_arrived_count == 1, "%s: exactly one arrived emission (got %d)" % [label, _arrived_count]) _check(rig._walk_mode_latched, "%s: steering mode latched" % label) _check(rig._walk_mode == expected_mode, "%s: latched to '%s' (got '%s')" % [label, expected_mode, rig._walk_mode]) _check(not mode_flipped, "%s: latched mode never flipped mid-walk" % label) # Position snapped to the exact final root target (feet + FOOT_OFFSET). var final_root: Vector2 = target_feet + RIG.FOOT_OFFSET _check(rig.global_position.distance_to(final_root) <= 0.01, "%s: position snapped to final root (dist=%.3f)" % [label, rig.global_position.distance_to(final_root)]) _check(not rig.is_walking(), "%s: is_walking false after arrival" % label) # Post-arrival stability: no position change for SETTLE_FRAMES physics frames. var p0: Vector2 = rig.global_position var stable := true for i: int in SETTLE_FRAMES: await physics_frame if rig.global_position != p0: stable = false break _check(stable and rig.global_position == p0, "%s: position unchanged for %d frames after arrival" % [label, SETTLE_FRAMES]) _free_stage(stage) # --------------------------------------------------------------------------- # Bug 5: terrain cursor ghost lifecycle # --------------------------------------------------------------------------- func _test_bug5_ghost_lifecycle() -> void: print("") print("--- Bug 5: terrain cursor ghost spawn / free / re-arm ---") var stage := _new_stage() var world: Node2D = stage._world var holder: Node2D = stage._ghost_holder _check(stage._ghost == null, "no ghost before placement armed") stage.set_placement_mode("ground") _check(stage._ghost != null, "_spawn_ghost creates a ghost for terrain placement") var ghost: Node2D = stage._ghost _check(ghost is TERRAIN_BLOCK, "terrain ghost is a TerrainBlock") _check(ghost.get_parent() == holder, "ghost is parented to the ghost holder, not World") _check(world.get_children().has(ghost) == false, "ghost is not a selectable World child") _check(is_equal_approx(ghost.modulate.a, 0.5), "ghost is translucent (alpha 0.5)") # Block-unit snapped ghost positioning (strides, not the 16-px grid/cursor). var raw_pos: Vector2 = stage._camera.get_global_mouse_position() \ + stage._spawner.get_spawn_offset("ground") var expected_cell: Vector2i = stage._world_to_terrain_cell(raw_pos) var expected_pos: Vector2 = stage._terrain_cell_center(expected_cell) stage._update_ghost_position() _check(ghost.position.distance_to(expected_pos) <= 0.01, "ghost positioned at the block-unit cell center (got %s, expected %s)" % [ghost.position, expected_pos]) _check(is_equal_approx(fmod(absf(ghost.position.x), GROUND_STRIDE_X), 0.0), "ghost x is a multiple of the %dpx stride (got %.1f)" % [GROUND_STRIDE_X, ghost.position.x]) _check(is_equal_approx(fmod(absf(ghost.position.y), GROUND_STRIDE_Y), 0.0), "ghost y is a multiple of the 32px stride (got %.1f)" % ghost.position.y) # Drag begin frees the single cursor ghost and builds per-cell ghosts. stage._begin_terrain_drag(stage._terrain_cell_center(Vector2i(30, 0))) _check(stage._ghost == null, "drag begin frees the single ghost") stage._update_terrain_drag(stage._terrain_cell_center(Vector2i(32, 0))) _check(stage._ghost_array.size() == 3, "drag path shows 3 per-cell ghosts (got %d)" % stage._ghost_array.size()) # Commit re-arms the single ghost (LMB repeated placement preserved). var before := _terrain_count(world) stage._commit_terrain_drag() _check(_terrain_count(world) - before == 3, "ghost lifecycle drag committed 3 blocks") _check(stage._ghost != null, "drag end re-arms the cursor ghost while tool is active") _check(stage._ghost_array.is_empty(), "per-cell ghosts freed after commit") _check(stage._ghost.get_parent() == holder, "re-armed ghost lives in the ghost holder") # set_placement_mode("") frees the ghost (queue_free) and empties the holder # at the end of the current frame. var held_ghost: Node2D = stage._ghost stage.set_placement_mode("") _check(stage._ghost == null, "set_placement_mode('') frees the ghost") _check(held_ghost != null and held_ghost.is_queued_for_deletion(), "freed ghost is queued for deletion") await process_frame _check(holder.get_child_count() == 0, "ghost holder is empty after clearing placement") _free_stage(stage) # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- func _new_stage() -> Node2D: var stage: Node2D = STAGE_SCENE.instantiate() root.add_child(stage) stage._snap_enabled = false return stage func _free_stage(stage: Node2D) -> void: stage.queue_free() await process_frame func _terrain_count(world: Node2D) -> int: var n := 0 for child: Node in world.get_children(): if child is TERRAIN_BLOCK: n += 1 return n func _terrain_blocks(world: Node2D) -> Array: var out: Array = [] for child: Node in world.get_children(): if child is TERRAIN_BLOCK: out.append(child) return out func _block_world_aabb(block: Node2D) -> Rect2: return STAGE_SELECTION.get_world_aabb(block) func _crate_aabb(crate: Node2D) -> Rect2: return STAGE_SELECTION.get_world_aabb(crate) ## Strict interior overlap - a shared edge or a shared corner is NOT an overlap ## (mirrors the fixed `_aabb_overlaps` in sandbox_stage.gd). func _strict_overlap(a: Rect2, b: Rect2) -> bool: return a.position.x < b.end.x and b.position.x < a.end.x \ and a.position.y < b.end.y and b.position.y < a.end.y func _func_body_contains(text: String, func_name: String, needle: String) -> bool: var marker := "func " + func_name var start := text.find(marker) if start == -1: return false var end := text.find("\nfunc ", start + marker.length()) if end == -1: end = text.length() return text.substr(start, end - start).contains(needle) func _check(condition: bool, message: String) -> void: _checks += 1 if condition: print("PASS: " + message) else: _failures += 1 print("FAIL: " + message)