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 ""