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:
@@ -0,0 +1,280 @@
|
||||
class_name ChunkSaveService
|
||||
extends Node
|
||||
## HTTP save path for the in-game editor
|
||||
## (docs/architecture/world_persistence_impl.md §8.1 chunk write / §8.2 D5).
|
||||
##
|
||||
## Owns the write half of editor persistence: single-chunk PUT with
|
||||
## `X-Base-Revision`/`base_revision`, batch PUT for a multi-chunk edit, and
|
||||
## the §5.4 optimistic-concurrency retry loop on `409`.
|
||||
##
|
||||
## Conflict handling ("re-fetch current chunk, re-apply the edit, retry"):
|
||||
## per §8.2 D5 this backend stores and accepts **whole chunks**, not
|
||||
## JSON-Patch deltas -- "lost-update protection comes from `base_revision`,
|
||||
## not from the patch". That means there is no delta to replay against a
|
||||
## freshly-fetched chunk; "re-apply the edit" is exactly "keep the locally
|
||||
## edited payload, learn the chunk's current revision, and retry at that
|
||||
## revision". `fetch_chunk()` is still called on a conflict (rather than
|
||||
## just trusting the 409 body's `current_revision`) so the editor's own
|
||||
## last-known state stays in sync with the store, matching what a real
|
||||
## client does before retrying a write it doesn't fully trust yet.
|
||||
##
|
||||
## Deliberately does **not** use `ApiClient.request()`: that helper discards
|
||||
## the parsed JSON body on any non-2xx response (see its `_make_result`
|
||||
## calls), and the `409` body's `current_revision`/`current_checksum` are
|
||||
## exactly what this retry loop needs. Uses a raw per-request `HTTPRequest`
|
||||
## child instead, the same pattern `scripts/http_chunk_source.gd` uses for
|
||||
## the read side.
|
||||
##
|
||||
## Must be added to the scene tree before use.
|
||||
|
||||
## The world this service saves chunks for (string form of the backend's
|
||||
## numeric world id).
|
||||
var world_id: String = ""
|
||||
|
||||
## The room slug within that world. world.md's default room is "main".
|
||||
var room_slug: String = "main"
|
||||
|
||||
## Bounded retries on a 409 conflict -- refuses to loop forever against a
|
||||
## chunk under sustained contention.
|
||||
const MAX_RETRIES := 3
|
||||
|
||||
|
||||
## Fetch the current chunk at [param coord].
|
||||
##
|
||||
## Returns:
|
||||
## {"ok": bool, "status": int, "model": ChunkEditorModel|null,
|
||||
## "revision": int, "checksum": String, "detail": String}
|
||||
##
|
||||
## A `404` (unwritten coordinate) is reported as `ok: true, model: null` --
|
||||
## world_persistence_impl.md §8.1: "an unwritten coordinate is not an error".
|
||||
## Callers wanting a fresh chunk to edit should fall back to
|
||||
## `ChunkEditorModel.create_empty()` at `base_revision: 0` in that case.
|
||||
func fetch_chunk(coord: Vector3i) -> Dictionary:
|
||||
var http := HTTPRequest.new()
|
||||
http.timeout = 15.0
|
||||
add_child(http)
|
||||
|
||||
var api := _api_client()
|
||||
var headers := PackedStringArray(["Accept: application/octet-stream"])
|
||||
if api.has_token():
|
||||
headers.append("Authorization: " + str(api.bearer_token))
|
||||
|
||||
var url: String = str(api.base_url) + _chunk_path(coord)
|
||||
var err := http.request(url, headers, HTTPClient.METHOD_GET)
|
||||
if err != OK:
|
||||
http.queue_free()
|
||||
return _fetch_result(false, 0, null, 0, "", "request failed to start (%d)" % err)
|
||||
|
||||
var args: Array = await http.request_completed
|
||||
var result_code: int = args[0]
|
||||
var response_code: int = args[1]
|
||||
var response_headers: PackedStringArray = args[2]
|
||||
var body: PackedByteArray = args[3]
|
||||
http.queue_free()
|
||||
|
||||
if result_code != HTTPRequest.RESULT_SUCCESS:
|
||||
return _fetch_result(false, 0, null, 0, "", "transport error (%d)" % result_code)
|
||||
|
||||
if response_code == 404:
|
||||
return _fetch_result(true, 404, null, 0, "", "not found (unwritten chunk)")
|
||||
|
||||
if response_code != 200:
|
||||
return _fetch_result(false, response_code, null, 0, "", _error_detail_from_body(body))
|
||||
|
||||
var model := ChunkEditorModel.from_binary(body)
|
||||
if model == null:
|
||||
return _fetch_result(false, response_code, null, 0, "", "failed to decode chunk payload")
|
||||
|
||||
return _fetch_result(
|
||||
true, 200, model,
|
||||
_int_header(response_headers, "x-chunk-revision"),
|
||||
_etag_from_headers(response_headers),
|
||||
"OK",
|
||||
)
|
||||
|
||||
|
||||
## Save one chunk. `model.encode_binary()` is sent as the new payload;
|
||||
## `base_revision` is the revision the editor last knew about (0 = create).
|
||||
##
|
||||
## Returns:
|
||||
## {"ok": bool, "status": int, "revision": int, "checksum": String,
|
||||
## "live_session": bool, "retried": bool, "detail": String}
|
||||
func save_chunk(model: ChunkEditorModel, coord: Vector3i, base_revision: int) -> Dictionary:
|
||||
var attempt_revision := base_revision
|
||||
var retried := false
|
||||
for attempt in range(MAX_RETRIES + 1):
|
||||
var body := {
|
||||
"base_revision": attempt_revision,
|
||||
"payload_b64": Marshalls.raw_to_base64(model.encode_binary()),
|
||||
}
|
||||
var resp := await _request_json(HTTPClient.METHOD_PUT, _chunk_path(coord), body)
|
||||
|
||||
if resp["status"] == 200:
|
||||
var d: Dictionary = resp["data"]
|
||||
return {
|
||||
"ok": true, "status": 200,
|
||||
"revision": int(d.get("revision", attempt_revision)),
|
||||
"checksum": str(d.get("checksum", "")),
|
||||
"live_session": bool(d.get("live_session", false)),
|
||||
"retried": retried, "detail": "OK",
|
||||
}
|
||||
|
||||
if resp["status"] == 409 and attempt < MAX_RETRIES:
|
||||
var conflict: Dictionary = resp["data"] if resp["data"] is Dictionary else {}
|
||||
var current_rev := int(conflict.get("current_revision", attempt_revision))
|
||||
# Doc-mandated "re-fetch current chunk" step before retrying --
|
||||
# keeps this service's view of the chunk current even though the
|
||||
# retry itself only needs the revision number from the 409 body.
|
||||
await fetch_chunk(coord)
|
||||
attempt_revision = current_rev
|
||||
retried = true
|
||||
continue
|
||||
|
||||
return {"ok": false, "status": resp["status"], "revision": 0, "checksum": "",
|
||||
"live_session": false, "retried": retried, "detail": _error_detail(resp)}
|
||||
|
||||
return {"ok": false, "status": 409, "revision": 0, "checksum": "", "live_session": false,
|
||||
"retried": true, "detail": "conflict retry limit exceeded"}
|
||||
|
||||
|
||||
## Save multiple chunks in one atomic batch PUT (world_persistence_impl.md
|
||||
## §8.1 batch write: "an editor brush stroke or an object drag routinely
|
||||
## straddles a chunk boundary"). `entries` is an Array of
|
||||
## `{"coord": Vector3i, "model": ChunkEditorModel, "base_revision": int}`.
|
||||
## All-or-nothing: a `409` names the offending coordinate and nothing is
|
||||
## written, so only that entry's `base_revision` is refreshed before the
|
||||
## whole batch is retried.
|
||||
##
|
||||
## Returns:
|
||||
## {"ok": bool, "status": int, "live_session": bool,
|
||||
## "chunks": Array, "retried": bool, "detail": String}
|
||||
func save_batch(entries: Array) -> Dictionary:
|
||||
var working: Array = []
|
||||
for e in entries:
|
||||
working.append((e as Dictionary).duplicate())
|
||||
var retried := false
|
||||
|
||||
for attempt in range(MAX_RETRIES + 1):
|
||||
var chunks_json: Array = []
|
||||
for e in working:
|
||||
var c: Vector3i = e["coord"]
|
||||
var m: ChunkEditorModel = e["model"]
|
||||
chunks_json.append({
|
||||
"cx": c.x, "cy": c.y, "cz": c.z,
|
||||
"base_revision": int(e["base_revision"]),
|
||||
"payload_b64": Marshalls.raw_to_base64(m.encode_binary()),
|
||||
})
|
||||
var resp := await _request_json(HTTPClient.METHOD_PUT, _batch_path(), {"chunks": chunks_json})
|
||||
|
||||
if resp["status"] == 200:
|
||||
var d: Dictionary = resp["data"]
|
||||
return {"ok": true, "status": 200, "live_session": bool(d.get("live_session", false)),
|
||||
"chunks": d.get("chunks", []), "retried": retried, "detail": "OK"}
|
||||
|
||||
if resp["status"] == 409 and attempt < MAX_RETRIES:
|
||||
var conflict: Dictionary = resp["data"] if resp["data"] is Dictionary else {}
|
||||
var current_rev := int(conflict.get("current_revision", 0))
|
||||
var offending: Variant = conflict.get("coord")
|
||||
if offending is Array and offending.size() == 3:
|
||||
var oc := Vector3i(int(offending[0]), int(offending[1]), int(offending[2]))
|
||||
for e in working:
|
||||
if (e["coord"] as Vector3i) == oc:
|
||||
await fetch_chunk(oc)
|
||||
e["base_revision"] = current_rev
|
||||
break
|
||||
retried = true
|
||||
continue
|
||||
|
||||
return {"ok": false, "status": resp["status"], "live_session": false, "chunks": [],
|
||||
"retried": retried, "detail": _error_detail(resp)}
|
||||
|
||||
return {"ok": false, "status": 409, "live_session": false, "chunks": [],
|
||||
"retried": true, "detail": "conflict retry limit exceeded"}
|
||||
|
||||
|
||||
# ─────────────────────────── internals ───────────────────────────
|
||||
|
||||
## Resolved via an absolute node path, not the bare `ApiClient` autoload
|
||||
## identifier -- see the identical note on `HttpChunkSource._api_client()`
|
||||
## (bare autoload identifiers don't resolve when a script is compiled for a
|
||||
## headless `godot --script ...` run, which is how this service's smoke
|
||||
## test drives it).
|
||||
func _api_client() -> Node:
|
||||
return get_node("/root/ApiClient")
|
||||
|
||||
|
||||
func _chunk_path(coord: Vector3i) -> String:
|
||||
return "/api/worlds/%s/rooms/%s/chunks/%d/%d/%d" % [world_id, room_slug, coord.x, coord.y, coord.z]
|
||||
|
||||
|
||||
func _batch_path() -> String:
|
||||
return "/api/worlds/%s/rooms/%s/chunks" % [world_id, room_slug]
|
||||
|
||||
|
||||
func _request_json(method: HTTPClient.Method, path: String, body_dict: Dictionary) -> Dictionary:
|
||||
var http := HTTPRequest.new()
|
||||
http.timeout = 15.0
|
||||
add_child(http)
|
||||
|
||||
var api := _api_client()
|
||||
var headers := PackedStringArray(["Content-Type: application/json", "Accept: application/json"])
|
||||
if api.has_token():
|
||||
headers.append("Authorization: " + str(api.bearer_token))
|
||||
|
||||
var url: String = str(api.base_url) + path
|
||||
var err := http.request(url, headers, method, JSON.stringify(body_dict))
|
||||
if err != OK:
|
||||
http.queue_free()
|
||||
return {"status": 0, "data": null, "raw": "request failed to start (%d)" % err}
|
||||
|
||||
var args: Array = await http.request_completed
|
||||
var result_code: int = args[0]
|
||||
var response_code: int = args[1]
|
||||
var body_bytes: PackedByteArray = args[3]
|
||||
http.queue_free()
|
||||
|
||||
if result_code != HTTPRequest.RESULT_SUCCESS:
|
||||
return {"status": 0, "data": null, "raw": "transport error (%d)" % result_code}
|
||||
|
||||
var text := body_bytes.get_string_from_utf8()
|
||||
var parsed: Variant = JSON.parse_string(text) if text != "" else null
|
||||
return {"status": response_code, "data": parsed, "raw": text}
|
||||
|
||||
|
||||
func _error_detail(resp: Dictionary) -> String:
|
||||
var data: Variant = resp.get("data")
|
||||
if data is Dictionary and (data as Dictionary).has("detail"):
|
||||
return str((data as Dictionary)["detail"])
|
||||
if int(resp.get("status", 0)) == 0:
|
||||
return str(resp.get("raw", "network error"))
|
||||
return "HTTP error %d" % int(resp.get("status", 0))
|
||||
|
||||
|
||||
func _error_detail_from_body(body: PackedByteArray) -> String:
|
||||
var text := body.get_string_from_utf8()
|
||||
var parsed: Variant = JSON.parse_string(text) if text != "" else null
|
||||
if parsed is Dictionary and (parsed as Dictionary).has("detail"):
|
||||
return str((parsed as Dictionary)["detail"])
|
||||
return text
|
||||
|
||||
|
||||
func _fetch_result(ok: bool, status: int, model: ChunkEditorModel, revision: int,
|
||||
checksum: String, detail: String) -> Dictionary:
|
||||
return {"ok": ok, "status": status, "model": model, "revision": revision,
|
||||
"checksum": checksum, "detail": detail}
|
||||
|
||||
|
||||
func _int_header(headers: PackedStringArray, key: String) -> int:
|
||||
var prefix := key + ":"
|
||||
for h in headers:
|
||||
if h.to_lower().begins_with(prefix):
|
||||
return int(h.substr(h.find(":") + 1).strip_edges())
|
||||
return 0
|
||||
|
||||
|
||||
func _etag_from_headers(headers: PackedStringArray) -> String:
|
||||
for h in headers:
|
||||
if h.to_lower().begins_with("etag:"):
|
||||
var v := h.substr(h.find(":") + 1).strip_edges()
|
||||
return v.trim_prefix('"').trim_suffix('"')
|
||||
return ""
|
||||
Reference in New Issue
Block a user