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
Implements the in-game editor's shared, data-native core and a
Control-based UI on top of it, per docs/architecture/editor.md §7.1/§7.2,
scoped to 2D per §8's own risk mitigation (the default world is flat,
CHUNK_SIZE_Z=1 -- no 3D gizmos here).
EditorCore (scripts/editor/):
- ChunkEditorModel wraps a ChunkDataDecoder.ChunkPayload directly (no
parallel data model) and adds a TileLayerData.set_tile() mutator
alongside the existing get_tile().
- EditorUndoRedoStack wraps Godot's built-in UndoRedo (game-usable, not
the editor-only EditorUndoRedoManager) so every mutation is a real,
revertible do/undo Callable pair over ChunkEditorModel's raw setters.
- SelectionManager tracks the selected object id as UI state, deliberately
outside the undo/redo stack.
- ToolRegistry + 5 tools (BrushTool, ObjectPlaceTool, SelectTool,
EraseTool, TransformTool -- the 5th covers move/rotate/scale, which the
doc's tool list doesn't literally name but the task requires) is the
single choke point every mutation goes through.
- EditorObjectCatalog (assets/editor/object_catalog.json) is a new,
small "placeable object" catalog -- AvatarCatalog and SpawnCatalog were
checked first and are both scoped to other concerns, so this mirrors
their loading convention rather than reusing either.
InGameEditor UI (scripts/editor/ui/): EditorViewport (2D top-down tile
grid + object dots), EditorToolbar, PalettePanel, InspectorPanel, wired
together in InGameEditor. Built from Control nodes in code (matching
game.gd's existing dynamic-UI convention) rather than a hand-authored
.tscn.
Entry point: game.gd gates build mode behind a new toggle_build_mode
input action (F2), freezing the player and opening a fresh empty
ChunkEditorModel. Loading a *real* chunk from the backend for editing,
and saving edits back, are explicitly out of scope -- a sibling task
owns the batch-PUT/conflict-handling save path
(world_persistence_impl.md).
Verification: scripts/smoke_test_editor_core.gd decodes a real backend
fixture (backend/tests/fixtures/chunks/simple_tile_chunk.bin), drives
place/move/rotate/scale/delete/paint/erase entirely through
EditorCore.use_tool()/ToolRegistry (never touching ChunkEditorModel's
raw setters directly), undoes and redoes every step, and round-trips the
result through encode_binary()/decode_binary() to prove the edited model
stays a valid, byte-compatible ChunkPayload. 69/69 assertions pass. Full
existing Godot smoke suite (16 other scripts/smoke_test_*.gd, backend +
dedicated-server-dependent ones included) still passes with no
regressions.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y2FESCaMXeJpC9gSctiEeD
ESC: player.gd changed scene directly while game.gd._exit_tree closed the
socket, which re-fired server_disconnected -> _on_server_lost ran mid-
teardown (add_child on an exiting node, await on a null get_tree(), a
second change_scene_to_file). Moved ESC to game.gd as one ordered path:
_leave_world() sets a _leaving guard, disconnects the _on_server_lost
handlers before closing the connection, then changes scene. _on_server_lost
and _exit_tree now respect the guard. Verified headless: ESC -> world_list,
no errors; 2p multiplayer still connects.
Avatar faced +Z but a Godot node's forward is -Z, so walking forward
showed the character its own face. Rebuilt the placeholder facing -Z (face
plates, eyes, jaw, toes, and front-mounted attachments all moved to
negative Z; backpack/ponytail to +Z). The procedural walk was already
written for -Z forward, so it's unchanged.
Build stamp was there but tiny/low-contrast bottom-left -- bumped to 20px
opaque yellow with a heavy outline, moved to the top-left strip.
limb_animator: guard _process with is_instance_valid(_skel) so a frame
landing during avatar teardown can't touch a freed skeleton.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
The blocky body rendered fully black from every angle: _append_box wound
its faces clockwise-from-outside, so every computed normal pointed inward
-- an inside-out shell, lit from behind on all sides, and the level had
ambient light disabled so nothing lifted it. Fixed the winding (normals
now verify outward 234:36) and switched test_level's environment to a
soft colour ambient so shaded sides read grey, not pure black.
Limbs were static because the rig had no animation at all. Added
scripts/avatar/limb_animator.gd: a clip-free walk cycle that swings the
upper arm/leg bones with ground speed, folds the knees on the rear
stride, and eases back to rest when idle. Wired into the local player
(speed from velocity) and remote players in game.gd (speed from the
per-snapshot authoritative position delta).
Verified: avatar smoke 14/14 from source and exported pack; fresh-clone
boot PASS; game + menu boot headless clean.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
Avatar: rebuild the placeholder base body as a Roblox-classic (R6) figure --
six chunky solid box parts (head, torso, two arms, two legs) skinned one
part per bone onto the same 24-bone canonical rig, arms hanging at the
sides instead of a T-pose, plus a painted-on smile face. Classic
yellow/blue/green "noob" palette, flat single-colour surfaces (the old
capsule-segment chain read as a banded scarecrow). Attachments resized to
match the new proportions; head/hair/glove primitives are now boxes too
(dropped ~130KB spheres to ~3KB each).
Build stamp: it never rendered because `set_anchors_and_offsets_preset()`
plus `.position` on a zero-size Label collapses the rect to negative width.
Switched to a bottom-anchored full-width strip with explicit offsets and
text alignment, brighter colour + black outline. Shows on the main menu
(bottom-right) and in-world HUD (bottom-left) as `build PC3 <commit>`.
Verified: avatar smoke 14/14 from source and against the exported pack;
game.tscn + main_menu.tscn boot headless with no script errors.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
config/version didn't survive export cleanly. Instead checkpoint_ops writes
assets/build_info.json at build time (a plain JSON file, packed by the same
include_filter as the avatar catalog) and the menu + in-world HUD read it
with FileAccess. Shows 'build PC3 <commit>' so playtest reports say exactly
which build is running.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
- Clients now hard-snap to the server's spawn position on the first
snapshot. player.gd starts everyone on the fixed PlayerSpawn marker but
the server spreads spawns out -- so one client saw players ~1m apart
(correct) while the other saw them stacked (its local player still on the
marker, never corrected because the ~1m drift was under the old 1.5m
threshold). Threshold also tightened to 0.6m.
- game.gd._exit_tree() closes the ENet connection cleanly when you leave a
world, so the server fires peer_disconnected and removes your player.
Before, leaving just changed scene and the server never knew -- everyone
else kept seeing a frozen "ghost".
- server.gd: once-a-second sweep of players whose peer is no longer
connected, as a backstop for a missed disconnect.
- Build stamp: checkpoint_ops bakes "<PC> <commit>" into
application/config/version at export; the menu and in-world HUD show
"build PC3 <hash>" so playtest reports aren't ambiguous about which
build is running.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
- SpringArm3D was colliding with the player's own capsule (same layer) ->
camera yanked up against the head. add_excluded_object(self) in _ready +
gentler default pitch, shorter arm, lower pivot.
- AvatarSystem.build_current_avatar() is now self-sufficient: loads the
catalog itself and falls back to a bare default loadout if login didn't
prime one. So the in-world player always shows an avatar, never the
capsule, as long as the assets are packed (they are now).
- New AvatarSystem.assemble_avatar(): builds a standalone avatar with a
throwaway loader, touching no shared state -- so it's safe to call once
for the local player AND once per remote peer. (load_avatar()'s shared
loader reset()s and freed the previous avatar on every call.)
- game.gd: remote players render as the assembled avatar too (shared default
look, tinted per-peer, name tag) instead of a plain capsule.
Client-side -- rebuild the exe.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
- Session autoload: carries {world_id, host, port} from the world browser
to the game scene.
- world_list.gd "Enter World": POST /api/worlds/{id}/join -> Session.begin
-> load game.tscn. On 503 (backend has no orchestration) falls back to
local single-player; other errors surface on the card.
- api_client.gd: join_world().
- game.gd (new, on game.tscn root): local single-player by default; when
Session.active, connects a NetworkClient to the world's dedicated server
and runs networked -- forwards the local Player's input each physics
frame, snaps to the server position past a generous drift threshold
(the Player keeps predicting locally with real collision), renders other
players from snapshots (labelled capsules, lerped -- avatar appearance
sync is a follow-up, the server doesn't replicate loadouts yet), and
returns to the world list on disconnect.
- player.gd: exposes net_input (the input frame it applied) and
apply_server_correction().
- network_client.gd: send_input() + get_own_server_state() for callers that
do their own collision-aware prediction.
- scripts/mp_game_flow_client.gd + run_game_multiplayer_test.sh: two real
clients each register -> join via the API -> game.tscn -> networked;
A sees B and moves. PASS.
Verified end to end against an orchestration-enabled backend. Non-networked
smokes green (enter_world 9/9, world_list 5/5, player 15/15,
avatar_in_world 10/10, main_menu_full_flow 12/12).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