extends SceneTree ## Real exercise of the in-game editor's data-native core ## (docs/architecture/editor.md §7.1/§7.2, scoped to 2D per §8). ## ## Loads a real chunk (the backend's golden fixture ## backend/tests/fixtures/chunks/simple_tile_chunk.bin -- the same file ## scripts/smoke_test_chunk_codec_interop.gd proves decodes correctly), ## wraps it in a ChunkEditorModel, and drives every operation ## (place/move/rotate/scale/delete an object, paint/erase a tile) through ## the actual EditorCore.use_tool()/ToolRegistry path -- never by calling ## ChunkEditorModel's raw setters directly, which would bypass the tool ## layer this task was scoped to build. Then it undoes every single one of ## those operations and checks the chunk is back to exactly its starting ## state, redoes them all back, and finally round-trips the result through ## encode_binary()/decode_binary() to prove the model stays a valid, ## byte-compatible ChunkPayload throughout -- the contract the sibling ## save-path task depends on. ## ## Run with: ## ~/godot/godot --headless --path . --script res://scripts/smoke_test_editor_core.gd const FIXTURE_PATH := "res://backend/tests/fixtures/chunks/simple_tile_chunk.bin" var passed := 0 var failed := 0 func _check(label: String, cond: bool) -> void: if cond: passed += 1 print(" PASS: ", label) else: failed += 1 printerr(" FAIL: ", label) func _initialize() -> void: _run() func _run() -> void: await _test_full_editing_loop() _test_catalog_rejects_unknown_id() _test_selection_is_not_undoable() print("\n=== SMOKE RESULT: %d/%d passed ===" % [passed, passed + failed]) quit(0 if failed == 0 else 1) func _read_fixture() -> PackedByteArray: var f := FileAccess.open(FIXTURE_PATH, FileAccess.READ) if f == null: push_error("Could not open fixture: " + FIXTURE_PATH) return PackedByteArray() return f.get_buffer(f.get_length()) func _test_full_editing_loop() -> void: print("--- EditorCore/ToolRegistry over a real decoded chunk (simple_tile_chunk.bin) ---") var original_bytes := _read_fixture() _check("fixture file read (non-empty)", original_bytes.size() > 0) if original_bytes.is_empty(): return var model := ChunkEditorModel.from_binary(original_bytes) _check("ChunkEditorModel.from_binary decoded the fixture", model != null) if model == null: return # Baseline sanity on the loaded real chunk before touching anything. _check("loaded chunk has 1 tile layer", model.payload.tile_layers.size() == 1) _check("loaded chunk is 4x4", model.get_layer(0).width == 4 and model.get_layer(0).height == 4) var baseline_tiles := (model.get_layer(0).tiles as PackedInt32Array).duplicate() var baseline_object_count := model.payload.objects.size() var catalog := EditorObjectCatalog.new() var cat_err := catalog.load_catalog() _check("object catalog loaded", cat_err == OK) var core := EditorCore.new(model, catalog) _check("EditorCore registers all 5 tools", core.tools.get_tool_ids().size() == 5) for expected_tool in ["brush", "object_place", "select", "erase", "transform"]: _check("ToolRegistry has tool '%s'" % expected_tool, core.tools.has_tool(expected_tool)) # ── 1. Paint a tile via the brush tool (through EditorCore/ToolRegistry) ── _check("tile (0,0) starts as 0 in the fixture", model.get_tile_raw(0, 0, 0) == 0) var painted := core.paint_tile(0, 0, 0, 1) _check("core.paint_tile (brush tool) applied", painted) _check("tile (0,0) is now 1 after paint", model.get_tile_raw(0, 0, 0) == 1) # ── 2. Place a catalog object (through EditorCore/ToolRegistry) ── var obj_id := core.place_object("crate_small", Vector3(1.5, 1.5, 0.0)) _check("core.place_object (object_place tool) returned a real id", obj_id != "") _check("placed object exists in the model", model.find_object(obj_id) != null) _check("placed object got the catalog's asset_id", model.find_object(obj_id).asset_id == "props/crate_small.glb") _check("placing auto-selected the new object", core.selection.get_selected() == obj_id) # ── 3. Select it explicitly via the select tool ── _check("core.select_object (select tool)", core.select_object(obj_id)) _check("selection reflects it", core.selection.get_selected() == obj_id) # ── 4. Move / rotate / scale via the transform tool ── var moved := core.move_object(obj_id, Vector3(5.0, 6.0, 0.0)) _check("core.move_object (transform tool) applied", moved) _check("object position updated", model.find_object(obj_id).position.is_equal_approx(Vector3(5.0, 6.0, 0.0))) var new_rot := Vector4(0.0, 0.7071068, 0.0, 0.7071068) # 90 deg yaw var rotated := core.rotate_object(obj_id, new_rot) _check("core.rotate_object (transform tool) applied", rotated) _check("object rotation updated", model.find_object(obj_id).rotation.is_equal_approx(new_rot)) var scaled := core.scale_object(obj_id, Vector3(2.0, 2.0, 2.0)) _check("core.scale_object (transform tool) applied", scaled) _check("object scale updated", model.find_object(obj_id).scale.is_equal_approx(Vector3(2.0, 2.0, 2.0))) # ── 5. Erase a different tile (the fixture already has a nonzero tile at # (1,0) -- world_id 12's palette makes it a real, non-empty tile id, so # erase has something genuine to clear without needing a manual seed). ── var original_tile_1_0 := model.get_tile_raw(0, 1, 0) _check("tile (1,0) is nonzero in the fixture (something real to erase)", original_tile_1_0 != 0) var erased_tile := core.erase_tile(0, 1, 0) _check("core.erase_tile (erase tool) applied", erased_tile) _check("tile (1,0) is 0 after erase", model.get_tile_raw(0, 1, 0) == 0) # ── 6. Delete the object via the erase tool ── var deleted := core.delete_object(obj_id) _check("core.delete_object (erase tool) applied", deleted) _check("object is gone from the model", model.find_object(obj_id) == null) _check("deleting the selected object cleared the selection", not core.selection.has_selection()) # ── 7. Undo every operation (6 ops) and verify each actually reverts ── print("--- undo/redo actually revert each step ---") _check("undo #1 (re-adds deleted object)", core.undo()) _check("object exists again after undo", model.find_object(obj_id) != null) _check("undo #2 (un-erases tile)", core.undo()) _check("tile (1,0) is back to its original value after undo", model.get_tile_raw(0, 1, 0) == original_tile_1_0) _check("undo #3 (un-scales)", core.undo()) _check("scale reverted", model.find_object(obj_id).scale.is_equal_approx(Vector3.ONE)) _check("undo #4 (un-rotates)", core.undo()) _check("rotation reverted", model.find_object(obj_id).rotation.is_equal_approx(Vector4(0, 0, 0, 1))) _check("undo #5 (un-moves)", core.undo()) _check("position reverted", model.find_object(obj_id).position.is_equal_approx(Vector3(1.5, 1.5, 0.0))) _check("undo #6 (un-places, object removed)", core.undo()) _check("placed object is gone after undo", model.find_object(obj_id) == null) _check("undo #7 (un-paints tile)", core.undo()) _check("tile (0,0) is back to 0 after undo", model.get_tile_raw(0, 0, 0) == 0) _check("no more undo history", not core.undo_redo.can_undo()) _check("tiles exactly match the original fixture after full undo", model.get_layer(0).tiles == baseline_tiles) _check("object count matches the original fixture after full undo", model.payload.objects.size() == baseline_object_count) # ── 8. Redo everything back ── print("--- redo replays every step forward again ---") var redo_count := 0 while core.redo(): redo_count += 1 _check("redo replayed all 7 recorded actions", redo_count == 7) _check("tile (0,0) painted again after full redo", model.get_tile_raw(0, 0, 0) == 1) _check("object gone again after full redo (last op was delete)", model.find_object(obj_id) == null) # ── 9. Round-trip the final state through encode/decode ── print("--- encode_binary()/decode_binary() round-trip after editing ---") var re_encoded := model.encode_binary() _check("re-encoded chunk is non-empty", re_encoded.size() > 0) var re_decoded := ChunkDataDecoder.decode_binary(re_encoded) _check("re-decoded without error", re_decoded != null) if re_decoded != null: _check("re-decoded tile layer matches edited model", re_decoded.tile_layers[0].tiles == model.get_layer(0).tiles) _check("re-decoded world_id preserved", re_decoded.world_id == model.payload.world_id) _check("re-decoded coord preserved", re_decoded.coord == model.payload.coord) _check("re-decoded bounds preserved", re_decoded.bounds.position.is_equal_approx(model.payload.bounds.position) and re_decoded.bounds.size.is_equal_approx(model.payload.bounds.size)) _check("re-decoded object count matches", re_decoded.objects.size() == model.payload.objects.size()) # ── 10. Also prove a fresh in-memory chunk (no fixture) works end to end, # including that the InGameEditor UI wires up against a real EditorCore # without crashing headlessly. ── await _test_ui_smoke() func _test_ui_smoke() -> void: print("--- InGameEditor UI wires up against a real EditorCore (headless, no crash) ---") var model := ChunkEditorModel.create_empty(8, 8, "1", "main", Vector3i.ZERO) var catalog := EditorObjectCatalog.new() catalog.load_catalog() var core := EditorCore.new(model, catalog) var editor := InGameEditor.new(core) root.add_child(editor) await process_frame # let _ready() run so the UI is actually built _check("InGameEditor instantiated and added to the tree without error", is_instance_valid(editor)) # Drive a real placement through the viewport's own click handler (not a # bare model mutation) to prove the UI layer -> EditorCore wiring works. # EditorViewport is nested a few levels down (ScrollContainer, etc.). var viewport: EditorViewport = _find_viewport(editor) _check("EditorViewport exists in the built UI", viewport != null) if viewport != null: viewport.pending_catalog_id = "rock_01" core.tools.set_active_tool("object_place") viewport._handle_click(Vector2(20, 20)) # inside tile (0,0) at cell_size 24 _check("clicking with object_place active through the viewport placed an object", model.payload.objects.size() == 1) editor.queue_free() func _find_viewport(node: Node) -> EditorViewport: if node is EditorViewport: return node for child in node.get_children(): var found := _find_viewport(child) if found != null: return found return null func _test_catalog_rejects_unknown_id() -> void: print("--- object_place tool rejects an unknown catalog id ---") var model := ChunkEditorModel.create_empty() var catalog := EditorObjectCatalog.new() catalog.load_catalog() var core := EditorCore.new(model, catalog) var result := core.place_object("does_not_exist", Vector3.ZERO) _check("unknown catalog_id is rejected (empty id returned)", result == "") _check("nothing was added to the model", model.payload.objects.is_empty()) _check("nothing was pushed onto the undo stack", not core.undo_redo.can_undo()) func _test_selection_is_not_undoable() -> void: print("--- selection is UI state, not chunk data (not on the undo stack) ---") var model := ChunkEditorModel.create_empty() var catalog := EditorObjectCatalog.new() catalog.load_catalog() var core := EditorCore.new(model, catalog) var obj_id := core.place_object("chest_01", Vector3(2, 2, 0)) _check("setup: object placed", obj_id != "") core.select_object(obj_id) _check("object selected", core.selection.get_selected() == obj_id) # Only one undoable action should exist (the placement) -- selecting it # must not have added a second one. core.undo() _check("a single undo removes the placed object (selection wasn't a 2nd undo step)", model.find_object(obj_id) == null) _check("no further undo history from selection", not core.undo_redo.can_undo())