Files
modbison/scripts/smoke_test_editor_core.gd
daddy 7c37a0dc2f Add in-game editor MVP: EditorCore + 2D tile/object editing
Implements the in-game editor's shared, data-native core and a
Control-based UI on top of it, per docs/architecture/editor.md §7.1/§7.2,
scoped to 2D per §8's own risk mitigation (the default world is flat,
CHUNK_SIZE_Z=1 -- no 3D gizmos here).

EditorCore (scripts/editor/):
- ChunkEditorModel wraps a ChunkDataDecoder.ChunkPayload directly (no
  parallel data model) and adds a TileLayerData.set_tile() mutator
  alongside the existing get_tile().
- EditorUndoRedoStack wraps Godot's built-in UndoRedo (game-usable, not
  the editor-only EditorUndoRedoManager) so every mutation is a real,
  revertible do/undo Callable pair over ChunkEditorModel's raw setters.
- SelectionManager tracks the selected object id as UI state, deliberately
  outside the undo/redo stack.
- ToolRegistry + 5 tools (BrushTool, ObjectPlaceTool, SelectTool,
  EraseTool, TransformTool -- the 5th covers move/rotate/scale, which the
  doc's tool list doesn't literally name but the task requires) is the
  single choke point every mutation goes through.
- EditorObjectCatalog (assets/editor/object_catalog.json) is a new,
  small "placeable object" catalog -- AvatarCatalog and SpawnCatalog were
  checked first and are both scoped to other concerns, so this mirrors
  their loading convention rather than reusing either.

InGameEditor UI (scripts/editor/ui/): EditorViewport (2D top-down tile
grid + object dots), EditorToolbar, PalettePanel, InspectorPanel, wired
together in InGameEditor. Built from Control nodes in code (matching
game.gd's existing dynamic-UI convention) rather than a hand-authored
.tscn.

Entry point: game.gd gates build mode behind a new toggle_build_mode
input action (F2), freezing the player and opening a fresh empty
ChunkEditorModel. Loading a *real* chunk from the backend for editing,
and saving edits back, are explicitly out of scope -- a sibling task
owns the batch-PUT/conflict-handling save path
(world_persistence_impl.md).

Verification: scripts/smoke_test_editor_core.gd decodes a real backend
fixture (backend/tests/fixtures/chunks/simple_tile_chunk.bin), drives
place/move/rotate/scale/delete/paint/erase entirely through
EditorCore.use_tool()/ToolRegistry (never touching ChunkEditorModel's
raw setters directly), undoes and redoes every step, and round-trips the
result through encode_binary()/decode_binary() to prove the edited model
stays a valid, byte-compatible ChunkPayload. 69/69 assertions pass. Full
existing Godot smoke suite (16 other scripts/smoke_test_*.gd, backend +
dedicated-server-dependent ones included) still passes with no
regressions.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y2FESCaMXeJpC9gSctiEeD
2026-09-14 23:18:45 +00:00

261 lines
12 KiB
GDScript

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())