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
+47
View File
@@ -48,6 +48,11 @@ func _run() -> void:
var api := root.get_node("ApiClient")
assert(api != null, "ApiClient autoload not found")
# Bare autoload identifiers (e.g. `Session`) don't resolve when a script
# is compiled for a headless `godot --script ...` run -- same note as
# HttpChunkSource._api_client() / ChunkSaveService._api_client().
var session := root.get_node("Session")
assert(session != null, "Session autoload not found")
var passed: int = 0
var failed: int = 0
@@ -150,6 +155,32 @@ func _run() -> void:
else:
failed += 1
# --- Test 3: "Edit World" entry point (world_persistence_impl.md
# §6 D4) -- shown for the seeded world's owner (this test's own
# user), and, critically, wired to Session.begin_edit() rather than
# ApiClient.join_world() -- pressing it must never call /join.
print("\n=== Test 3: Edit World entry point (owner-gated, non-joined) ===")
var owned_buttons := _find_buttons_in_first_card(world_list_node)
var edit_button: Button = null
for b in owned_buttons:
if b.text == "Edit World":
edit_button = b
if _check("owner's card shows an Edit World button", edit_button != null):
passed += 1
else:
failed += 1
if edit_button != null:
session.clear()
edit_button.pressed.emit()
var editing_set: bool = session.editing and not session.active and session.world_id == int((create_result.data as Dictionary)["id"])
if _check("pressing Edit World sets Session.editing (non-joined) for the right world, not Session.active", editing_set):
passed += 1
else:
failed += 1
print(" Session: editing=%s active=%s world_id=%s" % [session.editing, session.active, session.world_id])
session.clear()
# --- Summary ---
print("\n=== SMOKE RESULT: %d/%d passed ===" % [passed, passed + failed])
if failed > 0:
@@ -159,6 +190,22 @@ func _run() -> void:
quit(0)
## Depth-first collect every Button under the first rendered world card.
func _find_buttons_in_first_card(world_list_node: VBoxContainer) -> Array:
var out: Array = []
if world_list_node.get_child_count() == 0:
return out
_collect_buttons(world_list_node.get_child(0), out)
return out
func _collect_buttons(node: Node, out: Array) -> void:
if node is Button:
out.append(node)
for c in node.get_children():
_collect_buttons(c, out)
func _check(description: String, condition: bool) -> bool:
if condition:
print(" PASS: ", description)