extends SceneTree const STAGE_OBJECT_IDENTITY := preload("res://scripts/stage/stage_object_identity.gd") const STAGE_SCENE_IO := preload("res://scripts/stage/stage_scene_io.gd") const STAGE_SCENE_ADAPTER := preload("res://scripts/editors/stage/stage_scene_adapter.gd") const STAGE_SPAWNER := preload("res://scripts/stage/stage_spawner.gd") const STICKMAN_RIG := preload("res://scripts/characters/stickman/stickman_rig.gd") const PROP_BLOCK := preload("res://scripts/props/prop_block.gd") const MISSING_STICKMAN := preload("res://scripts/characters/stickman/missing_stickman_placeholder.gd") const MISSING_PROP := preload("res://scripts/props/missing_prop_placeholder.gd") var _checks: int = 0 var _failures: int = 0 func _initialize() -> void: call_deferred("_run") func _run() -> void: _test_scene_identity_and_rename() _test_current_format_validation() _test_legacy_migrations() await _test_adapter_identity_round_trip() print("PHASE 20A IDENTITY: %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_scene_identity_and_rename() -> void: print("\n--- Scene identity and naming ---") var world := Node2D.new() var identity: StageObjectIdentity = STAGE_OBJECT_IDENTITY.new() as StageObjectIdentity var milo: StickmanRig = STICKMAN_RIG.new() as StickmanRig var milo_two: StickmanRig = STICKMAN_RIG.new() as StickmanRig var prop: PropBlock = PROP_BLOCK.new() as PropBlock world.add_child(milo) world.add_child(milo_two) world.add_child(prop) identity.assign_new(milo, "Milo", world) identity.assign_new(milo_two, "Milo", world) identity.assign_new(prop, "milo", world) _check(not identity.get_id(milo).is_empty(), "first placed character gets a persistent ID") _check(identity.get_id(milo_two) != identity.get_id(milo), "duplicate character gets a fresh persistent ID") _check(identity.get_id(prop) != identity.get_id(milo) and identity.get_id(prop) != identity.get_id(milo_two), "prop ID is unique across both object types") _check(identity.get_name(milo) == "Milo", "first stage name keeps its familiar base") _check(identity.get_name(milo_two) == "Milo 2", "duplicate stage name receives the smallest free suffix") _check(identity.get_name(prop) == "milo 3", "character and prop names share case-insensitive uniqueness") var capitalization: Dictionary = identity.validate_name(" MILO ", world, milo) _check(bool(capitalization.get("ok", false)) and String(capitalization.get("name", "")) == "MILO", "capitalization-only rename trims outer spaces and is valid") var original_id: String = identity.get_id(milo) var duplicate: Dictionary = identity.rename(milo, "mIlO 2", world) _check(not bool(duplicate.get("ok", true)) and duplicate.get("reason", &"") == &"duplicate", "duplicate name is rejected case-insensitively") var suggestion: String = String(duplicate.get("suggestion", "")) var suggestion_check: Dictionary = identity.validate_name(suggestion, world, milo) _check(not suggestion.is_empty() and bool(suggestion_check.get("ok", false)), "duplicate name supplies an available suggestion") var blank: Dictionary = identity.rename(milo, " ", world) _check(not bool(blank.get("ok", true)) and blank.get("reason", &"") == &"blank", "blank name is rejected") _check(identity.get_name(milo) == "Milo", "invalid rename leaves the current name unchanged") var renamed: Dictionary = identity.rename(milo, " MAX ", world) _check(bool(renamed.get("ok", false)) and identity.get_name(milo) == "MAX", "valid rename stores the trimmed scene name") _check(identity.get_id(milo) == original_id, "renaming preserves the persistent object ID") world.remove_child(milo_two) milo_two.free() identity.rename(milo, "Milo", world) var next_milo: StickmanRig = STICKMAN_RIG.new() as StickmanRig world.add_child(next_milo) identity.assign_new(next_milo, "Milo", world) _check(identity.get_name(next_milo) == "Milo 2", "deleting an instance releases its stage name") world.free() func _test_current_format_validation() -> void: print("\n--- Current scene identity validation ---") var valid: Dictionary = _current_document([ _object("stickman_1", "stickman", "char-1", "Milo"), _object("prop_1", "prop", "prop-1", "Blue Door"), ]) _check(bool(STAGE_SCENE_IO._validate_root(valid).get("ok", false)), "unique current-format character and prop identities validate") var duplicate_ids: Dictionary = valid.duplicate(true) (duplicate_ids["objects"][1] as Dictionary)["stage_object_id"] = "char-1" _check(not bool(STAGE_SCENE_IO._validate_root(duplicate_ids).get("ok", true)), "current-format duplicate IDs across types are rejected") var empty_id: Dictionary = valid.duplicate(true) (empty_id["objects"][0] as Dictionary)["stage_object_id"] = " " _check(not bool(STAGE_SCENE_IO._validate_root(empty_id).get("ok", true)), "current-format empty IDs are rejected") var duplicate_names: Dictionary = valid.duplicate(true) (_other_named_object(duplicate_names))["stage_name"] = "mIlO" _check(not bool(STAGE_SCENE_IO._validate_root(duplicate_names).get("ok", true)), "current-format duplicate names ignoring capitalization are rejected") var whitespace_name: Dictionary = valid.duplicate(true) (_empty_names_object(whitespace_name))["stage_name"] = " Milo " _check(not bool(STAGE_SCENE_IO._validate_root(whitespace_name).get("ok", true)), "current-format names with outer whitespace are rejected") var missing_name: Dictionary = valid.duplicate(true) (_empty_names_object(missing_name)).erase("stage_name") _check(not bool(STAGE_SCENE_IO._validate_root(missing_name).get("ok", true)), "current-format objects missing a name are rejected") func _test_legacy_migrations() -> void: print("\n--- Legacy scene migrations ---") for version: String in ["1.0", "1.1", "1.2", "1.3", "1.4", "1.5", "1.6", "1.7"]: var source: Dictionary = _legacy_document(version) var migrated: Dictionary = STAGE_SCENE_IO._migrate_to_current(source) var migrated_again: Dictionary = STAGE_SCENE_IO._migrate_to_current(source.duplicate(true)) _check(String(migrated.get("version", "")) == STAGE_SCENE_IO.FILE_VERSION, "%s migrates to current version" % version) var objects: Array = migrated.get("objects", []) var repeat_objects: Array = migrated_again.get("objects", []) _check(objects.size() == 3 and repeat_objects.size() == 3, "%s preserves all named object entries" % version) var stage_ids: Dictionary = {} var stage_names: Dictionary = {} for object_index: int in objects.size(): var entry: Dictionary = objects[object_index] as Dictionary var repeat_entry: Dictionary = repeat_objects[object_index] as Dictionary var stage_id: String = String(entry.get("stage_object_id", "")) var stage_name: String = String(entry.get("stage_name", "")) _check(not stage_id.is_empty(), "%s object %d receives a persistent ID" % [version, object_index + 1]) _check(not stage_name.strip_edges().is_empty(), "%s object %d receives a nonempty name" % [version, object_index + 1]) _check(not stage_ids.has(stage_id), "%s object %d receives a unique ID" % [version, object_index + 1]) _check(not stage_names.has(stage_name.to_lower()), "%s object %d receives a case-insensitively unique name" % [version, object_index + 1]) stage_ids[stage_id] = true stage_names[stage_name.to_lower()] = true _check(stage_id == String(repeat_entry.get("stage_object_id", "")) and stage_name == String(repeat_entry.get("stage_name", "")), "%s migration is deterministic for object %d" % [version, object_index + 1]) var prop_entry: Dictionary = objects[2] as Dictionary var expected_prop_id: String = "prop_1" if version == "1.0" else "legacy-prop-id" _check(String(prop_entry.get("stage_object_id", "")) == expected_prop_id, "%s preserves the existing prop ID" % version) var validation: Dictionary = STAGE_SCENE_IO._validate_root(migrated) _check(bool(validation.get("ok", false)), "%s migrated document passes current-format validation" % version) var collision_source: Dictionary = _legacy_document("1.7") var collision_objects: Array = collision_source["objects"] (collision_objects[2] as Dictionary)["stage_object_id"] = "stickman:stickman_1" var collision_migrated: Dictionary = STAGE_SCENE_IO._migrate_to_current(collision_source) var collision_validation: Dictionary = STAGE_SCENE_IO._validate_root(collision_migrated) _check(String(((collision_migrated["objects"] as Array)[2] as Dictionary).get("stage_object_id", "")) == "stickman:stickman_1", "a saved prop ID is retained when a legacy character ID would collide") _check(bool(collision_validation.get("ok", false)), "legacy ID collision is resolved without producing invalid current-format data") func _test_adapter_identity_round_trip() -> void: print("\n--- Production adapter identity round-trip ---") var source_world := Node2D.new() root.add_child(source_world) var source_spawner: StageSpawner = STAGE_SPAWNER.new(source_world) as StageSpawner var adapter: StageSceneAdapter = STAGE_SCENE_ADAPTER.new() as StageSceneAdapter var real_character: StickmanRig = source_spawner.spawn_stickman_asset("res://content/stickmen/basic.stk", Vector2(-180.0, 0.0)) var real_prop: PropBlock = source_spawner.spawn_prop_asset("crate", Vector2(180.0, 0.0)) _check(real_character != null and real_prop != null, "production spawner creates real character and prop fixtures") if real_character == null or real_prop == null: source_world.queue_free() await process_frame return real_character.stage_object_id = "stage-char-real" real_character.stage_name = "Milo" real_prop.stage_object_id = "stage-prop-real" real_prop.stage_name = "Blue Crate" var missing_character_entry: Dictionary = { "file_id": "legacy-missing-character", "type": "stickman", "stage_object_id": "stage-char-missing", "stage_name": "Ava", "stk_path": "res://content/stickmen/phase20a_missing_character.stk", "position": {"x": -360.0, "y": 0.0}, "rotation": 0.0, "action_plan": [], } var missing_character: Node2D = MISSING_STICKMAN.new() as Node2D missing_character.configure(missing_character_entry) source_world.add_child(missing_character) var missing_prop_entry: Dictionary = { "file_id": "legacy-missing-prop", "type": "prop", "stage_object_id": "stage-prop-missing", "stage_name": "Secret Door", "asset_id": "res://content/props/phase20a_missing_prop.prop", "position": {"x": 360.0, "y": 0.0}, "rotation": 0.0, "scale": {"x": 1.0, "y": 1.0}, "use_spots": [], "next_use_spot_number": 1, } var missing_prop: Node2D = MISSING_PROP.new() as Node2D missing_prop.configure(missing_prop_entry) source_world.add_child(missing_prop) await process_frame var document: Dictionary = adapter.collect( source_world, {}, [], {"position": Vector2(32.0, -64.0), "zoom": 0.75}, {"scene_name": "Identity Round Trip"} ) var serialized: Dictionary = {} for entry_value: Variant in document.get("objects", []): if entry_value is Dictionary: var entry: Dictionary = entry_value serialized[String(entry.get("stage_object_id", ""))] = entry for identity_pair in [ ["stage-char-real", "Milo"], ["stage-prop-real", "Blue Crate"], ["stage-char-missing", "Ava"], ["stage-prop-missing", "Secret Door"], ]: var entry: Dictionary = serialized.get(identity_pair[0], {}) as Dictionary _check(String(entry.get("stage_name", "")) == identity_pair[1], "collect preserves name '%s' for %s" % [identity_pair[1], identity_pair[0]]) _check(not entry.is_empty(), "collect preserves identity %s" % identity_pair[0]) # write_file_safe uses absolute-path rename operations after writing its temporary file. var save_path: String = ProjectSettings.globalize_path("res://tests/.phase20a_identity_round_trip.scene") var write_result: Dictionary = STAGE_SCENE_IO.write_file_safe(save_path, document) _check(bool(write_result.get("ok", false)), "scene writer saves the collected identity document: %s" % String(write_result.get("error", ""))) var read_result: Dictionary = STAGE_SCENE_IO.read_file(save_path) _check(bool(read_result.get("ok", false)), "scene reader loads the saved identity document") var disk_document: Dictionary = read_result.get("data", {}) as Dictionary var apply_document: Dictionary = disk_document if not disk_document.is_empty() else document var disk_objects_by_id: Dictionary = {} for disk_entry_value: Variant in apply_document.get("objects", []): if disk_entry_value is Dictionary: var disk_entry: Dictionary = disk_entry_value as Dictionary disk_objects_by_id[String(disk_entry.get("stage_object_id", ""))] = disk_entry for identity_pair in [ ["stage-char-real", "Milo"], ["stage-prop-real", "Blue Crate"], ["stage-char-missing", "Ava"], ["stage-prop-missing", "Secret Door"], ]: var disk_entry: Dictionary = disk_objects_by_id.get(identity_pair[0], {}) as Dictionary _check(String(disk_entry.get("stage_name", "")) == identity_pair[1], "disk round-trip retains name '%s'" % identity_pair[1]) _check(not disk_entry.is_empty(), "disk round-trip retains ID %s" % identity_pair[0]) var target_world := Node2D.new() root.add_child(target_world) var target_spawner: StageSpawner = STAGE_SPAWNER.new(target_world) as StageSpawner var applied: Dictionary = adapter.apply(apply_document, target_world, target_spawner) var restored_by_id: Dictionary = {} for node_value: Variant in applied.get("loaded_nodes", []): var node: Node2D = node_value as Node2D if node != null: restored_by_id[String(node.get("stage_object_id"))] = node for identity_pair in [ ["stage-char-real", "Milo"], ["stage-prop-real", "Blue Crate"], ["stage-char-missing", "Ava"], ["stage-prop-missing", "Secret Door"], ]: var restored: Node2D = restored_by_id.get(identity_pair[0]) as Node2D _check(restored != null and String(restored.get("stage_name")) == identity_pair[1], "apply restores ID and name for %s" % identity_pair[0]) _check(restored_by_id["stage-char-missing"].get_script() == MISSING_STICKMAN, "missing character asset restores as its editable placeholder") _check(restored_by_id["stage-prop-missing"].get_script() == MISSING_PROP, "missing custom prop restores as its editable placeholder") var adapter_warnings: Array = applied.get("warnings", []) as Array _check(adapter_warnings.is_empty(), "adapter reports no malformed-object warnings during identity round-trip") target_world.queue_free() source_world.queue_free() await process_frame DirAccess.remove_absolute(ProjectSettings.globalize_path(save_path)) func _empty_names_object(document: Dictionary) -> Dictionary: return (document["objects"] as Array)[0] as Dictionary func _other_named_object(document: Dictionary) -> Dictionary: return (document["objects"] as Array)[1] as Dictionary func _object(file_id: String, object_type: String, stage_id: String, stage_name: String) -> Dictionary: return { "file_id": file_id, "type": object_type, "stage_object_id": stage_id, "stage_name": stage_name, "position": {"x": 0.0, "y": 0.0}, "rotation": 0.0, } func _current_document(objects: Array) -> Dictionary: for raw_object: Variant in objects: if raw_object is Dictionary and String((raw_object as Dictionary).get("type", "")) == "stickman": (raw_object as Dictionary)["action_plan"] = [] (raw_object as Dictionary).erase("action_queue") return { "version": STAGE_SCENE_IO.FILE_VERSION, "scene_name": "Identity Test", "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 _legacy_document(version: String) -> Dictionary: var prop: Dictionary = _object("prop_1", "prop", "legacy-prop-id", "Ignored") prop["asset_id"] = "res://content/props/briefcase.prop" prop["scale"] = {"x": 1.0, "y": 1.0} prop["material_preset"] = 0 if version == "1.0": prop.erase("stage_object_id") prop.erase("stage_name") var first_character: Dictionary = _object("stickman_1", "stickman", "", "") first_character["stk_path"] = "res://content/stickmen/test.stk" first_character["action_queue"] = [] first_character.erase("stage_object_id") first_character.erase("stage_name") var second_character: Dictionary = _object("stickman_2", "stickman", "", "") second_character["stk_path"] = "res://content/stickmen/test.stk" second_character["action_queue"] = [] second_character.erase("stage_object_id") second_character.erase("stage_name") var document: Dictionary = _current_document([first_character, second_character, prop]) document["version"] = version return document