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:
@@ -16,10 +16,12 @@ extends Control
|
||||
## InspectorPanel -- property editor for the selected object
|
||||
## EditorCore -- shared core (passed in, not owned/created here)
|
||||
##
|
||||
## This UI (and `EditorCore`) never talk to the network -- persisting edits
|
||||
## is a sibling task's job (world_persistence_impl.md's batch-PUT save
|
||||
## path). Closing this editor just discards the UI; the in-memory
|
||||
## `ChunkEditorModel` it was pointed at is unaffected either way.
|
||||
## Persisting edits (world_persistence_impl.md's single/batch-PUT save path,
|
||||
## §8.1-8.2) is wired through `ChunkSaveService`, set via
|
||||
## `setup_persistence()`. Without it (e.g. the F2 scratch-chunk path, or
|
||||
## `smoke_test_editor_core.gd`'s bare `InGameEditor.new(core)`), the Save
|
||||
## button is present but disabled -- closing the editor just discards the
|
||||
## UI and the in-memory `ChunkEditorModel` is unaffected either way.
|
||||
|
||||
signal closed
|
||||
|
||||
@@ -30,6 +32,18 @@ var _palette: PalettePanel
|
||||
var _inspector: InspectorPanel
|
||||
var _viewport: EditorViewport
|
||||
var _close_button: Button
|
||||
var _save_button: Button
|
||||
var _save_status_label: Label
|
||||
|
||||
## Save-path state (world_persistence_impl.md §8.1/§8.2), set via
|
||||
## `setup_persistence()`. `_save_service == null` means there is nothing to
|
||||
## save to (see the class doc above) -- the Save button stays disabled.
|
||||
var _save_service: ChunkSaveService = null
|
||||
var _world_id: String = ""
|
||||
var _room_slug: String = "main"
|
||||
var _coord: Vector3i = Vector3i.ZERO
|
||||
var _base_revision: int = 0
|
||||
var _saving: bool = false
|
||||
|
||||
|
||||
func _init(p_core: EditorCore) -> void:
|
||||
@@ -39,6 +53,21 @@ func _init(p_core: EditorCore) -> void:
|
||||
mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
|
||||
|
||||
## Wire this editor's Save button/Ctrl+S to a real backend round-trip.
|
||||
## `save_service` must already be in the scene tree (game.gd adds it as a
|
||||
## sibling before constructing this editor). `base_revision` is whatever the
|
||||
## caller last fetched for `coord` (0 if the chunk didn't exist yet).
|
||||
func setup_persistence(save_service: ChunkSaveService, world_id: String, room_slug: String,
|
||||
coord: Vector3i, base_revision: int) -> void:
|
||||
_save_service = save_service
|
||||
_world_id = world_id
|
||||
_room_slug = room_slug
|
||||
_coord = coord
|
||||
_base_revision = base_revision
|
||||
if _save_button != null:
|
||||
_save_button.disabled = false
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_build_ui()
|
||||
_refresh_history_buttons()
|
||||
@@ -66,11 +95,22 @@ func _build_ui() -> void:
|
||||
_toolbar.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
top_bar.add_child(_toolbar)
|
||||
|
||||
_save_button = Button.new()
|
||||
_save_button.text = "Save (Ctrl+S)"
|
||||
_save_button.disabled = (_save_service == null)
|
||||
_save_button.pressed.connect(_on_save_pressed)
|
||||
top_bar.add_child(_save_button)
|
||||
|
||||
_close_button = Button.new()
|
||||
_close_button.text = "Close (F2)"
|
||||
_close_button.pressed.connect(_on_close_pressed)
|
||||
top_bar.add_child(_close_button)
|
||||
|
||||
_save_status_label = Label.new()
|
||||
_save_status_label.autowrap_mode = TextServer.AUTOWRAP_WORD
|
||||
_save_status_label.visible = false
|
||||
root_vbox.add_child(_save_status_label)
|
||||
|
||||
var body := HBoxContainer.new()
|
||||
body.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
root_vbox.add_child(body)
|
||||
@@ -119,6 +159,9 @@ func _unhandled_key_input(event: InputEvent) -> void:
|
||||
elif key.ctrl_pressed and key.keycode == KEY_Y:
|
||||
_on_redo_pressed()
|
||||
get_viewport().set_input_as_handled()
|
||||
elif key.ctrl_pressed and key.keycode == KEY_S:
|
||||
_on_save_pressed()
|
||||
get_viewport().set_input_as_handled()
|
||||
elif key.keycode == KEY_DELETE and core.selection.has_selection():
|
||||
core.delete_object(core.selection.get_selected())
|
||||
_on_edit_applied()
|
||||
@@ -167,3 +210,41 @@ func _refresh_history_buttons() -> void:
|
||||
func _on_close_pressed() -> void:
|
||||
closed.emit()
|
||||
queue_free()
|
||||
|
||||
|
||||
## Single-chunk save (world_persistence_impl.md §8.1). `ChunkSaveService`
|
||||
## owns the actual HTTP round trip, retrying on `409` internally
|
||||
## (§8.2/§5.4) -- this just reflects the outcome and, per §6 D4, surfaces
|
||||
## the `live_session` flag as a warning instead of ignoring it.
|
||||
func _on_save_pressed() -> void:
|
||||
if _save_service == null or _saving:
|
||||
return
|
||||
_saving = true
|
||||
_save_button.disabled = true
|
||||
_set_save_status("Saving…", Color(0.85, 0.85, 0.85))
|
||||
|
||||
var result: Dictionary = await _save_service.save_chunk(core.model, _coord, _base_revision)
|
||||
|
||||
_saving = false
|
||||
_save_button.disabled = false
|
||||
|
||||
if result.get("ok", false):
|
||||
_base_revision = int(result.get("revision", _base_revision))
|
||||
var msg := "Saved (revision %d)." % _base_revision
|
||||
if bool(result.get("retried", false)):
|
||||
msg += " (resolved a save conflict automatically)"
|
||||
if bool(result.get("live_session", false)):
|
||||
msg += " This world has players in it right now -- they'll see your changes when the session restarts."
|
||||
_set_save_status(msg, Color(1.0, 0.8, 0.25))
|
||||
else:
|
||||
_set_save_status(msg, Color(0.55, 1.0, 0.55))
|
||||
else:
|
||||
_set_save_status("Save failed: %s" % str(result.get("detail", "unknown error")), Color(1.0, 0.45, 0.45))
|
||||
|
||||
|
||||
func _set_save_status(text: String, color: Color) -> void:
|
||||
if _save_status_label == null:
|
||||
return
|
||||
_save_status_label.text = text
|
||||
_save_status_label.add_theme_color_override("font_color", color)
|
||||
_save_status_label.visible = true
|
||||
|
||||
Reference in New Issue
Block a user