Compare commits

..

39 Commits

Author SHA1 Message Date
daddy b562e9f7b3 Design: server-side world persistence architecture
Decides where chunk/object content lives at rest and who may write it, now
that there are two server-side processes per world (the always-on FastAPI
backend and the per-join, reaper-killed Godot dedicated server).

Decisions: chunk data lives in the existing Postgres DB (not world.md's
per-world SQLite file); the FastAPI backend is the sole writer and the editor
saves to it over HTTP; the dedicated server pulls content read-only at startup
over a loopback /api/internal route with a per-spawn token; full-chunk PUT with
base_revision instead of JSON-Patch deltas; the client streams chunks over HTTP
through the existing ChunkStreamManager.set_server_rpc() seam.

Adds docs/architecture/world_persistence_impl.md and marks the superseded
sections of world.md (SS4, SS5) and editor.md (the request_chunk_edit path).
Design only -- no implementation code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y2FESCaMXeJpC9gSctiEeD
2026-09-14 19:19:10 +00:00
daddy 6809018ef1 Avatar customization: skin colour = swatches + shade slider
checkpoint / checkpoint (push) Successful in 1m45s
The single tone slider could never get back to the default yellow. Replaced
it with a row of preset colour swatches (yellow first, then skin tones and
a few fun colours) plus a "Shade" slider that darkens/lightens the picked
swatch (0.5 = as-is). Picking yellow at mid shade reproduces the baked
default exactly.

Verified: avatar smoke 14/14, fresh-clone, and drove the swatches + shade
in rendered screenshots (green skin, back to yellow, dark tan).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
2026-09-06 16:17:07 +00:00
daddy 43a3b888da Avatar customization: skin tone, head swap, and hair now actually apply
checkpoint / checkpoint (push) Successful in 1m45s
Three dead controls on the customization screen:

- Skin tone slider did nothing: neither the visualizer nor AvatarSystem
  ever read loadout.skin_tone. Added AvatarLoader.apply_skin_tone() -- it
  retints the base body's skin surfaces (matched by the placeholder skin
  albedo, i.e. head + arms) -- and both build paths call it. Slider now
  maps across a deep-to-pale skin range.

- "Cyber Head" did nothing: its catalog entry was type "body_part", which
  load_attachment() rejects, and the placeholder body is one mesh so there
  was nothing to hide either. Retyped it as an attachment -> it mounts on
  the Head bone as a cyber visor.

- Mohawk / ponytail sat inside the head: the Head and HeadTop bones were
  well below the visual head (bone at 1.58, head crown at ~2.03). Moved
  Head to the head's centre and HeadTop just above the crown (mesh is
  rigidly bound, so it doesn't move), and retuned the hair/visor/glasses
  meshes and offsets to match.

Verified: avatar smoke 14/14 (source + pack), fresh-clone, 2p multiplayer,
and drove the real customization screen in rendered screenshots (equip
head+mohawk+mid skin, then ponytail+glasses+pale skin).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
2026-09-06 16:10:46 +00:00
daddy 181ba6ae02 Avatar customization: drag-to-rotate preview, fill the dead space, seam fix
checkpoint / checkpoint (push) Successful in 1m45s
- The preview is now a turntable: drag it to rotate the avatar and see all
  sides; it slowly auto-spins after ~2.5s of no input. Starts facing the
  camera so you see the face. "drag to rotate" hint under the viewport.
- The big grey void to the right of the controls is gone -- the preview
  panel now expands to fill the leftover width; the controls dock to the
  right edge at a fixed width.
- Camera reframed to fit the whole ~2 m body; softer preview lights.
- Placeholder avatar: torso/arms/legs now share one 0.46 m depth so a limb
  always covers the torso slice behind it -- kills the bright "cyan seam"
  (it was a lit sliver of the torso side peeking past the thinner arm).

Verified headless (boot, 2p multiplayer, avatar smoke source+pack,
fresh-clone) + eyeballed the customization screen and the avatar seams in
rendered screenshots.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
2026-09-06 15:18:26 +00:00
daddy 4200a8b358 Roblox-style camera: Classic + Shift Lock + scroll zoom + first person
checkpoint / checkpoint (push) Successful in 1m46s
Matches Roblox's default controls so the feel doesn't stray:

Classic (default): mouse cursor is free; hold LMB/RMB and drag to orbit
the camera (RMB also turns the body to face the camera while held). The
camera stays where you leave it -- no auto-recenter. Body auto-rotates
toward its movement direction otherwise.

