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
+37 -1
View File
@@ -14,6 +14,13 @@ extends Control
@onready var back_button: Button = $VBoxContainer/HeaderHBox/BackButton
@onready var empty_label: Label = $VBoxContainer/EmptyLabel
## Cached so each world card can decide whether to show "Edit World"
## (docs/architecture/world_persistence_impl.md §5.3: chunk writes require
## `world.owner_id == caller`, same as `update_world`) without an extra
## round trip per card. -1 until resolved / on failure, in which case no
## card shows the edit entry point.
var _current_user_id: int = -1
func _ready() -> void:
refresh_button.disabled = false
@@ -22,6 +29,11 @@ func _ready() -> void:
error_label.visible = false
empty_label.visible = false
world_list.visible = false
var me_result: Dictionary = await ApiClient.get_me()
if me_result.ok and me_result.data is Dictionary:
_current_user_id = int((me_result.data as Dictionary).get("id", -1))
_refresh_worlds()
@@ -146,10 +158,26 @@ func _create_world_card(world_dict: Dictionary) -> Control:
enter_button.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
enter_button.pressed.connect(_on_enter_world_pressed.bind(world_id, enter_button))
var buttons_row := HBoxContainer.new()
buttons_row.add_child(enter_button)
# "Edit World" (world_persistence_impl.md §6 D4): a non-joined entry
# point into the in-game editor -- it does NOT call POST /join. Only
# shown for the world's owner, matching the write-side authorization the
# save path itself enforces (§5.3), so a non-owner never sees a button
# that would just 403 on the first save.
var owner_id := int(world_dict.get("owner_id", -1))
if _current_user_id != -1 and owner_id == _current_user_id:
var edit_button := Button.new()
edit_button.text = "Edit World"
edit_button.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
edit_button.pressed.connect(_on_edit_world_pressed.bind(world_id))
buttons_row.add_child(edit_button)
card.add_child(name_label)
card.add_child(desc_label)
card.add_child(meta_label)
card.add_child(enter_button)
card.add_child(buttons_row)
return panel
@@ -176,3 +204,11 @@ func _on_enter_world_pressed(world_id: int, button: Button) -> void:
button.text = "Enter World"
error_label.text = "Couldn't join: %s" % result.detail
error_label.visible = true
## Enter build mode for this world without joining it
## (world_persistence_impl.md §6 D4). No /join call, no ENet connection, no
## dedicated server -- game.gd loads/saves the world's chunks over HTTP.
func _on_edit_world_pressed(world_id: int) -> void:
Session.begin_edit(world_id)
get_tree().change_scene_to_file("res://scenes/game.tscn")