Editor save path: single/batch chunk PUT, 409 retry, non-joined Edit World entry
Implements world_persistence_impl.md §9 client/editor task items 2-3: - ChunkSaveService (scripts/editor/chunk_save_service.gd): single-chunk PUT with base_revision, batch PUT for multi-chunk edits, and a real 409 -> re-fetch -> retry loop. Full-chunk overwrite (§8.2 D5) means "re-apply the edit" is just retrying with the locally-edited payload at the freshly-learned revision, not delta replay. - InGameEditor gets a Save button (+ Ctrl+S) that surfaces live_session as an explicit warning instead of ignoring it, per §6 D4. - "Edit World" entry point on the world list (owner-gated) that calls Session.begin_edit() instead of ApiClient.join_world() -- editing never joins, opens no ENet connection, spawns no dedicated server. - game.gd wires Session.editing to fetch the real (0,0,0) chunk over HTTP and open the editor against it, falling back to an empty chunk for an unwritten coordinate. Verified against a real backend (register/world/save/reload, a genuine 409 from two racing saves, atomic 2-chunk batch, live_session field) -- new scripts/smoke_test_editor_save.gd (19/19) plus an extended smoke_test_world_list.gd covering the non-joined entry point (11/11). Full existing Godot smoke suite (18 files) and backend pytest (132) still pass unchanged. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y2FESCaMXeJpC9gSctiEeD
This commit is contained in:
+72
-4
@@ -39,14 +39,20 @@ var _build_editor: InGameEditor = null
|
||||
|
||||
func _ready() -> void:
|
||||
_show_build_stamp()
|
||||
if Session.active:
|
||||
if Session.editing:
|
||||
_open_editor_for_world()
|
||||
elif Session.active:
|
||||
_setup_networked()
|
||||
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
if event.is_action_pressed("toggle_build_mode"):
|
||||
get_viewport().set_input_as_handled()
|
||||
_toggle_build_mode()
|
||||
# world_persistence_impl.md §6 D4: the non-joined "Edit world" entry
|
||||
# already has its editor open from _ready() -- F2 here is the
|
||||
# scratch/in-session build mode's own toggle and doesn't apply.
|
||||
if not Session.editing:
|
||||
get_viewport().set_input_as_handled()
|
||||
_toggle_build_mode()
|
||||
return
|
||||
|
||||
# ESC leaves the world back to the browser (or, while build mode is
|
||||
@@ -56,7 +62,11 @@ func _unhandled_input(event: InputEvent) -> void:
|
||||
# against the scene change and looked like a crash.
|
||||
if event.is_action_pressed("ui_cancel"):
|
||||
get_viewport().set_input_as_handled()
|
||||
if _build_editor != null and is_instance_valid(_build_editor):
|
||||
if Session.editing:
|
||||
# Non-joined editing has no play session to fall back into --
|
||||
# closing it means leaving, same as the Close button.
|
||||
_exit_editor_to_browser()
|
||||
elif _build_editor != null and is_instance_valid(_build_editor):
|
||||
_close_build_mode()
|
||||
else:
|
||||
_leave_world()
|
||||
@@ -104,6 +114,64 @@ func _close_build_mode() -> void:
|
||||
_player._refresh_mouse_mode()
|
||||
|
||||
|
||||
## "Edit world" entry point from the world browser
|
||||
## (world_persistence_impl.md §6 D4 / §9 "Client/editor task"). Reached only
|
||||
## via Session.editing (set by world_list.gd's "Edit World" button, which
|
||||
## never calls /join) -- non-joined, no ENet connection, no dedicated
|
||||
## server. Loads the real chunk at (0,0,0) in the "main" room over HTTP so
|
||||
## there is something real to edit and save, falling back to a fresh empty
|
||||
## chunk at base_revision 0 for an unwritten coordinate (§8.1: "an unwritten
|
||||
## coordinate is not an error").
|
||||
func _open_editor_for_world() -> void:
|
||||
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
|
||||
_player.set_physics_process(false)
|
||||
_player.set_process_unhandled_input(false)
|
||||
|
||||
var world_id_str := str(Session.world_id)
|
||||
var room_slug := "main"
|
||||
var coord := Vector3i.ZERO
|
||||
|
||||
var save_service := ChunkSaveService.new()
|
||||
save_service.world_id = world_id_str
|
||||
save_service.room_slug = room_slug
|
||||
add_child(save_service)
|
||||
|
||||
var fetch_result: Dictionary = await save_service.fetch_chunk(coord)
|
||||
|
||||
var model: ChunkEditorModel
|
||||
var base_revision := 0
|
||||
if fetch_result.get("ok", false) and fetch_result.get("model") != null:
|
||||
model = fetch_result["model"]
|
||||
base_revision = int(fetch_result.get("revision", 0))
|
||||
else:
|
||||
if not fetch_result.get("ok", false):
|
||||
push_error("game.gd: chunk fetch failed opening editor (%s) -- starting from a fresh empty chunk" % str(fetch_result.get("detail", "")))
|
||||
model = ChunkEditorModel.create_empty(32, 32, world_id_str, room_slug, coord)
|
||||
base_revision = 0
|
||||
|
||||
var catalog := EditorObjectCatalog.new()
|
||||
var err := catalog.load_catalog()
|
||||
if err != OK:
|
||||
push_error("game.gd: failed to load editor object catalog (%d)" % err)
|
||||
var core := EditorCore.new(model, catalog)
|
||||
|
||||
_build_editor = InGameEditor.new(core)
|
||||
_build_editor.setup_persistence(save_service, world_id_str, room_slug, coord, base_revision)
|
||||
_build_editor.closed.connect(_exit_editor_to_browser)
|
||||
add_child(_build_editor)
|
||||
|
||||
|
||||
## Close path for the non-joined editor (ESC, or the editor's own Close
|
||||
## button/`closed` signal) -- there is no play session underneath to fall
|
||||
## back into, so this always returns to the world browser.
|
||||
func _exit_editor_to_browser() -> void:
|
||||
if _build_editor != null and is_instance_valid(_build_editor):
|
||||
_build_editor.queue_free()
|
||||
_build_editor = null
|
||||
Session.clear()
|
||||
get_tree().change_scene_to_file("res://scenes/world_list.tscn")
|
||||
|
||||
|
||||
func _leave_world() -> void:
|
||||
if _leaving:
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user