Scroll wheel zooms between 0.4 and 9.0 m; at/below 0.6 m it switches to
first person (mouse locks, camera at eye height, own avatar hidden so the
head doesn't fill the view).

Shift Lock (toggle Left Shift): mouse locks to a centre crosshair, camera
shifts 0.85 m to the right shoulder, body facing is slaved to the camera,
and WASD strafes / backpedals.

Movement stays camera-relative throughout. net_input.look_yaw is still
the body yaw, so remote players face the right way in every mode.

Verified headless (boot, 2p multiplayer, avatar smoke source+pack,
fresh-clone) and eyeballed all three modes in rendered screenshots
(classic behind / shift-lock over-shoulder+crosshair / first person).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
2026-09-06 14:52:21 +00:00
daddy 1e917e196b Free-orbit third-person camera
checkpoint / checkpoint (push) Successful in 1m45s
The mouse now orbits the camera around the player a full 360 in yaw
(unclamped) with a wide pitch range -- swing it around to look at the
avatar's face. The body no longer snaps to the camera yaw; instead it
turns smoothly to face its movement direction (lerp_angle at
BODY_TURN_SPEED), so controls feel the same: W is still "away from
camera", A/D still strafe relative to view.

Implementation: CameraPivot is set top_level so it doesn't inherit the
body's rotation; _update_camera() places it at the head each frame and
points it by _camera_yaw / _camera_pitch. Movement direction is built
from a yaw-only camera basis. net_input.look_yaw is now the body's
movement facing, so remote players render facing the way they walk.

Verified: headless boot clean, 2p multiplayer still connects/moves,
avatar smoke 14/14 (source + exported pack), fresh-clone PASS, and
eyeballed the orbit in rendered screenshots (behind -> side -> face).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
2026-09-06 14:33:49 +00:00
daddy eb4da62f99 Fix ESC-crashes-the-game, avatar facing backward, invisible build stamp
checkpoint / checkpoint (push) Successful in 1m47s
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
2026-09-06 14:24:45 +00:00
daddy 3fb45f0ef2 Avatar: tune Roblox proportions + walk cycle from real rendered screenshots
checkpoint / checkpoint (push) Successful in 1m47s
Now that cloudbison can render (Radeon GPU via Xvfb, or llvmpipe software),
added scripts/tools/screenshot_rig.gd + scenes/dev/ so a scene can be booted,
run a few frames, and its rendered frame saved to PNG -- caught three things
the headless smoke never could:

- limbs floated detached from the torso: widened the torso and overlapped the
  arm/leg boxes into it, and pulled limb depth in behind the torso front so
  they stop z-fighting.
- proportions were scrawny: reworked to Roblox R6 ratios (2-stud torso, 2-stud
  arms/legs, 1-stud head) with the head clearly pegged on top.
- the walk swing was a 40-deg lunge: STRIDE 0.7->0.42 rad, separate 0.32 arm
  swing, KNEE 1.0->0.5. Reads as a walk now, not a crouch.

test_level lighting was near-black on the shaded side (ambient was disabled) --
colour ambient at 0.8 + a stronger key light, so the avatar reads in-world.

avatar_preview.tscn (AGP_PREVIEW_WALK=1) renders the assembled avatar walking
in place for quick visual checks.

Verified: avatar smoke 14/14 source + exported pack; fresh-clone boot PASS;
eyeballed in rendered screenshots (game scene + walk sequence).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
2026-09-06 14:06:46 +00:00
daddy 1b1260f371 Avatar: fix inside-out black shell + add a procedural walk cycle
checkpoint / checkpoint (push) Successful in 1m52s
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
2026-09-06 13:07:27 +00:00
daddy 9731615c34 Roblox-style blocky avatar + actually-visible build stamp
checkpoint / checkpoint (push) Successful in 1m52s
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
2026-09-06 02:00:48 +00:00
daddy 6ad91b895c Fix: avatars are capsules in the exe -- use load().instantiate(), not runtime GLTFDocument
checkpoint / checkpoint (push) Successful in 1m47s
Playtest log had the smoking gun:
  WARNING: player.gd: no avatar to show; keeping the placeholder capsule
  ERROR: Parameter "data.tree" is null. at: get_tree

avatar_loader.gd read raw .glb bytes and ran GLTFDocument.append_from_buffer
+ generate_scene() at runtime. In a release export that path produced NO
Skeleton3D (works fine in the editor / headless-from-source), so
load_base_body() returned null and the player fell back to the capsule.
The queue_free() calls in that path also errored (loader runs detached from
any tree).

Fix: load(path).instantiate() the Godot-imported PackedScene (the .scn that
actually ships in the pack, with the skeleton + skin set up by the import
pipeline). The raw-bytes GLTFDocument path stays available for future
user-uploaded models but isn't used for the bundled catalog.

- run_checkpoint_tests.py: also runs the avatar smoke against the EXPORTED
  pack, not just source -- that's what catches release-only resource
  differences. Verified: 14/14 against export/game.pck.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
2026-09-06 00:58:49 +00:00
daddy 44bfcb1871 Build stamp via assets/build_info.json (reliable: same path as the catalog)
checkpoint / checkpoint (push) Successful in 1m39s
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
2026-09-06 00:41:57 +00:00
daddy 3639fb3132 Fix multiplayer state: stacked players, ghosts after rejoin; add a build stamp
checkpoint / checkpoint (push) Successful in 1m40s
- 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
2026-09-06 00:39:30 +00:00
daddy da4e1420ee Fix: rejoining stacked players on top of each other (one fell through)
checkpoint / checkpoint (push) Successful in 1m40s
All players spawned at the exact same PlayerSpawn point and were on the
same collision layer, so two overlapping CharacterBody3Ds triggered a
physics depenetration shove that could launch one through the 1-unit-thick
floor.

- server.gd: golden-angle spawn spread -- each join lands ~1m+ from the
  others, growing outward, never exactly repeating.
- server_player.tscn + player.tscn: players on collision layer 2, mask 1
  (world only) -- players no longer collide with each other at all
  (fine for a social game; also removes the shove).
- player_spawner.gd + player.gd: hard safety net -- if y < -10 (any
  tunnel-through), snap back to the spawn instead of falling forever.

Verified: 3 server players spawn 1.15m+ apart, none fall, all rest on the
floor. 2p multiplayer + network_client smokes green.

Client-side -- rebuild the exe.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
2026-09-06 00:00:27 +00:00
daddy 80f24bfd01 Avatars actually show; camera stops hugging the player
checkpoint / checkpoint (push) Successful in 1m40s
- 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
2026-09-05 23:52:10 +00:00
daddy 54bc763dff Fix backwards controls + the exe's bare-capsule avatar
checkpoint / checkpoint (push) Successful in 1m41s
Two real bugs from the last change, both only visible in a real run:

1. Controls reversed: the third-person SpringArm3D had a 180° Y rotation
   that put the camera IN FRONT of the player looking back -- a mirror, so
   W read as backward and mouse pitch as inverted. SpringArm3D extends its
   child along +Z, not -Z; with no rotation the camera sits behind the
   player (+Z world) and looks -Z straight at them. Verified the vectors
   headless: camera behind, W decreases Z, mouse-up looks up.

2. Bare capsule instead of an avatar in the .exe: avatar_catalog.gd reads
   assets/avatars/catalog.json with FileAccess.open(), but the export used
   `all_resources` + empty include_filter, which does NOT pack plain .json
   files. ensure_catalog() failed -> no loadout -> build_current_avatar()
   returned null -> capsule stayed visible. From source it worked because
   res:// is the real filesystem. Fixed: include_filter="*.json" in both
   export presets (verified catalog.json + the .glb pack are now in the
   exported .pck).

- scripts/verify_export_contents.gd + a checkpoint-runner step: export a
  pack and assert the non-resource runtime files are inside it. This is
  the guard for the "works from source, broken in the .exe" class.

Client-side -- rebuild the exe.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
2026-09-05 23:30:00 +00:00
daddy 87f78b0226 Third-person camera + fix the avatar customize preview
checkpoint / checkpoint (push) Successful in 1m34s
You couldn't see your own character because the camera was first-person
(inside the avatar's head), and the customize screen's 3D preview rendered
nothing.

- player.tscn: camera now hangs off a SpringArm3D on a CameraPivot, 4 m
  behind + above the player, pulled in on collision. player.gd: yaw still
  turns the body, pitch orbits the arm (starts tilted down to frame the
  avatar).
- avatar_customization_ui.gd: the preview SubViewport had own_world_3d=false,
  so it shared the Control-only main world -- no scenario, nothing rendered.
  Set own_world_3d=true, add a WorldEnvironment with ambient fill + a
  key/fill light pair, make the camera current, tweak framing.
- smoke_test_player: assert the third-person SpringArm rig (18/18).
- smoke_test_enter_world: wait longer for the scene change -- a real
  POST /join spawns a dedicated server, which is slow (9/9).

Client-side change -- rebuild the exe to get it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
2026-09-05 23:13:07 +00:00
daddy bd1bdc417a tests: assert players stay on the floor in the game-flow multiplayer test too
checkpoint / checkpoint (push) Successful in 1m33s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
2026-09-05 22:59:58 +00:00
daddy 7ccdd3063a Fix: players fall through the floor in multiplayer
checkpoint / checkpoint (push) Failing after 1m34s
scenes/server_player.tscn was a bare CharacterBody3D with NO CollisionShape3D,
so on the server move_and_slide() never hit the ground and is_on_floor() was
always false -> the server player fell forever, and the client (which snaps to
the server's authoritative position past a drift threshold) fell with it.

Give the server player the same CapsuleShape3D (r=0.35, h=1.6) as the client.
The 2-player test now also asserts the server keeps the player on the ground
(on_floor / min_y), which is how this slipped through -- the tests only
checked "position changes" and "sees the other player".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
2026-09-05 22:58:55 +00:00
daddy 67473dddcd CI is live: Gitea Actions + host act_runner, first green run
checkpoint / checkpoint (push) Successful in 1m33s
docs/ci.md updated to reflect the running state + the one sudo command to
make the runner a permanent systemd service.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
2026-09-05 22:38:45 +00:00
daddy d76bf4da19 CI: run the checkpoint suite in a venv (host Python is PEP 668 externally-managed)
checkpoint / checkpoint (push) Successful in 1m36s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
2026-09-05 22:36:24 +00:00
daddy 4df1caa279 CI: JS-action-free workflow (host runner needs no Node)
checkpoint / checkpoint (push) Failing after 1s
actions/checkout is a JavaScript action; a host-mode act_runner needs
 on PATH to run it, which the AGP box doesn't have. Replace it with
a plain git fetch from the local Gitea using the injected ${{ github.token }}.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
2026-09-05 22:35:45 +00:00
daddy 02747cc266 CI: move workflow to .gitea/workflows/ (the instance is Gitea, not Forgejo)
checkpoint / checkpoint (push) Failing after 10s
The self-hosted git is gitea/gitea:latest (1.26), which reads
.gitea/workflows/ + .github/workflows/, not .forgejo/. Updated docs/ci.md
with the real enable steps (app.ini [actions], act_runner host-mode
register via `gitea actions generate-runner-token`, no web UI needed).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
2026-09-05 22:32:50 +00:00
daddy 187ec90440 PC3: checkpoint doc for what's built (M4 + M5 + foundation; M6-M10 not started)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
2026-09-05 22:23:52 +00:00
daddy fb52c4fd08 PC3 Phase 3.3 fix: RATE_LIMIT_ENABLED flag so the smoke suite isn't throttled
Every checkpoint smoke test registers users against one backend from one
client IP, so the per-IP auth limit tripped mid-suite (main_menu_full_flow
-> 2/12). Add RATE_LIMIT_ENABLED (default true); run_checkpoint_tests.py
sets it false for its backend. test_rate_limit.py forces it on in-fixture
so the limiter is still really tested.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
2026-09-05 22:22:52 +00:00
daddy bc3901da68 PC3 Phase 3.3: rate-limit the auth endpoints
app/rate_limit.py: an in-process sliding-window limiter (single-worker
backend, so no Redis needed -- flagged for if that changes). /register and
/login are capped at 10 attempts / minute / client IP (X-Forwarded-For
aware), returning 429 + Retry-After past that. Blunts credential
brute-force, which the backend had no protection against.

- conftest.py resets the limiter between tests (every TestClient request
  looks like one IP).
- tests/test_rate_limit.py: threshold, shared bucket, per-key window. 77 passed.

CORS reviewed while here: already an explicit origin allowlist
(settings.CORS_ORIGINS), not "*" -- and the Godot client isn't a browser
so it doesn't apply there anyway. Left as-is.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
2026-09-05 22:20:18 +00:00
daddy 7bd8124199 PC3 Phase 3.2: real age verification (date of birth, not a checkbox)
POST /api/auth/register now requires a real date_of_birth (ISO YYYY-MM-DD).
The server computes age (auth.calculate_age) and rejects under-18s with 403;
a future/nonsensical date is 400. The DOB is stored and surfaced only to
that user (/api/auth/me).

Still self-attested (a user can enter a false DOB) -- but a concrete,
stored, legally-meaningful data point and the seam for a future ID-check
pass. Encryption-at-rest for the column and the Tier-2 hook are not done;
the limitation is stated plainly in docs/architecture/content_moderation.md 5.2.

- auth.py: RegisterRequest.date_of_birth (date); MIN_AGE=18; register()
  computes + enforces; UserResponse.date_of_birth typed `date | None`
  (the old `str | None` would have 500'd /me once a DOB was actually stored).
- api_client.gd / main_menu.gd: the Over18 checkbox is now a
  "Date of birth (YYYY-MM-DD)" field with a client-side sanity check.
- tests: test_auth_age_gate.py rewritten around the endpoint + age math
  (adult/minor/exactly-18/future-dob/stored); ~25 over_18 references across
  6 backend test files + 6 game smoke tests updated. Backend 74 passed;
  game smokes green (auth 6/6, menu_full_flow 12/12, e2e 4/4, etc.);
  fresh-clone boot PASS.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
2026-09-05 22:18:38 +00:00
daddy 855311e731 PC3 Phase 3.1: hermetic backend tests + fresh-clone boot check + CI workflow
- backend/tests/conftest.py: pytest_configure brings up an ephemeral
  Postgres via docker compose + runs migrations when DATABASE_URL isn't
  already reachable, and tears it down after. `cd backend && pytest` now
  works from a clean checkout with zero manual setup (72 passed in ~28s);
  no docker and no DB -> the whole suite skips with a clear message instead
  of erroring 60+ times.
- scripts/verify_fresh_clone.sh: exports a pristine tree (git archive, so no
  .godot), imports from scratch, headless-boots the main menu + game scene,
  fails on any SCRIPT ERROR / failed autoload. Catches the stale-class-cache
  class of bug. PASS.
- run_checkpoint_tests.py runs both the 2p multiplayer test and the
  fresh-clone check.
- .forgejo/workflows/checkpoint.yml + docs/ci.md: the CI workflow (runs the
  checkpoint suite on push/PR) and how to enable it -- it needs a Forgejo
  Actions `host` runner registered on the AGP box, a one-time operator step.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
2026-09-05 22:12:49 +00:00
daddy 50c6a31c25 PC3 Phase 2: orchestration under checkpoint control + networking doc
- pm/checkpoint_ops.py (framework): playtest backend now launches with
  GAME_SERVERS_ENABLED + GODOT_BIN + GAME_PROJECT_DIR + GAME_SERVER_HOST so
  playtesters actually share a world.
- docs/architecture/networking.md 7a/7b: the orchestration + client-play
  design as built.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
2026-09-05 22:08:02 +00:00
daddy dba865ae4b PC3 Phase 2.3: networked play path in the game
- 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
2026-09-05 22:07:06 +00:00
daddy 260b7a38da PC3 Phase 2.2: game-server orchestration + POST /api/worlds/{id}/join
- backend/app/services/game_servers.py: an in-process supervisor. One
  headless Godot dedicated-server process per world, keyed by world_id,
  spawned on demand from the join endpoint, port-allocated from a
  configured range, with a background reaper that kills a world's server
  once its status file reports 0 players for the idle timeout. Off by
  default (GAME_SERVERS_ENABLED); the checkpoint/playtest launcher enables it.
- POST /api/worlds/{id}/join -> {world_id, host, port}; 404 for
  unknown/hidden worlds, 503 when orchestration is disabled. Auth required.
- main.py lifespan starts/stops the supervisor.
- config.py: GODOT_BIN, GAME_PROJECT_DIR, GAME_SERVER_HOST, port range,
  idle timeout.
- tests/test_world_join.py (auth / 404 / visibility / 503) -- suite 72 passed.
- checkpoint runner also runs the 2p multiplayer shell test.

Verified end to end: enabled orchestration, POST /join spawned the Godot
server, a real client connected to the returned address and moved; backend
shutdown reaped the process.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
2026-09-05 22:02:55 +00:00
daddy 682f1f89f7 PC3 Phase 2.1: server-authoritative sim + prediction/reconciliation with replay
Made the multiplayer core actually work (it had never run a real sim):

- project.godot: physics pinned to 30 Hz -- the shared sim/snapshot/prediction rate.
- server.gd: loads the world's level (real ground + collision), spawns a
  server Player per peer at the level's PlayerSpawn, broadcasts a full
  snapshot every physics frame with each peer's state AND last_seq (the last
  input seq applied for that peer). Accepts `--port/--world/--status-file`
  args (status file = player count for a future idle reaper); defaults keep
  `godot res://scenes/server.tscn` working standalone.
- player_spawner.gd (server Player): rewrote -- drains its whole input queue
  each physics frame in seq order, integrates gravity/walk/jump with the
  shared constants, move_and_slide(), tracks last_applied_seq. No more no-op
  process_server_input().
- network_client.gd: rewrote reconciliation -- drops inputs the server has
  acked (last_seq), re-simulates the still-in-flight ones from the
  authoritative state, snaps only on real drift. Exposes get_remote_states()
  (peer_id -> latest snapshot state) for the game to render other players.
  predict_and_send() now also sends body yaw.
- proxies trimmed to the RPC set the rewrite actually uses.
- scripts/mp_test_client.gd + run_multiplayer_2p_test.sh: two real client
  processes vs one real server -- client A sees client B and its own
  movement round-trips. PASS.
- run_checkpoint_tests.py: also run the 2p shell test.

smoke_test_network_client 4/4, player 15/15, avatar_in_world 10/10, all green.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
2026-09-05 22:00:18 +00:00
daddy 587a15a52e PC3: checkpoint runner starts a dedicated server for the network-client smoke
smoke_test_network_client.gd needs a separate `godot scenes/server.tscn`
process on ENet 7777; the runner never started one, so that test has been
failing in every checkpoint run (the only failure in an otherwise-green
suite). Start it just for that test, tear it down after.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
2026-09-05 21:53:05 +00:00
daddy e59cf158e5 PC3 Phase 1.4-1.6: avatars wired into the game end to end
- api_client.gd: get_avatar_loadout() / save_avatar_loadout()
- main_menu.gd: on successful auth (fresh or session-restore), load the
  catalog + fetch the saved loadout into AvatarSystem so the in-world
  player renders as the player's avatar. Best-effort: a slow/failed prime
  never gates getting in (UI goes authenticated first, prime runs after).
  New "Customize Avatar" button (enabled only when signed in).
- avatar_system.gd: local-player convenience API -- ensure_catalog(),
  set_loadout_from_dict() (from the backend's GET shape), has_loadout(),
  build_current_avatar().
- player.gd: swaps the placeholder capsule for the assembled avatar
  (feet-aligned to the capsule bottom) when a loadout is primed; silent
  fallback to the capsule otherwise. Camera stays first-person for now
  (third-person is a noted follow-up).
- avatar_visualizer.gd: rewrote it -- the old version referenced
  AvatarLoader.SlotResult / load_slot() which never existed, so it didn't
  compile and took avatar_customization_ui.gd down with it. Now a thin
  wrapper over AvatarLoader for the preview.
- avatar_customization_ui.gd: seeds from the live loadout, saves through to
  AvatarSystem + PUT /api/avatar/loadout (local file kept as fallback),
  fixed a Camera3D.look_at() before-in-tree crash.
- scenes/avatar_customization.tscn rebuilt (old one used the Godot-3
  `ViewportContainer` node type) + avatar_customization_screen.gd wrapper
  that returns to the menu on close.
- smoke_test_avatar_in_world.gd: primed loadout -> game.tscn -> player
  shows the assembled avatar, capsule hidden, rig + bone mounts present.

Verified: cold-cache smokes green (player 15/15, avatar_system 14/14,
avatar_in_world 10/10, main_menu_ui 13/13); against a live backend
main_menu_full_flow 12/12, main_menu_back 20/20, world_list 4/4,
enter_world 9/9, e2e 4/4; plus a manual register->default-loadout->
customize->save->re-fetch-persisted round trip.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
2026-09-05 21:50:41 +00:00
daddy af85048deb PC3 Phase 1.3: backend avatar loadout persistence
New avatar_loadouts table (one row per user, loadout stored as a single
JSONB document matching the client's AvatarLoadout.to_json() shape) and
GET/PUT /api/avatar/loadout.

The server has no copy of the asset catalog, so it validates only the
envelope (JSON object, non-empty base_body, object slots, size cap) --
which-asset-in-which-slot validation stays client-side. New accounts get a
server default (base_human_male_01, no slots) with is_default:true so the
client can tell "never customised" from "customised back to base".

- models/avatar_loadout.py + 0004 migration + User.avatar_loadout relation
- routers/avatar.py wired into main.py
- tests/test_avatar_loadout.py (7 tests: default, save/read, replace,
  validation, auth) -- full suite 68 passed
- lifted the duplicated _override_db fixture from 3 test files into
  conftest.py (new test files need it too)
- docs/architecture/avatar.md §9a: the persistence design

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
2026-09-05 21:42:10 +00:00
daddy d6cd3293ae PC3 Phase 1.2: placeholder avatar asset pack + make the loader actually assemble
assets/avatars/catalog.json referenced 14 GLB files that were never in the
repo, so the avatar system could not load a single avatar. And the loader's
assembly path had never run against a real skeleton -- it called
Skeleton3D.has_bone() (no such method in Godot 4) and hand-copied bones in a
way that dropped the skin binding.

- scripts/tools/gen_placeholder_avatars.gd: generates the pack headlessly via
  GLTFDocument -- one rigged/skinned base body (the full 24-bone canonical
  rig from a T-pose) + 13 primitive attachments. Crude on purpose; real art
  swaps in as a pure content change (catalog paths unchanged).
- assets/avatars/*.glb: the generated pack (checked in).
- avatar_loader.gd: rewrote _parse_glb_as_avatar to lift the skin-bound
  Skeleton3D that generate_scene() already produces, instead of rebuilding it.
  Attachments now mount via BoneAttachment3D to the catalog's mount_bone and
  are actually added to the avatar tree (they were parsed then dropped).
  Fixed the has_bone() crash, the null ARRAY_BONES cast in _retarget_skin,
  and the Array[String] type mismatch on the accessory list.
- smoke_test_avatar_system.gd: replaced the "asserts it fails gracefully
  because the files don't exist" section with a real assembly check --
  Skeleton3D present, full 24-bone rig, canonical bones resolve by name,
  visible body mesh, equipped items mounted to bones. 14/14 from a cold cache.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
2026-09-05 21:38:00 +00:00
daddy f51c49fcfa PC3 Phase 1.1: game boots clean from a bare clone
Root cause: a stale .godot/global_script_class_cache.cfg was force-committed
to the repo (last regenerated before the avatar system merged), and Godot
does not rebuild the global class registry on a plain headless scene run --
only an editor scan / `--import` does. So every cross-file `class_name`
reference in the avatar module failed to resolve from a fresh clone, and the
AvatarSystem autoload failed to instantiate entirely.

- Untrack .godot/ and sandbox_probe/.godot/ (already in .gitignore; were
  force-added during old WIP checkpoints)
- Reference sibling avatar scripts via preload() constants instead of their
  global class_name, so the module parses independent of scan order. Keeps
  the class_name declarations for editor/tooling use.
- avatar_loadout.gd's static from_json() factory self-references via a
  preload const too (a script can't resolve its own class_name mid-compile
  on a cold cache).
- smoke_test_avatar_system.gd: same preload treatment so it runs from a bare
  `--script` invocation.

Verified from a wiped .godot/: main_menu.tscn and game.tscn boot with zero
script errors; smoke_test_avatar_system 10/10, smoke_test_player 15/15,
smoke_test_main_menu_ui 13/13, smoke_test_test_level PASS.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
2026-09-05 21:27:57 +00:00
daddy 3596922696 Fix real flake in smoke_test_network_client.gd's prediction-buffer check
Found while re-verifying the full PC2 test suite from a fresh clone:
the "prediction buffer has entries" check failed ~1 in 3 runs. Root
cause is a genuine race in the test, not in prediction/reconciliation
itself -- a server snapshot RPC can land on the 60-frame loop's final
`await process_frame`, triggering a legitimate reconciliation that
clears the buffer (network_client.gd's documented behavior) right
before the check reads it. The desync-correction check right after it
already proves reconciliation logic works correctly on its own, so
this was never actually catching a real bug -- just occasionally
racing a real correction event.

Fix: one more predict_and_send() right before the check guarantees a
fresh buffer entry regardless of whether a correction just fired.
Verified 5/5 clean runs against a real dedicated server after the fix
(previously observed a failure within 3 runs).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6rUEynmv2QmweocAQYARN
2026-08-21 01:44:28 +00:00
agent-bot 1526f3a636 Merge pull request 'Avatar loader & customization system: merge onto main + fix regressions, add smoke coverage' (#53) from agent/896fbb97de1d into main 2026-08-21 01:35:28 +00:00
117 changed files with 5297 additions and 1568 deletions
+39
View File
@@ -0,0 +1,39 @@
name: checkpoint
# Runs the full checkpoint suite (migrations, backend pytest, Godot smoke
# tests, multiplayer, fresh-clone boot) on every push and PR.
#
# Runs on a `host`-label act_runner on the AGP box: Docker, ~/godot/godot,
# and Python 3 are already there. Deliberately uses no JavaScript actions
# (actions/checkout etc.) so the host needs no Node install -- checkout is a
# plain git clone from the local Gitea. See docs/ci.md.
on:
push:
branches: [main]
pull_request:
jobs:
checkpoint:
runs-on: host
steps:
- name: Checkout
run: |
set -eu
rm -rf repo && mkdir repo && cd repo
git init -q
git remote add origin "http://x:${{ github.token }}@localhost:3000/${{ github.repository }}.git"
git fetch -q --depth 1 origin "${{ github.sha }}"
git checkout -q FETCH_HEAD
- name: Python env
working-directory: repo
run: |
set -eu
python3 -m venv .venv
.venv/bin/pip install --quiet --upgrade pip
.venv/bin/pip install --quiet -r backend/requirements.txt
- name: Checkpoint suite
working-directory: repo
run: .venv/bin/python scripts/run_checkpoint_tests.py --postgres-port 25690 --backend-port 18690
+1
View File
@@ -11,3 +11,4 @@ Thumbs.db
# Godot UID files (regenerated from path)
*.uid
assets/build_info.json
-1
View File
@@ -1 +0,0 @@
-77
View File
@@ -1,77 +0,0 @@
63f7b34db8d8cdea90c76aacccf841ec
::res://::1787275036
export_presets.cfg::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>::
README.md::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>::
::res://addons/::1787272930
::res://assets/::1787272930
::res://backend/::1787272930
alembic.ini::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>::
docker-compose.yml::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>::
pytest.ini::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>::
requirements.txt::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>::
::res://backend/alembic/::1787272930
::res://backend/alembic/versions/::1787272930
::res://backend/app/::1787272930
::res://backend/app/models/::1787272930
::res://backend/app/routers/::1787272930
::res://backend/app/services/::1787272930
::res://backend/tests/::1787272930
::res://docs/::1787272930
::res://docs/architecture/::1787272930
avatar.md::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>::
content_moderation.md::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>::
editor.md::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>::
networking.md::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>::
pc2_test_level.md::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>::
scripting_sandbox.md::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>::
world.md::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>::
::res://docs/checkpoints/::1787272930
PC1.md::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>::
::res://docs/planning/::1787272930
client-server-wiring.md::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>::
economy.md::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>::
social.md::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>::
::res://docs/testing/::1787275036
README.md::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>::
test_inventory.md::TextFile::-1::1787275036::0::1::::<><><>0<>0<><>::
::res://scenes/::1787275036
game.tscn::PackedScene::-1::1787272930::0::1::::<><><>0<>0<><>::res://scenes/worlds/test_level.tscn<>res://scenes/player.tscn
main.tscn::PackedScene::-1::1787272930::0::1::::<><><>0<>0<><>::res://scripts/chunk_stream_manager.gd
main_menu.tscn::PackedScene::-1::1787272930::0::1::::<><><>0<>0<><>::res://scripts/main_menu.gd
player.tscn::PackedScene::-1::1787272930::0::1::::<><><>0<>0<><>::res://scripts/player.gd
server.tscn::PackedScene::-1::1787275036::0::1::::<><><>0<>0<><>::res://scripts/server.gd
server_player.tscn::PackedScene::-1::1787275036::0::1::::<><><>0<>0<><>::res://scripts/player_spawner.gd
world_list.tscn::PackedScene::-1::1787272930::0::1::::<><><>0<>0<><>::res://scripts/world_list.gd
::res://scenes/worlds/::1787272930
test_level.tscn::PackedScene::-1::1787272930::0::1::::<><><>0<>0<><>::
::res://scripts/::1787275098
api_client.gd::GDScript::5412288119287126975::1787272930::0::1::::<>Node<><>0<>0<><>::
chunk.gd::GDScript::7364239597441807588::1787272930::0::1::::Chunk<>Node3D<><>0<>0<><>::
chunk_data_decoder.gd::GDScript::3922068956043581888::1787272930::0::1::::ChunkDataDecoder<>RefCounted<><>0<>0<><>::
chunk_scene.gd::GDScript::9207602004456600331::1787272930::0::1::::ChunkScene<>Node3D<><>0<>0<><>::
chunk_stream_manager.gd::GDScript::2366645676568508746::1787272930::0::1::::ChunkStreamManager<>Node<><>0<>0<><>::
dynamic_object_node.gd::GDScript::8863349716576659118::1787272930::0::1::::DynamicObjectNode<>Node3D<><>0<>0<><>::
main_menu.gd::GDScript::2834562513277893052::1787272930::0::1::::<>Control<><>0<>0<><>::
network_client.gd::GDScript::5408296350353838185::1787275098::0::1::::NetworkClient<>Node<><>0<>0<><>::
network_player_proxy.gd::GDScript::4787436336771232797::1787275036::0::1::::<>Node<><>0<>0<><>::
network_receiver_proxy.gd::GDScript::6742760436861380520::1787275036::0::1::::<>Node<><>0<>0<><>::
player.gd::GDScript::4510795778937304264::1787275036::0::1::::Player<>CharacterBody3D<><>0<>0<><>::
player_spawner.gd::GDScript::3637338339553764865::1787275036::0::1::::PlayerNode<>CharacterBody3D<><>0<>0<><>::
server.gd::GDScript::6336910781803684784::1787275036::0::1::::<>Node<><>0<>0<><>::
smoke_test_api.gd::GDScript::1760923834350699002::1787272930::0::1::::<>SceneTree<><>0<>0<><>::
smoke_test_auth.gd::GDScript::3027816090663230577::1787272930::0::1::::<>SceneTree<><>0<>0<><>::
smoke_test_e2e.gd::GDScript::9086409960595132737::1787272930::0::1::::<>SceneTree<><>0<>0<><>::
smoke_test_enter_world.gd::GDScript::4171815758762850910::1787275036::0::1::::<>SceneTree<><>0<>0<><>::
smoke_test_main_menu_back.gd::GDScript::4436937414562273157::1787272930::0::1::::<>SceneTree<><>0<>0<><>::
smoke_test_main_menu_full_flow.gd::GDScript::1948803640558784862::1787272930::0::1::::<>SceneTree<><>0<>0<><>::
smoke_test_main_menu_ui.gd::GDScript::396938597300585484::1787272930::0::1::::<>SceneTree<><>0<>0<><>::
smoke_test_network_client.gd::GDScript::3109386529184855644::1787275067::0::1::::<>SceneTree<><>0<>0<><>::
smoke_test_player.gd::GDScript::3497042634752676337::1787272930::0::1::::<>SceneTree<><>0<>0<><>::
smoke_test_test_level.gd::GDScript::2607555612829866949::1787272930::0::1::::<>SceneTree<><>0<>0<><>::
smoke_test_world_list.gd::GDScript::4300757286283894147::1787272930::0::1::::<>SceneTree<><>0<>0<><>::
static_object_node.gd::GDScript::5276389741929225897::1787272930::0::1::::StaticObjectNode<>Node3D<><>0<>0<><>::
tile_def.gd::GDScript::2430528079284085109::1787272930::0::1::::TileDef<>RefCounted<><>0<>0<><>::
tile_layer.gd::GDScript::4797906255131447844::1787272930::0::1::::TileLayer<>RefCounted<><>0<>0<><>::
tile_layer_node.gd::GDScript::9033225196818247616::1787272930::0::1::::TileLayerNode<>Node3D<><>0<>0<><>::
world_list.gd::GDScript::6119591848847211153::1787275036::0::1::::<>Control<><>0<>0<><>::
world_object.gd::GDScript::6429674491464200335::1787272930::0::1::::WorldObject<>RefCounted<><>0<>0<><>::
@@ -1,5 +0,0 @@
[folding]
node_unfolds=[NodePath("."), PackedStringArray("Layout"), NodePath("CenterContainer"), PackedStringArray("Layout"), NodePath("CenterContainer/VBoxContainer"), PackedStringArray("Layout", "Theme Overrides"), NodePath("CenterContainer/VBoxContainer/TitleLabel"), PackedStringArray("Layout", "Theme Overrides"), NodePath("CenterContainer/VBoxContainer/CustomizeButton"), PackedStringArray("Layout"), NodePath("CenterContainer/VBoxContainer/PlayButton"), PackedStringArray("Layout"), NodePath("CenterContainer/VBoxContainer/QuitButton"), PackedStringArray("Layout")]
resource_unfolds=[]
nodes_folded=[]
-11
View File
@@ -1,11 +0,0 @@
[game_view]
select_mode=0
[editor_metadata]
executable_path="/home/daddy/godot/godot"
[recent_files]
scenes=["res://scenes/main_menu.tscn", "res://scenes/server.tscn"]
@@ -1,5 +0,0 @@
[folding]
node_unfolds=[]
resource_unfolds=[]
nodes_folded=[]
-105
View File
@@ -1,105 +0,0 @@
list=[{
"base": &"Node3D",
"class": &"Chunk",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://scripts/chunk.gd"
}, {
"base": &"RefCounted",
"class": &"ChunkDataDecoder",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://scripts/chunk_data_decoder.gd"
}, {
"base": &"Node3D",
"class": &"ChunkScene",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://scripts/chunk_scene.gd"
}, {
"base": &"Node",
"class": &"ChunkStreamManager",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://scripts/chunk_stream_manager.gd"
}, {
"base": &"Node3D",
"class": &"DynamicObjectNode",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://scripts/dynamic_object_node.gd"
}, {
"base": &"Node",
"class": &"NetworkClient",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://scripts/network_client.gd"
}, {
"base": &"CharacterBody3D",
"class": &"Player",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://scripts/player.gd"
}, {
"base": &"CharacterBody3D",
"class": &"PlayerNode",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://scripts/player_spawner.gd"
}, {
"base": &"Node3D",
"class": &"StaticObjectNode",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://scripts/static_object_node.gd"
}, {
"base": &"RefCounted",
"class": &"TileDef",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://scripts/tile_def.gd"
}, {
"base": &"RefCounted",
"class": &"TileLayer",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://scripts/tile_layer.gd"
}, {
"base": &"Node3D",
"class": &"TileLayerNode",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://scripts/tile_layer_node.gd"
}, {
"base": &"RefCounted",
"class": &"WorldObject",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://scripts/world_object.gd"
}]
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,42 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://bm01jsuomi50i"
path="res://.godot/imported/accessory_belt_01.glb-a2b6be72b4422067d290c84868489d2d.scn"
[deps]
source_file="res://assets/avatars/accessory_belt_01.glb"
dest_files=["res://.godot/imported/accessory_belt_01.glb-a2b6be72b4422067d290c84868489d2d.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
Binary file not shown.
@@ -0,0 +1,42 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://dvou487niwnjt"
path="res://.godot/imported/accessory_necklace_01.glb-75d713dbb1ff9579d6f0bf8b7ddde1e2.scn"
[deps]
source_file="res://assets/avatars/accessory_necklace_01.glb"
dest_files=["res://.godot/imported/accessory_necklace_01.glb-75d713dbb1ff9579d6f0bf8b7ddde1e2.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
Binary file not shown.
@@ -0,0 +1,42 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://dknbtq33pssf4"
path="res://.godot/imported/back_backpack_01.glb-d0ccc39aed8e86403d649c8d07008591.scn"
[deps]
source_file="res://assets/avatars/back_backpack_01.glb"
dest_files=["res://.godot/imported/back_backpack_01.glb-d0ccc39aed8e86403d649c8d07008591.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
Binary file not shown.
@@ -0,0 +1,42 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://brfp6g6pcbbw7"
path="res://.godot/imported/base_human_male_01.glb-c917b85c8f8ab5d1974812a6891f052f.scn"
[deps]
source_file="res://assets/avatars/base_human_male_01.glb"
dest_files=["res://.godot/imported/base_human_male_01.glb-c917b85c8f8ab5d1974812a6891f052f.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
+1 -1
View File
@@ -8,7 +8,7 @@
"display_name": "Human Male Base"
},
"head_cyber_01": {
"type": "body_part",
"type": "attachment",
"slot": "head",
"path": "res://assets/avatars/head_cyber_01.glb",
"version": "1.0.0",
Binary file not shown.
+42
View File
@@ -0,0 +1,42 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://bva2etlpa8act"
path="res://.godot/imported/chest_bandit_01.glb-90867999feb79e02c469ef45d970f0e1.scn"
[deps]
source_file="res://assets/avatars/chest_bandit_01.glb"
dest_files=["res://.godot/imported/chest_bandit_01.glb-90867999feb79e02c469ef45d970f0e1.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
Binary file not shown.
+42
View File
@@ -0,0 +1,42 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://ctwcxf552y325"
path="res://.godot/imported/face_glasses_01.glb-fe06ebb12e6caf265dce06de37a74fc5.scn"
[deps]
source_file="res://assets/avatars/face_glasses_01.glb"
dest_files=["res://.godot/imported/face_glasses_01.glb-fe06ebb12e6caf265dce06de37a74fc5.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
Binary file not shown.
+42
View File
@@ -0,0 +1,42 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://j4dc6l3l0j6f"
path="res://.godot/imported/hair_mohawk_01.glb-f2a6883730b5d02facb56d1dbb0e371e.scn"
[deps]
source_file="res://assets/avatars/hair_mohawk_01.glb"
dest_files=["res://.godot/imported/hair_mohawk_01.glb-f2a6883730b5d02facb56d1dbb0e371e.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
Binary file not shown.
@@ -0,0 +1,42 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://dp4vqp2trrank"
path="res://.godot/imported/hair_ponytail_01.glb-cdf5913fc63278b1dd362cb640c2ecb4.scn"
[deps]
source_file="res://assets/avatars/hair_ponytail_01.glb"
dest_files=["res://.godot/imported/hair_ponytail_01.glb-cdf5913fc63278b1dd362cb640c2ecb4.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
Binary file not shown.
+42
View File
@@ -0,0 +1,42 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://bkp35fnns36xb"
path="res://.godot/imported/head_cyber_01.glb-8081a99d6b31c121d208dd2f9629df5c.scn"
[deps]
source_file="res://assets/avatars/head_cyber_01.glb"
dest_files=["res://.godot/imported/head_cyber_01.glb-8081a99d6b31c121d208dd2f9629df5c.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
Binary file not shown.
@@ -0,0 +1,42 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://bgfjpdu8kg0aj"
path="res://.godot/imported/left_foot_boot_01.glb-cab36c9e44befe0a3ff50ee651f2ff67.scn"
[deps]
source_file="res://assets/avatars/left_foot_boot_01.glb"
dest_files=["res://.godot/imported/left_foot_boot_01.glb-cab36c9e44befe0a3ff50ee651f2ff67.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
Binary file not shown.
@@ -0,0 +1,42 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://dtq7tgio1iymd"
path="res://.godot/imported/left_hand_glove_01.glb-4b237bea3299c6604547afa353a7b602.scn"
[deps]
source_file="res://assets/avatars/left_hand_glove_01.glb"
dest_files=["res://.godot/imported/left_hand_glove_01.glb-4b237bea3299c6604547afa353a7b602.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
Binary file not shown.
+42
View File
@@ -0,0 +1,42 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://yo204q1m6hs3"
path="res://.godot/imported/pants_cargo_01.glb-00f3e3b72fd692344cbb8bfd2b8449f9.scn"
[deps]
source_file="res://assets/avatars/pants_cargo_01.glb"
dest_files=["res://.godot/imported/pants_cargo_01.glb-00f3e3b72fd692344cbb8bfd2b8449f9.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
Binary file not shown.
@@ -0,0 +1,42 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://30ij0eapm6ti"
path="res://.godot/imported/right_foot_boot_01.glb-ffff27e561e5632596b7ba89fee5b5ea.scn"
[deps]
source_file="res://assets/avatars/right_foot_boot_01.glb"
dest_files=["res://.godot/imported/right_foot_boot_01.glb-ffff27e561e5632596b7ba89fee5b5ea.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
Binary file not shown.
@@ -0,0 +1,42 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://bcrtxtp47dv8h"
path="res://.godot/imported/right_hand_glove_01.glb-a59875b630ac413350c3bdf6acc84bdf.scn"
[deps]
source_file="res://assets/avatars/right_hand_glove_01.glb"
dest_files=["res://.godot/imported/right_hand_glove_01.glb-a59875b630ac413350c3bdf6acc84bdf.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
+1 -1
View File
@@ -8,7 +8,7 @@ from sqlalchemy.ext.asyncio import create_async_engine
from app.config import settings
from app.database import Base
from app.models import user, session, world, asset # noqa: F401
from app.models import user, session, world, asset, avatar_loadout # noqa: F401
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
@@ -0,0 +1,39 @@
"""add avatar_loadouts table
Revision ID: 0004_add_avatar_loadouts_table
Revises: 0003_add_refresh_tokens_table
Create Date: 2026-09-05 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = "0004_add_avatar_loadouts_table"
down_revision: Union[str, None] = "0003_add_refresh_tokens_table"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Create the avatar_loadouts table (one row per user)."""
op.create_table(
"avatar_loadouts",
sa.Column(
"user_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"),
primary_key=True,
),
sa.Column("data", postgresql.JSONB(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
def downgrade() -> None:
"""Drop the avatar_loadouts table."""
op.drop_table("avatar_loadouts")
+25
View File
@@ -75,11 +75,36 @@ class Settings(BaseSettings):
# CORS
CORS_ORIGINS: str = "http://localhost:8080,http://localhost:3000"
# Per-IP auth rate limiting (app/rate_limit.py). Disabled by the
# checkpoint smoke suite, which runs every test from one client IP.
RATE_LIMIT_ENABLED: bool = True
# File uploads
UPLOAD_DIR: str = "uploads"
MAX_UPLOAD_SIZE_MB: int = 100
GLTF_MAX_FILE_SIZE_MB: int = 200
# ── Game-server orchestration ──────────────────────────────────────────
# The join flow (POST /api/worlds/{id}/join) ensures a headless Godot
# dedicated-server process exists for a world and returns its address.
# Disabled by default so nothing tries to spawn Godot in environments
# that don't have it (plain pytest, CI without the engine); the
# checkpoint/playtest launcher turns it on.
GAME_SERVERS_ENABLED: bool = False
# Path to the Godot 4 binary. "godot" resolves via PATH.
GODOT_BIN: str = str(Path.home() / "godot" / "godot")
# Godot project directory (the game repo root -- backend/ is a subdir).
GAME_PROJECT_DIR: str = str(Path(__file__).resolve().parents[2])
# The host clients should connect to for ENet. Defaults to the LAN IP the
# launcher bakes into the exe; overridable for local testing.
GAME_SERVER_HOST: str = "127.0.0.1"
# Port range for per-world dedicated servers.
GAME_SERVER_PORT_MIN: int = 27000
GAME_SERVER_PORT_MAX: int = 27099
# Kill a world's server this long after its last join with no active
# players reported.
GAME_SERVER_IDLE_TIMEOUT_SECONDS: int = 300
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
+7 -3
View File
@@ -6,15 +6,18 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.config import settings
from app.routers import auth, users, worlds, assets
from app.routers import auth, users, worlds, assets, avatar
from app.services.game_servers import supervisor
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application lifespan: startup and shutdown events."""
# Startup: initialize database, connect to Redis, etc.
supervisor.start()
yield
# Shutdown: close connections, cleanup
await supervisor.shutdown()
app = FastAPI(
@@ -38,6 +41,7 @@ app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
app.include_router(users.router, prefix="/api/users", tags=["users"])
app.include_router(worlds.router, prefix="/api/worlds", tags=["worlds"])
app.include_router(assets.router, prefix="/api/assets", tags=["assets"])
app.include_router(avatar.router, prefix="/api/avatar", tags=["avatar"])
@app.get("/api/health")
+2 -1
View File
@@ -2,5 +2,6 @@ from app.models.user import User
from app.models.session import Session
from app.models.world import World
from app.models.asset import Asset
from app.models.avatar_loadout import AvatarLoadout
__all__ = ["User", "Session", "World", "Asset"]
__all__ = ["User", "Session", "World", "Asset", "AvatarLoadout"]
+41
View File
@@ -0,0 +1,41 @@
from sqlalchemy import Column, Integer, ForeignKey, DateTime
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.database import Base
# The default a brand-new account gets before they touch the customization
# screen. Kept in sync with assets/avatars/catalog.json (the base_body id) and
# with the client's AvatarLoadout.to_json() shape.
DEFAULT_LOADOUT: dict = {
"base_body": "base_human_male_01",
"slots": {},
}
class AvatarLoadout(Base):
"""A user's equipped avatar composition.
One row per user. The whole loadout is stored as a single JSON document
matching the client's ``AvatarLoadout.to_json()`` shape
(``{"base_body": str, "slots": {...}, "skin_tone"?: {...}}``) rather than
exploded into columns -- the server has no copy of the asset catalog, so
deep validation (does this asset exist, is it valid for this slot) stays
on the client. The server only enforces the envelope: valid JSON object
with a non-empty ``base_body``.
"""
__tablename__ = "avatar_loadouts"
user_id = Column(
Integer,
ForeignKey("users.id", ondelete="CASCADE"),
primary_key=True,
)
data = Column(JSONB, nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
user = relationship("User", back_populates="avatar_loadout")
+6
View File
@@ -24,6 +24,12 @@ class User(Base):
refresh_tokens = relationship("RefreshToken", back_populates="user", cascade="all, delete-orphan")
worlds = relationship("World", back_populates="owner")
assets = relationship("Asset", back_populates="owner")
avatar_loadout = relationship(
"AvatarLoadout",
back_populates="user",
uselist=False,
cascade="all, delete-orphan",
)
@property
def is_adult(self) -> bool:
+73
View File
@@ -0,0 +1,73 @@
"""Lightweight in-process rate limiting.
The backend runs single-worker (required by the game-server supervisor), so a
process-local sliding-window counter is sufficient and needs no Redis. Keyed
by client IP. Used to blunt credential brute-force on the auth endpoints.
If the backend is ever scaled to multiple workers/hosts this must move to a
shared store (Redis) -- flagged here rather than silently under-enforcing.
"""
from __future__ import annotations
import time
from collections import defaultdict, deque
from fastapi import HTTPException, Request, status
from app.config import settings
class SlidingWindowLimiter:
def __init__(self, max_hits: int, window_seconds: float) -> None:
self.max_hits = max_hits
self.window = window_seconds
self._hits: dict[str, deque[float]] = defaultdict(deque)
def check(self, key: str) -> None:
now = time.monotonic()
q = self._hits[key]
cutoff = now - self.window
while q and q[0] < cutoff:
q.popleft()
if len(q) >= self.max_hits:
retry = max(1, int(q[0] + self.window - now))
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Too many attempts. Try again shortly.",
headers={"Retry-After": str(retry)},
)
q.append(now)
def reset(self) -> None:
self._hits.clear()
def _client_key(request: Request) -> str:
# X-Forwarded-For first hop when behind the reverse proxy, else the socket.
fwd = request.headers.get("x-forwarded-for", "")
if fwd:
return fwd.split(",")[0].strip()
return request.client.host if request.client else "unknown"
# 10 auth attempts / minute / IP -- generous for a human, painful for a
# brute-forcer.
_auth_limiter = SlidingWindowLimiter(max_hits=10, window_seconds=60)
async def auth_rate_limit(request: Request) -> None:
"""FastAPI dependency: rate-limit auth attempts per client IP.
A no-op when ``RATE_LIMIT_ENABLED`` is false -- the checkpoint smoke
suite runs every test against one backend from one client IP, so the
per-IP cap would trip mid-suite. The dedicated ``test_rate_limit``
module exercises the real behaviour regardless of this flag.
"""
if not settings.RATE_LIMIT_ENABLED:
return
_auth_limiter.check(f"auth:{_client_key(request)}")
def reset_all() -> None:
"""Test hook."""
_auth_limiter.reset()
+29 -17
View File
@@ -16,11 +16,15 @@ from app.database import get_db
from app.dependencies import CurrentUser
from app.models.session import RefreshToken
from app.models.user import User
from app.rate_limit import auth_rate_limit
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
router = APIRouter()
## Minimum age to register. This platform is adults-only.
MIN_AGE = 18
# --- Pydantic schemas ---
@@ -39,14 +43,19 @@ class RegisterRequest(BaseModel):
username: str
email: EmailStr
password: str
over_18: bool # Simple boolean confirmation instead of date_of_birth
# Real date of birth -- the server computes age from it and rejects
# anyone under MIN_AGE. Still self-attested (no document check yet), but
# a concrete, stored, legally-meaningful data point rather than a
# throwaway checkbox, and the seam for a future hard ID-verification
# pass. See docs/architecture/content_moderation.md 5.2.
date_of_birth: date
class UserResponse(BaseModel):
id: int
username: str
email: str
date_of_birth: str | None = None
date_of_birth: date | None = None
is_active: bool
created_at: datetime | None = None
@@ -108,15 +117,9 @@ def hash_password(password: str) -> str:
def calculate_age(birth_date: date, today: date | None = None) -> int:
"""Calculate age in years from a birth date.
Not called by /register anymore (that endpoint now just trusts the
client's over_18 boolean, see RegisterRequest) -- kept as a real,
tested utility since date-of-birth storage/verification is coming
back in a future pass (real age math, not just a client-side claim,
the user explicitly wants to revisit this design). Removing it broke
backend/tests/test_auth_age_gate.py's collection entirely (ImportError
on `from app.routers.auth import calculate_age`) for zero benefit --
restoring it is the lower-risk path over rewriting or deleting 17
tests for logic this codebase will need again.
Used by /register (PC3 Phase 3) to compute age from the submitted
date_of_birth and enforce the 18+ gate. See
docs/architecture/content_moderation.md 5.2.
Args:
birth_date: The person's date of birth.
@@ -151,16 +154,25 @@ def _issue_refresh_token(user_id: int, db: AsyncSession) -> str:
# --- Endpoints ---
@router.post("/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED)
@router.post("/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED,
dependencies=[Depends(auth_rate_limit)])
async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
"""Register a new user and return access + refresh tokens.
Requires over_18 boolean confirmation; rejects users who are not 18+.
Requires a date of birth; the server computes age and rejects anyone
under MIN_AGE (18). See docs/architecture/content_moderation.md for the
verification design and its self-attestation limitation.
"""
if not req.over_18:
age = calculate_age(req.date_of_birth)
if age < 0 or req.date_of_birth > date.today():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Must be 18 or older to register",
detail="Invalid date of birth",
)
if age < MIN_AGE:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"You must be at least {MIN_AGE} to use this platform",
)
# Check if username already exists
@@ -183,7 +195,7 @@ async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
username=req.username,
email=req.email,
hashed_password=hash_password(req.password),
date_of_birth=None, # No longer collecting DOB
date_of_birth=req.date_of_birth,
)
db.add(user)
await db.flush() # flush to get user.id before creating refresh token
@@ -203,7 +215,7 @@ async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
)
@router.post("/login", response_model=TokenResponse)
@router.post("/login", response_model=TokenResponse, dependencies=[Depends(auth_rate_limit)])
async def login(form: OAuth2PasswordRequestForm = Depends(), db: AsyncSession = Depends(get_db)):
"""Authenticate user and return access + refresh tokens.
+78
View File
@@ -0,0 +1,78 @@
"""Avatar router - the current user's persisted avatar loadout.
The loadout is a single JSON document matching the client's
``AvatarLoadout.to_json()`` shape. The server has no copy of the asset
catalog, so it only validates the envelope (JSON object, non-empty
``base_body``); which assets are valid for which slot is the client's job.
"""
from typing import Annotated, Any
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.dependencies import CurrentUser
from app.models.avatar_loadout import AvatarLoadout, DEFAULT_LOADOUT
router = APIRouter()
DbDep = Annotated[AsyncSession, Depends(get_db)]
# A loadout document is small; reject anything absurd outright.
MAX_LOADOUT_BYTES = 16 * 1024
class LoadoutResponse(BaseModel):
data: dict[str, Any]
is_default: bool = Field(
description="True when the user has never saved a loadout and this is "
"the server default."
)
class LoadoutUpdate(BaseModel):
data: dict[str, Any]
def _validate_envelope(data: dict[str, Any]) -> None:
base_body = data.get("base_body")
if not isinstance(base_body, str) or not base_body.strip():
raise HTTPException(
status_code=422,
detail="loadout.data must have a non-empty string 'base_body'",
)
slots = data.get("slots", {})
if not isinstance(slots, dict):
raise HTTPException(status_code=422, detail="loadout.data.slots must be an object")
if len(str(data)) > MAX_LOADOUT_BYTES:
raise HTTPException(status_code=413, detail="loadout document too large")
@router.get("/loadout", response_model=LoadoutResponse)
async def get_loadout(current_user: CurrentUser, db: DbDep):
"""Return the current user's saved loadout, or the server default if they
have never saved one."""
row = await db.get(AvatarLoadout, current_user.id)
if row is None:
return LoadoutResponse(data=dict(DEFAULT_LOADOUT), is_default=True)
return LoadoutResponse(data=row.data, is_default=False)
@router.put("/loadout", response_model=LoadoutResponse)
async def put_loadout(payload: LoadoutUpdate, current_user: CurrentUser, db: DbDep):
"""Replace the current user's loadout."""
_validate_envelope(payload.data)
row = await db.get(AvatarLoadout, current_user.id)
if row is None:
row = AvatarLoadout(user_id=current_user.id, data=payload.data)
db.add(row)
else:
row.data = payload.data
await db.commit()
await db.refresh(row)
return LoadoutResponse(data=row.data, is_default=False)
+35
View File
@@ -12,6 +12,7 @@ from sqlalchemy.orm import selectinload
from app.database import get_db
from app.dependencies import CurrentUser
from app.models.world import World, WorldVisibility
from app.services.game_servers import supervisor
router = APIRouter()
@@ -106,6 +107,40 @@ async def get_world(
return world
class JoinResponse(BaseModel):
"""Address of the dedicated server hosting this world."""
world_id: int
host: str
port: int
@router.post("/{world_id}/join", response_model=JoinResponse)
async def join_world(
world_id: int,
current_user: CurrentUser,
db: DbDep,
):
"""Ensure a dedicated game server is running for this world and return its
ENet address for the client to connect to.
503 if game-server orchestration isn't enabled on this backend (e.g. a
plain dev/test instance with no Godot binary).
"""
result = await db.execute(select(World).where(World.id == world_id))
world = result.scalar_one_or_none()
if not world:
raise HTTPException(status_code=404, detail="World not found")
if world.visibility != WorldVisibility.PUBLIC and world.owner_id != current_user.id:
raise HTTPException(status_code=404, detail="World not found")
try:
info = await supervisor.ensure_server(str(world_id))
except RuntimeError as exc:
raise HTTPException(status_code=503, detail=str(exc))
return JoinResponse(world_id=world_id, host=info["host"], port=info["port"])
@router.post("/", response_model=WorldResponse, status_code=201)
async def create_world(
world_data: WorldCreate,
+182
View File
@@ -0,0 +1,182 @@
"""Game-server orchestration.
On ``POST /api/worlds/{id}/join`` the backend ensures a headless Godot
dedicated-server process is running for that world and returns its address.
One process per world, keyed by ``world_id``. A background reaper kills a
world's server once its status file reports zero players for
``GAME_SERVER_IDLE_TIMEOUT_SECONDS``.
This lives in-process (a module-level singleton) rather than as a separate
service, so the backend MUST run single-worker -- which it does
(``uvicorn app.main:app`` with no ``--workers``). The Godot binary and
project are on the host (see ``settings.GODOT_BIN`` / ``GAME_PROJECT_DIR``);
the backend spawns them as subprocesses.
Design notes / limits (PC3):
- All worlds currently load the same test level (per-world content is M2/M7).
``world_id`` still scopes a distinct server process + session.
- Port allocation is a linear scan of the configured range.
- No crash-restart: if a server process dies, the next join respawns it.
"""
from __future__ import annotations
import asyncio
import json
import socket
import subprocess
import tempfile
import time
from dataclasses import dataclass, field
from pathlib import Path
from app.config import settings
@dataclass
class _WorldServer:
world_id: str
port: int
proc: subprocess.Popen
status_path: Path
started_at: float = field(default_factory=time.time)
last_join: float = field(default_factory=time.time)
def status(self) -> dict:
try:
return json.loads(self.status_path.read_text())
except (OSError, ValueError):
return {}
def player_count(self) -> int:
return int(self.status().get("players", 0))
def alive(self) -> bool:
return self.proc.poll() is None
def stop(self) -> None:
if self.alive():
self.proc.terminate()
try:
self.proc.wait(timeout=10)
except subprocess.TimeoutExpired:
self.proc.kill()
self.status_path.unlink(missing_ok=True)
class GameServerSupervisor:
def __init__(self) -> None:
self._servers: dict[str, _WorldServer] = {}
self._lock = asyncio.Lock()
self._reaper: asyncio.Task | None = None
self._runtime_dir = Path(tempfile.gettempdir()) / "agp-game-servers"
self._runtime_dir.mkdir(parents=True, exist_ok=True)
# ── lifecycle ────────────────────────────────────────────────────────
def start(self) -> None:
if not settings.GAME_SERVERS_ENABLED:
return
if self._reaper is None or self._reaper.done():
self._reaper = asyncio.create_task(self._reap_loop())
async def shutdown(self) -> None:
if self._reaper:
self._reaper.cancel()
async with self._lock:
for srv in list(self._servers.values()):
srv.stop()
self._servers.clear()
# ── join ─────────────────────────────────────────────────────────────
async def ensure_server(self, world_id: str) -> dict:
"""Return ``{"host", "port", "world_id"}`` for the world's dedicated
server, spawning it if needed. Raises RuntimeError if orchestration
is disabled or no port/engine is available."""
if not settings.GAME_SERVERS_ENABLED:
raise RuntimeError("game servers are not enabled on this backend")
async with self._lock:
srv = self._servers.get(world_id)
if srv is not None and not srv.alive():
srv.stop()
srv = None
del self._servers[world_id]
if srv is None:
srv = self._spawn(world_id)
self._servers[world_id] = srv
# Give the engine a moment to bind the ENet port before we
# hand the address to a client.
await self._await_port(srv.port)
srv.last_join = time.time()
return {"host": settings.GAME_SERVER_HOST, "port": srv.port, "world_id": world_id}
# ── internals ────────────────────────────────────────────────────────
def _spawn(self, world_id: str) -> _WorldServer:
port = self._free_port()
status_path = self._runtime_dir / f"world_{world_id}_{port}.json"
status_path.unlink(missing_ok=True)
cmd = [
settings.GODOT_BIN, "--headless",
"--path", settings.GAME_PROJECT_DIR,
"res://scenes/server.tscn", "--",
"--port", str(port),
"--world", str(world_id),
"--status-file", str(status_path),
]
proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
return _WorldServer(world_id=world_id, port=port, proc=proc, status_path=status_path)
def _free_port(self) -> int:
used = {s.port for s in self._servers.values()}
for p in range(settings.GAME_SERVER_PORT_MIN, settings.GAME_SERVER_PORT_MAX + 1):
if p in used:
continue
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
try:
s.bind(("0.0.0.0", p))
return p
except OSError:
continue
raise RuntimeError("no free game-server port in the configured range")
async def _await_port(self, port: int, timeout: float = 8.0) -> None:
deadline = time.time() + timeout
while time.time() < deadline:
# ENet is UDP; a bind failure means something (our server) has it.
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
try:
s.bind(("0.0.0.0", port))
except OSError:
return
await asyncio.sleep(0.25)
async def _reap_loop(self) -> None:
try:
while True:
await asyncio.sleep(30)
async with self._lock:
now = time.time()
for wid, srv in list(self._servers.items()):
if not srv.alive():
srv.stop()
del self._servers[wid]
continue
idle_for = now - srv.last_join
if idle_for > settings.GAME_SERVER_IDLE_TIMEOUT_SECONDS and srv.player_count() == 0:
srv.stop()
del self._servers[wid]
except asyncio.CancelledError:
pass
def debug_state(self) -> list[dict]:
return [
{
"world_id": s.world_id, "port": s.port, "alive": s.alive(),
"players": s.player_count(), "uptime_s": round(time.time() - s.started_at),
}
for s in self._servers.values()
]
supervisor = GameServerSupervisor()
+150 -14
View File
@@ -1,38 +1,174 @@
"""Shared pytest fixtures.
"""Shared pytest fixtures + hermetic-DB bootstrap.
The auth integration tests hit the *real* Postgres database (per
``DATABASE_URL``), so they persist data between runs. Without cleanup a
second run of the suite fails with 409 conflicts because the users it
registered on the first run already exist. The session-scoped fixture below
truncates all application tables once before the first test, making the suite
re-runnable.
The integration tests hit a real Postgres (Postgres-specific types: JSONB,
native ENUMs). To make ``cd backend && pytest`` work from a clean checkout
with no manual setup, ``pytest_configure`` below brings up an ephemeral
Postgres via docker compose and runs the migrations IF ``DATABASE_URL`` is
not already pointing at a reachable database. When it is (dev loop, or the
checkpoint runner which stands the DB up itself), that path is skipped and
the existing database is reused.
Truncation order doesn't strictly matter because ``CASCADE`` follows foreign
keys, but the tables are listed explicitly for clarity.
If Docker isn't available and no DB is reachable, the whole DB-backed suite
is skipped with a clear message rather than erroring out 60+ times.
"""
import asyncio
import os
import socket
import subprocess
import sys
import time
from pathlib import Path
from urllib.parse import urlsplit
import pytest
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
from app.config import settings
from app.database import get_db
from app.main import app
TEST_TABLES = ("assets", "worlds", "sessions", "refresh_tokens", "users")
TEST_TABLES = ("avatar_loadouts", "assets", "worlds", "sessions", "refresh_tokens", "users")
BACKEND_DIR = Path(__file__).resolve().parents[1]
_COMPOSE_PROJECT = "agp-pytest"
_EPHEMERAL_PG_PORT = 25432
# Set by _ensure_database() when it starts a container it owns; torn down in
# pytest_unconfigure.
_started_container = False
_db_unavailable_reason: str | None = None
def _port_open(host: str, port: int, timeout: float = 1.0) -> bool:
try:
with socket.create_connection((host, port), timeout=timeout):
return True
except OSError:
return False
def _db_reachable(url: str) -> bool:
parts = urlsplit(url)
return _port_open(parts.hostname or "localhost", parts.port or 5432)
def _run(cmd: list[str], **kw) -> subprocess.CompletedProcess:
return subprocess.run(cmd, cwd=BACKEND_DIR, capture_output=True, text=True, **kw)
def pytest_configure(config):
global _started_container, _db_unavailable_reason
if _db_reachable(settings.DATABASE_URL):
return # dev loop / checkpoint runner already provided one
if not _run(["docker", "--version"]).returncode == 0:
_db_unavailable_reason = (
"no reachable DATABASE_URL and docker is unavailable -- run the "
"checkpoint suite (scripts/run_checkpoint_tests.py) or start Postgres "
"manually"
)
return
env = os.environ.copy()
env["COMPOSE_PROJECT_NAME"] = _COMPOSE_PROJECT
env["POSTGRES_HOST_PORT"] = str(_EPHEMERAL_PG_PORT)
env["POSTGRES_PORT"] = str(_EPHEMERAL_PG_PORT)
up = _run(["docker", "compose", "up", "-d", "postgres"], env=env)
if up.returncode != 0:
_db_unavailable_reason = f"docker compose up postgres failed:\n{up.stderr[-800:]}"
return
_started_container = True
for _ in range(30):
r = _run(
["docker", "compose", "exec", "-T", "postgres",
"pg_isready", "-U", "agp_user", "-d", "agp_db"],
env=env,
)
if r.returncode == 0:
break
time.sleep(1)
else:
_db_unavailable_reason = "ephemeral Postgres never became ready"
return
url = f"postgresql+asyncpg://agp_user:agp_password@localhost:{_EPHEMERAL_PG_PORT}/agp_db"
os.environ["DATABASE_URL"] = url
settings.DATABASE_URL = url
mig = _run([sys.executable, "-m", "alembic", "upgrade", "head"],
env={**os.environ, "DATABASE_URL": url})
if mig.returncode != 0:
_db_unavailable_reason = f"alembic upgrade head failed:\n{mig.stderr[-800:]}"
def pytest_unconfigure(config):
if not _started_container:
return
env = os.environ.copy()
env["COMPOSE_PROJECT_NAME"] = _COMPOSE_PROJECT
env["POSTGRES_HOST_PORT"] = str(_EPHEMERAL_PG_PORT)
env["POSTGRES_PORT"] = str(_EPHEMERAL_PG_PORT)
_run(["docker", "compose", "down", "-v"], env=env)
@pytest.fixture(scope="session", autouse=True)
def _reset_database():
"""Truncate all application tables once per test session."""
if _db_unavailable_reason:
pytest.skip(_db_unavailable_reason, allow_module_level=True)
async def _truncate() -> None:
engine = create_async_engine(settings.DATABASE_URL)
async with engine.begin() as conn:
await conn.execute(
text(f"TRUNCATE TABLE {', '.join(TEST_TABLES)} CASCADE")
)
await conn.execute(text(f"TRUNCATE TABLE {', '.join(TEST_TABLES)} CASCADE"))
await engine.dispose()
asyncio.run(_truncate())
yield
@pytest.fixture(autouse=True)
def _reset_rate_limiter():
"""Every TestClient request looks like the same client IP, so the
per-IP auth limiter would trip mid-suite. Clear it between tests; the
dedicated rate-limit test disables this to exercise the real behaviour.
"""
from app import rate_limit
rate_limit.reset_all()
yield
@pytest.fixture(autouse=True)
def _override_db():
"""Override get_db so the app uses a fresh session per test.
Deliberately does NOT reuse app.database's shared engine: FastAPI's
(sync) TestClient runs each request through anyio's blocking-portal
machinery, which can hand different requests different event loops.
asyncpg connections are bound to the loop they were created on, so
reusing one from the shared pool across requests raises "attached to a
different loop" the moment a test makes more than one request. NullPool
sidesteps it: every checkout is a brand-new connection.
"""
test_engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool)
test_session_factory = async_sessionmaker(test_engine, expire_on_commit=False)
async def _get_db():
async_session = test_session_factory()
try:
yield async_session
await async_session.commit()
except Exception:
await async_session.rollback()
raise
finally:
await async_session.close()
app.dependency_overrides[get_db] = _get_db
yield
app.dependency_overrides.clear()
+76 -66
View File
@@ -3,92 +3,102 @@
from datetime import date, timedelta
import pytest
from fastapi.testclient import TestClient
from app.routers.auth import calculate_age
from app.main import app
from app.routers.auth import calculate_age, MIN_AGE, RegisterRequest
class TestCalculateAge:
"""Unit tests for the calculate_age helper."""
def test_exactly_18(self):
"""A person born exactly 18 years ago today is 18."""
today = date(2025, 6, 15)
today = date(2026, 6, 15)
dob = today.replace(year=today.year - 18)
assert calculate_age(dob, today=today) == 18
def test_just_turned_18(self):
"""A person who turned 18 yesterday is 18."""
today = date(2025, 6, 15)
today = date(2026, 6, 15)
dob = today.replace(year=today.year - 18) - timedelta(days=1)
assert calculate_age(dob, today=today) == 18
def test_one_day_short_of_18(self):
"""A person born one day after their 18th birthday is 17."""
today = date(2025, 6, 15)
today = date(2026, 6, 15)
dob = today.replace(year=today.year - 18) + timedelta(days=1)
assert calculate_age(dob, today=today) == 17
def test_17_years_old(self):
"""A person who is 17 years old today is 17."""
today = date(2025, 6, 15)
dob = date(2008, 6, 15)
assert calculate_age(dob, today=today) == 17
def test_leap_day_birthday_before(self):
assert calculate_age(date(2000, 2, 29), today=date(2024, 2, 28)) == 23
def test_65_years_old(self):
"""A person who is 65 is correctly calculated."""
today = date(2025, 6, 15)
dob = today.replace(year=today.year - 65)
assert calculate_age(dob, today=today) == 65
def test_newborn(self):
"""A newborn is 0."""
today = date(2025, 6, 15)
dob = today
assert calculate_age(dob, today=today) == 0
def test_birthday_not_yet_this_year(self):
"""Person born Dec 31, 2000 — on Jan 1, 2025 they are still 24."""
dob = date(2000, 12, 31)
assert calculate_age(dob, today=date(2025, 1, 1)) == 24
def test_birthday_today(self):
"""Person born Dec 31, 2000 — on Dec 31, 2024 they turn 24."""
dob = date(2000, 12, 31)
assert calculate_age(dob, today=date(2024, 12, 31)) == 24
def test_leap_day_birthday_after(self):
assert calculate_age(date(2000, 2, 29), today=date(2024, 3, 1)) == 24
class TestRegisterRequestOver18Field:
"""Tests for RegisterRequest's over_18 field.
class TestRegisterRequestDob:
def test_dob_parsed_from_iso_string(self):
req = RegisterRequest(username="u", email="u@example.com", password="p",
date_of_birth="1990-01-01")
assert req.date_of_birth == date(1990, 1, 1)
Replaces TestRegisterRequestDateValidation -- date_of_birth and its
field_validator were removed from RegisterRequest 2026-08-20 (interim
simplification: a plain client-side over_18 confirmation instead of
collecting/validating an actual date of birth; see calculate_age's
own docstring in app/routers/auth.py for why that function is still
here even though this endpoint no longer calls it). There's no date
format left to validate, so the equivalent coverage is just: the
field exists, is a real bool, and is required."""
def test_over_18_true_accepted(self):
from app.routers.auth import RegisterRequest
req = RegisterRequest(username="u", email="u@example.com", password="p", over_18=True)
assert req.over_18 is True
def test_over_18_false_accepted(self):
"""False is a valid value at the schema level -- the endpoint,
not the schema, is what rejects it (see
TestRegister::test_register_rejects_not_over_18 in
test_auth_endpoints.py)."""
from app.routers.auth import RegisterRequest
req = RegisterRequest(username="u", email="u@example.com", password="p", over_18=False)
assert req.over_18 is False
def test_over_18_missing_rejected(self):
"""over_18 has no default -- omitting it entirely is a validation error."""
from app.routers.auth import RegisterRequest
from pydantic import ValidationError
with pytest.raises(ValidationError):
def test_dob_required(self):
with pytest.raises(Exception):
RegisterRequest(username="u", email="u@example.com", password="p")
def test_bad_dob_string_rejected(self):
with pytest.raises(Exception):
RegisterRequest(username="u", email="u@example.com", password="p",
date_of_birth="not-a-date")
class TestRegisterAgeGate:
"""End-to-end: the /register endpoint computes age from the DOB."""
def _dob_for_age(self, years: int) -> str:
return (date.today().replace(year=date.today().year - years)).isoformat()
def test_adult_accepted(self):
client = TestClient(app)
r = client.post("/api/auth/register", json={
"username": "gate_adult", "email": "gate_adult@e.com",
"password": "secret123", "date_of_birth": self._dob_for_age(25),
})
assert r.status_code == 201, r.text
def test_minor_rejected(self):
client = TestClient(app)
r = client.post("/api/auth/register", json={
"username": "gate_minor", "email": "gate_minor@e.com",
"password": "secret123", "date_of_birth": self._dob_for_age(15),
})
assert r.status_code == 403
assert str(MIN_AGE) in r.json()["detail"]
def test_exactly_18_accepted(self):
client = TestClient(app)
r = client.post("/api/auth/register", json={
"username": "gate_18", "email": "gate_18@e.com",
"password": "secret123",
"date_of_birth": (date.today().replace(year=date.today().year - 18) - timedelta(days=1)).isoformat(),
})
assert r.status_code == 201, r.text
def test_future_dob_rejected(self):
client = TestClient(app)
r = client.post("/api/auth/register", json={
"username": "gate_future", "email": "gate_future@e.com",
"password": "secret123", "date_of_birth": "2999-01-01",
})
assert r.status_code == 400
def test_dob_is_stored(self):
client = TestClient(app)
dob = self._dob_for_age(30)
reg = client.post("/api/auth/register", json={
"username": "gate_stored", "email": "gate_stored@e.com",
"password": "secret123", "date_of_birth": dob,
})
assert reg.status_code == 201
me = client.get("/api/auth/me", headers={
"Authorization": f"Bearer {reg.json()['access_token']}"
})
assert me.json()["date_of_birth"] == dob
+16 -24
View File
@@ -73,7 +73,7 @@ class TestRegister:
"username": "newuser",
"email": "new@example.com",
"password": "secret123",
"over_18": True,
"date_of_birth": "1990-01-01",
},
)
assert resp.status_code == 201
@@ -81,26 +81,23 @@ class TestRegister:
assert "access_token" in data
assert data["token_type"] == "bearer"
def test_register_rejects_not_over_18(self):
"""over_18=false gets a 400 with a detail message.
The interim design (2026-08-20) trusts a client-side boolean
confirmation instead of collecting/validating an actual date of
birth -- this only checks that the flag is respected, not real
age math (there's no date to verify against anymore). Real DOB
collection/verification may come back in a future pass."""
def test_register_rejects_under_18(self):
"""A DOB that computes to under 18 gets a 403 (see test_auth_age_gate
for the age-math coverage)."""
from datetime import date
client = TestClient(app)
minor_dob = date.today().replace(year=date.today().year - 14).isoformat()
resp = client.post(
"/api/auth/register",
json={
"username": "kid",
"email": "kid@example.com",
"password": "secret123",
"over_18": False,
"date_of_birth": minor_dob,
},
)
assert resp.status_code == 400
assert "18 or older" in resp.json()["detail"]
assert resp.status_code == 403
assert "at least 18" in resp.json()["detail"]
def test_register_rejects_duplicate_username(self):
"""Registering the same username twice returns 409."""
@@ -112,7 +109,7 @@ class TestRegister:
"username": "duplicate",
"email": "dup1@example.com",
"password": "secret123",
"over_18": True,
"date_of_birth": "1990-01-01",
},
)
# Second registration with same username, different email
@@ -122,7 +119,7 @@ class TestRegister:
"username": "duplicate",
"email": "dup2@example.com",
"password": "secret123",
"over_18": True,
"date_of_birth": "1990-01-01",
},
)
assert resp.status_code == 409
@@ -137,7 +134,7 @@ class TestRegister:
"username": "user1",
"email": "same@example.com",
"password": "secret123",
"over_18": True,
"date_of_birth": "1990-01-01",
},
)
resp = client.post(
@@ -146,19 +143,14 @@ class TestRegister:
"username": "user2",
"email": "same@example.com",
"password": "secret123",
"over_18": True,
"date_of_birth": "1990-01-01",
},
)
assert resp.status_code == 409
assert "Email already registered" in resp.json()["detail"]
def test_register_missing_over_18(self):
"""Missing the required over_18 field returns 422.
Replaces the old test_register_invalid_date_format -- there's no
date format to get wrong anymore (no date field at all), so the
closest equivalent "malformed request" case is the required
over_18 boolean being absent entirely."""
def test_register_missing_dob(self):
"""Missing the required date_of_birth field returns 422."""
client = TestClient(app)
resp = client.post(
"/api/auth/register",
@@ -189,7 +181,7 @@ class TestLogin:
"username": username,
"email": email,
"password": password,
"over_18": True,
"date_of_birth": "1990-01-01",
},
)
assert resp.status_code == 201
+1 -1
View File
@@ -93,7 +93,7 @@ def _register_user(client: TestClient, username: str) -> str:
"username": username,
"email": f"{username}@example.com",
"password": "secret123",
"over_18": True,
"date_of_birth": "1990-01-01",
},
)
assert resp.status_code == 201
+91
View File
@@ -0,0 +1,91 @@
"""Integration tests for GET/PUT /api/avatar/loadout.
Hits the real app against the configured Postgres (same contract as the
other integration tests -- migrations applied first).
"""
import pytest
from fastapi.testclient import TestClient
from app.main import app
from app.models.avatar_loadout import DEFAULT_LOADOUT
def _auth_headers(client: TestClient, username: str) -> dict:
resp = client.post(
"/api/auth/register",
json={
"username": username,
"email": f"{username}@example.com",
"password": "secret123",
"date_of_birth": "1990-01-01",
},
)
assert resp.status_code == 201, resp.text
return {"Authorization": f"Bearer {resp.json()['access_token']}"}
class TestGetLoadout:
def test_new_user_gets_the_default(self):
client = TestClient(app)
headers = _auth_headers(client, "av_default")
resp = client.get("/api/avatar/loadout", headers=headers)
assert resp.status_code == 200
body = resp.json()
assert body["is_default"] is True
assert body["data"] == DEFAULT_LOADOUT
def test_requires_auth(self):
client = TestClient(app)
assert client.get("/api/avatar/loadout").status_code == 401
class TestPutLoadout:
def test_save_then_read_back(self):
client = TestClient(app)
headers = _auth_headers(client, "av_save")
loadout = {
"base_body": "base_human_male_01",
"slots": {"hair": "hair_mohawk_01", "accessory": ["accessory_belt_01"]},
"skin_tone": {"r": 0.8, "g": 0.6, "b": 0.5, "a": 1.0},
}
put = client.put("/api/avatar/loadout", headers=headers, json={"data": loadout})
assert put.status_code == 200, put.text
assert put.json()["is_default"] is False
got = client.get("/api/avatar/loadout", headers=headers)
assert got.status_code == 200
assert got.json()["is_default"] is False
assert got.json()["data"] == loadout
def test_put_is_idempotent_replace(self):
client = TestClient(app)
headers = _auth_headers(client, "av_replace")
client.put(
"/api/avatar/loadout", headers=headers,
json={"data": {"base_body": "base_human_male_01", "slots": {"hair": "hair_mohawk_01"}}},
)
second = {"base_body": "base_human_male_01", "slots": {"chest": "chest_bandit_01"}}
client.put("/api/avatar/loadout", headers=headers, json={"data": second})
got = client.get("/api/avatar/loadout", headers=headers)
assert got.json()["data"] == second
def test_rejects_missing_base_body(self):
client = TestClient(app)
headers = _auth_headers(client, "av_nobase")
resp = client.put("/api/avatar/loadout", headers=headers, json={"data": {"slots": {}}})
assert resp.status_code == 422
def test_rejects_non_object_slots(self):
client = TestClient(app)
headers = _auth_headers(client, "av_badslots")
resp = client.put(
"/api/avatar/loadout", headers=headers,
json={"data": {"base_body": "base_human_male_01", "slots": "nope"}},
)
assert resp.status_code == 422
def test_requires_auth(self):
client = TestClient(app)
resp = client.put("/api/avatar/loadout", json={"data": {"base_body": "x", "slots": {}}})
assert resp.status_code == 401
+56
View File
@@ -0,0 +1,56 @@
"""The auth endpoints are rate-limited per client IP."""
import pytest
from fastapi.testclient import TestClient
from app import rate_limit
from app.config import settings
from app.main import app
@pytest.fixture(autouse=True)
def _clean_limiter():
# This module tests the limiter itself: force it on (the checkpoint
# runner disables it suite-wide via RATE_LIMIT_ENABLED) and start each
# test from an empty window.
prev = settings.RATE_LIMIT_ENABLED
settings.RATE_LIMIT_ENABLED = True
rate_limit.reset_all()
yield
rate_limit.reset_all()
settings.RATE_LIMIT_ENABLED = prev
def test_login_is_limited_after_the_threshold():
client = TestClient(app)
codes = []
for i in range(15):
r = client.post("/api/auth/login", data={"username": f"nobody{i}", "password": "x"})
codes.append(r.status_code)
# First 10 get through to the (failing) auth check; the rest are 429.
assert codes[:10] == [401] * 10
assert 429 in codes[10:]
assert codes[-1] == 429
def test_register_shares_the_same_bucket():
client = TestClient(app)
# Burn the budget on login...
for i in range(10):
client.post("/api/auth/login", data={"username": f"x{i}", "password": "y"})
# ...register from the same IP is now blocked too.
r = client.post("/api/auth/register", json={
"username": "rl_user", "email": "rl@e.com",
"password": "secret123", "date_of_birth": "1990-01-01",
})
assert r.status_code == 429
assert "Retry-After" in r.headers
def test_limiter_window_is_per_key():
limiter = rate_limit.SlidingWindowLimiter(max_hits=2, window_seconds=60)
limiter.check("a")
limiter.check("a")
with pytest.raises(Exception):
limiter.check("a")
limiter.check("b") # different key, unaffected
+2 -2
View File
@@ -59,7 +59,7 @@ class TestRefresh:
"username": username,
"email": email,
"password": password,
"over_18": True,
"date_of_birth": "1990-01-01",
},
)
assert resp.status_code == 201
@@ -143,7 +143,7 @@ class TestRefresh:
"username": "refep_loginrefuser",
"email": "loginref@example.com",
"password": "secret123",
"over_18": True,
"date_of_birth": "1990-01-01",
},
)
# Login to get a refresh token
+54
View File
@@ -0,0 +1,54 @@
"""Tests for POST /api/worlds/{id}/join.
The happy path spawns a real Godot process, so it's covered by a manual /
checkpoint integration test, not here. These cover the envelope: auth,
visibility, and the 503 when orchestration is disabled (the default).
"""
import pytest
from fastapi.testclient import TestClient
from app.main import app
def _auth(client: TestClient, name: str) -> tuple[dict, int]:
r = client.post("/api/auth/register", json={
"username": name, "email": f"{name}@e.com", "password": "secret123", "date_of_birth": "1990-01-01",
})
assert r.status_code == 201, r.text
headers = {"Authorization": f"Bearer {r.json()['access_token']}"}
me = client.get("/api/users/me", headers=headers)
return headers, me.json()["id"]
def _make_world(client: TestClient, headers: dict, visibility: str = "public") -> int:
r = client.post("/api/worlds/", headers=headers, json={"name": "W", "visibility": visibility})
assert r.status_code == 201, r.text
return r.json()["id"]
class TestJoinWorld:
def test_requires_auth(self):
client = TestClient(app)
assert client.post("/api/worlds/1/join").status_code == 401
def test_unknown_world_is_404(self):
client = TestClient(app)
headers, _ = _auth(client, "join_404")
assert client.post("/api/worlds/999999/join", headers=headers).status_code == 404
def test_private_world_of_another_user_is_404(self):
client = TestClient(app)
owner_headers, _ = _auth(client, "join_owner")
wid = _make_world(client, owner_headers, "private")
other_headers, _ = _auth(client, "join_other")
assert client.post(f"/api/worlds/{wid}/join", headers=other_headers).status_code == 404
def test_disabled_orchestration_returns_503(self):
# GAME_SERVERS_ENABLED defaults to False in tests.
client = TestClient(app)
headers, _ = _auth(client, "join_503")
wid = _make_world(client, headers, "public")
r = client.post(f"/api/worlds/{wid}/join", headers=headers)
assert r.status_code == 503
assert "not enabled" in r.json()["detail"]
+31
View File
@@ -449,6 +449,37 @@ The catalog is a JSON manifest mapping asset ids to metadata and resource paths:
---
## 9a. Loadout Persistence (implemented, PC3 Phase 1)
**Storage.** One row per user in `avatar_loadouts` (`user_id` PK/FK,
`data` JSONB, timestamps). The entire loadout is stored as a single JSON
document in the exact shape the client's `AvatarLoadout.to_json()` emits
(`{"base_body": str, "slots": {...}, "skin_tone"?: {r,g,b,a}}`) rather than
exploded into columns.
**Why one JSON blob, not columns.** The server has no copy of the asset
catalog (`assets/avatars/catalog.json` ships with the client). It therefore
cannot meaningfully validate *which* asset belongs in *which* slot, whether
an asset exists, or slot-exclusivity rules — that validation stays on the
client (`AvatarLoadout.validate()` against the loaded `AvatarCatalog`). The
server enforces only the envelope: a JSON object with a non-empty string
`base_body`, an object `slots`, and a sane size cap. Keeping the schema
opaque to the server also means catalog/loadout-shape changes don't need a
migration.
**Endpoints** (`/api/avatar`, bearer auth):
| Method | Path | Behaviour |
|---|---|---|
| `GET` | `/loadout` | The user's saved loadout, or the server default (`{"base_body": "base_human_male_01", "slots": {}}`, `is_default: true`) if they have never saved one. |
| `PUT` | `/loadout` | Replace the user's loadout (full document, not a patch). |
**Defaults.** A brand-new account has no row; `GET` returns
`models.avatar_loadout.DEFAULT_LOADOUT` with `is_default: true` so the client
can distinguish "never customised" from "customised back to the base look".
---
## 10. Summary
| Concern | Decision |
+18 -3
View File
@@ -227,18 +227,33 @@ An account has an **age verification status**:
### 5.2 Tier 1 — Self-Declaration Flow
> **Implemented, PC3 Phase 3.** `POST /api/auth/register` now takes a real
> `date_of_birth` (ISO `YYYY-MM-DD`), not the throwaway `over_18` boolean it
> had before. The server computes age (`auth.calculate_age`) and rejects
> anyone under `auth.MIN_AGE` (18) with a **403** — this platform is
> adults-only, so there is no "minor, restricted" tier here (the 13+ rows
> below are the general design, not the current gate). A future-in-the-past
> or nonsensical date is a **400**. The DOB is stored on the user row and
> surfaced only back to that user (`/api/auth/me`), never to other players.
>
> **Limitation, stated plainly:** this is still self-attested — a determined
> user can enter a false DOB. It is a concrete, stored, legally-meaningful
> data point (matching how most platforms gate before adding hard checks)
> and the seam for Tier 2. Encryption-at-rest for the DOB column and the
> Tier 2 ID-check hook below are **not yet implemented**.
```
User submits { date_of_birth }
│
▼
Server validates format & plausibility (age 13–120, not in future)
Server validates format & plausibility (real date, not in future)
│
▼
Compute age from DOB
│
▼
age >= 18 ──► status = self_declared (adult)
age >= 13 ──► status = self_declared (minor, restricted)
age >= 18 ──► status = self_declared (adult) ← the current gate
age >= 13 ──► status = self_declared (minor, restricted) ← design only
age < 13 ──► status = rejected (below minimum platform age)
```
+5
View File
@@ -4,6 +4,11 @@
> **Owner:** Platform team
> **Related:** World chunk data format & persistence, server authority + client
> prediction, content moderation & age verification, avatar component system
> **Note:** the recommendation here stands, but edits do **not** ride the
> `request_chunk_edit` ENet RPC (§4.2, §7.4). Per
> [`world_persistence_impl.md`](world_persistence_impl.md) the editor saves
> chunks over HTTP to the FastAPI backend, which is the sole writer; the
> validation/moderation boundary moves with it.
> **Godot version:** 4.6.2 stable
## 1. Overview
+56
View File
@@ -237,6 +237,62 @@ snapshot_state:
---
## 7a. Server Orchestration (implemented, PC3 Phase 2)
**One dedicated server process per world**, spawned on demand.
```
client backend (single worker) host
│ POST /api/worlds/{id}/join (bearer JWT) │
│ ───────────────────────────▶ GameServerSupervisor │
│ ensure_server(world_id): │
│ if none running for world ─── spawn:
│ godot --headless
│ res://scenes/server.tscn --
│ --port <p> --world <id>
│ --status-file <path>
│ ◀─────────────────────────── { host, port, world_id } │
│ │
│ ENet connect host:port ───────────────────────────────────▶ dedicated server
```
- **Supervisor** (`backend/app/services/game_servers.py`): in-process singleton,
so the backend MUST run single-worker. Keys servers by `world_id`. Allocates
ports from `GAME_SERVER_PORT_MIN..MAX`. A background reaper kills a world's
server once its **status file** (`{players, tick, updated}`, rewritten by the
server ~1×/s) reports `players == 0` for `GAME_SERVER_IDLE_TIMEOUT_SECONDS`.
No crash-restart — the next join respawns.
- **Enable flag:** `GAME_SERVERS_ENABLED` (default off). The checkpoint/playtest
launcher (`pm/checkpoint_ops.py`) turns it on, passes `GODOT_BIN`,
`GAME_PROJECT_DIR`, and `GAME_SERVER_HOST` (the LAN IP baked into the `.exe`).
A backend with it off returns **503** from `/join`, and the client falls back
to local single-player.
- **Per-world content:** all worlds currently load the same test level (M2/M7
work). `world_id` still scopes a distinct process + session.
### 7b. Client play path
`world_list.gd` → `POST /join` → `Session.begin(world_id, host, port)` →
load `game.tscn`. `game.gd` sees `Session.active` and connects a
`NetworkClient`:
- **Local player** keeps predicting with its own collision-aware
`CharacterBody3D` (`player.gd`) — that *is* the prediction. Its input frame
(`player.net_input`) is forwarded to the server every physics tick.
- **Correction backstop:** if the local position drifts from the server's by
more than `CORRECTION_THRESHOLD` (1.5 m — generous; catches teleport / a wall
walked through / real desync, not prediction noise) it snaps.
- **Remote players:** rendered from snapshots, position lerped. *Appearance sync
is a follow-up* — remotes currently show as labelled capsules; the server
does not yet replicate avatar loadouts.
- **Disconnect** → status message → back to the world list.
The `NetworkClient`'s own flat-plane prediction + input-replay reconciliation
(§5) is exercised by `smoke_test_network_client.gd`; the collision-aware game
path above uses the lighter `send_input()` + drift-snap instead.
---
## 8. Security & Anti-Cheat
- **No client-authoritative gameplay values.** Clients only send *inputs* and
+5 -1
View File
@@ -1,6 +1,10 @@
# World Chunk Data Format & Persistence Architecture
> **Status:** Proposed
> **Status:** Proposed. The chunk format (§2, §3) is normative and implemented
> on the client. **§4 (SQLite schema) and §5 (write ownership / write-behind)
> are superseded by [`world_persistence_impl.md`](world_persistence_impl.md)**,
> which stores chunks in the existing Postgres backend and makes the FastAPI
> process — not the per-world Godot server — the sole writer.
> **Owner:** Platform team
> **Related:** Server authority + client prediction, avatar component system, content moderation & age verification, backend auth (JWT)
+655
View File
@@ -0,0 +1,655 @@
# World Persistence: Server-Side Implementation Architecture
> **Status:** Accepted (design). Implementation is split across two follow-on
> tasks (backend endpoints; editor wiring).
> **Owner:** Platform team
> **Supersedes:** the *storage engine* and *write ownership* parts of
> [`world.md`](world.md) §4 and §5. Everything else in `world.md` — the chunk
> logical model, the JSON and binary encodings, checksums, revisions, content
> flags — stands unchanged and is normative.
> **Related:** [`world.md`](world.md) (chunk format), [`editor.md`](editor.md)
> (authoring surface), [`networking.md`](networking.md) (ENet sim transport),
> [`content_moderation.md`](content_moderation.md) (flags & gating).
## 1. Why this document exists
`world.md` was written during the client-side chunk-streaming work (M2). It
assumes **one** server that owns **one** embedded SQLite file and is both the
authoritative simulation and the authoritative store. That is not the system we
built. What actually exists today is:
| Process | Lifetime | Owns | Code |
|---|---|---|---|
| FastAPI backend | always on, single worker | Postgres (users, worlds, assets, avatar loadouts), game-server orchestration | `backend/app/` |
| Godot dedicated server, one per world | **spawned on join, reaped ~5 min after the last join when empty** | nothing durable; a 30 Hz in-memory player sim | `scripts/server.gd`, spawned by `backend/app/services/game_servers.py` |
| Godot client | player session | local chunk streaming, the in-game editor (planned) | `scripts/chunk_stream_manager.gd`, `scripts/chunk_data_decoder.gd` |
Nothing server-side has ever stored or served a real chunk. `server.gd`
`preload()`s `res://scenes/worlds/test_level.tscn` for **every** world and uses
`world_id` only for logging and the status file. `ChunkStreamManager` has a
`set_server_rpc()` seam that no code calls. The backend's `World` model is
metadata only (`id`, `name`, `description`, `owner_id`, `visibility`).
This document decides where chunk content lives, who may write it, how the
dedicated server gets it, and the exact API surface — so the two implementation
tasks that follow have no architectural decisions left to make.
---
## 2. Decisions at a glance
| # | Question | Decision |
|---|---|---|
| D1 | Where does chunk data live at rest? | **Postgres**, in the existing backend database. Not a per-world SQLite file. |
| D2 | Who writes it? | **The FastAPI backend, exclusively.** The editor writes through it over HTTP with optimistic concurrency. The dedicated Godot server never writes world content. |
| D3 | How does the dedicated server get content? | **HTTP pull from the backend at startup**, over a loopback-only `/api/internal` route authenticated by a per-spawn token the supervisor mints. Lazy fetch as players roam. |
| D4 | Edit while a live session exists? | **Out of scope this milestone, with a defined seam.** The editor is a non-joined client mode; writes always succeed and the response flags `live_session` so the UI can say "players see this when the session restarts". |
| D5 | Edit granularity on the wire | **Full-chunk PUT with `base_revision`**, not RFC-6902 deltas. |
| D6 | Client chunk transport | **HTTP from the backend**, not ENet from the dedicated server. The `set_server_rpc()` seam is satisfied by an HTTP-backed adapter. |
---
## 3. D1 — Chunk data lives in Postgres
### 3.1 Decision
World content (rooms, chunk payloads, chunk revisions) is stored in the
**existing Postgres database**, alongside `worlds`, in new tables created by an
Alembic migration. `world.md` §4's `world.db` SQLite file is **not** built.
The chunk **payload stays exactly what `world.md` §3.6 specifies**: the 48-byte
header + sectioned binary body, checksummed with SHA-256. It moves from a
SQLite `BLOB` to a Postgres `BYTEA`. Nothing about the format changes, so
`scripts/chunk_data_decoder.gd` remains correct and the format spec stays the
single contract.
### 3.2 Reasoning
1. **SQLite's premise does not hold here.** `world.md` §4 justifies SQLite as
"ideal for a server that owns the world store". No process in this system
owns a world store: the only process that could plausibly hold the file (the
dedicated Godot server) is *ephemeral* — reaped when idle, `SIGTERM`'d on
backend shutdown, `SIGKILL`'d 10 s later if it doesn't exit, and orphaned
entirely if the backend dies. Meanwhile the editor must write when no such
process exists. So the file would have to be shared by two processes with
independent lifetimes, which is precisely the workload SQLite's
single-writer/file-lock model handles worst.
2. **Postgres is already a hard dependency; SQLite would be a second one.** The
backend cannot start without Postgres. Adding a per-world SQLite file adds a
second storage engine, a second migration system (`world.md` §4.8 specifies a
hand-rolled `meta.schema_version` runner *next to* the Alembic we already
have), a second backup story, and a second consistency story.
3. **Referential integrity with metadata we already have.** `worlds` rows carry
`owner_id` and `visibility`, and every authorization rule for content derives
from them. In one database this is a join and a foreign key with
`ON DELETE CASCADE`; across two engines, deleting a world silently orphans a
file on disk and every ownership check becomes a cross-store read.
4. **Concurrency is the normal case, not the edge case.** Two creators editing
one world, or an editor writing while the backend serves chunks to a running
dedicated server, are ordinary. Postgres gives row-level MVCC and real
transactions for the read-modify-write in §5.4; SQLite gives a database-level
write lock and `SQLITE_BUSY`.
5. **The repo already has the precedent and the plumbing.** `avatar_loadouts`
(migration `0004`) stores a client-shaped document in Postgres JSONB rather
than inventing a side store, and `backend/app/database.py` already provides
the async session factory this code will use.
### 3.3 What we give up, and why it's acceptable
- **Zero-config embedding.** Irrelevant — the backend already requires a
configured Postgres.
- **`world.md` §5.5 write-behind + the `dirty` column.** Deliberately dropped
(§4.4). It existed to amortize disk I/O for a process that owned the file. Our
writes are human-rate editor saves, not sim-rate; each one is a single
transaction and is durable when the HTTP response returns. Dropping it also
deletes the entire "unclean shutdown recovery" scan in §5.5 — there is no
window in which committed content can be lost.
- **Sharding by `world_id` (`world.md` §8).** Postgres partitioning by
`world_id` remains available on the same table shape if a single table ever
becomes a bottleneck. At ~2–8 KB per chunk, 10 000 chunks is under 100 MB.
---
## 4. Schema (Alembic migration `0005_add_world_content_tables`)
New model module: `backend/app/models/world_content.py`, registered in
`backend/app/models/__init__.py`. `backend/app/models/world.py` is left alone
except for one added relationship, so nothing PC1/PC2 depends on moves.
Conventions follow the existing models: `Column(...)` declarative style,
`Integer` surrogate PKs, `DateTime(timezone=True)` with `server_default=func.now()`,
JSONB for document-shaped fields (as `avatar_loadout.py` does).
### 4.1 `world_rooms`
```python
class WorldRoom(Base):
__tablename__ = "world_rooms"
id = Column(Integer, primary_key=True, index=True)
world_id = Column(Integer, ForeignKey("worlds.id", ondelete="CASCADE"),
nullable=False, index=True)
slug = Column(String(64), nullable=False) # url-safe; "main" by default
name = Column(String(255), nullable=False)
# Chunk grid config. world.md §2.2 puts this on the world; it lives on the
# room so a world can later hold rooms with different grids, and so this
# migration is purely additive (the existing `worlds` table is untouched).
grid = Column(JSONB, nullable=False,
server_default='{"size": [32, 32, 1], "origin": [0, 0, 0]}')
bounds = Column(JSONB, nullable=True) # {"min":[x,y,z], "max":[x,y,z]}; null = unbounded
spawn = Column(JSONB, nullable=True) # {"pos":[x,y,z], "yaw": 0.0}
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(),
onupdate=func.now(), nullable=False)
__table_args__ = (UniqueConstraint("world_id", "slug", name="uq_world_rooms_world_slug"),)
```
`World` gains
`rooms = relationship("WorldRoom", back_populates="world", cascade="all, delete-orphan")`.
Every world gets a `main` room: created in `create_world`, and lazily created on
first room read/write for worlds that predate the migration. `grid` defaults
match `Chunk.CHUNK_SIZE_*` in `scripts/chunk.gd`, which is the client's source
of truth for the same constants.
### 4.2 `world_chunks`
```python
class WorldChunk(Base):
__tablename__ = "world_chunks"
id = Column(Integer, primary_key=True, index=True)
world_id = Column(Integer, ForeignKey("worlds.id", ondelete="CASCADE"),
nullable=False, index=True) # denormalized: world-scoped queries
room_id = Column(Integer, ForeignKey("world_rooms.id", ondelete="CASCADE"),
nullable=False, index=True)
chunk_key = Column(String(160), nullable=False, unique=True) # see §4.5
cx = Column(Integer, nullable=False)
cy = Column(Integer, nullable=False)
cz = Column(Integer, nullable=False, server_default="0")
format_version = Column(Integer, nullable=False, server_default="1")
payload = Column(LargeBinary, nullable=False) # world.md §3.6 bytes, BYTEA
payload_len = Column(Integer, nullable=False)
checksum = Column(String(64), nullable=False) # SHA-256 hex of payload body
revision = Column(Integer, nullable=False, server_default="1")
content_flags = Column(JSONB, nullable=False, server_default="{}") # world.md §3.4
is_mature = Column(Boolean, nullable=False, server_default="false")
updated_by_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(),
onupdate=func.now(), nullable=False)
__table_args__ = (
UniqueConstraint("room_id", "cx", "cy", "cz", name="uq_world_chunks_coord"),
Index("ix_world_chunks_room_coord", "room_id", "cx", "cy", "cz"),
Index("ix_world_chunks_mature", "world_id", postgresql_where=text("is_mature")),
)
```
Deltas from `world.md` §4.4:
- **Surrogate `id` PK + unique coordinate constraint**, with `chunk_key` kept as
a unique denormalized column. The key string is the client-facing contract
(`Chunk.build_key`) and is embedded in the payload, so storing it gives a
single-index lookup by key; the coordinate constraint is what actually
enforces one chunk per cell.
- **`dirty` removed** — see §3.3.
- **Content flags folded into the chunk row as JSONB + an indexed `is_mature`
boolean**, instead of `world.md` §4.6's separate 11-column `content_flags`
table. The flags are a document the client already produces in exactly this
shape (`ChunkDataDecoder.ContentFlags.to_dict()`), the only query we anticipate
is "is this mature", and folding them in removes a second write per save and
the `object_id = ''` sentinel hack that §4.6 needed to work around SQLite's
NULL-in-primary-key behaviour (Postgres has the same NULL semantics, so the
hack would have carried over).
- **`world.md` §4.5 `chunk_objects` is deferred, not dropped.** It is a derived
index that exists to answer queries ("which chunks contain a door?") that no
code asks yet, and maintaining it means a delete-and-reinsert of every object
row on every save. It is fully rebuildable from `payload`, so it can be added
in its own migration the day a query needs it, with a backfill. Do **not**
build it in `0005`.
### 4.3 `world_chunk_revisions`
```python
class WorldChunkRevision(Base):
__tablename__ = "world_chunk_revisions"
id = Column(Integer, primary_key=True, index=True)
chunk_key = Column(String(160), nullable=False, index=True) # deliberately NOT a FK
world_id = Column(Integer, nullable=False, index=True) # also not a FK, same reason
revision = Column(Integer, nullable=False)
action = Column(String(16), nullable=False) # 'create' | 'update' | 'delete'
actor_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
checksum = Column(String(64), nullable=False)
payload = Column(LargeBinary, nullable=True) # full payload at this revision
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
```
Kept from `world.md` §4.7 including its two design points: the full payload is
stored (rollback needs it) and `chunk_key`/`world_id` are **not** foreign keys,
so deleting a chunk or a world preserves the audit trail.
**Retention (new, required):** this table grows without bound — a creator
dragging a brush can produce hundreds of revisions per minute. The
implementation must ship a retention policy from day one: keep the newest
`WORLD_CHUNK_REVISION_KEEP` (default 20) payloads per `chunk_key` and null out
`payload` on older rows (keeping the metadata row for the audit trail), pruned
in the same transaction as the write. A `created_at` index supports a later
time-based prune.
### 4.4 Config additions (`backend/app/config.py`)
```
WORLD_CHUNK_MAX_PAYLOAD_BYTES: int = 1_048_576 # 1 MiB; a dense 32×32 uint16 layer is 2 KiB
WORLD_CHUNK_MAX_OBJECTS: int = 512
WORLD_CHUNK_MAX_BATCH: int = 64
WORLD_CHUNK_REVISION_KEEP: int = 20
GAME_SERVER_INTERNAL_API_URL: str = "http://127.0.0.1:8000"
```
### 4.5 Chunk key format
`world.md` §2.3 specifies `"{world_id}:{room_id}:{cx}:{cy}:{cz}"` and illustrates
it with `w_0001:r_0001`. Those ids were illustrative. The real key uses the
backend's identifiers:
```
"{world.id}:{room.slug}:{cx}:{cy}:{cz}" e.g. "12:main:3:-1:0"
```
The room **slug** rather than its numeric id, so a key is human-readable in logs
and stable if rooms are ever re-imported. `Chunk.build_key()` in
`scripts/chunk.gd` already produces this shape unmodified.
---
## 5. D2 — The FastAPI backend is the only writer
### 5.1 Decision
The in-game editor (`editor.md` §7.2) submits edits over **HTTP to the FastAPI
backend**, which validates, versions, and persists them. The dedicated Godot
server process **never writes world content** and does not sit in the edit path.
This preserves `world.md`'s and `editor.md`'s actual invariant — *clients never
touch storage; they submit edits to a server-authoritative tier that validates
them* — while identifying that tier as the always-on backend rather than the
ephemeral sim process. `editor.md` §4.2's claim that edits ride the same
`request_chunk_edit` RPC as gameplay is the one part of that doc this design
changes; the moderation and validation properties it derives from that claim are
unaffected, because the validation boundary moves with the writer.
### 5.2 Reasoning
1. **Nothing that can vanish in 300 seconds may own the write path.** The reaper
in `game_servers.py` `stop()`s an idle server with `SIGTERM` and escalates to
`SIGKILL` after 10 s; `supervisor.shutdown()` does the same to every server on
backend shutdown; and if the backend dies uncleanly the servers are orphaned
with no one to flush them. Because the sim process holds no unsaved world
content in this design, **no reap, crash, or kill can ever lose world data** —
that is the single strongest practical reason for this split.
2. **Editing must work with no dedicated server running.** A creator opening
their world to build should not have to spin up a 30 Hz sim process, burn a
port from the 100-port range, and hold it for 5 minutes past their last save.
3. **`server.gd` has no database access, no credentials, and no HTTP client.**
Routing writes through it means either shipping Postgres credentials into a
process spawned per join, or building an ENet→HTTP proxy inside the sim loop.
Both are strictly worse than the editor calling the backend directly.
4. **Authorization already lives in the backend.** `CurrentUser`, the
`owner_id` check, and `WorldVisibility` are all there. The dedicated server
has no user identities at all — it knows peer ids, not accounts.
### 5.3 Authorization
- **Read** a chunk: the same rule as `GET /api/worlds/{id}` — the world is
`PUBLIC`, or the caller owns it. Otherwise 404 (not 403), matching the
existing routers' deliberate non-disclosure.
- **Write** a chunk: caller must be `world.owner_id`, matching `update_world`;
403 otherwise. Collaborator/role-based editing is future work and slots in at
this one check.
- **Mature gating hook:** `is_mature` chunks are where `content_moderation.md`'s
age gate attaches on the read path. Specify the column and the index now; the
gate itself lands with that doc's work.
### 5.4 Concurrency
Optimistic concurrency exactly as `world.md` §5.4, expressed over HTTP:
1. Reader gets `revision` (header `X-Chunk-Revision`, or the JSON field).
2. Writer sends `base_revision`.
3. The service does `SELECT ... FOR UPDATE` on the chunk row, compares
`base_revision` to `revision`, and either commits at `revision + 1` or returns
**409** with `current_revision` and `current_checksum` so the editor can
re-fetch and re-apply.
4. `base_revision: 0` means "create; fail with 409 if it already exists".
### 5.5 Validation (the backend must not trust the client)
The write path decodes the submitted payload server-side before storing it. This
requires a **Python port of the `world.md` §3.6 codec** —
`backend/app/services/chunk_codec.py`, mirroring `scripts/chunk_data_decoder.gd`.
It checks: magic `AGPC`, `format_version <= 1`, declared vs. actual
`payload_len`, **recomputed** SHA-256 (the header's checksum is advisory), tile
layer dims equal the room's `grid.size`, tile indices in palette range, object
count ≤ `WORLD_CHUNK_MAX_OBJECTS`, object positions inside the chunk's bounds,
`chunk_key` in the payload matching the URL, and string-field length limits
(`world.md` §7). `content_flags` and `is_mature` are **recomputed** from the
payload's flag sections, never taken from the request body.
**Drift control (required):** the two codecs must be pinned by golden files —
fixture chunks under `tests/fixtures/chunks/` encoded by one implementation and
decoded byte-identically by the other, asserted in both the pytest suite and the
Godot headless suite. Without this the format has two implementations and no
contract test, which is exactly the failure `world.md` §5.2 was trying to avoid
by having one shared implementation.
---
## 6. D4 — Editing versus a live session (scope boundary)
**In scope for this milestone:**
- The editor is entered as **"Edit world" from the world browser**: a non-joined
client mode. It does **not** call `POST /api/worlds/{id}/join`, does not open
an ENet connection, and does not spawn a dedicated server. It loads chunks over
HTTP, edits them locally, and saves them over HTTP.
- Writes always succeed regardless of whether a session is live. The write
response carries `live_session: bool` (from `supervisor.debug_state()`), so the
editor can surface *"this world has players in it right now — they'll see your
changes when the session restarts."*
- A dedicated server started **after** a write sees the new content, because it
loads from the store at startup (§7).
**Explicitly out of scope, deferred:**
- Build-in-place while joined to a live session.
- `chunk_invalidated` push to connected clients (`world.md` §5.3) and hot-reload
of the dedicated server's collision world.
- Live collaborative editing / cursors (`editor.md` §9).
**The seam that makes the deferred work cheap:** the manifest endpoint (§7.2)
returns a monotonic `content_revision` for the world, bumped on every chunk
write. Hot reload is then: the dedicated server polls the manifest on a timer
(or the backend pushes to it over the same internal channel), re-fetches the
chunks whose revision changed, and rebuilds those collision bodies. Nothing in
this design has to change to add it.
**Why the editor must not join:** if it joined, the sim process would enforce
collision against content it loaded at spawn while the creator edits a diverging
local copy — players would walk through new walls and bounce off deleted ones,
with no error anywhere. Making build mode non-joined turns a silent correctness
bug into an explicit product boundary.
---
## 7. D3 — What the dedicated server does at startup
### 7.1 Decision
`server.gd` pulls its world's content from the backend over **HTTP** at startup,
authenticated by a **per-spawn token** the supervisor mints, and **does not bind
the ENet port until content is loaded**.
Rejected alternative — *supervisor writes a whole-world snapshot file at spawn
and passes `--world-file`*: attractive (no new auth surface, no HTTP client in
the sim process), but it makes spawn latency proportional to world size, gives
the server no way to fetch a chunk lazily as players roam beyond the preloaded
set, and still needs a separate channel for the hot-reload work above. Rejected —
*give the Godot process a Postgres connection*: there is no PG driver in
GDScript, and it would put database credentials in a process spawned per join.
### 7.2 Startup sequence
1. `_parse_args()` gains `--api-url` and `--server-token` (§7.3).
2. `GET /api/internal/worlds/{world_id}/manifest` → rooms, per-room `grid`,
`bounds`, `spawn`, `chunk_count`, and the world's `content_revision`.
3. `GET /api/internal/worlds/{world_id}/rooms/{slug}/chunks?` bounds query for
the spawn neighbourhood (radius from the spawn point, default 3 chunks), then
fetch each payload; decode with the existing `ChunkDataDecoder` and build
static collision from the tile layers and objects.
4. **Then** `network_peer.create_server(port)`, then write the status file.
5. **Fallback:** if the backend is unreachable, the world has no rooms, or the
room has zero chunks, load `test_level.tscn` as today and record
`"content": "fallback"` in the status file. A content outage must never make
a world un-joinable, and this keeps every PC3 flow working before any world
has authored content.
During the session the server may lazily fetch chunks as players approach
unloaded coordinates, through the same internal route. It **never writes**.
Dynamic entity runtime state (positions, health) stays in memory and out of
chunks, exactly as `world.md` §3.3 already specifies. Shutdown flushes nothing.
### 7.3 Internal authentication
`GameServerSupervisor._spawn()` generates `token = secrets.token_urlsafe(32)`,
stores it on the `_WorldServer` dataclass, and passes it on the command line
alongside the existing `--port/--world/--status-file`. A FastAPI dependency
`require_game_server` resolves `Authorization: Bearer <token>` against
`supervisor.validate_token(world_id, token)` — a dict lookup, since the
supervisor is an in-process singleton. Properties this gives us:
- A token grants access to **one** world and **dies with its process** (the
reaper's `del self._servers[wid]` invalidates it).
- No long-lived shared secret in config or on disk.
- It reuses the **already documented** single-worker constraint in
`game_servers.py`; it does not add a new one.
Rejected alternative — *let the dedicated server use the public routes with a
service user account*: the public routes enforce per-user visibility and
ownership, which a service identity would have to be exempted from, and it would
pollute `updated_by_id`/audit rows with a fake user. A separate `/api/internal`
prefix keeps the two auth models apart and can be blocked at the reverse proxy.
### 7.4 Lifecycle changes this requires in `game_servers.py`
These are real, load-bearing findings from the current implementation:
- **`_await_port()` is the wrong readiness signal now.** It returns as soon as
the UDP port is bound, and the port is bound before content exists today. With
content loading it must await a `"ready": true` field that `server.gd` writes
to the status file *after* content load + `create_server`. Otherwise the
backend hands a client an address for a server that is still fetching chunks.
- **`_await_port()` fails open.** After its 8 s deadline it returns normally,
with no error — `ensure_server` then reports success for a server that may
never have started. Content loading makes a slow start more likely, so the
readiness wait needs a real timeout error (503 to the joining client) and a
longer, configurable deadline.
- **The status file is the only server→backend channel**, and `_spawn` currently
passes no credentials at all — hence §7.3.
- **`player_count()` returns 0 when the status file is missing or unparseable**,
so a live server whose status write fails can be reaped after the idle timeout.
Harmless for data (the sim owns nothing durable — the point of D2) but worth a
comment where the fallback lives.
- **No crash restart** (documented in the module docstring): if a server dies,
the next join respawns it and it reloads content from the store. With D2 this
is now genuinely safe rather than merely tolerated.
---
## 8. API surface
All paths are under the existing `worlds` router prefix (`/api/worlds`) unless
noted. New router module: `backend/app/routers/world_content.py`, mounted at
`/api/worlds`; internal routes in `backend/app/routers/internal.py` at
`/api/internal`.
### 8.1 Public (editor & player client; JWT `CurrentUser`)
| Method | Path | Purpose |
|---|---|---|
| `GET` | `/api/worlds/{world_id}/rooms` | List rooms + grid config |
| `POST` | `/api/worlds/{world_id}/rooms` | Create a room (owner) |
| `GET` | `/api/worlds/{world_id}/rooms/{slug}/chunks` | Chunk **index** over a bounds query |
| `GET` | `/api/worlds/{world_id}/rooms/{slug}/chunks/{cx}/{cy}/{cz}` | Fetch one chunk |
| `PUT` | `/api/worlds/{world_id}/rooms/{slug}/chunks/{cx}/{cy}/{cz}` | Write one chunk |
| `PUT` | `/api/worlds/{world_id}/rooms/{slug}/chunks` | Write up to `WORLD_CHUNK_MAX_BATCH` chunks atomically |
| `DELETE` | `/api/worlds/{world_id}/rooms/{slug}/chunks/{cx}/{cy}/{cz}` | Delete one chunk |
| `GET` | `/api/worlds/{world_id}/rooms/{slug}/chunks/{cx}/{cy}/{cz}/revisions` | Revision history (owner) |
| `POST` | `/api/worlds/{world_id}/rooms/{slug}/chunks/{cx}/{cy}/{cz}/rollback` | Restore a revision (owner) |
**Room list** — `GET .../rooms` → `200`
```json
[{ "id": 7, "slug": "main", "name": "Main",
"grid": { "size": [32, 32, 1], "origin": [0, 0, 0] },
"bounds": null,
"spawn": { "pos": [0, 1.5, 0], "yaw": 0.0 },
"chunk_count": 41 }]
```
**Chunk index** — `GET .../chunks?cx_min=&cx_max=&cy_min=&cy_max=&cz=0` → `200`.
Payloads are *not* included; this is `world.md` §5.1's `list_chunks(bounds)` and
lets a streamer decide what to fetch and skip anything already cached by
checksum.
```json
{ "room": "main", "content_revision": 118,
"chunks": [{ "key": "12:main:0:0:0", "cx": 0, "cy": 0, "cz": 0,
"revision": 4, "checksum": "9f86…", "payload_len": 2344,
"is_mature": false, "updated_at": "2026-09-14T10:02:11Z" }] }
```
Bounds are capped (default 32×32 chunks per query) and return `422` beyond that.
**Chunk fetch** — `GET .../chunks/{cx}/{cy}/{cz}`, content-negotiated:
- `Accept: application/octet-stream` (default for the game client) → `200` with
the raw `world.md` §3.6 bytes, `ETag: "<checksum>"`,
`X-Chunk-Revision: <n>`, `X-Chunk-Key: <key>`.
- `Accept: application/json` (tooling, tests, the editor's debug view) → the
§3.5 JSON encoding, decoded server-side from the payload. JSON is derived, never
stored, so there is no dual-write drift (`world.md` §4.4).
- `If-None-Match: "<checksum>"` → `304`. This is what makes the client's
`user://chunk_cache/` cache cheap and is why the checksum is the ETag.
- `404` if the chunk does not exist (an unwritten coordinate is not an error to
the streamer — it renders empty).
**Chunk write** — `PUT .../chunks/{cx}/{cy}/{cz}`
```
Content-Type: application/octet-stream
X-Base-Revision: 4 # 0 = create
<binary payload>
```
or, for clients that prefer JSON:
`{"base_revision": 4, "payload_b64": "…"}`.
Responses:
| Code | Body / meaning |
|---|---|
| `200` | `{"key", "revision", "checksum", "payload_len", "updated_at", "is_mature", "live_session"}` |
| `409` | `{"detail": "revision conflict", "current_revision": 6, "current_checksum": "…"}` |
| `403` | not the world owner |
| `404` | world or room not found / not visible |
| `413` | payload over `WORLD_CHUNK_MAX_PAYLOAD_BYTES` |
| `422` | decode/validation failure (`{"detail", "reason"}`, e.g. `checksum_mismatch`, `layer_dims`, `object_out_of_bounds`) |
**Batch write** — `PUT .../chunks` with
`{"chunks": [{"cx","cy","cz","base_revision","payload_b64"}, …]}`. Applied in
**one transaction, all-or-nothing**: if any entry conflicts, nothing is written
and the response is `409` naming the offending coordinate. Justification: an
editor brush stroke or an object drag routinely straddles a chunk boundary, and
without a batch the editor either issues N round trips or leaves half of a user
action persisted. Capped at `WORLD_CHUNK_MAX_BATCH` chunks and 8 MB.
### 8.2 D5 — Full-chunk PUT, not JSON-Patch deltas
`world.md` §5.4 specifies RFC-6902 deltas against `base_revision`. This design
sends **whole chunks** instead.
- A 32×32×1 chunk is ~2 KB of dense tile indices plus its objects — a few KB.
The bandwidth a patch saves is negligible at editor rates.
- The stored form is an opaque binary blob. Patching it means the backend must
decode → apply a JSON-Patch → re-encode → re-checksum, i.e. implement and test
a *second* mutation format on top of the codec, with its own failure modes, and
keep it in sync with the client's patch generator. That is the same dual-path
drift risk `world.md` §5.2 exists to prevent.
- Lost-update protection comes from `base_revision`, not from the patch, so
nothing is given up on correctness.
Delta encoding stays available as a pure bandwidth optimization later —
`world.md` §8 already files it as future work.
### 8.3 Internal (dedicated server; `require_game_server` token)
| Method | Path | Purpose |
|---|---|---|
| `GET` | `/api/internal/worlds/{world_id}/manifest` | Rooms, grids, spawn, `content_revision`, chunk counts |
| `GET` | `/api/internal/worlds/{world_id}/rooms/{slug}/chunks` | Bounds index (same shape as §8.1) |
| `GET` | `/api/internal/worlds/{world_id}/rooms/{slug}/chunks/{cx}/{cy}/{cz}` | Binary payload |
Read-only by construction — there is no internal write route, and adding one
would violate D2. The token is world-scoped, so a path whose `world_id` differs
from the token's world is `403`. These routes skip the per-user visibility check
(the sim process has no user) and apply no mature gating (gating is a client/user
concern; the server still needs collision).
### 8.4 D6 — The client streams chunks over HTTP, not over ENet
`world.md` §5.3 routes `request_chunk` / `chunk_data` over the game's ENet
connection. This design has the playing client fetch chunks from the **backend
over HTTP**, the same routes the editor uses.
- The dedicated server has no content the backend doesn't have; proxying bytes
through it adds a hop and puts multi-megabyte reliable transfers in contention
with the 30 Hz snapshot broadcast on the same ENet peer.
- HTTP gives caching for free — `ETag`/`If-None-Match` on the checksum, and a
local `user://chunk_cache/<checksum>` that survives sessions.
- `ApiClient` (`scripts/api_client.gd`) already carries the JWT and the result
contract; no new transport code.
**This does not require rewriting `ChunkStreamManager`.** It already calls
through a `set_server_rpc()` seam that only needs an object with
`request_chunk(key)`. The editor-wiring task supplies an `HttpChunkSource` that
implements that method against `ApiClient` and feeds
`_on_chunk_data_received(key, bytes)` — which also finally gives that dormant
seam a caller. Note the existing `_load_chunk()` builds its key with empty
world/room ids (`Chunk.build_key("", "", coord)`); the adapter must supply the
real world id and room slug.
---
## 9. What each follow-on task owns
**Backend task**
- `backend/app/models/world_content.py` + `0005_add_world_content_tables`
- `backend/app/services/chunk_codec.py` (Python port of `world.md` §3.6) and the
golden-file fixtures shared with the Godot suite
- `backend/app/services/world_store.py` (the `world.md` §5.1 service interface,
minus `flush_dirty`/`rebuild_index`, over Postgres)
- `backend/app/routers/world_content.py`, `backend/app/routers/internal.py`
- `game_servers.py`: per-spawn token, `--api-url`/`--server-token` args,
readiness-from-status-file instead of `_await_port` (§7.4)
- `create_world` creates the `main` room
**Client/editor task**
- `HttpChunkSource` adapter → `ChunkStreamManager.set_server_rpc()`
- Editor save path (single + batch PUT), 409 conflict re-fetch-and-reapply,
`live_session` warning UI
- "Edit world" entry from the world browser that does **not** join
- `server.gd`: arg parsing, manifest + chunk fetch before `create_server`,
collision build from decoded chunks, `test_level.tscn` fallback,
`"ready"`/`"content"` fields in the status file
---
## 10. Open questions (deliberately not decided here)
- **Hot reload / build-in-place** while a session is live (§6) — the
`content_revision` seam is in place for it.
- **Collaborator permissions** beyond `owner_id` — one check in §5.3.
- **Mature-content gating** on the read path — column and index exist; the
policy belongs to `content_moderation.md`.
- **`world_chunk_objects`** derived index — spec'd in `world.md` §4.5, build it
when a query needs it (§4.2).
- **Payload compression** — the §3.6 header reserves a flags bitmask; Postgres
TOAST already compresses `BYTEA`, so measure before adding a second layer.
- **Chunk streaming for very large worlds** — the bounds-query cap in §8.1 is a
guardrail, not a scaling plan.
+97
View File
@@ -0,0 +1,97 @@
# Package Checkpoint 3 (PC3)
> **Status: partial.** PC3's planned scope was M4-avatars + M5-multiplayer +
> Foundation Hardening + M6–M10 (see `docs/build-plan-v1.md`). As of
> 2026-09-05, **M4, M5, and most of Foundation Hardening are done and
> checkpoint-verified**; M6–M10 have not been started. This doc records what
> is real. Whether to call this "PC3" (redefined scope, M6–M10 → PC4) or keep
> PC3 open is a user decision.
Built by Claude directly (fleet offline), branch `pc3` → `main`.
## What's real and verified (`scripts/run_checkpoint_tests.py`, all green)
| Check | Result |
|---|---|
| Alembic migrations, fresh DB | PASS |
| Backend `pytest` | 77/77 PASS (hermetic — auto-starts an ephemeral Postgres) |
| Godot smoke tests (~16 files) | ALL PASS |
| `smoke_test_network_client` (real dedicated server) | 4/4 PASS |
| 2-player multiplayer (`run_multiplayer_2p_test.sh`) | PASS |
| Fresh-clone boot (`verify_fresh_clone.sh`) | PASS |
| Linux + Windows exports | Build as real ELF64 / PE32+ binaries |
### M4 — Avatars in the game
- Placeholder avatar pack (14 GLBs, rigged 24-bone base body + attachments)
generated by `scripts/tools/gen_placeholder_avatars.gd`. Real art is a
content swap — catalog paths are stable.
- The avatar **loader was fixed** — it had never run against a real skeleton
(`Skeleton3D.has_bone()` isn't a Godot 4 method; the bone-copy dropped the
skin binding; attachments were parsed then discarded).
- `avatar_visualizer.gd` **rewritten** — the old one referenced a loader API
that never existed and didn't compile.
- Persistence: `avatar_loadouts` table + `GET/PUT /api/avatar/loadout`
(loadout stored as one JSON doc; server validates the envelope only).
- Wired into gameplay: the in-world player renders as the assembled avatar
(not a capsule); the loadout is fetched at login; "Customize Avatar" from
the main menu saves through to the backend.
- **Follow-up:** camera is still first-person, so the local player doesn't
see their own avatar much (third-person is a polish item); remote players'
avatar appearance is not replicated yet (they show as labelled capsules).
### M5 — Real multiplayer
- `server.gd` **actually runs a sim now** — loads the world level, spawns a
server player per peer, processes each peer's input queue in seq order at a
fixed 30 Hz, broadcasts full snapshots with a per-peer `last_seq`. (The
input queue was a no-op before.)
- `network_client.gd` reconciliation **replays in-flight inputs** from the
authoritative state (drops acked ones), snaps only on real drift.
- **Server orchestration** (`backend/app/services/game_servers.py`): one
headless Godot dedicated-server process per world, spawned on demand by
`POST /api/worlds/{id}/join`, port-allocated, with a status-file idle
reaper. Off by default; the playtest launcher (`checkpoint_ops.py`)
enables it.
- **Client play path**: world browser → `/join` → `Session` → `game.tscn`
connects → local player predicts with collision, input forwarded, server
position is a drift backstop, remote players render from snapshots,
disconnect returns to the world list. Falls back to local single-player
when the backend has no orchestration.
- **Verified**: two real client processes each `register → /join → play`,
hosted on an orchestrated server, see and move relative to each other
(`run_game_multiplayer_test.sh`).
### Foundation Hardening
- **Hermetic backend tests** — `cd backend && pytest` works from a clean
checkout (auto ephemeral Postgres + migrations, torn down after); skips
cleanly with a message if there's no Docker.
- **Fresh-clone boot check** — `verify_fresh_clone.sh` exports a pristine
tree, imports from scratch, boots headless, fails on any script error.
Also fixed the root cause: a stale `.godot/global_script_class_cache.cfg`
was committed to the repo.
- **Real age verification** — `/register` takes a date of birth (not a
checkbox), server computes age, 403 under 18. Still self-attested; the
limitation and the ID-check hook are documented
(`content_moderation.md` 5.2).
- **Auth rate limiting** — 10 attempts / min / IP on `/register` + `/login`.
- **CI workflow** — `.forgejo/workflows/checkpoint.yml` written, **not
running yet**: needs the operator to enable Forgejo Actions + register a
`host` runner (one-time; see `docs/ci.md`). Interim gate is
`run_checkpoint_tests.py` by hand / the dashboard.
## Not in PC3 (planned but not started)
- **M2** server-side world persistence
- **M6** scripting sandbox
- **M7** in-game editor
- **M8** social / chat
- **M9** economy
- **M10** discovery
## Playtest
Same as PC2 — grab the exe from the dashboard's `/checkpoints`, backend
pointed at it. New: with multiple people in the same world you now actually
see each other move.
+50
View File
@@ -0,0 +1,50 @@
# CI
`.gitea/workflows/checkpoint.yml` runs the full checkpoint suite
(`scripts/run_checkpoint_tests.py`: migrations on a fresh DB, backend
`pytest`, all Godot smoke tests, the 2-player multiplayer test, and the
fresh-clone boot check) on **every push to `main` and every PR**.
## Status: live
Set up 2026-09-05:
- Gitea Actions enabled (`[actions] ENABLED = true` in
`/DATA/AppData/forgejo/gitea/conf/app.ini`, container restarted; a
timestamped `.bak` is next to it).
- `act_runner` v0.2.13 registered as **`agp-host`** with label **`host`**
(host execution mode — jobs run directly on the AGP box, which already
has Docker, `~/godot/godot`, and Python). Lives in `~/act-runner/`.
- Green on the first successful run (`d76bf4d`).
The workflow uses **no JavaScript actions** (no `actions/checkout`), so the
host needs no Node: checkout is a shallow `git fetch` from the local Gitea
using the injected `${{ github.token }}`, and the suite runs in a venv the
job builds (host Python is PEP-668 externally-managed).
## Make the runner permanent (one command, needs sudo)
The runner currently runs as a plain background process — it won't survive a
reboot. A unit file is ready at `~/act-runner/act-runner.service`:
```bash
sudo cp ~/act-runner/act-runner.service /etc/systemd/system/
sudo systemctl daemon-reload
pkill -f 'act_runner daemon' # stop the temporary background one
sudo systemctl enable --now act-runner
```
## Interim / local
```bash
python scripts/run_checkpoint_tests.py
```
— also what `/checkpoint/<PC>` on the dashboard runs before a playtest build.
## Security note
A host-mode runner executes whatever a workflow says, directly on the box,
as `daddy` (in the `docker` group). Fine for a single-maintainer private
repo; revisit if the repo gets outside contributors (switch to a
docker-mode runner or an isolated VM).
+2 -2
View File
@@ -7,7 +7,7 @@ advanced_options=false
dedicated_server=false
custom_features=""
export_filter="all_resources"
include_filter=""
include_filter="*.json"
exclude_filter=""
export_path="export/linux/agp-game.x86_64"
encryption_include_filters=""
@@ -38,7 +38,7 @@ advanced_options=false
dedicated_server=false
custom_features=""
export_filter="all_resources"
include_filter=""
include_filter="*.json"
exclude_filter=""
export_path="export/windows/agp-game.exe"
encryption_include_filters=""
+7
View File
@@ -15,6 +15,7 @@ agp/api_base_url="http://127.0.0.1:8000"
ApiClient="*res://scripts/api_client.gd"
AvatarSystem="*res://scripts/avatar/avatar_system.gd"
Session="*res://scripts/session.gd"
[display]
@@ -45,6 +46,12 @@ move_down={
]
}
[physics]
; Fixed 30 Hz sim -- the multiplayer server, client prediction, and snapshot
; rate all key off this. See docs/architecture/networking.md 3.1.
common/physics_ticks_per_second=30
[rendering]
renderer/rendering_method="gl_compatibility"
-1
View File
@@ -1 +0,0 @@
+2 -139
View File
@@ -1,45 +1,6 @@
[gd_scene load_steps=3 format=3 uid="uid://avatar_customization"]
[gd_scene load_steps=2 format=3 uid="uid://avatar_customization"]
[ext_resource type="Script" path="res://scripts/avatar/avatar_customization_ui.gd" id="1"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_main_panel"]
bg_color = Color(0.12, 0.12, 0.15, 1)
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_slot_panel"]
bg_color = Color(0.18, 0.18, 0.22, 1)
border_width_left = 2
border_width_right = 2
border_width_top = 2
border_width_bottom = 2
border_color = Color(0.3, 0.3, 0.35, 1)
corner_radius_top_left = 4
corner_radius_top_right = 4
corner_radius_bottom_right = 4
corner_radius_bottom_left = 4
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_button_style"]
bg_color = Color(0.25, 0.25, 0.3, 1)
border_width_left = 1
border_width_right = 1
border_width_top = 1
border_width_bottom = 1
border_color = Color(0.4, 0.4, 0.45, 1)
corner_radius_top_left = 4
corner_radius_top_right = 4
corner_radius_bottom_right = 4
corner_radius_bottom_left = 4
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_button_hover"]
bg_color = Color(0.35, 0.35, 0.4, 1)
border_width_left = 1
border_width_right = 1
border_width_top = 1
border_width_bottom = 1
border_color = Color(0.5, 0.5, 0.55, 1)
corner_radius_top_left = 4
corner_radius_top_right = 4
corner_radius_bottom_right = 4
corner_radius_bottom_left = 4
[ext_resource type="Script" path="res://scripts/avatar_customization_screen.gd" id="1"]
[node name="AvatarCustomization" type="Control"]
layout_mode = 3
@@ -49,101 +10,3 @@ anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("1")
[node name="MainPanel" type="Panel" parent="."]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
theme_override_styles/panel = SubResource("StyleBoxFlat_main_panel")
[node name="VBox" type="VBoxContainer" parent="MainPanel"]
layout_mode = 1
size_flags_horizontal = 3
size_flags_vertical = 3
theme_override_constants/separation = 8
[node name="TitleLabel" type="Label" parent="MainPanel/VBox"]
layout_mode = 1
size_flags_horizontal = 3
text = "Avatar Customization"
horizontal_alignment = 1
theme_override_font_sizes/font_size = 24
[node name="HBox" type="HBoxContainer" parent="MainPanel/VBox"]
layout_mode = 1
size_flags_horizontal = 3
size_flags_vertical = 3
[node name="AvatarViewport" type="ViewportContainer" parent="MainPanel/VBox/HBox"]
layout_mode = 1
size_flags_horizontal = 3
size_flags_vertical = 3
expand_texture = true
stretch = true
[node name="AvatarViewport" type="Viewport" parent="MainPanel/VBox/HBox/AvatarViewport"]
layout_mode = 3
size_flags_horizontal = 3
size_flags_vertical = 3
width = 512
height = 512
transparent_bg = true
[node name="Avatar3D" type="Node3D" parent="MainPanel/VBox/HBox/AvatarViewport/AvatarViewport"]
layout_mode = 3
[node name="Camera3D" type="Camera3D" parent="MainPanel/VBox/HBox/AvatarViewport/AvatarViewport"]
layout_mode = 3
position = Vector3(0, 1.0, 3.0)
rotation = Vector3(-0.174533, 0, 0)
[node name="DirectionalLight3D" type="DirectionalLight3D" parent="MainPanel/VBox/HBox/AvatarViewport/AvatarViewport"]
layout_mode = 3
position = Vector3(2, 4, 2)
rotation = Vector3(0.785398, 0, 0)
light_color = Color(0.8, 0.8, 0.8, 1)
light_energy = 1.0
[node name="DirectionalLight3D2" type="DirectionalLight3D" parent="MainPanel/VBox/HBox/AvatarViewport/AvatarViewport"]
layout_mode = 3
position = Vector3(-2, 2, -2)
rotation = Vector3(0.785398, 0, 0)
light_color = Color(0.4, 0.4, 0.5, 1)
light_energy = 0.5
[node name="SlotList" type="VBoxContainer" parent="MainPanel/VBox/HBox"]
layout_mode = 1
size_flags_horizontal = 0
size_flags_vertical = 3
theme_override_constants/separation = 4
custom_minimum_size = Vector2(280, 0)
[node name="SlotHeader" type="Label" parent="MainPanel/VBox/HBox/SlotList"]
layout_mode = 1
text = "SLOTS"
theme_override_font_sizes/font_size = 16
font_color = Color(0.7, 0.7, 0.75, 1)
[node name="ScrollContainer" type="ScrollContainer" parent="MainPanel/VBox/HBox/SlotList"]
layout_mode = 1
size_flags_horizontal = 3
size_flags_vertical = 3
horizontal_mode = 2
horizontal_scroll_mode = 2
[node name="SlotsVBox" type="VBoxContainer" parent="MainPanel/VBox/HBox/SlotList/ScrollContainer"]
layout_mode = 1
size_flags_horizontal = 3
size_flags_vertical = 3
theme_override_constants/separation = 4
[node name="BackButton" type="Button" parent="MainPanel/VBox"]
layout_mode = 1
size_flags_horizontal = 3
text = "Back to Menu"
button_pressed = false
action_mode = 0
custom_minimum_size = Vector2(0, 36)
+6
View File
@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3 uid="uid://c0avpre7dev0rig"]
[ext_resource type="Script" path="res://scripts/tools/avatar_preview.gd" id="1"]
[node name="AvatarPreview" type="Node3D"]
script = ExtResource("1")
+6
View File
@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3 uid="uid://bqs8xk2rig0dev"]
[ext_resource type="Script" path="res://scripts/tools/screenshot_rig.gd" id="1"]
[node name="ScreenshotRig" type="Node"]
script = ExtResource("1")
+3 -1
View File
@@ -1,9 +1,11 @@
[gd_scene load_steps=3 format=3 uid="uid://game_scene_pc2"]
[gd_scene load_steps=4 format=3 uid="uid://game_scene_pc2"]
[ext_resource type="Script" path="res://scripts/game.gd" id="0_game"]
[ext_resource type="PackedScene" uid="uid://c49a36fc7db_test_level" path="res://scenes/worlds/test_level.tscn" id="1_level"]
[ext_resource type="PackedScene" uid="uid://player_scene_001" path="res://scenes/player.tscn" id="2_player"]
[node name="Game" type="Node3D"]
script = ExtResource("0_game")
[node name="Level" parent="." instance=ExtResource("1_level")]
+9 -2
View File
@@ -83,11 +83,11 @@ visible = false
placeholder_text = "Email"
size_flags_horizontal = 3
[node name="Over18Check" type="CheckBox" parent="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox"]
[node name="DobEdit" type="LineEdit" parent="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox"]
layout_mode = 1
visible = false
text = "I am 18 or older"
size_flags_horizontal = 3
placeholder_text = "Date of birth (YYYY-MM-DD)"
[node name="AuthButton" type="Button" parent="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox"]
layout_mode = 1
@@ -112,6 +112,12 @@ layout_mode = 1
text = "Play"
size_flags_horizontal = 4
[node name="CustomizeButton" type="Button" parent="CenterContainer/VBoxContainer"]
layout_mode = 1
text = "Customize Avatar"
size_flags_horizontal = 4
disabled = true
[node name="QuitButton" type="Button" parent="CenterContainer/VBoxContainer"]
layout_mode = 1
text = "Quit"
@@ -120,6 +126,7 @@ size_flags_horizontal = 4
[connection signal="pressed" from="CenterContainer/VBoxContainer/AuthButtonsRow/LoginButton" to="." method="_on_login_button_pressed"]
[connection signal="pressed" from="CenterContainer/VBoxContainer/AuthButtonsRow/RegisterButton" to="." method="_on_register_button_pressed"]
[connection signal="pressed" from="CenterContainer/VBoxContainer/PlayButton" to="." method="_on_play_button_pressed"]
[connection signal="pressed" from="CenterContainer/VBoxContainer/CustomizeButton" to="." method="_on_customize_button_pressed"]
[connection signal="pressed" from="CenterContainer/VBoxContainer/QuitButton" to="." method="_on_quit_button_pressed"]
[connection signal="pressed" from="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthButton" to="." method="_on_auth_button_pressed"]
[connection signal="text_submitted" from="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/UsernameEdit" to="." method="_on_auth_button_pressed"]
+11 -3
View File
@@ -1,4 +1,4 @@
[gd_scene load_steps=3 format=3 uid="uid://player_scene_001"]
[gd_scene load_steps=4 format=3 uid="uid://player_scene_001"]
[ext_resource type="Script" path="res://scripts/player.gd" id="1_player"]
@@ -11,6 +11,8 @@ radius = 0.35
height = 1.6
[node name="Player" type="CharacterBody3D"]
collision_layer = 2
collision_mask = 1
script = ExtResource("1_player")
[node name="CollisionShape3D" type="CollisionShape3D" parent="."]
@@ -19,6 +21,12 @@ shape = SubResource("CapsuleShape1")
[node name="CapsuleMesh" type="MeshInstance3D" parent="."]
mesh = SubResource("CapsuleMesh1")
[node name="Camera3D" type="Camera3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.6, 0)
[node name="CameraPivot" type="Node3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.2, 0)
[node name="SpringArm3D" type="SpringArm3D" parent="CameraPivot"]
spring_length = 3.5
margin = 0.2
[node name="Camera3D" type="Camera3D" parent="CameraPivot/SpringArm3D"]
current = true
+10 -1
View File
@@ -1,6 +1,15 @@
[gd_scene load_steps=2 format=3]
[gd_scene load_steps=3 format=3]
[ext_resource type="Script" path="res://scripts/player_spawner.gd" id="1"]
[sub_resource type="CapsuleShape3D" id="CapsuleShape_server_player"]
radius = 0.35
height = 1.6
[node name="Player" type="CharacterBody3D"]
collision_layer = 2
collision_mask = 1
script = ExtResource("1")
[node name="CollisionShape3D" type="CollisionShape3D" parent="."]
shape = SubResource("CapsuleShape_server_player")
+6 -5
View File
@@ -46,10 +46,10 @@ subdivisions_height = 8
[sub_resource type="Environment" id="WorldEnvironment_env"]
background_mode = 1
background_color = Color(0.4, 0.6, 0.8, 1)
ambient_light_source = 1
ambient_light_energy = 0.4
ambient_light_sky_energy = 1.0
background_color = Color(0.45, 0.62, 0.82, 1)
ambient_light_source = 2
ambient_light_color = Color(0.7, 0.75, 0.82, 1)
ambient_light_energy = 0.8
[node name="TestLevel" type="Node3D"]
@@ -110,7 +110,8 @@ shape = SubResource("SphereShape3D_prop5")
mesh = SubResource("SphereMesh_prop5")
[node name="Sun" type="DirectionalLight3D" parent="."]
rotation = Vector3(-0.785398, 0, 0)
rotation = Vector3(-0.95, -0.5, 0)
light_energy = 1.3
shadow_enabled = true
[node name="WorldEnvironment" type="WorldEnvironment" parent="."]
+27 -3
View File
@@ -178,18 +178,19 @@ func login(username: String, password: String) -> Dictionary:
## Register a new user. On success stores the returned access token.
## @param over_18 Must be true; the backend rejects registration otherwise.
## @param date_of_birth ISO "YYYY-MM-DD". The backend computes age from it
## and rejects under-18s (403).
func register(
username: String,
email: String,
password: String,
over_18: bool
date_of_birth: String
) -> Dictionary:
var payload := {
"username": username,
"email": email,
"password": password,
"over_18": over_18,
"date_of_birth": date_of_birth,
}
var result := await request(
HTTPClient.METHOD_POST,
@@ -221,6 +222,29 @@ func get_me() -> Dictionary:
return await request(HTTPClient.METHOD_GET, "/api/users/me", "", "application/json", true)
## Fetch the current user's saved avatar loadout (requires auth).
## On success, result.data is {"data": <loadout dict>, "is_default": bool} --
## `is_default` true means the user has never customised and this is the
## server default.
func get_avatar_loadout() -> Dictionary:
return await request(HTTPClient.METHOD_GET, "/api/avatar/loadout", "", "application/json", true)
## Ask the backend for a dedicated server to host [param world_id] and return
## its address. On success result.data is {"world_id", "host", "port"}.
## 503 if this backend has no game-server orchestration.
func join_world(world_id: int) -> Dictionary:
return await request(HTTPClient.METHOD_POST, "/api/worlds/%d/join" % world_id, "", "application/json", true)
## Save (replace) the current user's avatar loadout (requires auth).
## @param loadout A dict in the AvatarLoadout.to_json() shape
## ({"base_body": String, "slots": {...}, "skin_tone"?: {...}}).
func save_avatar_loadout(loadout: Dictionary) -> Dictionary:
var body := JSON.stringify({"data": loadout})
return await request(HTTPClient.METHOD_PUT, "/api/avatar/loadout", body, "application/json", true)
# ---------------------------------------------------------------------------
# Internals
# ---------------------------------------------------------------------------
+3
View File
@@ -9,6 +9,9 @@
class_name AvatarCatalog
extends RefCounted
# Preloaded rather than referenced by global class_name -- see avatar_loadout.gd.
const AvatarLoadout := preload("res://scripts/avatar/avatar_loadout.gd")
## Emitted when the catalog is loaded successfully.
signal catalog_loaded
+187 -31
View File
@@ -12,6 +12,13 @@
class_name AvatarCustomizationUI
extends Control
# Preloaded rather than referenced by global class_name -- see avatar_loadout.gd.
const AvatarLoadout := preload("res://scripts/avatar/avatar_loadout.gd")
const AvatarCatalog := preload("res://scripts/avatar/avatar_catalog.gd")
const AvatarLoader := preload("res://scripts/avatar/avatar_loader.gd")
const AvatarVisualizer := preload("res://scripts/avatar/avatar_visualizer.gd")
const AvatarSlotRegistry := preload("res://scripts/avatar/avatar_slot_registry.gd")
# ---------------------------------------------------------------------------
# Signals
@@ -69,6 +76,33 @@ var _preview_root: Node3D = null
## Currently selected slot id.
var _selected_slot: String = ""
## Skin: a base colour picked from a swatch, plus a shade multiplier from the
## slider (0.5 = the swatch as-is, lower = darker, higher = lighter). The
## first swatch is the placeholder default yellow.
const SKIN_SWATCHES: Array[Color] = [
Color(0.96, 0.80, 0.19), # yellow (default)
Color(0.95, 0.78, 0.66), # pale
Color(0.83, 0.62, 0.46), # tan
Color(0.55, 0.38, 0.27), # brown
Color(0.33, 0.21, 0.15), # deep brown
Color(0.62, 0.62, 0.64), # grey
Color(0.78, 0.24, 0.24), # red
Color(0.30, 0.62, 0.34), # green
Color(0.26, 0.46, 0.76), # blue
Color(0.62, 0.36, 0.72), # purple
]
var _skin_base: Color = SKIN_SWATCHES[0]
## Turntable: the preview avatar's Y rotation (radians), whether the player is
## currently dragging it, and how long since they last touched it (drives the
## slow idle auto-spin). Starts facing the camera.
var _turntable_yaw: float = PI
var _turntable_dragging: bool = false
var _turntable_idle: float = 0.0
const _TURNTABLE_IDLE_SPIN_DELAY := 2.5
const _TURNTABLE_IDLE_SPIN_SPEED := 0.5
const _TURNTABLE_DRAG_SPEED := 0.01
## UI node references.
var _slot_list: VBoxContainer = null
var _item_list: VBoxContainer = null
@@ -99,9 +133,14 @@ func _ready() -> void:
# Build the UI.
_build_ui()
# Create a fresh loadout.
loadout = AvatarLoadout.new()
loadout.base_body = default_base_body
# Seed from the player's live loadout (set at login) if there is one, so
# the screen opens showing what they're actually wearing; otherwise start
# from a bare default.
if _system != null and _system.current_loadout != null:
loadout = AvatarLoadout.from_json(_system.current_loadout.to_json())
if loadout == null or not loadout.validate():
loadout = AvatarLoadout.new()
loadout.base_body = default_base_body
# Initialize the visualizer.
_visualizer = AvatarVisualizer.new()
@@ -130,10 +169,11 @@ func _build_ui() -> void:
root_hbox.add_theme_constant_override("separation", 16)
add_child(root_hbox)
# --- Left: preview ---
# --- Left: preview (takes all the leftover width) ---
var preview_panel := PanelContainer.new()
preview_panel.name = "PreviewPanel"
preview_panel.custom_minimum_size = Vector2(480, 0)
preview_panel.custom_minimum_size = Vector2(420, 0)
preview_panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
root_hbox.add_child(preview_panel)
var preview_margin := MarginContainer.new()
@@ -143,23 +183,56 @@ func _build_ui() -> void:
preview_margin.add_theme_constant_override("margin_bottom", 8)
preview_panel.add_child(preview_margin)
var preview_vbox := VBoxContainer.new()
preview_vbox.add_theme_constant_override("separation", 6)
preview_margin.add_child(preview_vbox)
_preview_container = SubViewportContainer.new()
_preview_container.name = "PreviewViewportContainer"
_preview_container.stretch = true
_preview_container.custom_minimum_size = Vector2(464, 560)
preview_margin.add_child(_preview_container)
_preview_container.custom_minimum_size = Vector2(400, 520)
_preview_container.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_preview_container.size_flags_vertical = Control.SIZE_EXPAND_FILL
_preview_container.mouse_filter = Control.MOUSE_FILTER_STOP
_preview_container.gui_input.connect(_on_preview_gui_input)
preview_vbox.add_child(_preview_container)
var rotate_hint := Label.new()
rotate_hint.text = "drag to rotate"
rotate_hint.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
rotate_hint.add_theme_color_override("font_color", Color(1, 1, 1, 0.5))
rotate_hint.add_theme_font_size_override("font_size", 12)
preview_vbox.add_child(rotate_hint)
_preview_viewport = SubViewport.new()
_preview_viewport.name = "PreviewViewport"
_preview_viewport.size = Vector2i(464, 560)
_preview_viewport.size = Vector2i(400, 520)
_preview_viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS
_preview_viewport.transparent_bg = true
# Its own 3D world -- a SubViewport that shares the (Control-only) main
# world has no scenario/environment set up and renders nothing.
_preview_viewport.own_world_3d = true
_preview_container.add_child(_preview_viewport)
# Camera for the preview.
# Environment: ambient fill so the model reads even from a bad light angle.
var world_env := WorldEnvironment.new()
world_env.name = "PreviewEnv"
var env := Environment.new()
env.background_mode = Environment.BG_COLOR
env.background_color = Color(0.10, 0.11, 0.14)
env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
env.ambient_light_color = Color(0.62, 0.64, 0.7)
env.ambient_light_energy = 0.7
world_env.environment = env
_preview_viewport.add_child(world_env)
# Camera for the preview -- framed to fit the full ~2 m avatar.
_preview_camera = Camera3D.new()
_preview_camera.name = "PreviewCamera"
_preview_camera.position = Vector3(0, 1.0, 3.0)
_preview_camera.look_at(Vector3(0, 1.0, 0))
# look_at needs a global transform, which only exists once in the tree.
_preview_camera.look_at_from_position(
Vector3(0, 1.05, 3.5), Vector3(0, 0.98, 0), Vector3.UP)
_preview_camera.current = true
_preview_viewport.add_child(_preview_camera)
# Avatar root inside the viewport.
@@ -167,11 +240,17 @@ func _build_ui() -> void:
_preview_root.name = "AvatarPreviewRoot"
_preview_viewport.add_child(_preview_root)
# A light so the avatar is visible.
var light := DirectionalLight3D.new()
light.name = "PreviewLight"
light.rotation_degrees = Vector3(-45, 30, 0)
_preview_viewport.add_child(light)
# Key + fill lights so the avatar is clearly visible.
var key := DirectionalLight3D.new()
key.name = "PreviewKeyLight"
key.rotation_degrees = Vector3(-35, 35, 0)
key.light_energy = 1.0
_preview_viewport.add_child(key)
var fill := DirectionalLight3D.new()
fill.name = "PreviewFillLight"
fill.rotation_degrees = Vector3(-20, -140, 0)
fill.light_energy = 0.35
_preview_viewport.add_child(fill)
# --- Right: controls ---
var controls_panel := PanelContainer.new()
@@ -227,14 +306,28 @@ func _build_ui() -> void:
_item_list.add_theme_constant_override("separation", 4)
item_scroll.add_child(_item_list)
# Skin tone control.
# Skin colour: a row of preset swatches + a shade slider that darkens or
# lightens the picked colour. The first swatch is the default yellow.
var skin_header := Label.new()
skin_header.text = "Skin Tone"
skin_header.text = "Skin Colour"
skin_header.add_theme_font_size_override("font_size", 16)
controls_vbox.add_child(skin_header)
var swatch_grid := GridContainer.new()
swatch_grid.columns = 5
swatch_grid.add_theme_constant_override("h_separation", 4)
swatch_grid.add_theme_constant_override("v_separation", 4)
controls_vbox.add_child(swatch_grid)
for c in SKIN_SWATCHES:
swatch_grid.add_child(_make_swatch(c))
var shade_label := Label.new()
shade_label.text = "Shade"
shade_label.add_theme_font_size_override("font_size", 13)
controls_vbox.add_child(shade_label)
_skin_tone_slider = HSlider.new()
_skin_tone_slider.name = "SkinToneSlider"
_skin_tone_slider.name = "ShadeSlider"
_skin_tone_slider.min_value = 0.0
_skin_tone_slider.max_value = 1.0
_skin_tone_slider.step = 0.01
@@ -367,6 +460,31 @@ func _on_equip_pressed(asset_id: String) -> void:
loadout_changed.emit(loadout)
# ---------------------------------------------------------------------------
# Turntable (drag to rotate the preview, slow auto-spin when idle)
# ---------------------------------------------------------------------------
func _process(delta: float) -> void:
if _preview_root == null:
return
if _turntable_dragging:
_turntable_idle = 0.0
else:
_turntable_idle += delta
if _turntable_idle > _TURNTABLE_IDLE_SPIN_DELAY:
_turntable_yaw += delta * _TURNTABLE_IDLE_SPIN_SPEED
_preview_root.rotation.y = _turntable_yaw
func _on_preview_gui_input(event: InputEvent) -> void:
if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT:
_turntable_dragging = event.pressed
_turntable_idle = 0.0
elif event is InputEventMouseMotion and _turntable_dragging:
_turntable_yaw -= event.relative.x * _TURNTABLE_DRAG_SPEED
_turntable_idle = 0.0
# ---------------------------------------------------------------------------
# Preview refresh
# ---------------------------------------------------------------------------
@@ -399,18 +517,44 @@ func _refresh_preview() -> void:
# Skin tone
# ---------------------------------------------------------------------------
func _on_skin_tone_changed(value: float) -> void:
## Build one clickable colour swatch.
func _make_swatch(color: Color) -> Button:
var b := Button.new()
b.custom_minimum_size = Vector2(40, 30)
b.tooltip_text = "#" + color.to_html(false)
for state in ["normal", "hover", "pressed", "focus", "disabled"]:
var sb := StyleBoxFlat.new()
sb.bg_color = color
sb.set_corner_radius_all(3)
if state == "hover" or state == "pressed" or state == "focus":
sb.set_border_width_all(2)
sb.border_color = Color.WHITE
b.add_theme_stylebox_override(state, sb)
b.pressed.connect(_on_skin_swatch_pressed.bind(color))
return b
func _on_skin_swatch_pressed(color: Color) -> void:
_skin_base = color
_apply_skin_colour()
func _on_skin_tone_changed(_value: float) -> void:
_apply_skin_colour()
## Combine the picked swatch with the shade slider and push it to the loadout.
func _apply_skin_colour() -> void:
if loadout == null:
return
# Map slider 0..1 to a skin tone color range.
var t := value
var skin := Color(
0.6 + 0.3 * t, # R
0.4 + 0.2 * t, # G
0.3 + 0.15 * t, # B
1.0
)
loadout.skin_tone = skin
var shade: float = _skin_tone_slider.value if _skin_tone_slider != null else 0.5
var col := _skin_base
if shade < 0.5:
col = _skin_base.lerp(Color.BLACK, (0.5 - shade) * 1.7)
elif shade > 0.5:
col = _skin_base.lerp(Color.WHITE, (shade - 0.5) * 1.3)
col.a = 1.0
loadout.skin_tone = col
_refresh_preview()
loadout_changed.emit(loadout)
@@ -422,14 +566,26 @@ func _on_skin_tone_changed(value: float) -> void:
func _on_save_pressed() -> void:
if loadout == null:
return
# Local cache (offline fallback / fast reopen).
var json := loadout.to_json()
var file := FileAccess.open("user://loadout.json", FileAccess.WRITE)
if file:
file.store_string(json)
file.close()
_status_label.text = "Loadout saved."
# Apply to the live in-world avatar immediately.
if _system != null:
_system.set_loadout_from_dict(JSON.parse_string(json))
# Persist to the backend if we're signed in.
var api := get_node_or_null("/root/ApiClient")
if api != null and api.has_token():
_status_label.text = "Saving…"
var res: Dictionary = await api.save_avatar_loadout(JSON.parse_string(json))
_status_label.text = "Saved." if res.get("ok", false) else "Saved locally (backend: %s)" % res.get("detail", "error")
else:
_status_label.text = "Failed to save loadout."
_status_label.text = "Saved locally (not signed in)."
func _on_load_pressed() -> void:
+154 -155
View File
@@ -12,6 +12,9 @@
class_name AvatarLoader
extends Node
# Preloaded rather than referenced by global class_name -- see avatar_loadout.gd.
const AvatarCatalog := preload("res://scripts/avatar/avatar_catalog.gd")
# ---------------------------------------------------------------------------
# Constants
@@ -20,6 +23,11 @@ extends Node
## Maximum number of attachments allowed in the accessory slot.
const MAX_ACCESSORY_COUNT := 8
## The skin albedo baked into the placeholder base body by
## gen_placeholder_avatars.gd. apply_skin_tone() finds the mesh surfaces
## painted this colour (head + arms) and retints them.
const PLACEHOLDER_SKIN_ALBEDO := Color(0.96, 0.80, 0.19)
## Skeleton bone names that form the canonical rig (§3.1).
const CANONICAL_BONES := [
"Root",
@@ -116,14 +124,9 @@ func load_base_body(base_body_id: String) -> Node3D:
return null
var path: String = asset_meta.path
var glb_data: PackedByteArray = _load_file(path)
if glb_data.is_empty():
load_error.emit("Failed to read GLB file: %s" % path)
return null
avatar_node = _parse_glb_as_avatar(glb_data, path)
avatar_node = _instance_glb_as_avatar(path)
if avatar_node == null:
load_error.emit("Failed to parse GLB: %s" % path)
load_error.emit("Failed to load base body scene: %s" % path)
return null
skeleton = avatar_node.get_node_or_null("Skeleton3D") as Skeleton3D
@@ -172,36 +175,49 @@ func load_attachment(slot_id: String, asset_id: String) -> Node3D:
# Check slot exclusivity for accessory slot.
if slot_id == "accessory":
var current_accessories: Array[String] = _attachments.get("accessory", [])
var current_accessories: Array = _attachments.get("accessory", [])
if current_accessories.size() >= MAX_ACCESSORY_COUNT:
load_error.emit("Accessory slot full (%d/%d)" % [
MAX_ACCESSORY_COUNT, MAX_ACCESSORY_COUNT])
return null
var path: String = asset_meta.path
var glb_data: PackedByteArray = _load_file(path)
if glb_data.is_empty():
load_error.emit("Failed to read GLB file: %s" % path)
return null
var attachment_node = _parse_glb_as_attachment(glb_data, path, slot_id)
var attachment_node = _instance_glb_as_attachment(path, slot_id)
if attachment_node == null:
load_error.emit("Failed to parse attachment GLB: %s" % path)
load_error.emit("Failed to load attachment scene: %s" % path)
return null
# Retarget skin to the canonical skeleton.
# If the attachment carries its own skin, retarget it to the canonical
# skeleton; static props (the common case) are unaffected.
_retarget_skin(attachment_node)
# Parent static accessories to their mount bone.
_parent_attachment(attachment_node, slot_id, asset_meta)
# Mount to the appropriate bone via a BoneAttachment3D so it follows the
# skeleton. Falls back to a plain skeleton child if the mount bone is
# missing (a bad catalog entry) rather than dropping the attachment.
var mount_bone: String = asset_meta.get("mount_bone", "")
if mount_bone.is_empty():
mount_bone = _slot_to_mount_bone(slot_id)
var mount_node: Node3D
if not mount_bone.is_empty() and skeleton.find_bone(mount_bone) != -1:
var ba := BoneAttachment3D.new()
ba.name = "Mount_%s" % slot_id if slot_id != "accessory" else "Mount_accessory_%s" % asset_id
ba.bone_name = mount_bone
skeleton.add_child(ba)
ba.add_child(attachment_node)
mount_node = ba
else:
skeleton.add_child(attachment_node)
mount_node = attachment_node
# Store reference.
# Store the top-level mount node so unload can free the whole unit.
if slot_id == "accessory":
var list: Array[String] = _attachments.get("accessory", [])
list.append(asset_id)
var list: Array = _attachments.get("accessory", [])
list.append(mount_node)
_attachments["accessory"] = list
else:
_attachments[slot_id] = attachment_node
if _attachments.has(slot_id) and is_instance_valid(_attachments[slot_id]):
_attachments[slot_id].queue_free()
_attachments[slot_id] = mount_node
attachment_loaded.emit(slot_id, attachment_node)
return attachment_node
@@ -210,15 +226,13 @@ func load_attachment(slot_id: String, asset_id: String) -> Node3D:
## Remove an attachment from the given slot.
func unload_attachment(slot_id: String) -> void:
if slot_id == "accessory":
var list: Array[String] = _attachments.get("accessory", [])
for asset_id in list:
var node = _attachments.get(asset_id)
if node and node is Node:
for node in _attachments.get("accessory", []):
if is_instance_valid(node):
node.queue_free()
_attachments.erase("accessory")
else:
var node = _attachments.get(slot_id)
if node and node is Node:
if is_instance_valid(node):
node.queue_free()
_attachments.erase(slot_id)
@@ -230,6 +244,33 @@ func set_base_mesh_visible(slot_id: String, visible: bool) -> void:
mesh.visible = visible
## Retint the base body's skin surfaces (the ones the generator painted
## PLACEHOLDER_SKIN_ALBEDO -- head + arms) to [param color]. A no-op if the
## colour is unset (transparent). Real segmented bodies will do this per mesh.
func apply_skin_tone(color: Color) -> void:
if color.a <= 0.0:
return
if avatar_node == null or not is_instance_valid(avatar_node):
return
var skel := avatar_node.get_node_or_null("Skeleton3D") as Skeleton3D
if skel == null:
return
for child in skel.get_children():
if not (child is MeshInstance3D):
continue
var mi := child as MeshInstance3D
if mi.mesh == null:
continue
for s in mi.mesh.get_surface_count():
var mat := mi.get_active_material(s)
if mat is BaseMaterial3D and \
(mat as BaseMaterial3D).albedo_color.is_equal_approx(PLACEHOLDER_SKIN_ALBEDO):
var tinted := StandardMaterial3D.new()
tinted.albedo_color = color
tinted.roughness = 0.9
mi.set_surface_override_material(s, tinted)
## Get the current loadout as a dictionary (for serialization).
func get_current_loadout() -> Dictionary:
var loadout: Dictionary = {
@@ -277,164 +318,118 @@ func reset() -> void:
# Internal: GLB Parsing
# ---------------------------------------------------------------------------
## Parse a GLB byte array into an avatar Node3D structure.
## Creates: Avatar (Node3D) -> Skeleton3D + BodyMeshes + AttachmentPoints
func _parse_glb_as_avatar(glb_data: PackedByteArray, path: String) -> Node3D:
## Instantiate a base-body .glb (a Godot-imported PackedScene) into an avatar
## structure:
## Avatar (Node3D)
## Skeleton3D -- the canonical rig + skin-bound body meshes
## BodyMeshes (Node3D) -- unused container, kept for compatibility
## Attachments (Node3D)-- equipped attachment nodes (added by load_attachment)
##
## Uses load().instantiate() rather than the runtime GLTFDocument path: the
## imported .scn is the one that actually ends up in an exported build, with
## the skeleton + skin set up by Godot's import pipeline. (The raw-bytes
## GLTFDocument path -- for future user-uploaded models -- misbehaved in a
## release build: no Skeleton3D produced, so the player fell back to a capsule.)
func _instance_glb_as_avatar(path: String) -> Node3D:
var packed = load(path)
if not (packed is PackedScene):
push_error("Base body is not an importable scene: %s" % path)
return null
var scene_root: Node = packed.instantiate()
if not (scene_root is Node3D):
push_error("Base body scene root is not a Node3D: %s" % path)
scene_root.free()
return null
var src_skel := _find_first_skeleton(scene_root)
if src_skel == null:
push_error("No Skeleton3D in base body scene: %s" % path)
scene_root.free()
return null
var avatar := Node3D.new()
avatar.name = "Avatar"
# Create the skeleton container.
var skeleton_node := Skeleton3D.new()
skeleton_node.name = "Skeleton3D"
skeleton_node.position = Vector3(0, 0, 0)
avatar.add_child(skeleton_node)
src_skel.get_parent().remove_child(src_skel)
src_skel.name = "Skeleton3D"
src_skel.transform = Transform3D.IDENTITY
_clear_owner_recursive(src_skel)
avatar.add_child(src_skel)
scene_root.free()
# Create attachment points container.
var attach_points := Node3D.new()
attach_points.name = "AttachmentPoints"
avatar.add_child(attach_points)
# Create body meshes container.
var body_meshes := Node3D.new()
body_meshes.name = "BodyMeshes"
avatar.add_child(body_meshes)
# Create attachments container.
var attachments_container := Node3D.new()
attachments_container.name = "Attachments"
avatar.add_child(attachments_container)
# Parse the GLB and extract skeleton + meshes.
var gltf_document := GLTFDocument.new()
var gltf_state := GLTFState.new()
var parse_result := gltf_document.append_from_buffer(glb_data, "", gltf_state)
if parse_result != OK:
push_error("GLB parse error: %s" % error_string(parse_result))
avatar.queue_free()
return null
# Generate the actual scene tree from the parsed GLTF state.
var scene_root := gltf_document.generate_scene(gltf_state)
if scene_root == null:
push_error("Failed to generate scene from GLB: %s" % path)
avatar.queue_free()
return null
# Merge GLB nodes into our avatar structure (recurses into children).
if scene_root is Node3D:
_merge_node_into_avatar(scene_root, avatar, body_meshes,
attach_points, attachments_container)
return avatar
## Parse a GLB byte array into an attachment Node3D.
func _parse_glb_as_attachment(glb_data: PackedByteArray, path: String,
slot_id: String) -> Node3D:
## Depth-first search for the first Skeleton3D under [param node].
func _find_first_skeleton(node: Node) -> Skeleton3D:
if node is Skeleton3D:
return node as Skeleton3D
for child in node.get_children():
var found := _find_first_skeleton(child)
if found:
return found
return null
var gltf_document := GLTFDocument.new()
var gltf_state := GLTFState.new()
var parse_result := gltf_document.append_from_buffer(glb_data, "", gltf_state)
if parse_result != OK:
push_error("GLB parse error: %s" % error_string(parse_result))
## Instantiate an attachment .glb (imported PackedScene) into a single named
## Node3D the caller can mount to a bone as one unit.
func _instance_glb_as_attachment(path: String, slot_id: String) -> Node3D:
var packed = load(path)
if not (packed is PackedScene):
push_error("Attachment is not an importable scene: %s" % path)
return null
var scene_root := gltf_document.generate_scene(gltf_state)
if scene_root == null:
push_error("Failed to generate scene from attachment GLB: %s" % path)
var scene_root: Node = packed.instantiate()
if not (scene_root is Node3D):
push_error("Attachment scene root is not a Node3D: %s" % path)
scene_root.free()
return null
var attachment := Node3D.new()
attachment.name = "%s_%s" % [slot_id, path.get_file().get_basename()]
if scene_root is Node3D:
attachment.add_child(scene_root.duplicate())
for child in scene_root.get_children().duplicate():
scene_root.remove_child(child)
_clear_owner_recursive(child)
attachment.add_child(child)
scene_root.free()
return attachment
## Merge a GLB node tree into the avatar's container structure.
func _merge_node_into_avatar(node: Node3D, avatar: Node3D,
body_meshes: Node3D, attach_points: Node3D,
attachments_container: Node3D) -> void:
var node_name := node.name
# Identify skeleton nodes.
if node is Skeleton3D:
var src_skel := node as Skeleton3D
var dst_skel := avatar.get_node_or_null("Skeleton3D") as Skeleton3D
if dst_skel:
# Copy bones from source skeleton to destination.
for i in src_skel.get_bone_count():
var bone_name := src_skel.get_bone_name(i)
var bone_pose := src_skel.get_bone_pose(i)
var parent_idx := src_skel.get_bone_parent(i)
if dst_skel.has_bone(bone_name):
# Bone already exists (canonical).
dst_skel.set_bone_pose(i, bone_pose)
if parent_idx >= 0:
var parent_name := src_skel.get_bone_name(parent_idx)
var dst_parent_idx := dst_skel.find_bone(parent_name)
if dst_parent_idx >= 0:
dst_skel.set_bone_parent(i, dst_parent_idx)
else:
# Add new bone.
var new_idx := dst_skel.add_bone(bone_name)
dst_skel.set_bone_pose(new_idx, bone_pose)
if parent_idx >= 0:
var parent_name := src_skel.get_bone_name(parent_idx)
var dst_parent_idx := dst_skel.find_bone(parent_name)
if dst_parent_idx >= 0:
dst_skel.set_bone_parent(new_idx, dst_parent_idx)
# Skinned meshes are commonly children of the skeleton node in a GLB
# export, not siblings — fall through to the recursion below instead
# of returning, so they still get merged in.
for child in src_skel.get_children():
if child is Node3D:
_merge_node_into_avatar(child, avatar, body_meshes,
attach_points, attachments_container)
return
# Identify attachment point nodes (empty nodes named attach_*).
if node_name.begins_with("attach_"):
var cloned := node.duplicate()
cloned.name = node_name
attach_points.add_child(cloned)
return
# Identify mesh nodes.
if node is MeshInstance3D:
var mesh_inst := node as MeshInstance3D
var cloned := mesh_inst.duplicate() as MeshInstance3D
# Determine which container to place it in.
if node_name == "Body" or node_name.ends_with("_Mesh"):
body_meshes.add_child(cloned)
else:
attachments_container.add_child(cloned)
return
# For other nodes, recurse into children.
## generate_scene() stamps every node with the GLB root as `owner`; that
## becomes inconsistent (and noisy) once we reparent the node elsewhere.
func _clear_owner_recursive(node: Node) -> void:
node.owner = null
for child in node.get_children():
if child is Node3D:
_merge_node_into_avatar(child, avatar, body_meshes,
attach_points, attachments_container)
_clear_owner_recursive(child)
## Extract base body meshes from the avatar and store them by slot.
## Index the base body's skinned meshes by slot so equipping a body-part
## attachment can hide the corresponding base mesh. generate_scene() parents
## skinned meshes under the Skeleton3D, so scan there.
func _extract_base_meshes(avatar: Node3D) -> void:
var body_meshes := avatar.get_node_or_null("BodyMeshes") as Node3D
if body_meshes == null:
var skel := avatar.get_node_or_null("Skeleton3D") as Skeleton3D
if skel == null:
return
for child in body_meshes.get_children():
for child in skel.get_children():
if child is MeshInstance3D:
var mesh_name := child.name
var slot_id := _mesh_name_to_slot(mesh_name)
if slot_id != "":
_base_meshes[slot_id] = child
# The single "Body" placeholder mesh is registered only under
# "torso" -- mapping it to every body-part slot would make it
# vanish the moment any one body-part item is equipped. A real
# multi-mesh body registers each mesh under its own slot.
if child.name == "Body":
_base_meshes["torso"] = child
else:
var slot_id := _mesh_name_to_slot(child.name)
if slot_id != "":
_base_meshes[slot_id] = child
## Map a mesh name to a slot id based on naming conventions (§4.3).
@@ -481,8 +476,12 @@ func _retarget_skin(attachment_node: Node3D) -> void:
for surface_idx in surface_count:
var arrays := arr_mesh.surface_get_arrays(surface_idx)
var joints_array := arrays[Mesh.ARRAY_BONES] as PackedInt32Array
var weights_array := arrays[Mesh.ARRAY_WEIGHTS] as PackedFloat32Array
# Static props (the common attachment) carry no bone/weight
# arrays at all -- nothing to retarget.
if arrays[Mesh.ARRAY_BONES] == null or arrays[Mesh.ARRAY_WEIGHTS] == null:
continue
var joints_array: PackedInt32Array = arrays[Mesh.ARRAY_BONES]
var weights_array: PackedFloat32Array = arrays[Mesh.ARRAY_WEIGHTS]
if joints_array.is_empty():
continue
+10 -1
View File
@@ -8,6 +8,15 @@
class_name AvatarLoadout
extends RefCounted
# Sibling avatar scripts are referenced via preload rather than by their
# global class_name so this module parses correctly from a bare clone (a
# headless `godot <scene>` run does not rebuild the global class registry;
# only an editor scan / `--import` does). See docs/architecture/avatar.md.
const AvatarSlotRegistry := preload("res://scripts/avatar/avatar_slot_registry.gd")
# Self-reference for the static factory below -- referring to our own
# class_name doesn't resolve during a cold-cache compile (see note above).
const _Self := preload("res://scripts/avatar/avatar_loadout.gd")
## Maximum number of accessories allowed in the accessory slot.
const MAX_ACCESSORIES: int = 8
@@ -58,7 +67,7 @@ func _init() -> void:
## Create a loadout from a JSON string.
static func from_json(json_string: String) -> AvatarLoadout:
var loadout := AvatarLoadout.new()
var loadout := _Self.new()
var data: Variant = JSON.parse_string(json_string)
if not (data is Dictionary):
loadout.validation_errors.append("Loadout JSON root is not an object")
+102
View File
@@ -10,6 +10,16 @@
extends Node
# Sibling avatar scripts are referenced via preload rather than by their global
# class_name so this autoload parses correctly from a bare clone -- a headless
# `godot <scene>` run does not rebuild the global class registry (only an editor
# scan / `--import` does), and this script is parsed at startup before any such
# scan could have happened. See docs/architecture/avatar.md.
const AvatarCatalog := preload("res://scripts/avatar/avatar_catalog.gd")
const AvatarLoader := preload("res://scripts/avatar/avatar_loader.gd")
const AvatarLoadout := preload("res://scripts/avatar/avatar_loadout.gd")
const AvatarSlotRegistry := preload("res://scripts/avatar/avatar_slot_registry.gd")
# ---------------------------------------------------------------------------
# Signals
@@ -220,3 +230,95 @@ func get_assets_for_slot(slot_id: String) -> Array[String]:
func get_asset_display_name(asset_id: String) -> String:
var asset := catalog.resolve(asset_id)
return asset.get("display_name", asset_id)
# ---------------------------------------------------------------------------
# Local-player convenience API
#
# The rest of the game (the main menu, the in-world player) only needs three
# things: load the catalog once, hand us the player's loadout (as the dict the
# backend returns), and ask for an assembled avatar node to drop into a scene.
# ---------------------------------------------------------------------------
## True once a loadout has been handed to us (from the backend or a default).
func has_loadout() -> bool:
return current_loadout != null
## Ensure the catalog is loaded (idempotent). Safe to await repeatedly.
func ensure_catalog() -> bool:
if is_catalog_loaded:
return true
return load_catalog() == OK
## Set the current player's loadout from the dict shape the backend's
## GET /api/avatar/loadout returns (i.e. AvatarLoadout.to_json() parsed).
## Returns true if it validated and was stored.
func set_loadout_from_dict(loadout_dict: Dictionary) -> bool:
var lo := AvatarLoadout.from_json(JSON.stringify(loadout_dict))
if not lo.validate():
push_warning("AvatarSystem: rejecting invalid loadout dict: %s"
% str(lo.validation_errors))
return false
current_loadout = lo
loadout_changed.emit(current_loadout)
return true
## Assemble a fresh avatar Node3D from the current loadout, ready to parent
## into a scene (e.g. onto the in-world player). Returns null if there's no
## loadout or the catalog isn't loaded or assembly failed.
func build_current_avatar() -> Node3D:
# Self-sufficient: load the catalog if needed, and fall back to a bare
# default loadout when login didn't prime one -- the player should always
# get *an* avatar, never a placeholder capsule, as long as the assets exist.
if not ensure_catalog():
push_warning("AvatarSystem: catalog unavailable; cannot build avatar")
return null
if current_loadout == null:
var bases := get_assets_for_slot("base_body")
if bases.is_empty():
return null
set_loadout_from_dict({"base_body": bases[0], "slots": {}})
return assemble_avatar(current_loadout)
## Assemble a standalone avatar node from [param loadout] using a throwaway
## loader -- unlike load_avatar(), this touches no shared state, so it's safe
## to call many times (local player + one per remote peer). Returns null only
## if the assets are genuinely missing.
func assemble_avatar(loadout) -> Node3D:
if loadout == null or not is_catalog_loaded:
return null
var validation := catalog.validate_loadout(loadout)
if not validation.valid:
push_warning("AvatarSystem: invalid loadout, skipping: %s" % str(validation.errors))
return null
var l = AvatarLoader.new()
l.init_loader(catalog)
var base := l.load_base_body(loadout.base_body)
if base == null:
l.free()
return null
for slot_id in AvatarSlotRegistry.SLOT_IDS:
var v = loadout.slots.get(slot_id)
if slot_id == "accessory":
if v is Array:
for aid in v:
if aid is String and not (aid as String).is_empty():
l.load_attachment(slot_id, aid)
elif v is String and not (v as String).is_empty():
l.load_attachment(slot_id, v)
for slot_id in AvatarSlotRegistry.BODY_PART_SLOTS:
var v = loadout.slots.get(slot_id)
if v is String and not (v as String).is_empty():
l.set_base_mesh_visible(slot_id, false)
l.apply_skin_tone(loadout.skin_tone)
l.free()
return base
+71 -239
View File
@@ -1,285 +1,117 @@
## AvatarVisualizer
##
## Builds the render tree from an AvatarLoadout.
##
## Responsibilities:
## - Create a root Node3D (or Node2D for 2D mode) as the avatar container.
## - For each slot in the loadout, call AvatarLoader.load_slot() and attach
## the resulting Skeleton3D / MeshInstance3D tree to the root.
## - Apply skin-tone overrides to all MeshInstance3D nodes.
## - Apply a single Skeleton3D root (the "body" skeleton) and retarget all
## limb skeletons to it using the bone-map from AvatarSlotRegistry.
## - Expose `get_root()` for the caller to attach to the scene tree.
## - Expose `clear()` to destroy the tree and free resources.
## Renders an AvatarLoadout into a 3D node tree for previews (the customization
## screen). A thin wrapper over AvatarLoader -- it owns its own loader instance
## and catalog reference so building a preview never disturbs AvatarSystem's
## live in-world avatar state.
##
## Usage:
## var viz = AvatarVisualizer.new()
## viz.load_loadout(loadout)
## avatar_node.add_child(viz.get_root())
##
## The visualizer owns the Skeleton3D and MeshInstance3D trees it creates.
## The caller owns the root node's placement in the scene tree.
## var viz := AvatarVisualizer.new()
## viz.load_loadout(loadout, preview_root_node3d) # preview_root gets the avatar as a child
## ...
## viz.clear()
class_name AvatarVisualizer
extends Object
# Preloaded rather than referenced by global class_name -- see avatar_loadout.gd.
const AvatarLoadout := preload("res://scripts/avatar/avatar_loadout.gd")
const AvatarLoader := preload("res://scripts/avatar/avatar_loader.gd")
const AvatarCatalog := preload("res://scripts/avatar/avatar_catalog.gd")
const AvatarSlotRegistry := preload("res://scripts/avatar/avatar_slot_registry.gd")
## Emitted when the visualizer has finished building the render tree.
signal build_complete
## Emitted when the visualizer has been cleared.
signal cleared
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
## The root node that holds the avatar render tree.
## Set this to the desired parent before calling load_loadout().
## The node the assembled avatar is parented under.
var root: Node3D = null
## The Skeleton3D that serves as the body skeleton.
## All limb skeletons are retargeted to this skeleton's bone hierarchy.
var body_skeleton: Skeleton3D = null
## The assembled avatar node (child of `root`).
var avatar_node: Node3D = null
## The AvatarLoadout currently loaded (null if none).
## The loadout currently rendered.
var current_loadout: AvatarLoadout = null
## Whether the visualizer is currently building (true during load_loadout).
var is_building: bool = false
var _loader = null
var _catalog = null
# ---------------------------------------------------------------------------
# Core
# ---------------------------------------------------------------------------
## Load an AvatarLoadout and build the render tree.
##
## If [param root] is null, a new Node3D is created and assigned.
## If [param loadout] is null or invalid, the current tree is cleared.
func load_loadout(loadout: AvatarLoadout, root_node: Node3D = null) -> void:
# Clear any previous tree.
clear()
if loadout == null or not loadout.is_valid():
build_complete.emit()
return
current_loadout = loadout
is_building = true
# Create root if not provided.
## Build the avatar for [param loadout] and parent it under [param root_node]
## (or the previously-set `root`). Replaces any previously-built avatar.
func load_loadout(loadout, root_node: Node3D = null) -> void:
if root_node != null:
root = root_node
else:
root = Node3D.new()
root.name = "AvatarVisualizer"
if root == null or loadout == null:
return
# Create the body skeleton.
body_skeleton = Skeleton3D.new()
body_skeleton.name = "AvatarBody"
root.add_child(body_skeleton)
clear()
current_loadout = loadout
# Build each slot's contribution.
for slot_def in AvatarSlotRegistry.get_slot_definitions():
var slot_key: String = slot_def.slot_key
var slot_item: AvatarLoadout.SlotItem = loadout.slots.get(slot_key)
if _catalog == null:
var sys = root.get_node_or_null("/root/AvatarSystem")
if sys != null:
_catalog = sys.catalog
if _catalog == null:
_catalog = AvatarCatalog.new()
_catalog.load_catalog("res://assets/avatars/catalog.json")
if slot_item == null or slot_item.asset_id == "":
# Slot is empty — create a placeholder.
_add_placeholder_slot(slot_def)
continue
_loader = AvatarLoader.new()
root.add_child(_loader)
_loader.init_loader(_catalog)
# Load the slot's asset.
var slot_result: AvatarLoader.SlotResult = await AvatarLoader.load_slot(
slot_item,
slot_def,
loadout.skin_tone,
)
var base: Node3D = _loader.load_base_body(loadout.base_body)
if base == null:
return
root.add_child(base)
avatar_node = base
if slot_result == null:
push_warning("AvatarVisualizer: failed to load slot '%s' (%s)" % [slot_key, slot_item.asset_id])
_add_placeholder_slot(slot_def)
continue
for slot_id in AvatarSlotRegistry.SLOT_IDS:
var value = loadout.slots.get(slot_id)
if slot_id == "accessory":
if value is Array:
for aid in value:
if aid is String and not (aid as String).is_empty():
_loader.load_attachment(slot_id, aid)
elif value is String and not (value as String).is_empty():
_loader.load_attachment(slot_id, value)
# Attach the loaded tree to the root.
_attach_slot_tree(slot_result, slot_def)
for slot_id in AvatarSlotRegistry.BODY_PART_SLOTS:
var value = loadout.slots.get(slot_id)
if value is String and not (value as String).is_empty():
_loader.set_base_mesh_visible(slot_id, false)
# Retarget limb skeletons to the body skeleton.
_retarget_limbs()
_loader.apply_skin_tone(loadout.skin_tone)
# Apply skin-tone override to all meshes.
_apply_skin_tone(loadout.skin_tone)
is_building = false
build_complete.emit()
## Clear the render tree and free resources.
## Tear down the current preview avatar.
func clear() -> void:
if root != null:
root.queue_free()
root = null
body_skeleton = null
current_loadout = null
cleared.emit()
if avatar_node != null and is_instance_valid(avatar_node):
avatar_node.queue_free()
avatar_node = null
if _loader != null and is_instance_valid(_loader):
_loader.queue_free()
_loader = null
current_loadout = null
cleared.emit()
## Get the root node.
func get_root() -> Node3D:
return root
## Get a specific slot's Skeleton3D (if it has one).
func get_slot_skeleton(slot_key: String) -> Skeleton3D:
if body_skeleton == null:
return null
var bones := body_skeleton.get_bone_count()
for i in range(bones):
var bone_name: String = body_skeleton.get_bone_name(i)
if bone_name == slot_key:
return body_skeleton
return null
## Get all MeshInstance3D nodes in the render tree.
func get_all_mesh_instances() -> Array[MeshInstance3D]:
var meshes: Array[MeshInstance3D] = []
if root == null:
return meshes
_collect_meshes(root, meshes)
return meshes
var out: Array[MeshInstance3D] = []
if avatar_node:
_collect_meshes(avatar_node, out)
return out
## Get all Skeleton3D nodes in the render tree.
func get_all_skeletons() -> Array[Skeleton3D]:
var skeletons: Array[Skeleton3D] = []
if root == null:
return skeletons
_collect_skeletons(root, skeletons)
return skeletons
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
## Add a placeholder (empty) skeleton for a slot that has no asset.
func _add_placeholder_slot(slot_def: AvatarSlotRegistry.SlotDefinition) -> void:
if body_skeleton == null:
return
var bone_name: String = slot_def.slot_key
var bone_id: int = body_skeleton.add_bone(bone_name)
# Set the bone's rest pose to identity (placeholder).
var rest := Transform3D()
rest.origin = slot_def.default_offset
body_skeleton.set_bone_rest(bone_id, rest)
# Set parent to root (bone 0 is the root bone).
body_skeleton.set_bone_parent(bone_id, 0)
## Attach a loaded slot's tree to the root.
func _attach_slot_tree(result: AvatarLoader.SlotResult, slot_def: AvatarSlotRegistry.SlotDefinition) -> void:
if result.skeleton == null:
return
# Add the skeleton as a child of the body skeleton.
# The skeleton's bones will be merged into the body skeleton during retargeting.
result.skeleton.name = "%s_skeleton" % slot_def.slot_key
result.skeleton.visible = true
root.add_child(result.skeleton)
## Retarget all limb skeletons to the body skeleton.
func _retarget_limbs() -> void:
if body_skeleton == null:
return
# Collect all child Skeleton3D nodes (limb skeletons).
var limb_skeletons: Array[Skeleton3D] = []
for child in root.get_children():
if child is Skeleton3D and child != body_skeleton:
limb_skeletons.append(child as Skeleton3D)
# For each limb skeleton, merge its bones into the body skeleton.
for limb in limb_skeletons:
_merge_skeleton_into_body(limb)
limb.queue_free()
## Merge a limb skeleton's bones into the body skeleton.
func _merge_skeleton_into_body(limb: Skeleton3D) -> void:
if limb == null or body_skeleton == null:
return
var limb_bone_count: int = limb.get_bone_count()
var start_id: int = body_skeleton.get_bone_count()
for i in range(limb_bone_count):
var bone_name: String = limb.get_bone_name(i)
var bone_id: int = body_skeleton.add_bone(bone_name)
# Copy the rest transform.
var rest: Transform3D = limb.get_bone_rest(i)
body_skeleton.set_bone_rest(bone_id, rest)
# Copy the parent.
var parent_id: int = limb.get_bone_parent(i)
if parent_id >= 0:
# Map parent ID to body skeleton.
if parent_id == 0:
# Root of limb skeleton -> parent is the body root.
body_skeleton.set_bone_parent(bone_id, 0)
else:
# Offset by start_id to map to body skeleton bones.
body_skeleton.set_bone_parent(bone_id, start_id + parent_id)
else:
body_skeleton.set_bone_parent(bone_id, 0)
# Copy custom bone data (if any).
var custom_data_count: int = limb.get_bone_custom_data_count(i)
for j in range(custom_data_count):
var data: Variant = limb.get_bone_custom_data(i, j)
body_skeleton.set_bone_custom_data(bone_id, j, data)
# Copy mesh connections.
var mesh_count: int = limb.get_bone_mesh_count(i)
for m in range(mesh_count):
var mesh: MeshInstance3D = limb.get_bone_mesh(i, m)
if mesh != null:
# Re-parent mesh to body skeleton.
mesh.skeleton = body_skeleton
mesh.skeleton_path = body_skeleton.get_path()
body_skeleton.add_bone_mesh(bone_id, mesh)
## Apply skin-tone override to all MeshInstance3D nodes.
func _apply_skin_tone(skin_tone: Color) -> void:
if skin_tone == Color.TRANSPARENT:
return
var meshes: Array[MeshInstance3D] = get_all_mesh_instances()
for mesh_instance in meshes:
if mesh_instance.material_override == null:
mesh_instance.material_override = StandardMaterial3D.new()
var mat: StandardMaterial3D = mesh_instance.material_override
mat.albedo_color = skin_tone
## Recursively collect all MeshInstance3D nodes.
func _collect_meshes(node: Node, result: Array[MeshInstance3D]) -> void:
func _collect_meshes(node: Node, out: Array[MeshInstance3D]) -> void:
if node is MeshInstance3D:
result.append(node as MeshInstance3D)
out.append(node)
for child in node.get_children():
_collect_meshes(child, result)
## Recursively collect all Skeleton3D nodes.
func _collect_skeletons(node: Node, result: Array[Skeleton3D]) -> void:
if node is Skeleton3D:
result.append(node as Skeleton3D)
for child in node.get_children():
_collect_skeletons(child, result)
_collect_meshes(child, out)
+72
View File
@@ -0,0 +1,72 @@
extends Node
## Procedural locomotion for the placeholder blocky avatar.
##
## There are no animation clips yet -- this just swings the upper arm/leg bones
## around their local X by a sine wave whose phase advances with distance
## travelled, bends the knees on the rear half of the stride, and eases the
## whole thing back to the rest pose when the character stops.
##
## The owner sets `speed` (horizontal m/s) every frame; setup() is given the
## avatar's Skeleton3D. Bones are addressed by canonical name, so this works
## for the local player and for snapshot-driven remote players alike.
const STRIDE := 0.42 ## peak hip swing, radians (~24 deg)
const ARM_SWING := 0.32 ## peak shoulder swing, radians
const KNEE := 0.5 ## peak knee bend, radians
const CYCLE := 1.9 ## phase (radians) advanced per metre travelled
const BLEND := 12.0 ## ease rate toward the target pose
const MOVE_EPS := 0.35 ## m/s below which we treat the avatar as idle
var speed: float = 0.0
var _skel: Skeleton3D = null
var _idx := {} # bone name -> skeleton index
var _phase := 0.0
var _amount := 0.0 # 0 = idle, 1 = full walk
const _BONES := [
"LeftUpperLeg", "RightUpperLeg", "LeftLowerLeg", "RightLowerLeg",
"LeftUpperArm", "RightUpperArm",
]
func setup(skeleton: Skeleton3D) -> void:
_skel = skeleton
_idx.clear()
if skeleton == null:
return
for b in _BONES:
var i := skeleton.find_bone(b)
if i != -1:
_idx[b] = i
func _process(delta: float) -> void:
if not is_instance_valid(_skel) or _idx.is_empty():
return
var moving := speed > MOVE_EPS
_amount = lerpf(_amount, 1.0 if moving else 0.0, clampf(BLEND * delta, 0.0, 1.0))
_phase += maxf(speed, 0.0) * delta * CYCLE
if _phase > TAU:
_phase -= TAU
var s := sin(_phase)
var swing := s * STRIDE * _amount
var arm := s * ARM_SWING * _amount
_pitch("LeftUpperLeg", swing)
_pitch("RightUpperLeg", -swing)
_pitch("LeftUpperArm", -arm)
_pitch("RightUpperArm", arm)
# Knee folds only while that leg is swinging back (negative pitch phase).
_pitch("LeftLowerLeg", maxf(0.0, -s) * KNEE * _amount)
_pitch("RightLowerLeg", maxf(0.0, s) * KNEE * _amount)
func _pitch(bone: String, angle: float) -> void:
if not _idx.has(bone):
return
var i: int = _idx[bone]
var rest_q := _skel.get_bone_rest(i).basis.get_rotation_quaternion()
_skel.set_bone_pose_rotation(i, rest_q * Quaternion(Vector3.RIGHT, angle))
+20
View File
@@ -0,0 +1,20 @@
extends Control
## Full-screen host for the AvatarCustomizationUI.
##
## The UI itself (scripts/avatar/avatar_customization_ui.gd) builds its own
## layout programmatically and emits `closed` when the player backs out; this
## thin wrapper is what makes it a navigable scene reachable from the main menu.
const AvatarCustomizationUI := preload("res://scripts/avatar/avatar_customization_ui.gd")
func _ready() -> void:
var ui := AvatarCustomizationUI.new()
ui.name = "AvatarCustomizationUI"
ui.set_anchors_preset(Control.PRESET_FULL_RECT)
add_child(ui)
ui.closed.connect(_on_closed)
func _on_closed() -> void:
get_tree().change_scene_to_file("res://scenes/main_menu.tscn")
+265
View File
@@ -0,0 +1,265 @@
extends Node3D
## Root controller for scenes/game.tscn.
##
## Local single-player by default (walk around the test level). When
## Session.active (set by world_list.gd after POST /api/worlds/{id}/join),
## connects a NetworkClient to the world's dedicated server and runs
## networked: the local Player keeps predicting with its own collision-aware
## movement, its input is forwarded to the server each physics frame, the
## server's authoritative position is a correction backstop, and other
## players are rendered from snapshots.
##
## Appearance sync is a follow-up -- remote players show as labelled capsules,
## not their own avatars (the server doesn't replicate loadouts yet).
const REMOTE_LERP := 12.0
## Snap the local player to the server position past this drift (metres).
## Tight enough that the client always ends up where the server actually
## spawned it (which is spread out, not the fixed marker player.gd uses).
const CORRECTION_THRESHOLD := 0.6
@onready var _player: Player = $Player
var _net: NetworkClient = null
var _networked := false
var _leaving := false # a scene change is already in flight
var _aligned := false # snapped to the server spawn yet?
var _remotes: Dictionary = {} # peer_id -> Node3D
var _remote_prev: Dictionary = {} # peer_id -> Vector3 (last snapshot position)
var _status_label: Label = null
func _ready() -> void:
_show_build_stamp()
if Session.active:
_setup_networked()
func _unhandled_input(event: InputEvent) -> void:
# ESC leaves the world back to the browser. Handled here (the scene root
# that owns the connection) rather than in player.gd so the teardown is
# a single ordered path -- doing it from two places raced _on_server_lost
# against the scene change and looked like a crash.
if event.is_action_pressed("ui_cancel"):
get_viewport().set_input_as_handled()
_leave_world()
func _leave_world() -> void:
if _leaving:
return
_leaving = true
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
_teardown_net()
Session.clear()
get_tree().change_scene_to_file("res://scenes/world_list.tscn")
## Close the ENet connection and stop it from calling us back mid-teardown.
func _teardown_net() -> void:
if _net == null or not is_instance_valid(_net):
return
if _net.server_disconnected.is_connected(_on_server_lost):
_net.server_disconnected.disconnect(_on_server_lost)
if _net.connection_failed.is_connected(_on_server_lost):
_net.connection_failed.disconnect(_on_server_lost)
_net.disconnect_from_server()
static func _build_label() -> String:
var f := FileAccess.open("res://assets/build_info.json", FileAccess.READ)
if f == null:
return "dev"
var d = JSON.parse_string(f.get_as_text())
if d is Dictionary:
return "%s %s" % [d.get("checkpoint", "?"), d.get("commit", "?")]
return "dev"
func _show_build_stamp() -> void:
var layer := CanvasLayer.new()
layer.layer = 128
add_child(layer)
var lbl := Label.new()
lbl.text = "build %s" % _build_label()
lbl.add_theme_color_override("font_color", Color(1, 1, 0.35))
lbl.add_theme_color_override("font_outline_color", Color(0, 0, 0, 1))
lbl.add_theme_constant_override("outline_size", 6)
lbl.add_theme_font_size_override("font_size", 20)
# Full-width strip pinned to the TOP-left. (A bare preset + .position on a
# zero-size Label collapses the rect to negative width and nothing draws --
# which is why this never showed; explicit offsets fix it.)
lbl.set_anchors_preset(Control.PRESET_TOP_WIDE)
lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT
lbl.offset_left = 12
lbl.offset_right = -12
lbl.offset_top = 8
lbl.offset_bottom = 40
lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
layer.add_child(lbl)
func _exit_tree() -> void:
# Backstop for any exit path that didn't go through _leave_world (e.g. the
# app quitting): close the ENet connection so the server fires
# peer_disconnected and drops our player instead of leaving a ghost.
_teardown_net()
func _setup_networked() -> void:
_show_status("Connecting to %s…" % Session.host)
_net = NetworkClient.new()
_net.name = "NetworkClient"
add_child(_net)
_net.server_disconnected.connect(_on_server_lost)
_net.connection_failed.connect(_on_server_lost)
var ok: bool = await _net.connect_to_server(Session.host, Session.port)
if not ok:
_on_server_lost()
return
_networked = true
_hide_status()
# Physics-process AFTER the player so net_input reflects this frame.
process_physics_priority = 10
func _physics_process(_delta: float) -> void:
if not _networked or _net == null or not _net.is_connected_to_server():
return
# 1. Forward the local player's input to the server.
var inp: Dictionary = _player.net_input
_net.send_input(inp["move_x"], inp["move_z"], inp["jump"], inp["look_yaw"])
# 2. Server-authoritative position.
var own: Dictionary = _net.get_own_server_state()
if own.has("position"):
var server_pos: Vector3 = own["position"]
if not _aligned:
# First snapshot: hard-snap to where the server actually spawned
# us (player.gd starts everyone on the fixed marker, but the
# server spreads spawns out).
_player.apply_server_correction(server_pos)
_aligned = true
elif _player.global_position.distance_to(server_pos) > CORRECTION_THRESHOLD:
_player.apply_server_correction(server_pos)
# 3. Remote players.
_sync_remotes(_net.get_remote_states(), _delta)
func _sync_remotes(states: Dictionary, delta: float) -> void:
for pid in states:
var st: Dictionary = states[pid]
var node: Node3D = _remotes.get(pid)
if node == null:
node = _make_remote(pid)
_remotes[pid] = node
add_child(node)
node.global_position = st.get("position", Vector3.ZERO)
var target: Vector3 = st.get("position", node.global_position)
node.global_position = node.global_position.lerp(target, clampf(REMOTE_LERP * delta, 0.0, 1.0))
node.rotation.y = st.get("rotation_y", node.rotation.y)
# Drive the remote's walk cycle from how far its authoritative
# position moved this snapshot.
var prev: Vector3 = _remote_prev.get(pid, target)
var anim := node.get_node_or_null("LimbAnim")
if anim != null and delta > 0.0:
anim.set("speed", (target - prev).length() / delta)
_remote_prev[pid] = target
for pid in _remotes.keys():
if not states.has(pid):
_remotes[pid].queue_free()
_remotes.erase(pid)
_remote_prev.erase(pid)
func _make_remote(pid: int) -> Node3D:
var root := Node3D.new()
root.name = "Remote_%d" % pid
# Other players get the assembled avatar too (everyone shares the default
# look for now -- per-player loadout replication is the follow-up), tinted
# by peer id so they're still tellable apart, plus a name tag.
var tint := Color.from_hsv(fmod(float(pid) * 0.61803, 1.0), 0.55, 0.95)
var avatars := get_node_or_null("/root/AvatarSystem")
var visual: Node3D = null
if avatars and avatars.has_method("build_current_avatar"):
visual = avatars.build_current_avatar()
if visual != null:
visual.position = Vector3(0, -0.85, 0) # feet -> ground (root is at hip height from server)
for mi in _all_mesh_instances(visual):
var m := StandardMaterial3D.new()
m.albedo_color = tint
mi.material_override = m
root.add_child(visual)
var skel := visual.find_child("Skeleton3D", true, false)
if skel is Skeleton3D:
var anim := preload("res://scripts/avatar/limb_animator.gd").new()
anim.name = "LimbAnim"
root.add_child(anim)
anim.call("setup", skel)
else:
var body := MeshInstance3D.new()
var capsule := CapsuleMesh.new()
capsule.radius = 0.35
capsule.height = 1.7
body.mesh = capsule
body.position = Vector3(0, 0.85, 0)
var mat := StandardMaterial3D.new()
mat.albedo_color = tint
body.material_override = mat
root.add_child(body)
var tag := Label3D.new()
tag.text = "Player %d" % pid
tag.position = Vector3(0, 2.1, 0)
tag.billboard = BaseMaterial3D.BILLBOARD_ENABLED
tag.no_depth_test = true
tag.pixel_size = 0.004
root.add_child(tag)
return root
func _all_mesh_instances(node: Node, out: Array = []) -> Array:
if node is MeshInstance3D:
out.append(node)
for c in node.get_children():
_all_mesh_instances(c, out)
return out
func _on_server_lost() -> void:
if _leaving:
return
_leaving = true
_networked = false
_show_status("Disconnected from the server. Returning to worlds…")
await get_tree().create_timer(1.5).timeout
Session.clear()
get_tree().change_scene_to_file("res://scenes/world_list.tscn")
# ─── tiny on-screen status ────────────────────────────────────────────────────
func _show_status(text: String) -> void:
if _status_label == null:
var layer := CanvasLayer.new()
layer.name = "StatusLayer"
add_child(layer)
_status_label = Label.new()
_status_label.position = Vector2(16, 16)
_status_label.add_theme_font_size_override("font_size", 18)
layer.add_child(_status_label)
_status_label.text = text
_status_label.visible = true
func _hide_status() -> void:
if _status_label:
_status_label.visible = false
+100 -14
View File
@@ -35,11 +35,12 @@ var _auth_mode_label: Label
var _username_edit: LineEdit
var _password_edit: LineEdit
var _email_edit: LineEdit
var _over_18_check: CheckBox
var _dob_edit: LineEdit
var _auth_button: Button
var _status_label: Label
var _welcome_label: Label
var _play_button: Button
var _customize_button: Button
var _quit_button: Button
var _is_register_mode: bool = false
@@ -48,7 +49,37 @@ var _is_authenticating: bool = false
# ─── Lifecycle ────────────────────────────────────────────────────────────────
static func _build_label() -> String:
var f := FileAccess.open("res://assets/build_info.json", FileAccess.READ)
if f == null:
return "dev"
var d = JSON.parse_string(f.get_as_text())
if d is Dictionary:
return "%s %s" % [d.get("checkpoint", "?"), d.get("commit", "?")]
return "dev"
func _ready() -> void:
var v := Label.new()
v.text = "build %s" % _build_label()
v.add_theme_color_override("font_color", Color(1, 1, 1, 0.7))
v.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.85))
v.add_theme_constant_override("outline_size", 4)
v.add_theme_font_size_override("font_size", 12)
# Full-width strip pinned to the bottom, text right-aligned. A bare
# BOTTOM_RIGHT preset + .position on a zero-size Label collapses the rect
# and nothing renders -- which is why this never showed up.
v.set_anchors_preset(Control.PRESET_BOTTOM_WIDE)
v.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
v.vertical_alignment = VERTICAL_ALIGNMENT_BOTTOM
v.offset_left = 8
v.offset_right = -10
v.offset_top = -28
v.offset_bottom = -6
v.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(v)
# Cache node references.
_auth_buttons_row = $CenterContainer/VBoxContainer/AuthButtonsRow
_login_button = $CenterContainer/VBoxContainer/AuthButtonsRow/LoginButton
@@ -58,11 +89,12 @@ func _ready() -> void:
_username_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/UsernameEdit
_password_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/PasswordEdit
_email_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/EmailEdit
_over_18_check = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/Over18Check
_dob_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/DobEdit
_auth_button = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthButton
_status_label = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthStatusLabel
_welcome_label = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/WelcomeLabel
_play_button = $CenterContainer/VBoxContainer/PlayButton
_customize_button = $CenterContainer/VBoxContainer/CustomizeButton
_quit_button = $CenterContainer/VBoxContainer/QuitButton
# Default to Play being gated. When a token exists, the async session
@@ -70,6 +102,7 @@ func _ready() -> void:
# keeping the button disabled until validation succeeds prevents a fast
# click from navigating to world_list with an expired/invalid token.
_play_button.disabled = true
_customize_button.disabled = true
# Check whether a valid session already exists (e.g. user logged in,
# navigated to world_list, then clicked Back). If so, restore the
@@ -80,6 +113,7 @@ func _ready() -> void:
var username: String = str((me_result.data as Dictionary).get("username", ""))
if username != "":
_show_authenticated(username)
await _prime_avatar()
auth_state_ready.emit()
return
@@ -111,10 +145,11 @@ func _show_authenticated(username: String) -> void:
_username_edit.visible = false
_password_edit.visible = false
_email_edit.visible = false
_over_18_check.visible = false
_dob_edit.visible = false
_auth_button.visible = false
_auth_mode_label.visible = false
_play_button.disabled = false
_customize_button.disabled = false
_auth_buttons_row.visible = false
print("Restored authenticated session for: ", username)
@@ -126,9 +161,10 @@ func _reset_to_logged_out() -> void:
_status_label.visible = false
_welcome_label.visible = false
_email_edit.visible = false
_over_18_check.visible = false
_over_18_check.button_pressed = false
_dob_edit.visible = false
_dob_edit.text = ""
_play_button.disabled = true # Gate play behind successful auth.
_customize_button.disabled = true
_update_auth_mode_ui()
@@ -142,10 +178,29 @@ func _on_play_button_pressed() -> void:
get_tree().change_scene_to_file("res://scenes/world_list.tscn")
func _on_customize_button_pressed() -> void:
if _customize_button.disabled:
return
get_tree().change_scene_to_file("res://scenes/avatar_customization.tscn")
func _on_quit_button_pressed() -> void:
get_tree().quit()
## Loose client-side check -- YYYY-MM-DD, a real calendar date, not in the
## future. The backend does the authoritative age computation.
func _is_valid_dob(dob: String) -> bool:
var m := RegEx.new()
m.compile("^\\d{4}-\\d{2}-\\d{2}$")
if m.search(dob) == null:
return false
var parts := dob.split("-")
var d := {"year": int(parts[0]), "month": int(parts[1]), "day": int(parts[2]), "hour": 0, "minute": 0, "second": 0}
var unix := Time.get_unix_time_from_datetime_dict(d)
return unix > 0 and unix < Time.get_unix_time_from_system()
# ─── Auth panel toggle ────────────────────────────────────────────────────────
func _on_login_button_pressed() -> void:
@@ -172,12 +227,12 @@ func _update_auth_mode_ui() -> void:
_auth_mode_label.text = "Register"
_auth_button.text = "Register"
_email_edit.visible = true
_over_18_check.visible = true
_dob_edit.visible = true
else:
_auth_mode_label.text = "Login"
_auth_button.text = "Login"
_email_edit.visible = false
_over_18_check.visible = false
_dob_edit.visible = false
# ─── Auth submission ──────────────────────────────────────────────────────────
@@ -204,8 +259,9 @@ func _on_auth_button_pressed() -> void:
if email == "":
_show_status("Email is required.")
return
if not _over_18_check.button_pressed:
_show_status("You must confirm you are 18 or older to register.")
var dob := _dob_edit.text.strip_edges()
if not _is_valid_dob(dob):
_show_status("Enter your date of birth as YYYY-MM-DD.")
return
_auth_button.disabled = true
@@ -213,8 +269,8 @@ func _on_auth_button_pressed() -> void:
_is_authenticating = true
_clear_status()
var result := await ApiClient.register(username, email, password, true)
_on_auth_complete(result)
var result := await ApiClient.register(username, email, password, dob)
await _on_auth_complete(result)
else:
_auth_button.disabled = true
_auth_button.text = "Logging in..."
@@ -222,7 +278,7 @@ func _on_auth_button_pressed() -> void:
_clear_status()
var result := await ApiClient.login(username, password)
_on_auth_complete(result)
await _on_auth_complete(result)
func _on_auth_complete(result: Dictionary) -> void:
@@ -232,24 +288,54 @@ func _on_auth_complete(result: Dictionary) -> void:
_is_authenticating = false
if result.ok:
# Success — hide the auth form fields, show only the welcome label.
# Update the UI to the authenticated state first, synchronously, so
# the player sees "Welcome" + an enabled Play immediately -- then
# prime the avatar (a catalog load + a loadout fetch) in the
# background. A slow or failing prime must never gate getting in.
_username_edit.visible = false
_password_edit.visible = false
_email_edit.visible = false
_over_18_check.visible = false
_dob_edit.visible = false
_auth_button.visible = false
_auth_mode_label.visible = false
_status_label.visible = false
_welcome_label.text = "Welcome, %s!" % _username_edit.text.strip_edges()
_welcome_label.visible = true
_play_button.disabled = false
_customize_button.disabled = false
_auth_buttons_row.visible = false
print("Auth successful. Token stored.")
await _prime_avatar()
else:
# Failure — show backend error detail.
_show_status(result.detail)
# ─── Avatar priming ───────────────────────────────────────────────────────────
## After a successful auth, load the asset catalog and the player's saved
## loadout so the in-world player renders as their avatar. Best-effort: a
## failure here just means the player falls back to the default capsule, it
## never blocks getting into the game.
func _prime_avatar() -> void:
if not Engine.has_singleton("AvatarSystem") and get_node_or_null("/root/AvatarSystem") == null:
return
var avatars = get_node_or_null("/root/AvatarSystem")
if avatars == null:
return
if not await avatars.ensure_catalog():
push_warning("main_menu: avatar catalog failed to load; using fallback look")
return
var res: Dictionary = await ApiClient.get_avatar_loadout()
if res.ok and res.data is Dictionary and (res.data as Dictionary).has("data"):
avatars.set_loadout_from_dict((res.data as Dictionary)["data"])
else:
# No saved loadout reachable -- fall back to the catalog's first base body.
var bases: Array = avatars.get_assets_for_slot("base_body")
if bases.size() > 0:
avatars.set_loadout_from_dict({"base_body": bases[0], "slots": {}})
# ─── Status helpers ───────────────────────────────────────────────────────────
func _show_status(message: String) -> void:
+83
View File
@@ -0,0 +1,83 @@
extends SceneTree
## One client process for the full networked game-flow test
## (scripts/run_game_multiplayer_test.sh): register -> POST /api/worlds/{id}/join
## -> Session.begin -> load scenes/game.tscn -> run networked.
##
## godot --headless --path . --script res://scripts/mp_game_flow_client.gd -- \
## --role a|b --world 1
##
## role b: joins, holds still, prints RESULT b in_world and stays ~40 s.
## role a: joins, moves forward, prints RESULT a remotes=<n> moved=<bool>;
## exits 0 iff it saw a remote player node AND moved.
var role := "b"
var world_id := 1
func _initialize() -> void:
var args := OS.get_cmdline_user_args()
for i in args.size():
if args[i] == "--role" and i + 1 < args.size():
role = args[i + 1]
elif args[i] == "--world" and i + 1 < args.size():
world_id = int(args[i + 1])
_run()
func _run() -> void:
await process_frame
var api := root.get_node("ApiClient")
var session := root.get_node("Session")
var uname := "mpflow_%s_%d" % [role, int(Time.get_unix_time_from_system() * 1000) % 1000000]
var reg: Dictionary = await api.register(uname, uname + "@e.com", "secret123", "1990-01-01")
if not reg.ok:
print("RESULT %s register_failed %s" % [role, reg.detail])
quit(1); return
var join: Dictionary = await api.join_world(world_id)
if not (join.ok and join.data is Dictionary):
print("RESULT %s join_failed status=%d %s" % [role, join.status, join.detail])
quit(1); return
var d: Dictionary = join.data
session.begin(world_id, str(d["host"]), int(d["port"]))
change_scene_to_file("res://scenes/game.tscn")
# Wait for the scene + NetworkClient connect (game.gd awaits internally).
for i in 180:
await process_frame
var game := current_scene
var netc = game.get_node_or_null("NetworkClient") if game else null
if netc == null or not netc.is_connected_to_server():
print("RESULT %s not_connected" % role)
quit(1); return
if role == "b":
print("RESULT b in_world")
for i in 1200:
await process_frame
quit(0); return
# role a: hold still briefly (confirm we don't fall through the floor),
# then move and look for a remote node.
var player = game.get_node("Player")
var min_y := 1000.0
for i in 30:
await process_frame
min_y = minf(min_y, player.global_position.y)
var on_floor: bool = min_y > -3.0
var start: Vector3 = player.global_position
var max_remotes := 0
for i in 120:
player._input_direction = Vector2(0, 1) # fake held forward
await process_frame
var n := 0
for c in game.get_children():
if c.name.begins_with("Remote_"):
n += 1
max_remotes = maxi(max_remotes, n)
var moved: bool = player.global_position.distance_to(start) > 1.0
print("RESULT a remotes=%d moved=%s on_floor=%s" % [max_remotes, str(moved), str(on_floor)])
quit(0 if (moved and max_remotes >= 1 and on_floor) else 1)
+64
View File
@@ -0,0 +1,64 @@
extends SceneTree
## One client process for the 2-player multiplayer test
## (scripts/run_multiplayer_2p_test.sh). Two real OS processes = real network.
##
## godot --headless --path . --script res://scripts/mp_test_client.gd -- --role a --port 7777
##
## role "a": moves forward for ~2 s, then prints
## RESULT a remotes=<n> moved=<bool> and exits 0 iff it both moved
## and saw >=1 remote player.
## role "b": connects, holds still for ~4 s, prints RESULT b connected exits 0.
var role := "b"
var port := 7777
func _initialize() -> void:
var args := OS.get_cmdline_user_args()
for i in args.size():
if args[i] == "--role" and i + 1 < args.size():
role = args[i + 1]
elif args[i] == "--port" and i + 1 < args.size():
port = int(args[i + 1])
_run()
func _run() -> void:
await process_frame
var net := NetworkClient.new()
root.add_child(net)
var ok: bool = await net.connect_to_server("127.0.0.1", port)
if not ok:
print("RESULT %s connect_failed" % role)
quit(1)
return
for i in 15:
await process_frame
if role == "b":
# Hold still and stay connected long enough for A to boot, connect,
# and observe us (A's Godot startup alone is a couple of seconds).
print("RESULT b connected")
for i in 1200:
await process_frame
net.disconnect_from_server()
quit(0)
return
# role a: move forward, watch for a remote and our own displacement, and
# confirm the server keeps us on the ground (not falling through it).
var start: Vector3 = net.position
var max_remotes := 0
var min_server_y := 1000.0
for i in 90:
net.predict_and_send(1.0 / 30.0, 0.0, -1.0, false, 0.0)
await process_frame
max_remotes = maxi(max_remotes, net.get_remote_states().size())
var st: Dictionary = net.get_own_server_state()
if st.has("position"):
min_server_y = minf(min_server_y, st["position"].y)
var moved: bool = net.position.distance_to(start) > 1.0
var on_floor: bool = min_server_y > -2.0
print("RESULT a remotes=%d moved=%s on_floor=%s (min_y=%.2f)" % [max_remotes, str(moved), str(on_floor), min_server_y])
net.disconnect_from_server()
quit(0 if (moved and max_remotes >= 1 and on_floor) else 1)
+158 -133
View File
@@ -1,75 +1,64 @@
class_name NetworkClient
extends Node
## Client-side networked player: input prediction + server reconciliation.
## Client-side multiplayer: ENet connection, local-player input prediction +
## server reconciliation (with input replay), and remote-player state for the
## game to render.
##
## Implements docs/architecture/networking.md §5 (Prediction & Reconciliation)
## for the *local* player only -- remote-entity interpolation (§5.3) and full
## delta-compressed multi-entity snapshots (§6) are explicitly out of scope
## for this first pass; the server already only ever has one player in PC2's
## single-peer testing, and this task's own description scopes it to "the
## player controller built in the dependency task" (singular).
## RPC path-matching: Godot resolves an rpc_id() call's target by matching the
## sending node's NodePath on the receiver. scripts/server.gd is the SceneTree
## root named "Server"; it spawns each peer's PlayerNode as a child named
## "Player_<peer_id>". So the client needs local nodes at /root/Server (for the
## server-initiated _receive_snapshot) and /root/Server/Player_<own_id> (the
## target of rpc_request_input). connect_to_server() builds that once the peer
## id is known.
##
## RPC path-matching, the part that makes this actually work:
## Godot's high-level rpc_id() resolves the *receiving* node by matching the
## *sending* node's NodePath on the caller's side. The server (scripts/
## server.gd) is the SceneTree root node named "Server" when the dedicated
## server runs scenes/server.tscn as its main scene, and it spawns each
## connected player as a *child* of itself named "Player_<peer_id>"
## (scripts/server.gd's _on_peer_connected). For an RPC sent by the client to
## actually land on the right node, the client needs a LOCAL node tree at the
## exact same paths -- /root/Server for _receive_snapshot (called by the
## server on itself), and /root/Server/Player_<own_peer_id> for
## rpc_request_input (a real method on the server's PlayerNode). Those local
## nodes don't need to run any server logic -- they only need to exist at the
## right path so Godot's RPC routing finds them. connect_to_server() builds
## that structure once the peer id is known (only available after the ENet
## handshake completes).
##
## Usage: instantiate directly (not an autoload -- this only exists for the
## lifetime of an actual multiplayer session, not on every client launch):
## Usage:
## var net := NetworkClient.new()
## get_tree().root.add_child(net)
## var ok := await net.connect_to_server("127.0.0.1", 7777)
## if await net.connect_to_server(host, port):
## # each physics frame, from the local player controller:
## net.predict_and_send(delta, move_x, move_z, jump, body_yaw)
## # read net.position / net.get_remote_states() to render
# Movement constants -- MUST match scripts/server.gd and player_spawner.gd.
const WALK_SPEED := 5.0
const JUMP_VELOCITY := 4.5
const GRAVITY := 9.8
# ---------------------------------------------------------------------------
# Movement constants -- MUST match scripts/player_spawner.gd exactly, per
# docs/architecture/networking.md §5.1 ("the same physics constants as the
# server"). A mismatch here doesn't break anything outright, it just makes
# reconciliation corrections more frequent/visible.
# ---------------------------------------------------------------------------
## Predicted vs authoritative drift beyond this (metres) snaps rather than
## trusting the prediction.
const RECONCILE_ERROR_THRESHOLD := 0.35
const WALK_SPEED: float = 5.0
const JUMP_VELOCITY: float = 4.5
const GRAVITY: float = 9.8
## How far predicted and authoritative position can drift before snapping
## rather than trusting the prediction was "close enough." Per §5.2.
const RECONCILE_ERROR_THRESHOLD: float = 0.5
## How many past predicted states to keep for reconciliation replay. Real
## sizing per §5.4 is RTT-adaptive; a fixed generous buffer is the
## appropriately-scoped first pass here -- 120 ticks at a 30Hz send rate is
## 4 seconds of history, comfortably more than any realistic LAN/local RTT.
const PREDICTION_BUFFER_SIZE: int = 120
## Ring buffer cap. 120 frames at 30 Hz = 4 s of history, well past any
## realistic LAN RTT.
const PREDICTION_BUFFER_SIZE := 120
signal reconciled(correction_distance: float)
signal connected_to_server
signal connection_failed
signal server_disconnected
var peer: ENetMultiplayerPeer
var _server_root: Node # local /root/Server proxy
var _player_proxy: Node # local /root/Server/Player_<peer_id> proxy
var my_peer_id: int = 0
var _server_root: Node
var _player_proxy: Node
var _seq: int = 0
## Ring buffer of {seq, input, position, velocity} predicted at send time,
## oldest first. Trimmed as the server acknowledges ticks via snapshots.
## Ring buffer of {seq, move_x, move_z, jump, delta, position, velocity},
## oldest first. Entries are dropped as the server acks their seq.
var _prediction_buffer: Array[Dictionary] = []
## Local predicted player state.
var position: Vector3 = Vector3.ZERO
var velocity: Vector3 = Vector3.ZERO
var _on_floor: bool = false
var _on_floor: bool = true
## peer_id -> latest authoritative state dict from the most recent snapshot,
## for every peer that isn't us. The game reads this to render other players.
var _remote_states: Dictionary = {}
## Our own latest authoritative state from the most recent snapshot.
var _own_state: Dictionary = {}
var _last_snapshot_tick: int = -1
func connect_to_server(host: String, port: int) -> bool:
@@ -81,28 +70,24 @@ func connect_to_server(host: String, port: int) -> bool:
return false
multiplayer.multiplayer_peer = peer
multiplayer.connected_to_server.connect(_on_connected)
multiplayer.connection_failed.connect(func():
connection_failed.emit()
)
multiplayer.server_disconnected.connect(func(): server_disconnected.emit())
for i in 300: # ~5s at 60fps
for _i in 300: # ~5 s at 60 fps
await get_tree().process_frame
var status := peer.get_connection_status()
if status == MultiplayerPeer.CONNECTION_CONNECTED:
return true
elif status == MultiplayerPeer.CONNECTION_DISCONNECTED:
connection_failed.emit()
return false
match peer.get_connection_status():
MultiplayerPeer.CONNECTION_CONNECTED:
_on_connected()
return true
MultiplayerPeer.CONNECTION_DISCONNECTED:
connection_failed.emit()
return false
connection_failed.emit()
return false
func _on_connected() -> void:
var my_id := multiplayer.get_unique_id()
my_peer_id = multiplayer.get_unique_id()
# Build the local proxy tree at the exact paths the server's own RPC
# calls target -- see the class docstring for why this has to exist.
_server_root = Node.new()
_server_root.name = "Server"
_server_root.set_script(preload("res://scripts/network_receiver_proxy.gd"))
@@ -110,7 +95,7 @@ func _on_connected() -> void:
_server_root.set("owner_client", self)
_player_proxy = Node.new()
_player_proxy.name = "Player_%d" % my_id
_player_proxy.name = "Player_%d" % my_peer_id
_player_proxy.set_script(preload("res://scripts/network_player_proxy.gd"))
_server_root.add_child(_player_proxy)
@@ -123,60 +108,56 @@ func disconnect_from_server() -> void:
if _server_root and is_instance_valid(_server_root):
_server_root.queue_free()
multiplayer.multiplayer_peer = null
_prediction_buffer.clear()
_remote_states.clear()
func is_connected_to_server() -> bool:
return peer != null and peer.get_connection_status() == MultiplayerPeer.CONNECTION_CONNECTED
# ---------------------------------------------------------------------------
# Prediction -- called every physics frame by whatever drives input (a real
# controller, or a test harness). Applies input locally immediately (so it
# renders with zero perceived latency) and sends it to the server, per §5.1.
# Prediction -- call every physics frame from the local player controller.
# Applies input locally immediately (zero perceived latency) and sends it.
# ---------------------------------------------------------------------------
func predict_and_send(delta: float, move_x: float, move_z: float, jump: bool) -> void:
func predict_and_send(delta: float, move_x: float, move_z: float, jump: bool, look_yaw: float = 0.0) -> void:
_apply_movement(delta, move_x, move_z, jump)
_seq += 1
var record := {
_prediction_buffer.append({
"seq": _seq,
"input": {"move_x": move_x, "move_z": move_z, "jump": jump},
"position": position,
"velocity": velocity,
}
_prediction_buffer.append(record)
"move_x": move_x, "move_z": move_z, "jump": jump,
"delta": delta,
"position": position, "velocity": velocity,
})
if _prediction_buffer.size() > PREDICTION_BUFFER_SIZE:
_prediction_buffer.pop_front()
if _player_proxy and is_instance_valid(_player_proxy):
_player_proxy.rpc_id(1, "rpc_request_input", record["input"], _seq)
_player_proxy.rpc_id(1, "rpc_request_input",
{"move_x": move_x, "move_z": move_z, "jump": jump, "look_yaw": look_yaw}, _seq)
func _apply_movement(delta: float, move_x: float, move_z: float, jump: bool) -> void:
# Mirrors scripts/player_spawner.gd's _process_input + gravity/jump,
# intentionally without its ground-friction smoothing -- friction is a
# server-authoritative-feel detail, not something worth risking a
# prediction/server mismatch over in this first pass. Position still
# reconciles against the server's real (frictioned) result every
# snapshot, so this doesn't silently drift.
if not _on_floor:
velocity.y -= GRAVITY * delta
elif jump:
velocity.y = JUMP_VELOCITY
var direction := Vector3(move_x, 0.0, move_z)
if direction.length() > 0.01:
direction = direction.normalized()
velocity.x = direction.x * WALK_SPEED
velocity.z = direction.z * WALK_SPEED
var dir := Vector3(move_x, 0.0, move_z)
if dir.length() > 0.01:
dir = dir.normalized()
velocity.x = dir.x * WALK_SPEED
velocity.z = dir.z * WALK_SPEED
else:
velocity.x = 0.0
velocity.z = 0.0
if jump and _on_floor:
velocity.y = JUMP_VELOCITY
position += velocity * delta
# Headless prediction has no collision shape of its own to test
# is_on_floor() against -- treat "at or below the spawn-plane ground"
# as grounded. A real render-side controller would swap this for the
# actual CharacterBody3D.is_on_floor() of whatever visual node this
# drives; this class only owns the *networking* half.
# Headless prediction has no collision shape; treat the spawn plane as the
# floor. A render-side controller drives the real CharacterBody3D and reads
# `position` back for reconciliation only.
if position.y <= 0.0:
position.y = 0.0
velocity.y = 0.0
@@ -186,49 +167,93 @@ func _apply_movement(delta: float, move_x: float, move_z: float, jump: bool) ->
# ---------------------------------------------------------------------------
# Reconciliation -- called by _ReceiverScript when a snapshot arrives.
# Reconciliation -- _ReceiverProxy calls this when a snapshot arrives.
# ---------------------------------------------------------------------------
func _handle_snapshot(snapshot_data: Dictionary) -> void:
var my_id := multiplayer.get_unique_id()
var players: Dictionary = snapshot_data.get("players", {})
# Server keys player_states by peer_id -- Dictionary keys from JSON-ish
# RPC payloads can arrive as String or int depending on the transport
# encoding, so check both rather than assuming.
var my_state: Variant = players.get(my_id, players.get(str(my_id), null))
if my_state == null or not (my_state is Dictionary):
func _handle_snapshot(snap: Dictionary) -> void:
var snap_tick: int = snap.get("tick", 0)
if snap_tick <= _last_snapshot_tick:
return # Stale / out-of-order (unreliable channel).
_last_snapshot_tick = snap_tick
var players: Dictionary = snap.get("players", {})
# --- Remote players ---
_remote_states.clear()
for key in players:
var pid := int(key)
if pid == my_peer_id:
continue
_remote_states[pid] = players[key]
# --- Local player: reconcile ---
var mine: Variant = players.get(my_peer_id, players.get(str(my_peer_id), null))
if not (mine is Dictionary):
return
_own_state = mine
var auth_pos: Vector3 = mine.get("position", position)
var auth_vel: Vector3 = mine.get("velocity", velocity)
var acked_seq: int = int(mine.get("last_seq", 0))
var authoritative_pos: Vector3 = (my_state as Dictionary).get("position", position)
# Drop inputs the server has already applied.
while not _prediction_buffer.is_empty() and _prediction_buffer[0]["seq"] <= acked_seq:
_prediction_buffer.pop_front()
# Real limitation, noted explicitly rather than silently faked: the
# server's snapshot_state (scripts/server.gd's _broadcast_snapshot) does
# not currently echo back "last input seq processed for this peer" --
# its `tick` field is the server's own internal simulation tick counter,
# a completely different clock from this client's per-input `_seq`. True
# tick-correlated rollback (docs/architecture/networking.md §5.2 step 4,
# "re-simulate any inputs that occurred after the server's tick") needs
# that ack echoed back per-peer -- a real, scoped follow-up, not
# something to fake here. Until then, reconcile against the MOST RECENT
# prediction (not a tick-matched one): still a real error measurement
# and a real correction, just without precise replay-from-exact-tick.
if _prediction_buffer.is_empty():
return
var latest_entry: Dictionary = _prediction_buffer[-1]
# Re-simulate the still-in-flight inputs from the authoritative state.
var replay_pos := auth_pos
var replay_vel := auth_vel
var replay_floor := replay_pos.y <= 0.0
for entry in _prediction_buffer:
var d: float = entry["delta"]
if not replay_floor:
replay_vel.y -= GRAVITY * d
elif entry["jump"]:
replay_vel.y = JUMP_VELOCITY
var dir := Vector3(entry["move_x"], 0.0, entry["move_z"])
if dir.length() > 0.01:
dir = dir.normalized()
replay_vel.x = dir.x * WALK_SPEED
replay_vel.z = dir.z * WALK_SPEED
else:
replay_vel.x = 0.0
replay_vel.z = 0.0
replay_pos += replay_vel * d
if replay_pos.y <= 0.0:
replay_pos.y = 0.0
replay_vel.y = 0.0
replay_floor = true
else:
replay_floor = false
var error: float = (latest_entry["position"] as Vector3).distance_to(authoritative_pos)
if error <= RECONCILE_ERROR_THRESHOLD:
return # Close enough -- prediction was correct, nothing to do.
var error := position.distance_to(replay_pos)
if error > RECONCILE_ERROR_THRESHOLD:
position = replay_pos
velocity = replay_vel
_on_floor = replay_floor
reconciled.emit(error)
# Correct: snap to the authoritative position. Without a real ack-seq
# from the server there's no reliable "replay inputs after this point"
# window to recompute, so the buffer is cleared and prediction resumes
# fresh from the corrected position on the next input -- still visibly
# responsive (no waiting for a round trip before the *next* input
# applies), just without this pass replaying in-flight inputs.
position = authoritative_pos
velocity = Vector3.ZERO
_prediction_buffer.clear()
reconciled.emit(error)
# ---------------------------------------------------------------------------
# Remote-player state for rendering
# ---------------------------------------------------------------------------
## peer_id -> {position, rotation_y, velocity, on_floor, last_seq} from the most
## recent snapshot. Read this each frame to spawn/update remote avatars.
func get_remote_states() -> Dictionary:
return _remote_states
## Our own latest authoritative state from the most recent snapshot, or {}.
func get_own_server_state() -> Dictionary:
return _own_state
## Send an input frame to the server WITHOUT running NetworkClient's own
## flat-plane prediction -- for callers that predict locally with their own
## collision-aware CharacterBody3D (scripts/game.gd) and only want the server
## as an authority/correction backstop.
func send_input(move_x: float, move_z: float, jump: bool, look_yaw: float) -> void:
_seq += 1
if _player_proxy and is_instance_valid(_player_proxy):
_player_proxy.rpc_id(1, "rpc_request_input",
{"move_x": move_x, "move_z": move_z, "jump": jump, "look_yaw": look_yaw}, _seq)
+7 -39
View File
@@ -1,43 +1,11 @@
extends Node
## Client-side proxy for the server's real PlayerNode (scripts/
## player_spawner.gd), attached at runtime to a Node named "Player_<peer_id>"
## under the /root/Server proxy by NetworkClient._on_connected().
## Client-side proxy for the server's PlayerNode (scripts/player_spawner.gd),
## attached at runtime as "Player_<own_peer_id>" under the /root/Server proxy.
##
## rpc_request_input is the only one of these actually CALLED from this
## side -- the real implementations all live on the server. But two things
## force every @rpc method on the real PlayerNode to be mirrored here too,
## not just the one this class uses:
## 1. Godot's rpc_id() looks up the RPC transfer config (mode/reliability)
## from the LOCAL script before it will even send a call, so the method
## being called has to be declared locally with a matching annotation.
## 2. Godot's multiplayer layer also verifies an "RPC node checksum" --
## both peers must declare the *same set* of @rpc methods at a matching
## path, or every RPC at that path starts failing, not just the one
## that's actually missing. So even rpc_request_interact/attack/
## set_position/set_rotation, which this class never calls, have to be
## declared here with matching annotations or rpc_request_input itself
## breaks too.
## rpc_request_input is the only RPC the client sends. Godot resolves the RPC
## transfer config from the LOCAL node's declaration and verifies both peers
## declare the same @rpc set at this path, so it must match player_spawner.gd.
@rpc("any_peer", "call_remote", "reliable")
func rpc_request_input(_input_state: Dictionary, _seq: int) -> void:
pass # Never invoked locally -- see the note above.
@rpc("any_peer", "call_remote", "reliable")
func rpc_request_interact(_target_id: int) -> void:
pass
@rpc("any_peer", "call_remote", "reliable")
func rpc_request_attack(_target_id: int, _aim_dir: Vector3) -> void:
pass
@rpc("authority", "call_remote", "reliable")
func rpc_set_position(_pos: Vector3) -> void:
pass
@rpc("authority", "call_remote", "reliable")
func rpc_set_rotation(_rot: Vector3) -> void:
pass
func rpc_request_input(_input: Dictionary, _seq: int) -> void:
pass # Real implementation is on the server.
+6 -29
View File
@@ -1,38 +1,15 @@
extends Node
## Client-side proxy node, attached at runtime to a Node named "Server" at
## the SceneTree root by NetworkClient.connect_to_server(). Exists purely so
## Godot's RPC path-matching finds a receiver for _receive_snapshot -- see
## network_client.gd's class docstring for the full explanation of why this
## has to exist at this exact path. Not a real server in any sense.
##
## Godot's multiplayer layer verifies an "RPC node checksum" -- both peers
## must declare the *same set* of @rpc methods at a matching path, or every
## RPC at that path (not just the mismatched one) starts failing with
## "rpc node checksum failed". So every @rpc method scripts/server.gd
## declares on its own root "Server" node has to be mirrored here too, even
## the ones this proxy never actually needs to receive.
## Client-side proxy node, attached at runtime as a Node named "Server" at the
## SceneTree root by NetworkClient. Exists purely so Godot's RPC path-matching
## finds a receiver for _receive_snapshot (the server calls it on its own root
## "Server" node, so the client needs a node at /root/Server declaring the same
## @rpc set). Not a real server.
var owner_client: Node = null
## Must match scripts/server.gd's own @rpc annotation on _receive_snapshot
## exactly -- Godot enforces the receiving method's declared mode/transfer
## config, a mismatch here would silently drop the call.
## Must match scripts/server.gd's @rpc annotation on _receive_snapshot exactly.
@rpc("any_peer", "call_remote", "reliable")
func _receive_snapshot(snapshot_data: Dictionary) -> void:
if owner_client and is_instance_valid(owner_client):
owner_client._handle_snapshot(snapshot_data)
## Mirrors scripts/server.gd's _kick -- never actually invoked on this side
## (the server calls it on a Player_<id> node's own rpc_id target, not this
## root proxy), declared only so the RPC checksum for this path matches.
@rpc("any_peer", "call_remote", "reliable")
func _kick(_reason: String) -> void:
pass
## Mirrors scripts/server.gd's _disconnect -- same reasoning as _kick above.
@rpc("call_remote", "reliable")
func _disconnect(_reason: String) -> void:
pass
+244 -49
View File
@@ -2,15 +2,23 @@ class_name Player
extends CharacterBody3D
## The player character controller.
##
## A simple CharacterBody3D with WASD/arrow locomotion, gravity, jump, and a
## first-person Camera3D with mouse-look. Reads the spawn point from the
## current scene's `PlayerSpawn` Marker3D (see docs/architecture/pc2_test_level.md §3).
## Roblox-style third-person controller:
##
## No networking, no chunk code, no avatar UI — PC2 is local-only.
## * Classic camera (default): the mouse cursor is free; hold a mouse button
## and drag to orbit the camera 360 in yaw (pitch clamped). Right button
## also turns the character to face the camera while held. Scroll wheel
## zooms; zoom all the way in -> first person.
## * Shift Lock (toggle Left Shift): mouse locks to a centre crosshair, the
## camera moves over the right shoulder, and the character's facing is
## locked to the camera -- WASD strafes / backpedals.
##
## Movement is always camera-relative (W = away from the camera). Outside the
## camera-facing modes the body auto-rotates toward its movement direction.
## Spawn point comes from the current scene's `PlayerSpawn` Marker3D.
# ---------------------------------------------------------------------------
# Movement constants
# Movement / camera constants
# ---------------------------------------------------------------------------
const WALK_SPEED: float = 5.0
@@ -20,34 +28,64 @@ const GRAVITY: float = 9.8
## Mouse-look sensitivity (radians per pixel of relative motion).
const MOUSE_SENSITIVITY: float = 0.003
## Camera pitch is clamped so the player can't flip past looking straight
## up/down (which would otherwise let mouse-look invert on itself).
const PITCH_LIMIT: float = deg_to_rad(89.0)
## Vertical offset applied to the assembled avatar so its feet (origin y=0)
## line up with the bottom of the player's collision capsule.
const AVATAR_FEET_OFFSET: float = -0.8
## Camera pitch limits (radians): near top-down to looking up from below.
## Yaw is deliberately unclamped -- full 360 orbit.
const PITCH_MIN: float = -1.35
const PITCH_MAX: float = 1.2
## Camera pivot height above the player origin, third-person vs first-person.
const PIVOT_Y_TP: float = 1.2
const PIVOT_Y_FP: float = 0.65
## Zoom (spring length) range, scroll step, and the distance at or below which
## we switch to first person.
const ZOOM_MIN: float = 0.4
const ZOOM_MAX: float = 9.0
const ZOOM_STEP: float = 0.6
const FIRST_PERSON_ZOOM: float = 0.6
const ZOOM_DEFAULT: float = 3.5
## How far right the camera sits in Shift Lock (metres).
const SHOULDER_OFFSET: float = 0.85
## How fast the body swings toward its target facing.
const BODY_TURN_SPEED: float = 12.0
# ---------------------------------------------------------------------------
# Input state
# State
# ---------------------------------------------------------------------------
var _input_direction := Vector2.ZERO
var _camera_pitch: float = 0.0
var _camera_yaw: float = 0.0 # unclamped, wraps
var _camera_pitch: float = -0.12
var _zoom: float = ZOOM_DEFAULT
var _shift_lock: bool = false
var _mouse_look_held: bool = false # LMB or RMB held (classic drag)
var _rmb_held: bool = false # RMB specifically (turns the body)
var _spawn_position: Vector3 = Vector3(0, 1.5, 0)
@onready var _camera: Camera3D = $Camera3D
@onready var _camera: Camera3D = $CameraPivot/SpringArm3D/Camera3D
@onready var _camera_pivot: Node3D = $CameraPivot
@onready var _spring_arm: SpringArm3D = $CameraPivot/SpringArm3D
@onready var _capsule_mesh: MeshInstance3D = $CapsuleMesh
var _avatar_view: Node3D = null
var _limb_anim: Node = null
var _crosshair: CanvasLayer = null
# ---------------------------------------------------------------------------
# _ready — spawn at PlayerSpawn marker, capture the mouse for look
# _ready
# ---------------------------------------------------------------------------
func _ready() -> void:
# Per §3 of pc2_test_level.md: look up PlayerSpawn from the current
# scene root. The lookup walks the full subtree (recursive=true) so
# it finds the marker whether it is a direct child of the level or
# nested deeper.
#
# current_scene is null in headless --script mode and in any scene
# before the main scene is fully swapped in, so guard it rather than
# blindly dereferencing.
# Per §3 of pc2_test_level.md: look up PlayerSpawn from the current scene
# root (recursive), guarding the null current_scene of headless runs.
var current_scene: Node = get_tree().current_scene
var spawn: Node = null
if current_scene != null:
@@ -55,35 +93,140 @@ func _ready() -> void:
if spawn is Node3D:
global_position = (spawn as Node3D).global_position
# Align player forward with spawn yaw (optional polish).
var spawn_yaw := (spawn as Node3D).rotation.y
rotation.y = spawn_yaw
rotation.y = (spawn as Node3D).rotation.y
else:
push_warning("player.gd: no PlayerSpawn marker found; spawning at origin")
global_position = Vector3(0, 1.0, 0)
_spawn_position = global_position
# Capture the mouse for FPS-style look. Headless/test runs have no real
# window to capture input for, so guard against that rather than error.
if DisplayServer.get_name() != "headless":
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
# The spring arm must not collide with the player's own capsule (same
# layer) or the camera gets yanked against the player's head.
_spring_arm.add_excluded_object(self)
# The camera pivot orbits the player freely -- it can't inherit the body's
# yaw, so detach it and drive its world transform ourselves each frame.
_camera_pivot.top_level = true
_camera_yaw = rotation.y
_build_crosshair()
_refresh_mouse_mode()
_update_camera()
_apply_avatar()
func _build_crosshair() -> void:
if DisplayServer.get_name() == "headless":
return
_crosshair = CanvasLayer.new()
_crosshair.layer = 64
var l := Label.new()
l.text = "+"
l.add_theme_font_size_override("font_size", 22)
l.add_theme_color_override("font_color", Color(1, 1, 1, 0.85))
l.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.8))
l.add_theme_constant_override("outline_size", 4)
l.set_anchors_preset(Control.PRESET_CENTER)
l.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
l.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
l.offset_left = -12
l.offset_right = 12
l.offset_top = -16
l.offset_bottom = 16
l.mouse_filter = Control.MOUSE_FILTER_IGNORE
_crosshair.add_child(l)
add_child(_crosshair)
_crosshair.visible = false
## Show the player's assembled avatar and hide the placeholder capsule.
func _apply_avatar() -> void:
var avatars := get_node_or_null("/root/AvatarSystem")
if avatars == null or not avatars.has_method("build_current_avatar"):
return
var avatar: Node3D = avatars.build_current_avatar()
if avatar == null:
push_warning("player.gd: no avatar to show; keeping the placeholder capsule")
return
avatar.position = Vector3(0.0, AVATAR_FEET_OFFSET, 0.0)
add_child(avatar)
_avatar_view = avatar
if _capsule_mesh:
_capsule_mesh.visible = false
# Procedural walk cycle -- no clips yet, just swing the limb bones with speed.
var skel := avatar.find_child("Skeleton3D", true, false)
if skel is Skeleton3D:
_limb_anim = preload("res://scripts/avatar/limb_animator.gd").new()
add_child(_limb_anim)
_limb_anim.call("setup", skel)
# ---------------------------------------------------------------------------
# _unhandled_input — capture keyboard / mouse for movement
# Input
# ---------------------------------------------------------------------------
func _is_first_person() -> bool:
return _zoom <= FIRST_PERSON_ZOOM
## Mouse should be captured (hidden, locked) whenever the camera is in an
## aim-style mode or the player is mid-drag.
func _want_mouse_captured() -> bool:
return _shift_lock or _is_first_person() or _mouse_look_held
## Body facing is slaved to the camera in Shift Lock, first person, or while
## the right mouse button is held.
func _want_face_camera() -> bool:
return _shift_lock or _is_first_person() or _rmb_held
func _refresh_mouse_mode() -> void:
if DisplayServer.get_name() == "headless":
return
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED if _want_mouse_captured() \
else Input.MOUSE_MODE_VISIBLE
if _crosshair != null:
_crosshair.visible = _shift_lock or _is_first_person()
func _set_zoom(v: float) -> void:
var was_fp := _is_first_person()
_zoom = clampf(v, ZOOM_MIN, ZOOM_MAX)
if _is_first_person() != was_fp:
_refresh_mouse_mode()
_update_camera()
func _unhandled_input(event: InputEvent) -> void:
if event is InputEventKey:
var key := event as InputEventKey
if key.pressed and not key.echo:
_update_input_direction(key)
if key.keycode == KEY_SHIFT:
_shift_lock = not _shift_lock
_refresh_mouse_mode()
_update_camera()
else:
_update_input_direction(key)
elif not key.pressed:
_clear_input_direction(key)
# Escape exits the level back to the world browse list, mirroring
# the Back button pattern already used in world_list.gd/main_menu.gd.
if key.pressed and key.keycode == KEY_ESCAPE:
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
get_tree().change_scene_to_file("res://scenes/world_list.tscn")
# ESC (leave the world) is handled by game.gd, which owns the connection.
elif event is InputEventMouseButton:
var mb := event as InputEventMouseButton
match mb.button_index:
MOUSE_BUTTON_WHEEL_UP:
if mb.pressed:
_set_zoom(_zoom - ZOOM_STEP)
MOUSE_BUTTON_WHEEL_DOWN:
if mb.pressed:
_set_zoom(_zoom + ZOOM_STEP)
MOUSE_BUTTON_LEFT, MOUSE_BUTTON_RIGHT:
_mouse_look_held = Input.is_mouse_button_pressed(MOUSE_BUTTON_LEFT) \
or Input.is_mouse_button_pressed(MOUSE_BUTTON_RIGHT)
_rmb_held = Input.is_mouse_button_pressed(MOUSE_BUTTON_RIGHT)
_refresh_mouse_mode()
elif event is InputEventMouseMotion and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED:
_handle_mouse_look(event as InputEventMouseMotion)
@@ -113,34 +256,86 @@ func _clear_input_direction(key: InputEventKey) -> void:
func _handle_mouse_look(event: InputEventMouseMotion) -> void:
# Yaw rotates the whole body (so movement direction turns with the
# look direction); pitch only tilts the camera, so the capsule itself
# never tips over.
rotation.y -= event.relative.x * MOUSE_SENSITIVITY
_camera_pitch = clampf(_camera_pitch - event.relative.y * MOUSE_SENSITIVITY, -PITCH_LIMIT, PITCH_LIMIT)
_camera.rotation.x = _camera_pitch
_camera_yaw = wrapf(_camera_yaw - event.relative.x * MOUSE_SENSITIVITY, -PI, PI)
_camera_pitch = clampf(_camera_pitch - event.relative.y * MOUSE_SENSITIVITY,
PITCH_MIN, PITCH_MAX)
_update_camera()
## Place the orbit pivot at the player's head and point it per yaw/pitch.
## Applies the first-person / shoulder framing. Called from _ready, on input,
## and every physics frame (the player moves under it).
func _update_camera() -> void:
if not is_instance_valid(_camera_pivot):
return
var fp := _is_first_person()
_camera_pivot.global_position = global_position \
+ Vector3(0.0, PIVOT_Y_FP if fp else PIVOT_Y_TP, 0.0)
_camera_pivot.global_rotation = Vector3(_camera_pitch, _camera_yaw, 0.0)
_spring_arm.spring_length = 0.0 if fp else _zoom
_spring_arm.position.x = SHOULDER_OFFSET if (_shift_lock and not fp) else 0.0
# Hide our own avatar in first person so the head doesn't fill the view.
if _avatar_view != null and is_instance_valid(_avatar_view):
_avatar_view.visible = not fp
# ---------------------------------------------------------------------------
# Physics — gravity, jump, move_and_slide
# Physics
# ---------------------------------------------------------------------------
## Input frame forwarded to the server by scripts/game.gd each physics tick:
## world-space move direction (yaw-transformed, normalized), jump edge, body yaw.
var net_input: Dictionary = {"move_x": 0.0, "move_z": 0.0, "jump": false, "look_yaw": 0.0}
func _physics_process(delta: float) -> void:
# Apply gravity.
if not is_on_floor():
velocity.y -= GRAVITY * delta
# Jump.
var jumped := false
if is_on_floor() and Input.is_action_just_pressed("ui_accept"):
velocity.y = JUMP_VELOCITY
jumped = true
# Build movement direction from input. Godot's local "forward" is -Z, not
# +Z, so W (input.y = +1) must use -basis.z -- using +basis.z here would
# move the player backward when pressing forward (confirmed by reading
# Camera3D/Node3D's own convention: a node looks down its local -Z).
var direction := (_input_direction.x * transform.basis.x -
_input_direction.y * transform.basis.z).normalized()
# Camera-relative movement. A node looks down its local -Z, so forward is
# -cam_basis.z and W (input.y = +1) heads away from the camera.
var cam_basis := Basis.from_euler(Vector3(0.0, _camera_yaw, 0.0))
var direction := (_input_direction.x * cam_basis.x
- _input_direction.y * cam_basis.z).normalized()
velocity.x = direction.x * WALK_SPEED
velocity.z = direction.z * WALK_SPEED
# Body facing: locked to the camera in aim modes, otherwise turns toward
# the movement direction.
if _want_face_camera():
rotation.y = _camera_yaw
elif direction.length_squared() > 0.01:
var target_yaw := atan2(-direction.x, -direction.z)
rotation.y = lerp_angle(rotation.y, target_yaw,
clampf(BODY_TURN_SPEED * delta, 0.0, 1.0))
move_and_slide()
_update_camera()
if _limb_anim != null:
_limb_anim.set("speed", Vector2(velocity.x, velocity.z).length())
# Safety net -- never fall forever.
if global_position.y < -10.0:
global_position = _spawn_position
velocity = Vector3.ZERO
net_input = {
"move_x": direction.x, "move_z": direction.z,
"jump": jumped, "look_yaw": rotation.y,
}
## Server-authoritative correction from scripts/game.gd when the local
## predicted position has drifted too far.
func apply_server_correction(pos: Vector3) -> void:
global_position = pos
velocity.x = 0.0
velocity.z = 0.0
+59 -205
View File
@@ -1,232 +1,86 @@
class_name PlayerNode
extends CharacterBody3D
## PlayerNode - Server-side player entity.
## Server-authoritative player entity.
##
## Represents a connected player on the server. Handles movement,
## input processing, and state synchronization.
## One per connected peer, spawned as a child of the Server node. Movement runs
## in _physics_process at the project's fixed physics rate (30 Hz, matching
## scripts/server.gd's snapshot rate and the client's prediction step).
# --- Signals ---
signal health_changed(new_health: int)
signal player_died(player_node: PlayerNode)
# --- Constants ---
## Movement constants -- mirror scripts/server.gd (the source of truth) and
## scripts/network_client.gd (the client's prediction).
const WALK_SPEED := 5.0
const SPRINT_MULTIPLIER := 1.6
const JUMP_VELOCITY := 4.5
const GRAVITY := 9.8
const MAX_FALL_SPEED := 20.0
const FRICTION := 8.0
const AIR_CONTROL := 0.3
# --- Configurable ---
@export var walk_speed: float = WALK_SPEED
@export var sprint_multiplier: float = SPRINT_MULTIPLIER
@export var jump_velocity: float = JUMP_VELOCITY
@export var max_fall_speed: float = MAX_FALL_SPEED
# --- State ---
@export var max_health: int = 100
var current_health: int = 0
var is_alive: bool = true
var is_sprinting: bool = false
var peer_id: int = -1
var player_name: String = ""
var entity_id: int = 0
# --- Input buffer ---
## Where the server spawned this player (set by server.gd). Used as the
## respawn point if the player somehow ends up below the world.
var spawn_position: Vector3 = Vector3(0, 1.5, 0)
## Highest input seq applied authoritatively so far (echoed in snapshots so
## the client can drop acked inputs and replay the rest).
var last_applied_seq: int = 0
## Queue of {seq:int, move_x:float, move_z:float, jump:bool, look_yaw:float}
## appended by rpc_request_input, drained each physics frame.
var _input_queue: Array[Dictionary] = []
var _last_processed_seq: int = -1
# --- Movement ---
var _velocity: Vector3 = Vector3.ZERO
func _ready() -> void:
current_health = max_health
velocity = _velocity
# Start alive
is_alive = true
var _look_yaw: float = 0.0
func _physics_process(delta: float) -> void:
if not is_alive:
return
if _input_queue.is_empty():
_integrate(0.0, 0.0, false, delta)
else:
# Reliable ENet preserves order; sort defensively anyway.
_input_queue.sort_custom(func(a, b): return a["seq"] < b["seq"])
for input in _input_queue:
_look_yaw = input.get("look_yaw", _look_yaw)
_integrate(input.get("move_x", 0.0), input.get("move_z", 0.0), input.get("jump", false), delta)
last_applied_seq = input["seq"]
_input_queue.clear()
# Process input from queue
_process_input(delta)
rotation.y = _look_yaw
# Apply gravity
# Safety net: if a player ends up below the world (physics shove, tunnel
# through the floor), put them back on their spawn instead of falling
# forever.
if global_position.y < -10.0:
global_position = spawn_position
velocity = Vector3.ZERO
func _integrate(move_x: float, move_z: float, jump: bool, delta: float) -> void:
if not is_on_floor():
velocity.y -= GRAVITY * delta
velocity.y = clampf(velocity.y, -max_fall_speed, max_fall_speed)
elif jump:
velocity.y = JUMP_VELOCITY
# Apply friction on ground
if is_on_floor():
var move_dir := Vector2(velocity.x, velocity.z)
if move_dir.length() > 0.01:
var friction_factor := clampf(FRICTION * delta, 0.0, 1.0)
velocity.x = move_dir.normalized().x * move_dir.length() * (1.0 - friction_factor)
velocity.z = move_dir.normalized().y * move_dir.length() * (1.0 - friction_factor)
else:
velocity.x = 0.0
velocity.z = 0.0
# Move and slide
velocity.y = clampf(velocity.y, -max_fall_speed, max_fall_speed)
move_and_slide()
# Update visual position (for remote clients via RPC)
_update_visual_state()
func _process_input(delta: float) -> void:
"""Process buffered input from the server's input queue."""
if _input_queue.is_empty():
return
var input_data: Dictionary = _input_queue.pop_front()
var input_state: Dictionary = input_data.get("input_state", {})
# Calculate movement direction
var direction := Vector3.ZERO
direction.x = input_state.get("move_x", 0.0)
direction.z = input_state.get("move_z", 0.0)
# Sprint check
is_sprinting = input_state.get("sprint", false)
if direction.length() > 0.01:
direction = direction.normalized()
var speed = walk_speed * sprint_multiplier if is_sprinting else walk_speed
velocity.x = direction.x * speed
velocity.z = direction.z * speed
var dir := Vector3(move_x, 0.0, move_z)
if dir.length() > 0.01:
dir = dir.normalized()
velocity.x = dir.x * WALK_SPEED
velocity.z = dir.z * WALK_SPEED
else:
# On ground, apply friction (handled in physics_process)
if not is_on_floor():
velocity.x *= (1.0 - AIR_CONTROL * delta * 5.0)
velocity.z *= (1.0 - AIR_CONTROL * delta * 5.0)
velocity.x = 0.0
velocity.z = 0.0
# Jump
if input_state.get("jump", false) and is_on_floor():
velocity.y = jump_velocity
func take_damage(amount: int, source_id: int) -> Dictionary:
"""Take damage and return result dict."""
if not is_alive:
return {"success": false, "reason": "already_dead"}
current_health = max(0, current_health - amount)
health_changed.emit(current_health)
if current_health <= 0:
die()
return {"success": true, "health": 0, "dead": true}
return {"success": true, "health": current_health}
func die() -> void:
"""Handle player death."""
is_alive = false
velocity = Vector3.ZERO
player_died.emit(self)
# Respawn after delay
await get_tree().create_timer(3.0).timeout
respawn()
func respawn() -> void:
"""Respawn the player at spawn point."""
current_health = max_health
is_alive = true
velocity = Vector3.ZERO
health_changed.emit(current_health)
# Move to spawn point (default: origin)
position = Vector3.ZERO
func set_input_queue(inputs: Array[Dictionary]) -> void:
"""Set the input queue from the server."""
_input_queue = inputs
func get_state() -> Dictionary:
"""Get current player state for replication."""
return {
"entity_id": entity_id,
"peer_id": peer_id,
"position": position,
"rotation": rotation,
"health": current_health,
"max_health": max_health,
"is_alive": is_alive,
"is_sprinting": is_sprinting,
}
move_and_slide()
if is_on_floor() and velocity.y < 0.0:
velocity.y = 0.0
func get_server_state() -> Dictionary:
"""Get current player state for server snapshot broadcast."""
return get_state()
func process_server_input(tick: int) -> void:
"""Called by server tick to process buffered input."""
# This is called from the server's _tick loop.
# The input queue is populated by RPCs from clients.
pass
func _update_visual_state() -> void:
"""Update visual state (for remote interpolation)."""
# In a full implementation, this would update a remote player's
# visual representation. For now, the node itself is the authority.
pass
# --- RPCs ---
@rpc("any_peer", "call_remote", "reliable")
func rpc_request_input(input_state: Dictionary, seq: int) -> void:
"""Receive input from client."""
if not is_alive:
return
# Store for processing in _physics_process
_input_queue.append({
"input_state": input_state,
"seq": seq,
"timestamp": Time.get_ticks_msec(),
})
return {
"position": global_position,
"rotation_y": rotation.y,
"velocity": velocity,
"on_floor": is_on_floor(),
}
@rpc("any_peer", "call_remote", "reliable")
func rpc_request_interact(target_id: int) -> void:
"""Handle interaction request from client."""
if not is_alive:
return
# TODO: Implement interaction logic
print("Player %d interacted with entity %d" % [peer_id, target_id])
@rpc("any_peer", "call_remote", "reliable")
func rpc_request_attack(target_id: int, aim_dir: Vector3) -> void:
"""Handle attack request from client."""
if not is_alive:
return
# TODO: Implement attack logic
print("Player %d attacked entity %d with aim %s" % [peer_id, target_id, aim_dir])
@rpc("authority", "call_remote", "reliable")
func rpc_set_position(pos: Vector3) -> void:
"""Server authoritative position update."""
position = pos
@rpc("authority", "call_remote", "reliable")
func rpc_set_rotation(rot: Vector3) -> void:
"""Server authoritative rotation update."""
rotation = rot
func rpc_request_input(input: Dictionary, seq: int) -> void:
var d := input.duplicate()
d["seq"] = seq
_input_queue.append(d)
+74 -3
View File
@@ -90,6 +90,10 @@ def main():
db_url = f"postgresql+asyncpg://agp_user:agp_password@localhost:{args.postgres_port}/agp_db"
backend_env = os.environ.copy()
backend_env["DATABASE_URL"] = db_url
# Every smoke test registers users against this one backend from one
# client IP; the per-IP auth rate limit would trip mid-suite.
# test_rate_limit.py exercises the limiter itself regardless.
backend_env["RATE_LIMIT_ENABLED"] = "false"
print("\n=== Backend: alembic upgrade head ===")
r = run([sys.executable, "-m", "alembic", "upgrade", "head"], cwd=BACKEND, env=backend_env)
@@ -133,10 +137,77 @@ def main():
for test in smoke_tests:
rel = os.path.relpath(test, ROOT)
print(f"\n--- {rel} ---")
r = run([godot_bin, "--headless", "--path", str(ROOT), "--script", f"res://{rel}"], env=godot_env)
if r.returncode != 0:
godot_ok = False
# smoke_test_network_client.gd needs a separate dedicated
# server process listening on ENet 7777 -- stand one up
# just for that test and tear it down after.
game_server = None
if os.path.basename(rel) == "smoke_test_network_client.gd":
print("(starting scenes/server.tscn for the network client test)")
game_server = subprocess.Popen(
[godot_bin, "--headless", "--path", str(ROOT), "res://scenes/server.tscn"],
env=godot_env,
)
time.sleep(3)
try:
r = run([godot_bin, "--headless", "--path", str(ROOT), "--script", f"res://{rel}"], env=godot_env)
if r.returncode != 0:
godot_ok = False
finally:
if game_server is not None:
game_server.terminate()
try:
game_server.wait(timeout=10)
except subprocess.TimeoutExpired:
game_server.kill()
results["godot smoke tests"] = godot_ok
# 2-player multiplayer test (its own shell harness: server + 2
# real client processes).
mp_script = ROOT / "scripts" / "run_multiplayer_2p_test.sh"
if mp_script.exists():
print("\n--- scripts/run_multiplayer_2p_test.sh ---")
mp_env = os.environ.copy()
mp_env["GODOT_BIN"] = godot_bin
r = run(["bash", str(mp_script)], env=mp_env)
results["multiplayer 2p test"] = r.returncode == 0
# Fresh-clone boot: a from-scratch tree must import + boot with
# zero script errors (no reliance on a committed .godot cache).
fc_script = ROOT / "scripts" / "verify_fresh_clone.sh"
if fc_script.exists():
print("\n--- scripts/verify_fresh_clone.sh ---")
fc_env = os.environ.copy()
fc_env["GODOT_BIN"] = godot_bin
r = run(["bash", str(fc_script)], env=fc_env)
results["fresh-clone boot"] = r.returncode == 0
# Export packaging: the game reads some files (catalog.json)
# via raw FileAccess, which `all_resources` export doesn't
# pack unless include_filter covers them. Passes from source,
# bare capsule in the .exe. This guards that.
ec_script = ROOT / "scripts" / "verify_export_contents.gd"
if ec_script.exists():
print("\n--- scripts/verify_export_contents.gd ---")
r = run([godot_bin, "--headless", "--path", str(ROOT),
"--script", "res://scripts/verify_export_contents.gd"], env=godot_env)
results["export packaging"] = r.returncode == 0
# Run a key smoke against the EXPORTED pack, not just source --
# release-build resources differ (e.g. the runtime GLTFDocument
# path produced no skeleton in a real export; load().instantiate()
# of the imported .scn does). This runs the avatar smoke on
# exactly what ships.
exported_pck = ROOT / "export" / "_ci_check.pck"
exported_pck.parent.mkdir(exist_ok=True)
ep = run([godot_bin, "--headless", "--path", str(ROOT),
"--export-pack", "Linux", str(exported_pck)], env=godot_env)
if ep.returncode == 0 and exported_pck.exists():
r = run([godot_bin, "--headless", "--main-pack", str(exported_pck),
"--script", "res://scripts/smoke_test_avatar_system.gd"], env=godot_env)
results["avatar smoke (exported pack)"] = r.returncode == 0
exported_pck.unlink(missing_ok=True)
else:
results["avatar smoke (exported pack)"] = False
finally:
server_proc.terminate()
try:
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env bash
# Full networked game flow, two real client processes:
# register -> POST /api/worlds/{id}/join -> Session -> game.tscn -> networked.
# Client A must see client B rendered as a remote player and its own movement
# must round-trip. Requires a backend with GAME_SERVERS_ENABLED and the Godot
# binary reachable from it.
#
# Usage: scripts/run_game_multiplayer_test.sh <backend_base_url> <world_id>
set -uo pipefail
cd "$(dirname "$0")/.."
BASE_URL="${1:?usage: run_game_multiplayer_test.sh <backend_url> <world_id>}"
WORLD_ID="${2:?usage: run_game_multiplayer_test.sh <backend_url> <world_id>}"
GODOT_BIN="${GODOT_BIN:-godot}"
command -v "$GODOT_BIN" >/dev/null 2>&1 || GODOT_BIN="$HOME/godot/godot"
export AGP_API_BASE_URL="$BASE_URL"
cleanup() { kill "${B_PID:-}" 2>/dev/null; wait 2>/dev/null; }
trap cleanup EXIT
echo "=== client B: join world $WORLD_ID and hold ==="
"$GODOT_BIN" --headless --path . --script res://scripts/mp_game_flow_client.gd -- \
--role b --world "$WORLD_ID" >/tmp/mpflow_b.log 2>&1 &
B_PID=$!
for _ in $(seq 1 40); do grep -q 'RESULT b in_world' /tmp/mpflow_b.log && break; sleep 1; done
grep -q 'RESULT b in_world' /tmp/mpflow_b.log || { echo "B never entered the world:"; tail -5 /tmp/mpflow_b.log; echo "=== SMOKE RESULT: FAIL ==="; exit 1; }
echo "=== client A: join world $WORLD_ID, move, look for B ==="
"$GODOT_BIN" --headless --path . --script res://scripts/mp_game_flow_client.gd -- \
--role a --world "$WORLD_ID" 2>&1 | tee /tmp/mpflow_a.log
A_LINE=$(grep '^RESULT a' /tmp/mpflow_a.log || true)
echo
echo "A: ${A_LINE:-<none>}"
echo "B: $(grep '^RESULT b' /tmp/mpflow_b.log || echo '<none>')"
if echo "$A_LINE" | grep -q 'moved=true' && echo "$A_LINE" | grep -qE 'remotes=[1-9]'; then
echo "=== SMOKE RESULT: PASS ==="
exit 0
fi
echo "=== SMOKE RESULT: FAIL ==="
exit 1
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env bash
# Two real client processes against one real dedicated server: client A must
# see client B and its own movement must round-trip through the server.
#
# Usage: scripts/run_multiplayer_2p_test.sh
# Exit 0 iff A reports moved=true and remotes>=1.
set -uo pipefail
cd "$(dirname "$0")/.."
GODOT_BIN="${GODOT_BIN:-godot}"
command -v "$GODOT_BIN" >/dev/null 2>&1 || GODOT_BIN="$HOME/godot/godot"
PORT=7787
cleanup() { kill "${SRV_PID:-}" "${B_PID:-}" 2>/dev/null; wait 2>/dev/null; }
trap cleanup EXIT
echo "=== starting dedicated server on $PORT ==="
"$GODOT_BIN" --headless --path . res://scenes/server.tscn -- --port "$PORT" >/tmp/mp2p_server.log 2>&1 &
SRV_PID=$!
sleep 3
echo "=== starting client B (idle observer) ==="
"$GODOT_BIN" --headless --path . --script res://scripts/mp_test_client.gd -- --role b --port "$PORT" >/tmp/mp2p_b.log 2>&1 &
B_PID=$!
# Wait until B is actually connected before starting A.
for _ in $(seq 1 30); do grep -q 'RESULT b connected' /tmp/mp2p_b.log && break; sleep 1; done
echo "=== running client A (mover) ==="
"$GODOT_BIN" --headless --path . --script res://scripts/mp_test_client.gd -- --role a --port "$PORT" 2>&1 | tee /tmp/mp2p_a.log
A_LINE=$(grep '^RESULT a' /tmp/mp2p_a.log || true)
wait "$B_PID" 2>/dev/null
B_LINE=$(grep '^RESULT b' /tmp/mp2p_b.log || true)
echo
echo "server: $(grep -c 'joined' /tmp/mp2p_server.log) joins seen"
echo "A: ${A_LINE:-<none>}"
echo "B: ${B_LINE:-<none>}"
if echo "$A_LINE" | grep -q 'moved=true' \
&& echo "$A_LINE" | grep -qE 'remotes=[1-9]' \
&& echo "$A_LINE" | grep -q 'on_floor=true'; then
echo "=== SMOKE RESULT: PASS ==="
exit 0
fi
echo "=== SMOKE RESULT: FAIL ==="
exit 1
+136 -116
View File
@@ -1,121 +1,146 @@
## ServerMain - Dedicated server root node.
## Accepts ENet connections, spawns Player nodes for each peer,
## and runs the simulation tick loop at 20 Hz.
## ServerMain - headless dedicated server for one world.
##
## Accepts ENet connections, spawns a server-authoritative Player per peer in a
## real loaded level, and broadcasts a full snapshot (every peer's state + the
## last input seq processed for that peer) every physics frame. The project's
## physics rate is pinned to 30 Hz so the sim is deterministic and matches the
## client's prediction step.
##
## Launched by the backend's game-server supervisor
## (backend/app/services/game_servers.py):
## godot --headless --path . res://scenes/server.tscn -- \
## --port 27000 --world 3 --status-file /path/to/status.json
## All args are optional; the defaults let `godot res://scenes/server.tscn`
## run standalone on 7777 for smoke_test_network_client.gd.
extends Node
## Maximum number of concurrent players.
const MAX_PLAYERS := 16
const DEFAULT_PORT := 7777
const PHYSICS_HZ := 30
## Rewrite the status file (player count, for the supervisor's idle reaper)
## every this many physics frames.
const STATUS_WRITE_EVERY := 30
## Server tick rate (Hz).
const TICK_RATE := 20
## Tick duration in seconds.
const TICK_DURATION := 1.0 / TICK_RATE
## ENet server port.
const SERVER_PORT := 7777
## Network peer used for ENet communication.
var network_peer: ENetMultiplayerPeer
var port: int = DEFAULT_PORT
var world_id: String = ""
var status_file_path: String = ""
## Map of peer_id -> Player node.
## peer_id -> Player node
var players: Dictionary = {}
## Current tick counter (server-authoritative).
var tick: int = 0
## Whether the server is running.
var is_running: bool = false
## Monotonic count of joins ever -- drives spawn spread so two players never
## land on the same spot (which used to shove one through the floor).
var _join_count: int = 0
## Accumulated real time for fixed timestep simulation.
var accumulator: float = 0.0
var _player_scene: PackedScene = preload("res://scenes/server_player.tscn")
var _level_scene: PackedScene = preload("res://scenes/worlds/test_level.tscn")
var _spawn_point: Vector3 = Vector3(0, 1.5, 0)
## Player scene resource (loaded once).
var _player_scene: PackedScene
func _enter_tree() -> void:
multiplayer.peer_connected.connect(_on_peer_connected)
multiplayer.peer_disconnected.connect(_on_peer_disconnected)
func _ready() -> void:
# Preload the player scene once.
_player_scene = preload("res://scenes/server_player.tscn")
_parse_args()
Engine.physics_ticks_per_second = PHYSICS_HZ
# Create and assign the ENet server peer.
network_peer = _create_server_peer()
if network_peer:
multiplayer.multiplayer_peer = network_peer
is_running = true
print("[Server] Started on port %d" % SERVER_PORT)
else:
printerr("[Server] Failed to create ENet peer.")
# Load the world's level so players have real ground + collision.
var level := _level_scene.instantiate()
level.name = "World"
add_child(level)
var marker := level.find_child("PlayerSpawn", true, false)
if marker is Node3D:
_spawn_point = (marker as Node3D).global_position
network_peer = ENetMultiplayerPeer.new()
var err := network_peer.create_server(port, MAX_PLAYERS)
if err != OK:
printerr("[Server] create_server(%d) failed: %d" % [port, err])
get_tree().quit(1)
return
multiplayer.multiplayer_peer = network_peer
is_running = true
_write_status()
print("[Server] world=%s listening on ENet %d (%d Hz)" % [world_id, port, PHYSICS_HZ])
# ---------------------------------------------------------------------------
# Server creation
# ---------------------------------------------------------------------------
func _create_server_peer() -> ENetMultiplayerPeer:
var peer := ENetMultiplayerPeer.new()
var error := peer.create_server(SERVER_PORT, MAX_PLAYERS)
if error != OK:
printerr("[Server] create_server error: %d" % error)
return null
return peer
## A spawn point offset from the level's marker so joins don't stack. Golden-
## angle spiral: evenly spread, never exactly repeats, grows slowly outward.
func _spawn_for(index: int) -> Vector3:
if index == 0:
return _spawn_point
var angle := index * 2.399963 # golden angle (radians)
var radius := 0.9 + 0.25 * float(index)
return _spawn_point + Vector3(cos(angle) * radius, 0.0, sin(angle) * radius)
# ---------------------------------------------------------------------------
# Fixed-timestep simulation loop
# ---------------------------------------------------------------------------
func _parse_args() -> void:
var args := OS.get_cmdline_user_args()
var i := 0
while i < args.size():
match args[i]:
"--port":
if i + 1 < args.size():
port = int(args[i + 1])
i += 1
"--world":
if i + 1 < args.size():
world_id = args[i + 1]
i += 1
"--status-file":
if i + 1 < args.size():
status_file_path = args[i + 1]
i += 1
i += 1
func _process(delta: float) -> void:
func _physics_process(_delta: float) -> void:
if not is_running:
return
# Fixed timestep accumulator.
accumulator += delta
while accumulator >= TICK_DURATION:
accumulator -= TICK_DURATION
_tick()
func _tick() -> void:
"""Main server simulation tick (called at TICK_RATE)."""
tick += 1
# Process player inputs and update state.
for peer_id in players:
var player: Node3D = players[peer_id]
if player and player.has_method("process_server_input"):
player.process_server_input(tick)
# Broadcast snapshot to all clients.
if tick % PHYSICS_HZ == 0:
_sweep_stale_players()
_broadcast_snapshot()
if tick % STATUS_WRITE_EVERY == 0:
_write_status()
## Backstop for _on_peer_disconnected: once a second, drop any player whose
## peer is no longer connected (covers a missed disconnect signal, so a
## rejoining client never sees a frozen ghost of its old self).
func _sweep_stale_players() -> void:
var connected := multiplayer.get_peers()
for peer_id in players.keys():
if peer_id not in connected:
var p: Node = players[peer_id]
if is_instance_valid(p):
p.queue_free()
players.erase(peer_id)
print("[Server] swept stale player %d (%d left)" % [peer_id, players.size()])
func _broadcast_snapshot() -> void:
"""Broadcast the current authoritative state to all connected clients."""
var snapshot_data := {
"tick": tick,
"players": {},
}
var snap := {"tick": tick, "players": {}}
for peer_id in players:
var player: Node3D = players[peer_id]
if player and player.has_method("get_server_state"):
snapshot_data.players[peer_id] = player.get_server_state()
# Send snapshot to all peers except the server itself (peer_id == 1).
var peer_ids: Array = multiplayer.get_peers()
for pid in peer_ids:
if pid == 1: # 1 is the server itself
var player: Node = players[peer_id]
if is_instance_valid(player):
var s: Dictionary = player.get_server_state()
s["last_seq"] = player.last_applied_seq
snap["players"][peer_id] = s
for pid in multiplayer.get_peers():
if pid == 1:
continue
rpc_id(pid, "_receive_snapshot", snapshot_data)
rpc_id(pid, "_receive_snapshot", snap)
# Remote: clients receive snapshots.
# Client-side receiver lives on the NetworkClient's proxy at the same path.
@rpc("any_peer", "call_remote", "reliable")
func _receive_snapshot(snapshot_data: Dictionary) -> void:
pass # Handled by client scene.
func _receive_snapshot(_snapshot_data: Dictionary) -> void:
pass
# ---------------------------------------------------------------------------
@@ -123,52 +148,47 @@ func _receive_snapshot(snapshot_data: Dictionary) -> void:
# ---------------------------------------------------------------------------
func _on_peer_connected(peer_id: int) -> void:
print("[Server] Peer %d connected." % peer_id)
if players.size() >= MAX_PLAYERS:
print("[Server] Max players reached. Disconnecting peer %d." % peer_id)
rpc_id(peer_id, "_kick", "Server is full.")
print("[Server] full; rejecting peer %d" % peer_id)
multiplayer.multiplayer_peer.disconnect_peer(peer_id)
return
# Spawn a Player node for this peer.
var player: Node3D = _player_scene.instantiate()
player.name = "Player_%d" % peer_id
player.peer_id = peer_id
add_child(player)
var spawn := _spawn_for(_join_count)
_join_count += 1
player.global_position = spawn
player.spawn_position = spawn
players[peer_id] = player
print("[Server] Spawned player node for peer %d." % peer_id)
print("[Server] peer %d joined (%d players)" % [peer_id, players.size()])
_write_status()
func _on_peer_disconnected(peer_id: int) -> void:
print("[Server] Peer %d disconnected." % peer_id)
if players.has(peer_id):
var player: Node3D = players[peer_id]
if player.is_inside_tree():
var player: Node = players[peer_id]
if is_instance_valid(player):
player.queue_free()
players.erase(peer_id)
print("[Server] peer %d left (%d players)" % [peer_id, players.size()])
_write_status()
# ---------------------------------------------------------------------------
# RPCs
# Status file for the supervisor's idle reaper
# ---------------------------------------------------------------------------
@rpc("any_peer", "call_remote", "reliable")
func _kick(reason: String) -> void:
print("[Server] Kicking caller: %s" % reason)
rpc_id(multiplayer.get_remote_sender_id(), "_disconnect", reason)
@rpc("call_remote", "reliable")
func _disconnect(reason: String) -> void:
print("[Server] Disconnecting: %s" % reason)
get_tree().quit(0)
# ---------------------------------------------------------------------------
# Signal connections
# ---------------------------------------------------------------------------
func _enter_tree() -> void:
multiplayer.peer_connected.connect(_on_peer_connected)
multiplayer.peer_disconnected.connect(_on_peer_disconnected)
func _write_status() -> void:
if status_file_path.is_empty():
return
var f := FileAccess.open(status_file_path, FileAccess.WRITE)
if f:
f.store_string(JSON.stringify({
"world_id": world_id,
"port": port,
"players": players.size(),
"tick": tick,
"updated": Time.get_unix_time_from_system(),
}))
f.close()
+27
View File
@@ -0,0 +1,27 @@
extends Node
## Autoload "Session" -- carries the "which world am I joining" handoff from
## the world browser to the game scene.
##
## world_list.gd calls begin() with the address returned by
## 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).
var world_id: int = 0
var host: String = ""
var port: int = 0
var active: bool = false
func begin(p_world_id: int, p_host: String, p_port: int) -> void:
world_id = p_world_id
host = p_host
port = p_port
active = true
func clear() -> void:
world_id = 0
host = ""
port = 0
active = false

Some files were not shown because too many files have changed in this diff Show More