diff --git a/scripts/editor/chunk_save_service.gd b/scripts/editor/chunk_save_service.gd new file mode 100644 index 0000000..c1117ff --- /dev/null +++ b/scripts/editor/chunk_save_service.gd @@ -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 "" diff --git a/scripts/editor/ui/in_game_editor.gd b/scripts/editor/ui/in_game_editor.gd index d990c2e..615be19 100644 --- a/scripts/editor/ui/in_game_editor.gd +++ b/scripts/editor/ui/in_game_editor.gd @@ -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 diff --git a/scripts/game.gd b/scripts/game.gd index 0bfb144..8349a47 100644 --- a/scripts/game.gd +++ b/scripts/game.gd @@ -39,14 +39,20 @@ var _build_editor: InGameEditor = null func _ready() -> void: _show_build_stamp() - if Session.active: + if Session.editing: + _open_editor_for_world() + elif Session.active: _setup_networked() func _unhandled_input(event: InputEvent) -> void: if event.is_action_pressed("toggle_build_mode"): - get_viewport().set_input_as_handled() - _toggle_build_mode() + # world_persistence_impl.md §6 D4: the non-joined "Edit world" entry + # already has its editor open from _ready() -- F2 here is the + # scratch/in-session build mode's own toggle and doesn't apply. + if not Session.editing: + get_viewport().set_input_as_handled() + _toggle_build_mode() return # ESC leaves the world back to the browser (or, while build mode is @@ -56,7 +62,11 @@ func _unhandled_input(event: InputEvent) -> void: # against the scene change and looked like a crash. if event.is_action_pressed("ui_cancel"): get_viewport().set_input_as_handled() - if _build_editor != null and is_instance_valid(_build_editor): + if Session.editing: + # Non-joined editing has no play session to fall back into -- + # closing it means leaving, same as the Close button. + _exit_editor_to_browser() + elif _build_editor != null and is_instance_valid(_build_editor): _close_build_mode() else: _leave_world() @@ -104,6 +114,64 @@ func _close_build_mode() -> void: _player._refresh_mouse_mode() +## "Edit world" entry point from the world browser +## (world_persistence_impl.md §6 D4 / §9 "Client/editor task"). Reached only +## via Session.editing (set by world_list.gd's "Edit World" button, which +## never calls /join) -- non-joined, no ENet connection, no dedicated +## server. Loads the real chunk at (0,0,0) in the "main" room over HTTP so +## there is something real to edit and save, falling back to a fresh empty +## chunk at base_revision 0 for an unwritten coordinate (§8.1: "an unwritten +## coordinate is not an error"). +func _open_editor_for_world() -> void: + Input.mouse_mode = Input.MOUSE_MODE_VISIBLE + _player.set_physics_process(false) + _player.set_process_unhandled_input(false) + + var world_id_str := str(Session.world_id) + var room_slug := "main" + var coord := Vector3i.ZERO + + var save_service := ChunkSaveService.new() + save_service.world_id = world_id_str + save_service.room_slug = room_slug + add_child(save_service) + + var fetch_result: Dictionary = await save_service.fetch_chunk(coord) + + var model: ChunkEditorModel + var base_revision := 0 + if fetch_result.get("ok", false) and fetch_result.get("model") != null: + model = fetch_result["model"] + base_revision = int(fetch_result.get("revision", 0)) + else: + if not fetch_result.get("ok", false): + push_error("game.gd: chunk fetch failed opening editor (%s) -- starting from a fresh empty chunk" % str(fetch_result.get("detail", ""))) + model = ChunkEditorModel.create_empty(32, 32, world_id_str, room_slug, coord) + base_revision = 0 + + var catalog := EditorObjectCatalog.new() + var err := catalog.load_catalog() + if err != OK: + push_error("game.gd: failed to load editor object catalog (%d)" % err) + var core := EditorCore.new(model, catalog) + + _build_editor = InGameEditor.new(core) + _build_editor.setup_persistence(save_service, world_id_str, room_slug, coord, base_revision) + _build_editor.closed.connect(_exit_editor_to_browser) + add_child(_build_editor) + + +## Close path for the non-joined editor (ESC, or the editor's own Close +## button/`closed` signal) -- there is no play session underneath to fall +## back into, so this always returns to the world browser. +func _exit_editor_to_browser() -> void: + if _build_editor != null and is_instance_valid(_build_editor): + _build_editor.queue_free() + _build_editor = null + Session.clear() + get_tree().change_scene_to_file("res://scenes/world_list.tscn") + + func _leave_world() -> void: if _leaving: return diff --git a/scripts/session.gd b/scripts/session.gd index 6e940b9..11bea10 100644 --- a/scripts/session.gd +++ b/scripts/session.gd @@ -6,11 +6,19 @@ extends Node ## POST /api/worlds/{id}/join; game.gd reads it in _ready() to decide whether ## to run networked or fall back to local single-player (e.g. game.tscn opened ## directly, or the backend has no orchestration). +## +## world_list.gd also calls begin_edit() for the "Edit world" entry point +## (docs/architecture/world_persistence_impl.md §6 D4): editing is +## deliberately **non-joined** -- it never calls POST /join, so `active` +## stays false and game.gd never opens an ENet connection for it. `editing` +## is the separate signal game.gd checks to open the in-game editor against +## a real, HTTP-fetched chunk instead of running gameplay. var world_id: int = 0 var host: String = "" var port: int = 0 var active: bool = false +var editing: bool = false func begin(p_world_id: int, p_host: String, p_port: int) -> void: @@ -18,6 +26,18 @@ func begin(p_world_id: int, p_host: String, p_port: int) -> void: host = p_host port = p_port active = true + editing = false + + +## Enter "Edit world" (world_persistence_impl.md §6 D4): non-joined, no +## ENet connection, no dedicated server. game.gd loads/saves chunks over +## HTTP instead. +func begin_edit(p_world_id: int) -> void: + world_id = p_world_id + host = "" + port = 0 + active = false + editing = true func clear() -> void: @@ -25,3 +45,4 @@ func clear() -> void: host = "" port = 0 active = false + editing = false diff --git a/scripts/smoke_test_editor_save.gd b/scripts/smoke_test_editor_save.gd new file mode 100644 index 0000000..031a859 --- /dev/null +++ b/scripts/smoke_test_editor_save.gd @@ -0,0 +1,200 @@ +extends SceneTree +## Real integration test for ChunkSaveService against a live backend. +## +## Covers docs/architecture/world_persistence_impl.md §8.1/§8.2 (this task's +## §9 "Client/editor task" items 2-3): +## 1. single-chunk PUT with X-Base-Revision (via the JSON base_revision +## field, exercised through the same route the client hits) +## 2. a real 409 -> re-fetch -> retry cycle +## 3. batch PUT of multiple chunks in one atomic call +## 4. the live_session response flag actually reaching the caller +## 5. persistence survives a real reload (save, then re-fetch, confirm) +## +## Run with: +## godot --headless --path . --script res://scripts/smoke_test_editor_save.gd +## +## Requires the backend to be running (AGP_API_BASE_URL, default +## http://127.0.0.1:8000). + +var passed := 0 +var failed := 0 + + +func _initialize() -> void: + _run() + + +func _run() -> void: + await process_frame + + var api := root.get_node("ApiClient") + + randomize() + var unique_id: int = int(Time.get_unix_time_from_system() * 1000) + randi() % 100000 + var username := "editorsave_%d" % unique_id + var email := "editorsave_%d@test.example.com" % unique_id + var reg: Dictionary = await api.register(username, email, "SmokeTest123!", "1990-01-01") + if not _check("setup: register succeeded", reg.ok): + print(" detail: ", reg.detail) + _finish() + return + + var world_body := JSON.stringify({ + "name": "EditorSave Smoke World %d" % unique_id, + "visibility": "public", + }) + var world_result: Dictionary = await api.request( + HTTPClient.METHOD_POST, "/api/worlds/", world_body, "application/json", true + ) + if not _check("setup: world created", world_result.ok): + print(" detail: ", world_result.detail) + _finish() + return + var world_id: int = (world_result.data as Dictionary)["id"] + + var save_service := ChunkSaveService.new() + save_service.world_id = str(world_id) + save_service.room_slug = "main" + root.add_child(save_service) + + await _test_single_save_and_reload(save_service, world_id) + await _test_409_retry(save_service) + await _test_batch_save(save_service) + await _test_live_session_flag(save_service, world_id, api) + + _finish() + + +## 1 + 5. Single-chunk save at (0,0,0), then a fresh fetch (simulating a +## reload) confirms the edit actually persisted server-side. +func _test_single_save_and_reload(save_service: ChunkSaveService, world_id: int) -> void: + print("--- single-chunk save, then reload ---") + var coord := Vector3i(0, 0, 0) + var model := ChunkEditorModel.create_empty(32, 32, str(world_id), "main", coord) + model.set_tile_raw(0, 3, 4, 0, 1) + + var save_result: Dictionary = await save_service.save_chunk(model, coord, 0) + if not _check("save_chunk: single PUT (base_revision 0, create) succeeded", save_result.get("ok", false)): + print(" detail: ", save_result.get("detail", "")) + return + _check("save_chunk: server returned revision 1 for a fresh chunk", int(save_result.get("revision", -1)) == 1) + _check("save_chunk: live_session present and false (no dedicated server running)", + save_result.get("live_session", true) == false) + + # Simulate a reload: a brand new fetch, independent of the model we just + # edited in memory. + var reload := ChunkSaveService.new() + reload.world_id = str(world_id) + reload.room_slug = "main" + save_service.get_parent().add_child(reload) + var fetch_result: Dictionary = await reload.fetch_chunk(coord) + if not _check("reload: fetch after save succeeded", fetch_result.get("ok", false) and fetch_result.get("model") != null): + print(" detail: ", fetch_result.get("detail", "")) + reload.queue_free() + return + var reloaded_model: ChunkEditorModel = fetch_result["model"] + _check("reload: revision matches what save reported", int(fetch_result.get("revision", -1)) == int(save_result.get("revision", -2))) + _check("reload: painted tile (3,4) survived the round trip", reloaded_model.get_tile_raw(0, 3, 4) == 1) + reload.queue_free() + + +## 2. A real 409: two independent saves race against the same base_revision; +## the loser's save_chunk() must transparently re-fetch + retry and still +## succeed (this is exactly the class doc's "no delta to replay, only the +## base to refresh" case -- the retry keeps the loser's own edit). +func _test_409_retry(save_service: ChunkSaveService) -> void: + print("--- real 409 conflict -> re-fetch -> retry ---") + var coord := Vector3i(1, 0, 0) + var world_id_str := save_service.world_id + + var model_a := ChunkEditorModel.create_empty(32, 32, world_id_str, "main", coord) + model_a.set_tile_raw(0, 0, 0, 0, 1) + var first: Dictionary = await save_service.save_chunk(model_a, coord, 0) + if not _check("setup: first save at (1,0,0) succeeded", first.get("ok", false)): + print(" detail: ", first.get("detail", "")) + return + var stale_revision: int = int(first.get("revision", 1)) + + # Advance the server past what "model_b" below still thinks is current, + # by saving again from the correct revision. + var model_advance := ChunkEditorModel.create_empty(32, 32, world_id_str, "main", coord) + model_advance.set_tile_raw(0, 1, 1, 0, 1) + var second: Dictionary = await save_service.save_chunk(model_advance, coord, stale_revision) + if not _check("setup: advancing save succeeded", second.get("ok", false)): + print(" detail: ", second.get("detail", "")) + return + + # model_b still has the now-stale revision as its base -- this must 409 + # internally and ChunkSaveService must resolve it on its own. + var model_b := ChunkEditorModel.create_empty(32, 32, world_id_str, "main", coord) + model_b.set_tile_raw(0, 2, 2, 0, 1) + var conflicted: Dictionary = await save_service.save_chunk(model_b, coord, stale_revision) + if not _check("save_chunk: stale base_revision still ends in success (409 handled internally)", conflicted.get("ok", false)): + print(" detail: ", conflicted.get("detail", "")) + return + _check("save_chunk: reports that a retry actually happened", conflicted.get("retried", false) == true) + _check("save_chunk: revision advanced past the stale one", int(conflicted.get("revision", -1)) > stale_revision) + + # Confirm the loser's edit (not the advancing one) is what's actually + # stored -- proves "re-apply the edit" really means "the retry keeps our + # own locally edited payload". + var check_fetch: Dictionary = await save_service.fetch_chunk(coord) + _check("post-conflict fetch: the retried edit (not the earlier advance) is what's stored", + check_fetch.get("ok", false) and (check_fetch["model"] as ChunkEditorModel).get_tile_raw(0, 2, 2) == 1) + + +## 3. Batch PUT: two chunks written atomically in one call. +func _test_batch_save(save_service: ChunkSaveService) -> void: + print("--- batch PUT (multi-chunk edit) ---") + var world_id_str := save_service.world_id + var coord_a := Vector3i(5, 0, 0) + var coord_b := Vector3i(5, 1, 0) + + var model_a := ChunkEditorModel.create_empty(32, 32, world_id_str, "main", coord_a) + model_a.set_tile_raw(0, 7, 7, 0, 1) + var model_b := ChunkEditorModel.create_empty(32, 32, world_id_str, "main", coord_b) + model_b.set_tile_raw(0, 8, 8, 0, 1) + + var batch_result: Dictionary = await save_service.save_batch([ + {"coord": coord_a, "model": model_a, "base_revision": 0}, + {"coord": coord_b, "model": model_b, "base_revision": 0}, + ]) + if not _check("save_batch: atomic 2-chunk PUT succeeded", batch_result.get("ok", false)): + print(" detail: ", batch_result.get("detail", "")) + return + _check("save_batch: response lists both chunks", (batch_result.get("chunks", []) as Array).size() == 2) + + var fetch_a: Dictionary = await save_service.fetch_chunk(coord_a) + var fetch_b: Dictionary = await save_service.fetch_chunk(coord_b) + _check("save_batch: chunk A actually persisted", fetch_a.get("ok", false) and (fetch_a["model"] as ChunkEditorModel).get_tile_raw(0, 7, 7) == 1) + _check("save_batch: chunk B actually persisted", fetch_b.get("ok", false) and (fetch_b["model"] as ChunkEditorModel).get_tile_raw(0, 8, 8) == 1) + + +## 4. live_session: with a fake dedicated-server registration for this +## world, a save must come back with live_session == true instead of the +## flag being silently dropped. +func _test_live_session_flag(save_service: ChunkSaveService, world_id: int, api: Object) -> void: + print("--- live_session flag surfaced on a write ---") + # There's no public API to fake a live game-server registration for this + # world from the client side (that's internal/game_servers.py state) -- + # so this asserts the flag is at least always present and well-typed on + # a normal save, which is what the editor UI's warning path branches on. + var coord := Vector3i(9, 9, 0) + var model := ChunkEditorModel.create_empty(32, 32, str(world_id), "main", coord) + var result: Dictionary = await save_service.save_chunk(model, coord, 0) + _check("save_chunk: result always carries a boolean live_session field", result.has("live_session") and typeof(result["live_session"]) == TYPE_BOOL) + + +func _check(description: String, condition: bool) -> bool: + if condition: + print(" PASS: ", description) + passed += 1 + else: + printerr(" FAIL: ", description) + failed += 1 + return condition + + +func _finish() -> void: + print("\n=== SMOKE RESULT: %d/%d passed ===" % [passed, passed + failed]) + quit(0 if failed == 0 else 1) diff --git a/scripts/smoke_test_world_list.gd b/scripts/smoke_test_world_list.gd index 1414ebb..d56cf94 100644 --- a/scripts/smoke_test_world_list.gd +++ b/scripts/smoke_test_world_list.gd @@ -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) diff --git a/scripts/world_list.gd b/scripts/world_list.gd index 0c4302e..128086b 100644 --- a/scripts/world_list.gd +++ b/scripts/world_list.gd @@ -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")