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:
2026-09-14 23:35:33 +00:00
parent 31f6974c7a
commit 4ff74ae823
7 changed files with 742 additions and 9 deletions
+72 -4
View File
@@ -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