extends SceneTree const STAGE_ACTION_PLAN := preload("res://scripts/stage/stage_action_plan.gd") const STAGE_ACTION_IDENTITY := preload("res://scripts/stage/stage_action_identity.gd") const STAGE_SCENE_IO := preload("res://scripts/stage/stage_scene_io.gd") const ACTION_REGISTRY := preload("res://scripts/stage/action_registry.gd") const ACTION_RUNNER := preload("res://scripts/characters/stickman/stickman_action_runner.gd") const STICKMAN_RIG_SCENE := preload("res://scenes/characters/stickman/stickman_rig.tscn") const STICKMAN_RIG := preload("res://scripts/characters/stickman/stickman_rig.gd") const STICKMAN_LOCOMOTION := preload("res://scripts/characters/stickman/stickman_locomotion.gd") const PROP_BLOCK := preload("res://scripts/props/prop_block.gd") const TARGET_CAPTURE := preload("res://scripts/editors/stage/stage_target_capture_session.gd") const STAGE_PLACEMENT_OVERLAY := preload("res://scripts/stage/stage_placement_overlay.gd") const STAGE_DIRECTOR_VISUALS := preload("res://scripts/stage/stage_director_visuals.gd") class FakeLocomotion extends "res://scripts/characters/stickman/stickman_locomotion.gd": var walking: bool = false var current_target: Vector2 = Vector2.ZERO var retargets: Array[Vector2] = [] var styles: Array[String] = [] func project_target_to_navigation(target: Vector2) -> Vector2: return target func move_to(target: Vector2, style: String = "walk", _speed_override: float = -1.0) -> bool: current_target = target styles.append(style) walking = true return true func retarget_walk(target: Vector2) -> bool: current_target = target retargets.append(target) walking = true return true func is_walking() -> bool: return walking func cancel_walk(_restore_idle_pose: bool = true) -> void: walking = false func consume_walk_failed() -> bool: return false func consume_walk_completed() -> bool: return false var _checks: int = 0 var _failures: int = 0 func _initialize() -> void: call_deferred("_run") func _run() -> void: _test_plan_helpers() _test_v18_migration() _test_v19_validation_and_round_trip() await _test_group_runtime_barrier() await _test_group_recover_say_runtime_barrier() await _test_moving_prop_pursuit() print("PHASE 20B ACTION PLAN: %d checks, %d failures" % [_checks, _failures]) quit(1 if _failures > 0 else 0) func _check(condition: bool, message: String) -> void: _checks += 1 if condition: print("PASS: " + message) else: _failures += 1 push_error("FAIL: " + message) func _test_plan_helpers() -> void: var placement_overlay: StagePlacementOverlay = STAGE_PLACEMENT_OVERLAY.new() as StagePlacementOverlay placement_overlay.set_action_trajectory(Vector2.ZERO, Vector2(100.0, 80.0), true) _check(placement_overlay.action_show_trajectory and not placement_overlay.action_grounded_trajectory, "ordinary trajectories keep their authored direction") placement_overlay.set_grounded_action_trajectory(Vector2.ZERO, Vector2(100.0, 80.0), true) _check(placement_overlay.action_show_trajectory and placement_overlay.action_grounded_trajectory, "walk and run previews can keep a horizontal ground route") placement_overlay.set_action_marker(Vector2(40.0, 55.0)) _check(placement_overlay.action_visible and not placement_overlay.action_show_trajectory and not placement_overlay.action_grounded_trajectory and placement_overlay.action_target.is_equal_approx(Vector2(40.0, 55.0)), "selection previews keep a cursor marker without a movement trajectory") placement_overlay.free() var director_visuals: StageDirectorVisuals = STAGE_DIRECTOR_VISUALS.new() as StageDirectorVisuals director_visuals.set_plan_candidate(null, "candidate", {}, false, Vector2.INF, "run") _check(director_visuals._plan_candidate_movement_label == "Run", "candidate route label follows the selected Run style") director_visuals.free() var place_capture: Dictionary = TARGET_CAPTURE.normalize_plan_destination(Vector2(33.0, 44.0)) _check(String(place_capture.get("kind", "")) == "place" and (place_capture.get("position", Vector2.INF) as Vector2).is_equal_approx(Vector2(33.0, 44.0)), "PLAN_PLACE capture Vector2 normalizes to an explicit free-place destination") var prop_capture: Dictionary = {"kind": "prop", "target_prop_id": "stage-prop-1", "approach_offset": Vector2(12.0, -8.0)} _check(TARGET_CAPTURE.normalize_plan_destination(prop_capture) == prop_capture, "exact-prop capture dictionaries preserve the stable target ID and world offset") _check(TARGET_CAPTURE.normalize_plan_destination(null).is_empty(), "unsupported capture payloads do not author a destination") var move: Dictionary = {"action_id": "move-1", "type": "move_to", "style": "walk", "destination": {"kind": "place", "position": Vector2(120.0, 420.0)}} var say: Dictionary = {"action_id": "say-1", "type": "speak", "text": "Ready", "duration": 1.0} var wait_action: Dictionary = {"action_id": "wait-1", "type": "wait", "duration": 0.25} var together: Array[Dictionary] = [{"actions": [move, say]}] _check(STAGE_ACTION_PLAN.validate_steps(together).is_empty(), "Move plus Say is a valid together step") _check(not STAGE_ACTION_PLAN.validate_steps([{"actions": [move, wait_action]}]).is_empty(), "Move plus Wait is rejected as an authored together step") _check(STAGE_ACTION_PLAN.can_add_together({"actions": [wait_action]}, "speak"), "Wait step offers Say as a compatible together action") for action_type: String in ACTION_REGISTRY.types(): if action_type == "speak": continue var primary_action: Dictionary = {"action_id": "primary-%s" % action_type, "type": action_type} _check(STAGE_ACTION_PLAN.can_add_together({"actions": [say]}, action_type), "%s step offers Say as a compatible together action" % ACTION_REGISTRY.label(action_type)) _check(STAGE_ACTION_PLAN.can_add_together({"actions": [primary_action]}, "speak"), "Say can join %s" % ACTION_REGISTRY.label(action_type)) _check(STAGE_ACTION_PLAN.validate_steps([{"actions": [primary_action, say]}]).is_empty(), "%s plus Say is a valid together step" % ACTION_REGISTRY.label(action_type)) _check(not STAGE_ACTION_PLAN.can_add_together({"actions": [say]}, "speak"), "a step cannot contain two Say actions") _check(not STAGE_ACTION_PLAN.can_add_together({"actions": [move, say]}, "wait"), "full step rejects a third together action") var reordered: Array[Dictionary] = STAGE_ACTION_PLAN.move_step([{"actions": [move]}, {"actions": [wait_action]}], 0, 1) _check(String(reordered[1]["actions"][0]["action_id"]) == "move-1", "step reorder preserves action identity") var removed: Array[Dictionary] = STAGE_ACTION_PLAN.remove_action(together, "say-1") _check(removed.size() == 1 and (removed[0]["actions"] as Array).size() == 1, "deleting one grouped action preserves the remaining step") var identity: StageActionIdentity = STAGE_ACTION_IDENTITY.new() as StageActionIdentity var world: Node2D = Node2D.new() var rig: StickmanRig = STICKMAN_RIG.new() as StickmanRig world.add_child(rig) rig.set_action_plan(together) var first_id: String = identity.allocate(world) _check(not first_id.is_empty() and first_id not in ["move-1", "say-1"], "scene-scoped allocator avoids IDs already present on a live actor") world.free() func _test_v18_migration() -> void: var original: Dictionary = _legacy_v18_document([ {"type": "speak", "text": "Go!", "duration": 0.4, "keep_acting": true}, {"type": "move_to", "target": {"x": 130.0, "y": 420.0}, "style": "run"}, {"type": "speak", "text": "Wait", "duration": 0.4, "keep_acting": true}, {"type": "wait", "duration": 0.2}, {"type": "speak", "text": "Legacy", "duration": 0.4, "keep_acting": true}, {"type": "ragdoll"}, ]) var migrated: Dictionary = STAGE_SCENE_IO._migrate_to_current(original) var repeated: Dictionary = STAGE_SCENE_IO._migrate_to_current(original.duplicate(true)) var objects: Array = migrated.get("objects", []) var actor: Dictionary = objects[0] as Dictionary var plan: Array = actor.get("action_plan", []) _check(String(migrated.get("version", "")) == "1.9", "v1.8 migrates to v1.9") _check(not actor.has("action_queue"), "v1.8 migration removes the legacy authoritative queue") _check(plan.size() == 4 and (plan[0]["actions"] as Array).size() == 2, "keep_acting Say pairs with its following Move") _check(bool((plan[0]["actions"] as Array)[0].get("legacy_nonblocking", false)), "paired legacy Say retains nonblocking compatibility") var moved_action: Dictionary = (plan[0]["actions"] as Array)[1] as Dictionary var moved_destination: Dictionary = moved_action.get("destination", {}) _check(String(moved_destination.get("kind", "")) == "place" and moved_destination.get("position", {}) == {"x": 130.0, "y": 420.0}, "legacy flat Move migrates to an explicit place target") _check((plan[1]["actions"] as Array).size() == 2 and String(((plan[1]["actions"] as Array)[1] as Dictionary).get("type", "")) == "wait", "keep_acting Say pairs with its following Wait") _check(bool(((plan[2]["actions"] as Array)[0] as Dictionary).get("legacy_nonblocking", false)), "unpaired legacy Say retains its explicit compatibility flag") _check(JSON.stringify(plan) == JSON.stringify((repeated["objects"] as Array)[0]["action_plan"]), "legacy action IDs are deterministic across repeated migration") _check(String(migrated.get("sky_id", "")) == "sky_sunset" and String(migrated.get("background_kind", "")) == "plain", "v1.8 migration keeps its authored background values") var warning_source: Dictionary = _legacy_v18_document([{"type": "not_a_real_action"}]) var warning_migration: Dictionary = STAGE_SCENE_IO._migrate_to_current(warning_source) _check(not (warning_migration.get("_migration_warnings", []) as Array).is_empty(), "malformed legacy actions keep skip-and-warn migration behavior") func _test_v19_validation_and_round_trip() -> void: var canonical_plan: Array[Dictionary] = [ {"actions": [ {"action_id": "stable-move", "type": "move_to", "style": "run", "destination": {"kind": "prop", "target_prop_id": "prop-blue-door", "approach_offset": Vector2(-42.0, 0.0), "last_known_approach": Vector2(310.0, 420.0)}}, {"action_id": "stable-say", "type": "speak", "text": "Let's go!", "duration": 1.2}, ]}, {"actions": [{"action_id": "stable-place", "type": "move_to", "style": "walk", "destination": {"kind": "place", "position": Vector2(510.0, 420.0)}}]}, ] var encoded_result: Dictionary = STAGE_SCENE_IO.encode_plan(canonical_plan) var encoded_steps: Array = encoded_result.get("steps", []) _check((encoded_result.get("warnings", []) as Array).is_empty(), "canonical plans encode without warnings") var doc: Dictionary = _current_document([_stickman_entry("actor-a", "stage-actor-a", "Milo", encoded_steps)]) _check(bool(STAGE_SCENE_IO._validate_root(doc).get("ok", false)), "valid v1.9 grouped Move+Say and place actions validate") var current_move: Dictionary = (((doc["objects"] as Array)[0] as Dictionary)["action_plan"] as Array)[0]["actions"][0] var current_destination: Dictionary = current_move.get("destination", {}) _check(String(current_destination.get("target_prop_id", "")) == "prop-blue-door", "prop destination persists the exact stage object ID") _check(current_destination.get("approach_offset", {}) == {"x": -42.0, "y": 0.0}, "prop destination persists its world-aligned offset") var bad_duplicate: Dictionary = doc.duplicate(true) var duplicated_actor: Dictionary = _stickman_entry("actor-b", "stage-actor-b", "Ava", encoded_steps.duplicate(true)) (bad_duplicate["objects"] as Array).append(duplicated_actor) _check(not bool(STAGE_SCENE_IO._validate_root(bad_duplicate).get("ok", true)), "duplicate stable action IDs across actors are rejected") var bad_queue: Dictionary = doc.duplicate(true) ((bad_queue["objects"] as Array)[0] as Dictionary)["action_queue"] = [] _check(not bool(STAGE_SCENE_IO._validate_root(bad_queue).get("ok", true)), "v1.9 rejects a second authoritative action_queue") var bad_group: Dictionary = doc.duplicate(true) var bad_first_action: Dictionary = ((((bad_group["objects"] as Array)[0] as Dictionary)["action_plan"] as Array)[0]["actions"] as Array)[0] bad_first_action["type"] = "ragdoll" bad_first_action.erase("style") bad_first_action.erase("destination") var bad_second_action: Dictionary = ((((bad_group["objects"] as Array)[0] as Dictionary)["action_plan"] as Array)[0]["actions"] as Array)[1] bad_second_action["type"] = "wait" bad_second_action["duration"] = 0.5 bad_second_action.erase("text") _check(not bool(STAGE_SCENE_IO._validate_root(bad_group).get("ok", true)), "v1.9 rejects incompatible grouped actions") var bad_destination: Dictionary = doc.duplicate(true) var bad_move: Dictionary = ((((bad_destination["objects"] as Array)[0] as Dictionary)["action_plan"] as Array)[0]["actions"] as Array)[0] (bad_move["destination"] as Dictionary).erase("target_prop_id") _check(not bool(STAGE_SCENE_IO._validate_root(bad_destination).get("ok", true)), "v1.9 rejects a prop destination with a missing exact ID") var save_path: String = ProjectSettings.globalize_path("res://tests/.phase20b_plan_round_trip.scene") var write_result: Dictionary = STAGE_SCENE_IO.write_file_safe(save_path, doc) var read_result: Dictionary = STAGE_SCENE_IO.read_file(save_path) _check(bool(write_result.get("ok", false)) and bool(read_result.get("ok", false)), "v1.9 plan document writes and reads through the production IO path") var read_actor: Dictionary = (((read_result.get("data", {}) as Dictionary).get("objects", []) as Array)[0] as Dictionary) var decoded: Dictionary = STAGE_SCENE_IO.decode_plan(read_actor.get("action_plan", [])) var decoded_steps: Array = decoded.get("steps", []) var decoded_prop: Dictionary = (((decoded_steps[0]["actions"] as Array)[0] as Dictionary).get("destination", {}) as Dictionary) var decoded_place: Dictionary = (((decoded_steps[1]["actions"] as Array)[0] as Dictionary).get("destination", {}) as Dictionary) _check(String(((decoded_steps[0]["actions"] as Array)[0] as Dictionary).get("action_id", "")) == "stable-move", "round-trip preserves stable action IDs") _check(decoded_prop.get("approach_offset", Vector2.ZERO) == Vector2(-42.0, 0.0), "round-trip decodes the prop offset as a Vector2") _check(String(decoded_place.get("kind", "")) == "place" and decoded_place.get("position", Vector2.ZERO) == Vector2(510.0, 420.0), "nearby free-place intent remains an explicit place after round-trip") DirAccess.remove_absolute(save_path) DirAccess.remove_absolute(save_path + ".bak") func _test_group_runtime_barrier() -> void: var rig: StickmanRig = STICKMAN_RIG_SCENE.instantiate() as StickmanRig root.add_child(rig) await process_frame var runner: StickmanActionRunner = rig.get_node("RigComponents/ActionRunner") as StickmanActionRunner var started: Array[Dictionary] = [] rig.planned_action_started.connect(func(action_id: String, _action: Dictionary, step_index: int) -> void: started.append({"id": action_id, "step": step_index, "frame": Engine.get_physics_frames()}) ) var grouped: Array[Dictionary] = [ {"actions": [ {"action_id": "runtime-wait", "type": "wait", "duration": 0.05}, {"action_id": "runtime-say", "type": "speak", "text": "Together", "duration": 0.3, "legacy_fixed": true}, ]}, {"actions": [{"action_id": "runtime-after", "type": "wait", "duration": 0.05}]}, ] _check(rig.set_action_plan(grouped), "runtime fixture accepts a grouped plan") rig.start_queue() runner.tick(0.01, int(StickmanRig.RigState.ANIMATED), false) _check(started.size() == 2 and String(started[0]["id"]) == "runtime-wait" and String(started[1]["id"]) == "runtime-say", "group actions emit start together in plan order") _check(int(started[0]["frame"]) == int(started[1]["frame"]), "grouped actions start on the same runtime tick") runner.tick(0.06, int(StickmanRig.RigState.ANIMATED), false) _check(started.size() == 2, "next step waits after primary Wait ends while grouped Say remains active") runner.pause_simulation() runner.tick(2.0, int(StickmanRig.RigState.ANIMATED), false) _check(started.size() == 2, "paused runtime freezes the Say barrier") runner.resume_simulation() runner.tick(0.31, int(StickmanRig.RigState.ANIMATED), false) _check(started.size() == 2, "completed Say releases the barrier before the next runner tick") runner.tick(0.01, int(StickmanRig.RigState.ANIMATED), false) _check(started.size() == 3 and String(started[2]["id"]) == "runtime-after", "next step starts only after every grouped action ends") rig.stop_queue() rig.queue_free() await process_frame func _test_group_recover_say_runtime_barrier() -> void: var rig: StickmanRig = STICKMAN_RIG_SCENE.instantiate() as StickmanRig root.add_child(rig) await process_frame var runner: StickmanActionRunner = rig.get_node("RigComponents/ActionRunner") as StickmanActionRunner var started: Array[String] = [] rig.planned_action_started.connect(func(action_id: String, _action: Dictionary, _step_index: int) -> void: started.append(action_id) ) var grouped: Array[Dictionary] = [ {"actions": [ {"action_id": "runtime-recover", "type": "recover"}, {"action_id": "runtime-recover-say", "type": "speak", "text": "I am okay!", "duration": 0.3, "legacy_fixed": true}, ]}, {"actions": [{"action_id": "runtime-after-recover", "type": "wait", "duration": 0.05}]}, ] _check(rig.set_action_plan(grouped), "runtime fixture accepts Recover plus Say") rig.start_queue() runner.tick(0.01, int(StickmanRig.RigState.ANIMATED), false) _check(started == ["runtime-recover", "runtime-recover-say"], "Recover and Say start on the same runtime tick") runner.tick(0.01, int(StickmanRig.RigState.ANIMATED), false) _check(started.size() == 2, "next step waits after Recover ends while its grouped Say remains active") runner.tick(0.31, int(StickmanRig.RigState.ANIMATED), false) _check(started.size() == 2, "Recover plus Say completes its speech barrier before advancing") runner.tick(0.01, int(StickmanRig.RigState.ANIMATED), false) _check(started.size() == 3 and started[2] == "runtime-after-recover", "the following step starts after Recover and Say both end") rig.stop_queue() rig.queue_free() await process_frame func _test_moving_prop_pursuit() -> void: var world: Node2D = Node2D.new() root.add_child(world) var rig: StickmanRig = STICKMAN_RIG_SCENE.instantiate() as StickmanRig world.add_child(rig) var prop: PropBlock = PROP_BLOCK.new() as PropBlock prop.stage_object_id = "prop-fetch-target" prop.global_position = Vector2(120.0, 420.0) world.add_child(prop) var fake_locomotion: FakeLocomotion = FakeLocomotion.new() as FakeLocomotion var runner: StickmanActionRunner = ACTION_RUNNER.new() as StickmanActionRunner root.add_child(runner) await process_frame runner.configure(rig, fake_locomotion) var outcome: Array[StringName] = [] runner.planned_action_ended.connect(func(action_id: String, result: StringName, _step: int) -> void: if action_id == "fetch-move": outcome.append(result) ) var move_plan: Array[Dictionary] = [{"actions": [{ "action_id": "fetch-move", "type": "move_to", "style": "run", "destination": {"kind": "prop", "target_prop_id": prop.stage_object_id, "approach_offset": Vector2(-30.0, 0.0), "last_known_approach": Vector2(90.0, 420.0)}, }]}] _check(runner.set_action_plan(move_plan), "pursuit fixture accepts an exact prop Move") runner.start_queue() runner.tick(0.01, int(StickmanRig.RigState.ANIMATED), false) _check(runner.is_prop_pursuit_active() and fake_locomotion.current_target == Vector2(90.0, 420.0), "prop Move starts toward its world-aligned approach offset") prop.global_position += Vector2(24.0, 0.0) runner.pre_movement(0.11) _check(fake_locomotion.retargets.size() == 1 and fake_locomotion.retargets[0] == Vector2(114.0, 420.0), "moving prop retargets after its shift using the fixed world offset") runner.pause_simulation() prop.global_position += Vector2(30.0, 0.0) runner.pre_movement(0.5) _check(fake_locomotion.retargets.size() == 1, "paused pursuit does not retarget or spend its time budget") runner.resume_simulation() runner.pre_movement(0.11) _check(fake_locomotion.retargets.size() == 2 and fake_locomotion.retargets[1] == Vector2(144.0, 420.0), "pursuit resumes and retargets from the prop's translated position") prop.queue_free() await process_frame runner.pre_movement(0.11) _check(outcome == [&"failed"], "vanished prop ends the active Move as failure, not arrival") runner.stop_queue() world.queue_free() runner.queue_free() fake_locomotion.free() await process_frame func _legacy_v18_document(queue: Array) -> Dictionary: var wall: Dictionary = { "file_id": "wall-1", "type": "wall_panel", "position": {"x": 0.0, "y": 0.0}, "rotation": 0.0, "size": {"x": 320.0, "y": 400.0}, "finish_id": "finish_wall_paint", "color": "#123456", } var actor: Dictionary = _stickman_entry("actor-a", "stage-actor-a", "Milo", []) actor["action_queue"] = queue.duplicate(true) return { "version": "1.8", "scene_name": "Legacy Plan", "sky_id": "sky_sunset", "background_kind": "plain", "plain_color": "#F5EFE0", "camera": {"position": {"x": 0.0, "y": 0.0}, "zoom": 0.5}, "objects": [actor, wall], "event_rules": [], "metadata": {}, } func _current_document(objects: Array) -> Dictionary: return { "version": STAGE_SCENE_IO.FILE_VERSION, "scene_name": "Plan Round Trip", "sky_id": "sky_neutral", "background_kind": "sky", "plain_color": "#F5EFE0", "camera": {"position": {"x": 0.0, "y": 0.0}, "zoom": 0.5}, "objects": objects, "event_rules": [], "metadata": {}, } func _stickman_entry(file_id: String, stage_id: String, stage_name: String, action_plan: Array) -> Dictionary: return { "file_id": file_id, "type": "stickman", "stage_object_id": stage_id, "stage_name": stage_name, "stk_path": "res://content/stickmen/basic.stk", "position": {"x": 0.0, "y": 0.0}, "rotation": 0.0, "action_plan": action_plan.duplicate(true), }