extends SceneTree const PROP_USE_SPOT_DATA := preload("res://scripts/props/prop_use_spot_data.gd") const PROP_BLOCK_SCRIPT := preload("res://scripts/props/prop_block.gd") const STAGE_SCENE_IO := preload("res://scripts/stage/stage_scene_io.gd") const ACTION_REGISTRY := preload("res://scripts/stage/action_registry.gd") const STAGE_RULE_ENGINE := preload("res://scripts/stage/stage_rule_engine.gd") const PROP_INTERACTOR_SCRIPT := preload("res://scripts/characters/stickman/stickman_prop_interactor.gd") const STICKMAN_RIG_SCENE := preload("res://scenes/characters/stickman/stickman_rig.tscn") var _checks: int = 0 var _failures: int = 0 var _last_use_failure: String = "" func _initialize() -> void: call_deferred("_run") func _run() -> void: _test_migration_and_schema() _test_spot_round_trip() _test_action_and_trigger_references() _test_rule_matching() await _test_coordinates_and_empty_hands() print("----------------------------------------") if _failures == 0: print("RESULT: ALL PASSED (%d checks)" % _checks) quit(0) else: print("RESULT: %d FAILURE(S) out of %d checks" % [_failures, _checks]) quit(1) func _expect(condition: bool, message: String) -> void: _checks += 1 if condition: print("PASS: " + message) else: _failures += 1 push_error("Phase 17b check failed: " + message) func _test_migration_and_schema() -> void: for version: String in ["1.0", "1.1", "1.2"]: var legacy: Dictionary = {"version": version, "objects": [{"file_id": "prop_a", "type": "prop"}]} var migrated: Dictionary = STAGE_SCENE_IO._migrate_to_current(legacy) var entry: Dictionary = migrated["objects"][0] _expect(String(migrated["version"]) == STAGE_SCENE_IO.FILE_VERSION and entry.get("use_spots") == [] and int(entry.get("next_use_spot_number", 0)) == 1, "%s prop receives current-format empty Use spot defaults" % version) var valid_root: Dictionary = _valid_root() _expect(bool(STAGE_SCENE_IO._validate_root(valid_root).get("ok", false)), "valid Use spot data passes root validation") var duplicate: Dictionary = _valid_root() duplicate["objects"][0]["use_spots"] = [ {"id": "spot_1", "number": 1, "name": "A", "position": {"x": 2.0, "y": 3.0}}, {"id": "spot_1", "number": 1, "name": "B", "position": {"x": 4.0, "y": 5.0}}, ] duplicate["objects"][0]["next_use_spot_number"] = 2 _expect(not bool(STAGE_SCENE_IO._validate_root(duplicate).get("ok", false)), "duplicate Use spot IDs and numbers are rejected") var nonfinite: Dictionary = _valid_root() nonfinite["objects"][0]["use_spots"][0]["position"] = {"x": INF, "y": 0.0} _expect(not bool(STAGE_SCENE_IO._validate_root(nonfinite).get("ok", false)), "nonfinite Use spot coordinates are rejected") var bad_counter: Dictionary = _valid_root() bad_counter["objects"][0]["next_use_spot_number"] = 1 _expect(not bool(STAGE_SCENE_IO._validate_root(bad_counter).get("ok", false)), "Use spot counter must be greater than stored numbers") func _test_spot_round_trip() -> void: var spots: Array[Dictionary] = [{"id": "spot_2", "number": 2, "name": "Button", "position": Vector2(24.5, -18.25)}] var encoded: Array[Dictionary] = PROP_USE_SPOT_DATA.encode(spots) var decoded: Array[Dictionary] = PROP_USE_SPOT_DATA.decode(encoded) _expect(encoded[0]["position"] == {"x": 24.5, "y": -18.25}, "spot serializer writes local point coordinates") _expect(decoded[0]["position"] == Vector2(24.5, -18.25), "spot decoder restores nonzero vector coordinates") _expect(String(decoded[0]["id"]) == "spot_2" and int(decoded[0]["number"]) == 2, "spot serializer preserves stable identity") func _test_action_and_trigger_references() -> void: var action: Dictionary = {"type": "use_prop", "target_prop_id": "prop_missing", "target_spot_id": "spot_9"} var encoded_queue: Dictionary = STAGE_SCENE_IO.encode_queue([action]) var decoded_queue: Dictionary = STAGE_SCENE_IO.decode_queue(encoded_queue["actions"]) var queue_action: Dictionary = decoded_queue["actions"][0] _expect(queue_action == action, "Use queue action preserves missing stable prop and spot IDs for repair") var flat_action: Dictionary = action.duplicate(true) var consequence: Dictionary = ACTION_REGISTRY.to_rule_action(flat_action, 42) var rule: Dictionary = { "id": 3, "trigger": {"type": "prop_used", "source": 42, "target": -1, "params": {"target_prop_id": "prop_missing", "spot_scope": "specific", "target_spot_id": "spot_9"}}, "actions": [consequence], } var encoded_rules: Dictionary = STAGE_SCENE_IO.encode_rules([rule], {42: "actor_a"}) var encoded_trigger: Dictionary = encoded_rules["rules"][0]["trigger"] _expect(encoded_trigger["source_id"] == "actor_a" and encoded_trigger["target_id"] == null, "Used prop saves actor source and null target") var decoded_rules: Dictionary = STAGE_SCENE_IO.decode_rules(encoded_rules["rules"], {"actor_a": 42}, {}) var decoded_trigger: Dictionary = decoded_rules["rules"][0]["trigger"] _expect(String(decoded_trigger["params"]["target_prop_id"]) == "prop_missing" and String(decoded_trigger["params"]["target_spot_id"]) == "spot_9", "Used prop round-trip preserves repairable stable target IDs") var invalid_target: Dictionary = rule.duplicate(true) invalid_target["trigger"]["target"] = 88 _expect(STAGE_SCENE_IO.encode_rules([invalid_target], {42: "actor_a"})["rules"].is_empty(), "Used prop in-memory target must be exactly -1") var saved_bad_target: Dictionary = encoded_rules["rules"][0].duplicate(true) saved_bad_target["trigger"]["target_id"] = "other_prop" _expect(STAGE_SCENE_IO.decode_rules([saved_bad_target], {"actor_a": 42, "other_prop": 99}, {}).get("rules", []).is_empty(), "Used prop saved target_id must be null") var any_with_spot: Dictionary = rule.duplicate(true) any_with_spot["trigger"]["params"] = {"target_prop_id": "prop_missing", "spot_scope": "any", "target_spot_id": "spot_9"} _expect(STAGE_SCENE_IO.encode_rules([any_with_spot], {42: "actor_a"})["rules"].is_empty(), "Any Used prop trigger omits a specific spot ID") func _test_rule_matching() -> void: var actor: Node2D = Node2D.new() var prop: PropBlock = PROP_BLOCK_SCRIPT.new() actor.name = "UseActor" prop.stage_object_id = "prop_live" root.add_child(actor) root.add_child(prop) var engine: StageRuleEngine = STAGE_RULE_ENGINE.new() var base: Dictionary = {"type": "prop_used", "source": actor.get_instance_id(), "target": -1, "params": {"target_prop_id": "prop_live", "spot_scope": "specific", "target_spot_id": "spot_2"}} var rule: Dictionary = {"id": 1, "trigger": base, "actions": []} var event: Dictionary = {"type": "prop_used", "source": actor, "target": prop, "spot_id": "spot_2"} _expect(engine.matches_rule(rule, event), "specific Used prop rule matches exact live prop and spot") event["spot_id"] = "spot_1" _expect(not engine.matches_rule(rule, event), "specific Used prop rule rejects a different spot") var any_rule: Dictionary = rule.duplicate(true) any_rule["trigger"]["params"] = {"target_prop_id": "prop_live", "spot_scope": "any"} event["spot_id"] = "spot_1" _expect(engine.matches_rule(any_rule, event), "Any Used prop rule matches another spot on the same prop") any_rule["trigger"]["params"]["target_prop_id"] = "prop_missing" _expect(not engine.matches_rule(any_rule, event), "Used prop wildcard target does not match a different prop") actor.queue_free() prop.queue_free() func _test_coordinates_and_empty_hands() -> void: var prop: PropBlock = PROP_BLOCK_SCRIPT.new() prop.shape_type = PropBlock.ShapeType.POLYGON prop.polygon_points = PackedVector2Array([Vector2(-40.0, -30.0), Vector2(40.0, -30.0), Vector2(40.0, 30.0), Vector2(-40.0, 30.0)]) prop.position = Vector2(180.0, 90.0) prop.rotation = deg_to_rad(27.0) prop.scale = Vector2(-1.5, 1.25) root.add_child(prop) await process_frame var spot_id: String = prop.add_use_spot(Vector2(12.0, -8.0)) var expected_world: Vector2 = prop.global_transform * Vector2(12.0, -8.0) _expect(spot_id == "spot_1" and prop.use_spot_world_position(spot_id).is_equal_approx(expected_world), "spot follows prop position, rotation, and signed EDIT scale") var inverse_point: Vector2 = prop.use_spot_local_from_world(expected_world) _expect(inverse_point.is_equal_approx(Vector2(12.0, -8.0)), "world authoring coordinate converts back to prop-local space") prop.begin_play_instance_scale() var play_expected: Vector2 = prop.global_transform * (Vector2(12.0, -8.0) * Vector2(-1.5, 1.25)) _expect(prop.use_spot_world_position(spot_id).is_equal_approx(play_expected), "signed authored scale is baked into PLAY spot position") prop.end_play_instance_scale() _expect(prop.scale.is_equal_approx(Vector2(-1.5, 1.25)), "STOP restores authored prop scale") var rig: StickmanRig = STICKMAN_RIG_SCENE.instantiate() as StickmanRig root.add_child(rig) await process_frame var interactor: Node = rig.get_node("RigComponents/PropInteractor") var held: PropBlock = PROP_BLOCK_SCRIPT.new() root.add_child(held) interactor.set("_held_prop", held) prop.apply_runtime_role(PropBlock.RuntimeRole.START_PHYSICS) prop.linear_velocity = Vector2(31.0, -4.0) var start_freeze: bool = prop.freeze var start_layer: int = prop.collision_layer var start_mask: int = prop.collision_mask interactor.connect("use_failed", Callable(self, "_on_use_failed")) var started: bool = bool(interactor.call("start_action", {"type": "use_prop", "target_prop_id": prop.stage_object_id, "target_spot_id": spot_id})) _expect(not started and _last_use_failure == "hands_full", "Use rejects an actor with full hands") _expect(prop.runtime_role == PropBlock.RuntimeRole.START_PHYSICS and prop.freeze == start_freeze and prop.collision_layer == start_layer and prop.collision_mask == start_mask, "failed Use leaves target physics profile unchanged") _expect(prop.linear_velocity.is_equal_approx(Vector2(31.0, -4.0)), "failed Use does not alter target velocity") held.queue_free() rig.queue_free() prop.queue_free() await process_frame func _on_use_failed(reason_code: String, _prop: PropBlock, _spot_id: String) -> void: _last_use_failure = reason_code func _valid_root() -> Dictionary: return { "version": STAGE_SCENE_IO.FILE_VERSION, "sky_id": "sky_neutral", "background_kind": "sky", "plain_color": STAGE_SCENE_IO.DEFAULT_PLAIN_COLOR, "objects": [{ "file_id": "prop_file", "type": "prop", "stage_object_id": "prop_live", "use_spots": [{"id": "spot_1", "number": 1, "name": "Button", "position": {"x": 24.0, "y": -18.0}}], "next_use_spot_number": 2, }], }