extends SceneTree const PROP_LIBRARY := preload("res://scripts/prop_library.gd") const STAGE_SPAWNER := preload("res://scripts/stage_spawner.gd") const PROP_BLOCK := preload("res://scripts/prop_block.gd") const STAGE_SELECTION := preload("res://scripts/stage_selection.gd") const THUMBNAIL_CACHE := preload("res://scripts/thumbnails/thumbnail_cache.gd") const PROP_FILE := preload("res://scripts/prop_file.gd") const STAGE_SCENE := preload("res://scenes/sandbox_stage.tscn") const MISSING_PROP_PLACEHOLDER := preload("res://scripts/missing_prop_placeholder.gd") const TEMP_PROP_PATH := "user://phase10_custom_integration.prop" var failures: int = 0 var checks: int = 0 func _initialize() -> void: call_deferred("_run") func _run() -> void: _write_temp_prop() var builtins: Array[Dictionary] = PROP_LIBRARY.get_entries() _check(builtins.size() == 4, "built-in templates remain four entries") var entries: Array[Dictionary] = PROP_LIBRARY.scan() _check(entries.size() == 8, "scan returns built-ins and checked-in custom props") var entry: Dictionary = PROP_LIBRARY.get_entry("res://props/briefcase.prop") _check(not entry.is_empty() and bool(entry.get("is_custom", false)), "custom path resolves without selector scan") _check((entry.get("shapes", []) as Array).size() > 1, "custom entry preserves the full shape stack") var hull: PackedVector2Array = entry.get("collision_hull", PackedVector2Array()) _check(hull.size() >= 3, "custom entry has a valid collision hull") var world: Node2D = Node2D.new() root.add_child(world) var spawner: StageSpawner = STAGE_SPAWNER.new(world) var prop: PropBlock = spawner.spawn_prop_asset("res://props/briefcase.prop", Vector2(100, 50)) _check(prop != null and prop.has_custom_shapes(), "spawner creates layered custom PropBlock") if prop != null: var visuals: Node2D = prop.get_node_or_null(NodePath("Visuals")) as Node2D _check(visuals != null and visuals.get_child_count() == (entry.get("shapes", []) as Array).size(), "each authored shape has one visual group") var collision: CollisionPolygon2D = prop.get_node_or_null(NodePath("CollisionPolygon2D")) as CollisionPolygon2D _check(collision != null and collision.polygon.size() >= 3 and not collision.disabled, "custom prop uses one enabled collision hull") var aabb: Rect2 = STAGE_SELECTION.get_world_aabb(prop) _check(aabb.size.x > 0.0 and aabb.size.y > 0.0, "selection bounds include custom visuals") var cache: ThumbnailCache = THUMBNAIL_CACHE.new() _check(cache.prop_file_png("res://props/briefcase.prop") != cache.prop_file_png("user://briefcase.prop"), "thumbnail keys include complete canonical path") world.queue_free() await process_frame await _test_authored_visual_values() await _test_missing_prop_round_trip() await _test_custom_prop_lifecycle() DirAccess.remove_absolute(TEMP_PROP_PATH) print("RESULT: %d checks, %d failures" % [checks, failures]) quit(1 if failures > 0 else 0) func _check(condition: bool, label: String) -> void: checks += 1 if condition: print("PASS: " + label) else: failures += 1 print("FAIL: " + label) func _write_temp_prop() -> void: var shapes: Array[Dictionary] = [ {"shape_type": "polygon", "points": PackedVector2Array([Vector2(-30, -20), Vector2(30, -20), Vector2(30, 20), Vector2(-30, 20)]), "fill_color": "#123456FF", "outline_color": "#ABCDEF80", "outline_width": 3.5, "closed": true, "vertex_flags": PackedInt32Array([0, 0, 0, 0]), "reshaped": false}, {"shape_type": "line", "points": PackedVector2Array([Vector2(-45, 35), Vector2(45, 35)]), "fill_color": "#00000000", "outline_color": "#FEDCBAFF", "outline_width": 5.0, "closed": false, "vertex_flags": PackedInt32Array([0, 0]), "reshaped": false}, ] _check(PROP_FILE.save(TEMP_PROP_PATH, PROP_FILE.to_file_dict("Temporary Test Prop", PROP_BLOCK.MaterialPreset.METAL, shapes)), "temporary custom prop writes") func _test_authored_visual_values() -> void: var world: Node2D = Node2D.new() root.add_child(world) var spawner: StageSpawner = STAGE_SPAWNER.new(world) var prop: PropBlock = spawner.spawn_prop_asset(TEMP_PROP_PATH, Vector2.ZERO) _check(prop != null and prop.has_custom_shapes(), "temporary custom prop spawns as layered visual") if prop != null: var shape_zero: Node2D = prop.get_node_or_null(NodePath("Visuals/Shape_0")) as Node2D var fill: Polygon2D = shape_zero.get_node_or_null(NodePath("Polygon2D")) as Polygon2D var closed_outline: Line2D = shape_zero.get_node_or_null(NodePath("Line2D")) as Line2D _check(fill != null and fill.color.is_equal_approx(Color("#123456FF")), "custom fill retains authored color") _check(closed_outline != null and closed_outline.default_color.is_equal_approx(Color("#ABCDEF80")) and is_equal_approx(closed_outline.width, 3.5), "custom outline retains authored color and width") _check(closed_outline != null and closed_outline.points.size() == 5 and closed_outline.points[0].is_equal_approx(Vector2(-30, -20)) and closed_outline.points[4].is_equal_approx(Vector2(-30, -20)), "closed shape outline repeats authored first point") var line_outline: Line2D = prop.get_node_or_null(NodePath("Visuals/Shape_1/Line2D")) as Line2D _check(line_outline != null and line_outline.points.size() == 2 and line_outline.points[0].is_equal_approx(Vector2(-45, 35)) and line_outline.points[1].is_equal_approx(Vector2(45, 35)) and line_outline.default_color.is_equal_approx(Color("#FEDCBAFF")), "open line retains authored points and outline") world.queue_free() await process_frame func _test_missing_prop_round_trip() -> void: var stage: Node2D = STAGE_SCENE.instantiate() as Node2D root.add_child(stage) await process_frame var source: Dictionary = {"version": "1.0", "scene_name": "Missing Custom", "camera": {}, "metadata": {}, "objects": [{"file_id": "prop_1", "type": "prop", "asset_id": TEMP_PROP_PATH, "position": {"x": 42.0, "y": 75.0}, "rotation": 15.0, "scale": {"x": 1.2, "y": 0.8}, "material_preset": PROP_BLOCK.MaterialPreset.WOOD}], "event_rules": [{"id": 1, "trigger": {"type": "action_finished", "source_id": "prop_1", "target_id": null, "params": {}}, "actions": [{"type": "wait", "target_id": null, "params": {"duration": 0.1}}]}]} DirAccess.remove_absolute(TEMP_PROP_PATH) _check(stage._apply_scene_data(source, "user://phase10_missing.scene", false), "missing custom-prop scene loads") var placeholder: Node2D = stage._world.get_child(0) as Node2D _check(placeholder != null and placeholder.get_script() == MISSING_PROP_PLACEHOLDER, "missing custom prop becomes placeholder") var preserved: Dictionary = stage._collect_scene_data() var object_entry: Dictionary = (preserved.get("objects", []) as Array)[0] _check(String(object_entry.get("asset_id", "")) == TEMP_PROP_PATH and (preserved.get("event_rules", []) as Array).size() == 1 and String(((preserved["event_rules"] as Array)[0]["trigger"] as Dictionary).get("source_id", "")) == "prop_1", "placeholder save preserves asset path and rule reference") _write_temp_prop() _check(stage._apply_scene_data(preserved, "user://phase10_missing.scene", false), "scene reloads after custom prop is restored") var restored: PropBlock = stage._world.get_child(0) as PropBlock _check(restored != null and restored.has_custom_shapes() and stage._event_rules.size() == 1 and int((stage._event_rules[0]["trigger"] as Dictionary).get("source", -1)) == restored.get_instance_id(), "restored custom prop replaces placeholder and resolves rule") stage.queue_free() await process_frame func _test_custom_prop_lifecycle() -> void: var stage: Node2D = STAGE_SCENE.instantiate() as Node2D root.add_child(stage) await process_frame var prop: PropBlock = stage._spawner.spawn_prop_asset(TEMP_PROP_PATH, Vector2(160, -40)) prop.rotation = deg_to_rad(20.0) prop.scale = Vector2(1.5, 1.5) stage._finalize_placed_node(prop) var authored_position: Vector2 = prop.global_position var authored_rotation: float = prop.global_rotation var authored_scale: Vector2 = prop.scale _check(prop.freeze, "custom prop is frozen in EDIT") stage.set_mode(stage.StageMode.PLAY) _check(not prop.freeze, "custom prop unfreezes in PLAY") var play_scene: Dictionary = stage._collect_scene_data() var play_entry: Dictionary = (play_scene.get("objects", []) as Array)[0] var saved_scale: Vector2 = Vector2(float((play_entry.get("scale", {}) as Dictionary).get("x", 0.0)), float((play_entry.get("scale", {}) as Dictionary).get("y", 0.0))) _check(saved_scale.is_equal_approx(authored_scale), "save during PLAY preserves authored prop scale") prop.global_position += Vector2(90, 70) prop.global_rotation += 0.4 prop.linear_velocity = Vector2(50, 20) prop.angular_velocity = 2.0 stage.set_mode(stage.StageMode.EDIT) _check(prop.freeze and prop.global_position.is_equal_approx(authored_position) and is_equal_approx(prop.global_rotation, authored_rotation) and prop.scale.is_equal_approx(authored_scale) and prop.linear_velocity.is_zero_approx() and is_zero_approx(prop.angular_velocity), "PLAY to EDIT restores authored pose, keeps scale, and clears velocity") stage.queue_free() await process_frame