Files
stickman/tests/test_phase20b_action_plan.gd
T

377 lines
21 KiB
GDScript

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),
}