Compare commits

..

14 Commits

Author SHA1 Message Date
daddy 9661b9a81c Add social graph models: friendships, follows, blocks
Models + Alembic migration (0007, head) only, per docs/planning/social.md
US-1 -- the friends/follow API router is a separate sibling task.

- friendships: requester_id/addressee_id (FK users, CASCADE), status
  (pending/accepted native enum), unique pair constraint, indexed FKs.
- follows: follower_id/followed_id (FK users, CASCADE), unique pair.
- blocks: blocker_id/blocked_id (FK users, CASCADE), unique pair.
- Relationships wired onto User for both directions of each table.
- tests/test_social_models.py exercises request/accept, the unique-pair
  constraint, follow, block, and DB-level CASCADE delete against real
  Postgres. Full backend suite (152 tests) still green.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y2FESCaMXeJpC9gSctiEeD
2026-09-15 00:10:15 +00:00
daddy 4eb4476fc3 Presence & status model + endpoints (Redis heartbeat) -- recovered
checkpoint / checkpoint (push) Successful in 2m25s
Real feature built in August, checkpointed mid-task by the old fleet's
graceful-shutdown mechanism, then orphaned when checkpoint_focus_
feature_ids scoped the fleet away from M8 and it was never resumed --
the PM task had shown done/reviewed for a month with zero actual code on
main. Ported forward from the 225-commits-stale branch onto current
main's conventions (DbDep pattern, migration rebased onto 0005's head),
fixing two real bugs found in the process: the original PresenceStore's
explicit connect() was never called from anywhere (would 500 on every
real request), and a naive fix hits 'event loop is closed' under this
repo's TestClient(app) pattern -- fixed with lazy per-event-loop client
caching, same class of issue already documented for asyncpg in
conftest.py. conftest.py extended to also bring up ephemeral Redis
symmetrically with its existing Postgres bootstrap.

144/144 backend tests independently re-run via the fully automated
hermetic path (zero manual setup, matching what a fresh contributor
gets), plus a manual end-to-end heartbeat/TTL-expiry/batch-query check
against real Postgres+Redis.
2026-09-15 00:01:24 +00:00
daddy f34a4806ef Port presence feature forward from orphaned agent/dd2a126b57a1 branch
Recovers a real August feature that was never merged (3 WIP checkpoints +
fix commit 47ab492, 225 commits behind main): a Redis-backed heartbeat
presence store plus /api/presence/{heartbeat,presence,status} endpoints.
Ported onto current main's conventions rather than rebuilt:

- app/models/user.py: last_seen_at/status_message columns (unchanged from
  the orphaned branch; still compatible with the current model).
- alembic/versions/0006_add_presence_columns.py: same column migration,
  rebased as the new head after 0005_add_world_content_tables (was
  2a9b9873244a off 0001_initial).
- app/routers/presence.py: same three endpoints, updated to the DbDep
  pattern other routers (avatar.py) now use; retains the 47ab492 fix
  (is_online_batch via MGET, correct StatusUpdate Field annotation).
- app/services/presence.py: real adaptation beyond a mechanical port --
  the original PresenceStore required an explicit connect() that nothing
  ever called (main.py's lifespan never wired it up), so every real
  request would have raised "not connected". Made the Redis client connect
  lazily on first use instead, and cache it per running event loop rather
  than globally: this repo's TestClient(app) pattern (no `with` block) can
  dispatch different requests onto different event loops, which broke a
  single cached connection the same way NullPool's comment in conftest.py
  describes for asyncpg. Dropped the unused get_presence()/
  update_status_message() methods and the never-wired init/shutdown
  singleton helpers -- dead code even in the original.
- tests/conftest.py: extended the existing ephemeral-Postgres bootstrap to
  also bring up ephemeral Redis (docker-compose.yml already defined the
  service) when REDIS_URL isn't already reachable, so `pytest` stays
  hermetic.
- tests/test_presence.py: heartbeat sets online, batch presence over
  mixed online/offline/unknown ids (the exact 47ab492 regression), status
  message update/strip/length-limit, and TTL expiry via a short-TTL
  PresenceStore instance (real Redis SET/EXISTS/MGET, no 60s sleep needed).

Verified: full backend pytest suite (144 tests) passes against a fresh
Postgres+Redis. Also drove the real endpoints end-to-end against a running
uvicorn + docker-compose Postgres/Redis: heartbeat -> online, batch query,
forced TTL expiry -> offline with last_seen_at retained, status message
trim + 422 over 140 chars.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y2FESCaMXeJpC9gSctiEeD
2026-09-14 23:59:14 +00:00
daddy 9f04cef447 Editor: save world edits to the server; re-enter and see the changes persist
checkpoint / checkpoint (push) Successful in 2m18s
ChunkSaveService (single PUT + base_revision, 409 -> refetch -> retry,
atomic batch PUT), wired into InGameEditor (Save/Ctrl+S, live_session
surfaced as a warning, not ignored). Owner-gated 'Edit World' entry from
the world browser that sets Session.editing without ever calling
join_world() -- editing is non-joined per world_persistence_impl.md §6.
game.gd fetches the real chunk over HTTP when entering edit mode.

19/19 new save-path smoke assertions (incl. a real two-writer 409
conflict resolved transparently, and reload confirming persistence) and
the full existing suite (18 Godot smoke tests + 132 backend pytest)
independently re-verified with zero regressions.
2026-09-14 23:38:46 +00:00
daddy 4ff74ae823 Editor save path: single/batch chunk PUT, 409 retry, non-joined Edit World entry
Implements world_persistence_impl.md §9 client/editor task items 2-3:

- ChunkSaveService (scripts/editor/chunk_save_service.gd): single-chunk
  PUT with base_revision, batch PUT for multi-chunk edits, and a real
  409 -> re-fetch -> retry loop. Full-chunk overwrite (§8.2 D5) means
  "re-apply the edit" is just retrying with the locally-edited payload
  at the freshly-learned revision, not delta replay.
- InGameEditor gets a Save button (+ Ctrl+S) that surfaces live_session
  as an explicit warning instead of ignoring it, per §6 D4.
- "Edit World" entry point on the world list (owner-gated) that calls
  Session.begin_edit() instead of ApiClient.join_world() -- editing
  never joins, opens no ENet connection, spawns no dedicated server.
- game.gd wires Session.editing to fetch the real (0,0,0) chunk over
  HTTP and open the editor against it, falling back to an empty chunk
  for an unwritten coordinate.

Verified against a real backend (register/world/save/reload, a genuine
409 from two racing saves, atomic 2-chunk batch, live_session field) --
new scripts/smoke_test_editor_save.gd (19/19) plus an extended
smoke_test_world_list.gd covering the non-joined entry point (11/11).
Full existing Godot smoke suite (18 files) and backend pytest (132)
still pass unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y2FESCaMXeJpC9gSctiEeD
2026-09-14 23:35:33 +00:00
daddy 31f6974c7a In-game editor MVP: place / move / rotate / scale / delete catalog objects
checkpoint / checkpoint (push) Successful in 2m15s
EditorCore (data-native, shared): ChunkEditorModel over the existing
ChunkPayload, an UndoRedo-backed command stack, SelectionManager,
ToolRegistry with 5 tools (brush/object-place/select/erase/transform --
transform added beyond editor.md's named list since move/rotate/scale
needed a home). InGameEditor UI (2D top-down, per editor.md $8's own
scope guidance -- CHUNK_SIZE_Z=1, no 3D gizmos yet), gated behind a new
F2 build-mode toggle in game.gd. In-memory only by design -- saving to
the backend is the sibling task's scope.

69/69 new smoke assertions (place/move/rotate/scale/delete/paint/erase,
undo AND redo actually verified to revert/replay, a full
encode_binary/decode_binary round-trip after editing, a real UI click
through EditorViewport, and a rejected-unknown-catalog-id case) and the
full sibling smoke suite (16 other suites, backend-dependent ones run
against a real backend + Postgres) all independently re-verified with
zero regressions.
2026-09-14 23:22:12 +00:00
daddy 7c37a0dc2f Add in-game editor MVP: EditorCore + 2D tile/object editing
Implements the in-game editor's shared, data-native core and a
Control-based UI on top of it, per docs/architecture/editor.md §7.1/§7.2,
scoped to 2D per §8's own risk mitigation (the default world is flat,
CHUNK_SIZE_Z=1 -- no 3D gizmos here).

EditorCore (scripts/editor/):
- ChunkEditorModel wraps a ChunkDataDecoder.ChunkPayload directly (no
  parallel data model) and adds a TileLayerData.set_tile() mutator
  alongside the existing get_tile().
- EditorUndoRedoStack wraps Godot's built-in UndoRedo (game-usable, not
  the editor-only EditorUndoRedoManager) so every mutation is a real,
  revertible do/undo Callable pair over ChunkEditorModel's raw setters.
- SelectionManager tracks the selected object id as UI state, deliberately
  outside the undo/redo stack.
- ToolRegistry + 5 tools (BrushTool, ObjectPlaceTool, SelectTool,
  EraseTool, TransformTool -- the 5th covers move/rotate/scale, which the
  doc's tool list doesn't literally name but the task requires) is the
  single choke point every mutation goes through.
- EditorObjectCatalog (assets/editor/object_catalog.json) is a new,
  small "placeable object" catalog -- AvatarCatalog and SpawnCatalog were
  checked first and are both scoped to other concerns, so this mirrors
  their loading convention rather than reusing either.

InGameEditor UI (scripts/editor/ui/): EditorViewport (2D top-down tile
grid + object dots), EditorToolbar, PalettePanel, InspectorPanel, wired
together in InGameEditor. Built from Control nodes in code (matching
game.gd's existing dynamic-UI convention) rather than a hand-authored
.tscn.

Entry point: game.gd gates build mode behind a new toggle_build_mode
input action (F2), freezing the player and opening a fresh empty
ChunkEditorModel. Loading a *real* chunk from the backend for editing,
and saving edits back, are explicitly out of scope -- a sibling task
owns the batch-PUT/conflict-handling save path
(world_persistence_impl.md).

Verification: scripts/smoke_test_editor_core.gd decodes a real backend
fixture (backend/tests/fixtures/chunks/simple_tile_chunk.bin), drives
place/move/rotate/scale/delete/paint/erase entirely through
EditorCore.use_tool()/ToolRegistry (never touching ChunkEditorModel's
raw setters directly), undoes and redoes every step, and round-trips the
result through encode_binary()/decode_binary() to prove the edited model
stays a valid, byte-compatible ChunkPayload. 69/69 assertions pass. Full
existing Godot smoke suite (16 other scripts/smoke_test_*.gd, backend +
dedicated-server-dependent ones included) still passes with no
regressions.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y2FESCaMXeJpC9gSctiEeD
2026-09-14 23:18:45 +00:00
daddy 4c95ad79e6 Client persistence plumbing: HttpChunkSource adapter + server.gd real content loading
checkpoint / checkpoint (push) Successful in 2m16s
Implements docs/architecture/world_persistence_impl.md §9's client-side
plumbing: HttpChunkSource wired into ChunkStreamManager.set_server_rpc()
(ETag/304 caching), and server.gd's startup sequence now fetches a
manifest + spawn-neighborhood chunks over the internal API before binding
the ENet port, builds real collision from decoded chunk data, and falls
back to test_level.tscn when the backend is unreachable. This is the
prerequisite both normal play (Enter World) and the upcoming in-game
editor need -- neither had anything real to load/save before this.

Found and fixed 4 real, previously-untested bugs in the process (nothing
had ever exercised encode_binary()/the set_server_rpc() seam before this
task): a missing section header in _build_metadata_section that corrupted
every encode_binary() call, a String.split()/Array[String] type mismatch
in _parse_coord_from_key, a nonexistent data.format_version field read
(should be .version), and (found during review) a client-side min+size/
min+max bounds mismatch identical in kind to the backend bug fixed in
eb39e43, masked by every existing test using a zero chunk position.

Independently re-verified: 11/11 HttpChunkSource smoke assertions, 33/33
codec interop assertions (incl. a new nonzero-bounds regression case), and
the full sibling smoke suite (16 suites) against a real backend + real
GameServerSupervisor-spawned server.gd process -- real content load,
empty-room fallback, and unreachable-backend fallback all confirmed with
actual collision-shape counts, not just HTTP status codes.
2026-09-14 23:00:47 +00:00
daddy 65995f30a6 Fix latent client-side encode_binary bounds min+size/min+max bug (nonzero position)
Found during review of the persistence-plumbing branch: _build_metadata_section
wrote raw bounds.size into the wire's 2nd float triple, but the wire format
(and _parse_metadata_section) treats that triple as max_pos, computing
size = max - min. AABB(Vector3.ZERO, size) masks this since size == max when
position is 0 -- exactly what every existing test used. Any chunk at a real,
nonzero world position would silently corrupt on its own client-local
encode/decode round-trip. Same bug class as the backend's min+size/min+max
mismatch fixed in chunk_codec.py (eb39e43), just on the client side this
time. Added a regression case to smoke_test_chunk_codec_interop.gd using a
real nonzero chunk position (96,-32,0) -- 33/33 passing. Full sibling smoke
suite re-verified clean afterward.
2026-09-14 23:00:17 +00:00
daddy fd88cef32f Wire client chunk streaming + dedicated-server content load to the backend
Implements the client-side half of docs/architecture/world_persistence_impl.md
(the backend API in backend/app/routers/{world_content,internal}.py and
game_servers.py's --api-url/--server-token/_await_ready were already merged):

- scripts/http_chunk_source.gd: new HttpChunkSource adapter satisfying
  ChunkStreamManager.set_server_rpc()'s seam. Fetches chunks over HTTP from
  the public world_content API (JWT via ApiClient), with ETag/If-None-Match
  (304) support backed by a user://chunk_cache/<checksum> cache, and a
  bounds-query list_chunks() helper (§8.1's chunk index). A 404 (unwritten
  coordinate) renders empty instead of erroring, per §8.1.

- scripts/server.gd: startup now parses --api-url/--server-token, and
  before binding the ENet port fetches the world's manifest + the
  spawn-neighbourhood chunks from /api/internal (§7.2/§7.3/§8.3), decodes
  them, and builds real StaticBody3D collision from solid tiles + non-
  walkable objects. Falls back to test_level.tscn if the backend is
  unreachable, the world has no rooms, or the room has zero chunks -- never
  hangs or crashes. Writes "ready"/"content" (plus diagnostic
  chunks_loaded/collision_shapes counts) into the status file that
  game_servers.py's _await_ready() already reads.

- scripts/api_client.gd: exposes a `base_url` getter so other client code
  building its own requests (HttpChunkSource; server.gd doesn't use this
  autoload at all, since its auth model is the per-spawn server token, not
  a player JWT) can target the same backend.

Also fixes three real, previously-dormant bugs this work's first real
exercise of these code paths uncovered (nothing before this called
encode_binary() or fed real data through set_server_rpc()):

- chunk_data_decoder.gd: `_build_metadata_section` never wrapped its
  output with the section id/length header every other section builder
  uses, so `encode_binary()` produced corrupt payloads the decoder read as
  a single garbage-length section ("truncated section body").
- chunk_stream_manager.gd: `_parse_coord_from_key` assigned a
  PackedStringArray (String.split()'s real return type) to an
  Array[String]-typed variable -- a hard crash.
- chunk_stream_manager.gd: `_instantiate_chunk_scene_with_data` read a
  nonexistent `data.format_version` field; ChunkPayload's field is
  `version`.

Adds scripts/smoke_test_http_chunk_source.gd, a real end-to-end smoke test
(register, create world, PUT a real chunk, stream it back through
ChunkStreamManager + HttpChunkSource, plus the unwritten-coordinate and
304-refetch paths).

Verified against a real backend + Postgres:
- Seeded real chunk content, joined through the real
  GameServerSupervisor spawn path: status file reports
  content=loaded, chunks_loaded=1, collision_shapes=33.
- A room with zero chunks and an unreachable backend (closed port) both
  fall back to test_level.tscn cleanly (content=fallback, ready=true, no
  hang/crash).
- Full existing Godot smoke suite (16 suites, including
  smoke_test_enter_world.gd and smoke_test_network_client.gd) passes with
  no regressions.
- Backend pytest (132/132) untouched and still green -- no backend/
  changes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y2FESCaMXeJpC9gSctiEeD
2026-09-14 22:54:56 +00:00
daddy eb39e4393d Fix 3 real client/server chunk-codec interop bugs, caught by a new golden-fixture test
checkpoint / checkpoint (push) Successful in 2m13s
No test ever exercised the client ChunkDataDecoder against real byte data
before this (M2 chunk streaming was never fed real content on PC2/PC3).
Added scripts/smoke_test_chunk_codec_interop.gd, decoding the backend's
exact golden fixtures (backend/tests/fixtures/chunks/*.bin) and asserting
byte-identical logical values -- the real cross-language contract this
whole persistence system depends on. It found three bugs on first run:

1. chunk_data_decoder.gd's _read_float32/_write_float32 used `bits as
   float`/`val as int`, a numeric cast in GDScript, not an IEEE-754
   bitcast -- silently corrupted every non-integer position/rotation/
   scale/bounds value (1.5 decoded as 1069547520.0, verified). Fixed with
   PackedByteArray.decode_float/encode_float (real bitcast, same
   little-endian order as the rest of this format).

2. backend/app/services/chunk_codec.py's binary codec wrote bounds as
   min+size, disagreeing with world.md §3's documented min+max wire
   format and with the client's actual (correct) decoder -- corrupted
   every chunk's bounds on the wire despite the backend's own JSON path
   using min+max correctly. Fixed the binary encode/decode to match the
   spec; regenerated the golden fixtures.

3. chunk_data_decoder.gd's object-properties parsing used
   `JSON.parse_string(props_str) or {}` -- GDScript's `or` is a boolean
   operator, not Python/JS-style short-circuit-return, so this silently
   evaluated to a bool for any object with real property data, crashing
   on assignment to the typed `properties: Dictionary` field. Fixed with
   an explicit type check.

Full backend suite (132/132) and 4 sibling client smoke tests re-verified
clean after all three fixes; no other `or {}`/`or []` instances of bug 3's
pattern found elsewhere in the codebase.
2026-09-14 19:56:44 +00:00
daddy 9c086a4b10 Backend: world content persistence endpoints (save/load chunk data)
Implements docs/architecture/world_persistence_impl.md §9's backend scope:
world_rooms/world_chunks/world_chunk_revisions (Postgres, migration 0005),
a Python port of the §3.6 binary chunk codec with full server-side
validation, the public save/load API (batch PUT, X-Base-Revision conflict
detection, revision history) and the internal dedicated-server-only API
(bearer-token gated), game_servers.py per-spawn tokens + a real _await_ready
replacing _await_port (which failed open past its timeout with no error --
confirmed real bug). create_world now auto-creates the main room. 132/132
backend tests pass (independently re-run), alembic upgrade/downgrade/upgrade
round-trips clean (independently re-run).
2026-09-14 19:52:07 +00:00
daddy 186a79a43e UGC scripting DSL: data-driven verb/condition interpreter
checkpoint / checkpoint (push) Successful in 1m52s
Implements docs/architecture/scripting_sandbox.md's primary recommendation
(a fixed verb/condition/trigger DSL expressed as data, zero attack
surface) rather than the out-of-process sidecar the sibling task had
originally assumed. ScriptBehavior/ScriptRule parse and whitelist rule
data at load time; ScriptInterpreterNode is the only code that ever runs,
dispatching every verb through a closed match() -- never call()-by-name
from data. Verbs: give_item, play_sound, move, rotate, spawn_from_catalog,
set_timer. Triggers: on_interact, on_enter, on_touch, on_timer. Demo
wired into test_level.tscn (ScriptedChest). 50/50 smoke assertions,
independently re-verified, including 9 distinct malformed/unsafe-rule
rejection cases and a real spawn_from_catalog resolving only through the
trusted catalog, never a raw data-supplied path.
2026-09-14 19:34:12 +00:00
daddy 8822a03f53 Add ScriptBehavior DSL: data-driven, non-Turing-complete UGC scripting
Implements the DSL that docs/architecture/scripting_sandbox.md recommends
as the primary (and, for now, sufficient) approach in place of running
untrusted GDScript in-process: a fixed set of trigger/condition/action
verbs, expressed as plain JSON data, interpreted by trusted GDScript with
zero ability to reference engine singletons, call arbitrary methods, or
execute code.

- ScriptBehavior (scripts/scripting/script_behavior.gd): parses and
  validates rules from JSON/Dictionary data. Every trigger, condition
  type/op, and verb is checked against an explicit whitelist at parse
  time; malformed or unknown entries are dropped and recorded in
  rejected_rules, never partially executed. Carries an optional
  content_flags passthrough so this data type isn't hostile to future
  moderation-queue integration (docs/architecture/content_moderation.md).
- ScriptInterpreterNode (scripts/scripting/script_interpreter.gd): the
  only code that executes rules. Triggers: on_interact (proximity +
  "interact" input action), on_enter (edge-triggered Area3D overlap),
  on_touch (continuous overlap), on_timer (physics-clocked Timer). Verbs:
  give_item, play_sound, move, rotate, spawn_from_catalog, set_timer --
  each a fixed, hand-written function; verb dispatch is an explicit
  match(), never call()-by-name from data. move/rotate animate via Tween
  rather than teleporting.
- PlayerInventory (scripts/scripting/player_inventory.gd): a small
  per-player item-count stub for give_item to target, since no real
  inventory/entity-state system exists yet (M9 Economy). Attached to
  scenes/player.tscn as "Inventory".
- SpawnCatalog (scripts/scripting/spawn_catalog.gd): trusted catalog_id ->
  asset_id lookup for spawn_from_catalog, mirroring the AvatarCatalog
  data-driven-JSON convention. Rule data only ever supplies a catalog_id
  key, never a resource path.
- Wired a real demo ("ScriptedChest") into scenes/worlds/test_level.tscn
  with an on_interact rule (give_item + play_sound + rotate), plus an
  "interact" input action (project.godot).
- scripts/smoke_test_script_behavior.gd: exercises every trigger type,
  every verb, and every condition type against the real interpreter and
  the wired demo scene, and proves unknown/malformed/unsafe rules
  (unknown verb, unknown trigger, non-scalar smuggled arguments, etc.)
  are rejected at parse time and never run -- 50/50 checks pass.
2026-09-14 19:32:22 +00:00
57 changed files with 5932 additions and 71 deletions
+40
View File
@@ -0,0 +1,40 @@
{
"objects": {
"crate_small": {
"asset_id": "props/crate_small.glb",
"type": "prop",
"display_name": "Small Crate",
"category": "props"
},
"rock_01": {
"asset_id": "props/rock_01.glb",
"type": "prop",
"display_name": "Rock",
"category": "nature"
},
"tree_pine_01": {
"asset_id": "props/tree_pine_01.glb",
"type": "prop",
"display_name": "Pine Tree",
"category": "nature"
},
"lamp_post_01": {
"asset_id": "props/lamp_post_01.glb",
"type": "prop",
"display_name": "Lamp Post",
"category": "props"
},
"chest_01": {
"asset_id": "props/chest.glb",
"type": "interactable",
"display_name": "Treasure Chest",
"category": "interactables"
},
"spawn_point": {
"asset_id": "",
"type": "spawn_point",
"display_name": "Spawn Point",
"category": "gameplay"
}
}
}
@@ -0,0 +1,17 @@
{
"content_flags": {
"violence": "none",
"mature": false
},
"rules": [
{
"trigger": {"type": "on_interact"},
"conditions": [],
"actions": [
{"verb": "give_item", "item_id": "gold_coin", "count": 3},
{"verb": "play_sound", "sound_id": "chest_open"},
{"verb": "rotate", "target": "self", "degrees": 90, "axis": "y", "duration": 0.4}
]
}
]
}
+8
View File
@@ -0,0 +1,8 @@
{
"templates": {
"small_crate": {
"asset_id": "props/crate_small.glb",
"display_name": "Small Crate"
}
}
}
@@ -0,0 +1,33 @@
"""add presence columns to users
Revision ID: 0006_add_presence_columns
Revises: 0005_add_world_content_tables
Create Date: 2026-09-14 00:00:00.000000
Ported forward from the orphaned agent/dd2a126b57a1 branch (originally
2a9b9873244a_add_presence_columns.py, down_revision 0001_initial) and
rebased onto the current migration head. See app/services/presence.py /
app/routers/presence.py.
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "0006_add_presence_columns"
down_revision: Union[str, None] = "0005_add_world_content_tables"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Add last_seen_at and status_message columns to users table."""
op.add_column("users", sa.Column("last_seen_at", sa.DateTime(timezone=True), nullable=True))
op.add_column("users", sa.Column("status_message", sa.Text(), nullable=True))
def downgrade() -> None:
"""Remove presence columns from users table."""
op.drop_column("users", "status_message")
op.drop_column("users", "last_seen_at")
@@ -0,0 +1,118 @@
"""add social graph tables (friendships, follows, blocks)
Revision ID: 0007_add_social_graph_tables
Revises: 0006_add_presence_columns
Create Date: 2026-09-15 00:00:00.000000
docs/planning/social.md US-1 / app/models/social.py. Models + migration
only -- the request/accept/block business logic lives in the sibling
"Friends & follow API router" task.
"""
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 = "0007_add_social_graph_tables"
down_revision: Union[str, None] = "0006_add_presence_columns"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
friendship_status = postgresql.ENUM(
"pending", "accepted", name="friendship_status"
)
friendship_status.create(op.get_bind(), checkfirst=True)
op.create_table(
"friendships",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"requester_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"addressee_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"status",
postgresql.ENUM(
"pending", "accepted", name="friendship_status", create_type=False
),
nullable=False,
server_default="pending",
),
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),
sa.UniqueConstraint("requester_id", "addressee_id", name="uq_friendships_pair"),
)
op.create_index("ix_friendships_requester_id", "friendships", ["requester_id"])
op.create_index("ix_friendships_addressee_id", "friendships", ["addressee_id"])
op.create_table(
"follows",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"follower_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"followed_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"),
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),
sa.UniqueConstraint("follower_id", "followed_id", name="uq_follows_pair"),
)
op.create_index("ix_follows_follower_id", "follows", ["follower_id"])
op.create_index("ix_follows_followed_id", "follows", ["followed_id"])
op.create_table(
"blocks",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"blocker_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"blocked_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"),
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),
sa.UniqueConstraint("blocker_id", "blocked_id", name="uq_blocks_pair"),
)
op.create_index("ix_blocks_blocker_id", "blocks", ["blocker_id"])
op.create_index("ix_blocks_blocked_id", "blocks", ["blocked_id"])
def downgrade() -> None:
op.drop_index("ix_blocks_blocked_id", table_name="blocks")
op.drop_index("ix_blocks_blocker_id", table_name="blocks")
op.drop_table("blocks")
op.drop_index("ix_follows_followed_id", table_name="follows")
op.drop_index("ix_follows_follower_id", table_name="follows")
op.drop_table("follows")
op.drop_index("ix_friendships_addressee_id", table_name="friendships")
op.drop_index("ix_friendships_requester_id", table_name="friendships")
op.drop_table("friendships")
postgresql.ENUM(name="friendship_status").drop(op.get_bind(), checkfirst=True)
+2 -1
View File
@@ -6,7 +6,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from app.config import settings from app.config import settings
from app.routers import auth, users, worlds, assets, avatar, world_content, internal from app.routers import auth, users, worlds, assets, avatar, world_content, internal, presence
from app.services.game_servers import supervisor from app.services.game_servers import supervisor
@@ -44,6 +44,7 @@ app.include_router(world_content.router, prefix="/api/worlds", tags=["world-cont
app.include_router(assets.router, prefix="/api/assets", tags=["assets"]) app.include_router(assets.router, prefix="/api/assets", tags=["assets"])
app.include_router(avatar.router, prefix="/api/avatar", tags=["avatar"]) app.include_router(avatar.router, prefix="/api/avatar", tags=["avatar"])
app.include_router(internal.router, prefix="/api/internal", tags=["internal"]) app.include_router(internal.router, prefix="/api/internal", tags=["internal"])
app.include_router(presence.router, prefix="/api/presence", tags=["presence"])
@app.get("/api/health") @app.get("/api/health")
+2
View File
@@ -4,8 +4,10 @@ from app.models.world import World
from app.models.asset import Asset from app.models.asset import Asset
from app.models.avatar_loadout import AvatarLoadout from app.models.avatar_loadout import AvatarLoadout
from app.models.world_content import WorldRoom, WorldChunk, WorldChunkRevision from app.models.world_content import WorldRoom, WorldChunk, WorldChunkRevision
from app.models.social import Friendship, Follow, Block, FriendshipStatus
__all__ = [ __all__ = [
"User", "Session", "World", "Asset", "AvatarLoadout", "User", "Session", "World", "Asset", "AvatarLoadout",
"WorldRoom", "WorldChunk", "WorldChunkRevision", "WorldRoom", "WorldChunk", "WorldChunkRevision",
"Friendship", "Follow", "Block", "FriendshipStatus",
] ]
+106
View File
@@ -0,0 +1,106 @@
"""Social graph: friendships, follows, blocks.
docs/planning/social.md §4/§5 (US-1). Friends = mutual (request -> accept),
Follow = one-way (no approval), Block = hard deny. This module is models +
schema only -- the request/accept/block business logic (idempotent
duplicate/self handling, removing an existing friendship/follow on block,
etc.) belongs to the API router task that depends on this one.
"""
import enum
from sqlalchemy import Column, Integer, ForeignKey, DateTime, UniqueConstraint
from sqlalchemy.dialects.postgresql import ENUM
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.database import Base
class FriendshipStatus(str, enum.Enum):
PENDING = "pending"
ACCEPTED = "accepted"
class Friendship(Base):
"""A friend request, directional until accepted.
``requester_id`` sent the request to ``addressee_id``. Once accepted the
friendship is mutual, but the row stays directional (who asked whom) for
audit purposes; the API layer treats it as symmetric once accepted.
"""
__tablename__ = "friendships"
id = Column(Integer, primary_key=True, index=True)
requester_id = Column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
addressee_id = Column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
status = Column(
ENUM(
FriendshipStatus,
name="friendship_status",
create_type=False,
values_callable=lambda enum_cls: [e.value for e in enum_cls],
),
nullable=False,
default=FriendshipStatus.PENDING,
server_default=FriendshipStatus.PENDING.value,
)
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)
requester = relationship("User", foreign_keys=[requester_id], back_populates="sent_friend_requests")
addressee = relationship("User", foreign_keys=[addressee_id], back_populates="received_friend_requests")
__table_args__ = (
UniqueConstraint("requester_id", "addressee_id", name="uq_friendships_pair"),
)
class Follow(Base):
"""A one-way follow relationship: ``follower_id`` follows ``followed_id``."""
__tablename__ = "follows"
id = Column(Integer, primary_key=True, index=True)
follower_id = Column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
followed_id = Column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=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)
follower = relationship("User", foreign_keys=[follower_id], back_populates="following")
followed = relationship("User", foreign_keys=[followed_id], back_populates="followers")
__table_args__ = (
UniqueConstraint("follower_id", "followed_id", name="uq_follows_pair"),
)
class Block(Base):
"""A hard deny: ``blocker_id`` has blocked ``blocked_id``."""
__tablename__ = "blocks"
id = Column(Integer, primary_key=True, index=True)
blocker_id = Column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
blocked_id = Column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=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)
blocker = relationship("User", foreign_keys=[blocker_id], back_populates="blocks_initiated")
blocked = relationship("User", foreign_keys=[blocked_id], back_populates="blocked_by")
__table_args__ = (
UniqueConstraint("blocker_id", "blocked_id", name="uq_blocks_pair"),
)
+43 -1
View File
@@ -1,7 +1,7 @@
from datetime import date from datetime import date
from typing import Optional from typing import Optional
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Date from sqlalchemy import Column, Integer, String, Boolean, DateTime, Date, Text
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from sqlalchemy.sql import func from sqlalchemy.sql import func
@@ -20,6 +20,10 @@ class User(Base):
created_at = Column(DateTime(timezone=True), server_default=func.now()) created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now()) updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# Presence / status (backend/app/services/presence.py, app/routers/presence.py)
last_seen_at = Column(DateTime(timezone=True), nullable=True)
status_message = Column(Text, nullable=True)
sessions = relationship("Session", back_populates="user", cascade="all, delete-orphan") sessions = relationship("Session", back_populates="user", cascade="all, delete-orphan")
refresh_tokens = relationship("RefreshToken", back_populates="user", cascade="all, delete-orphan") refresh_tokens = relationship("RefreshToken", back_populates="user", cascade="all, delete-orphan")
worlds = relationship("World", back_populates="owner") worlds = relationship("World", back_populates="owner")
@@ -31,6 +35,44 @@ class User(Base):
cascade="all, delete-orphan", cascade="all, delete-orphan",
) )
# Social graph (docs/planning/social.md US-1; app/models/social.py)
sent_friend_requests = relationship(
"Friendship",
foreign_keys="Friendship.requester_id",
back_populates="requester",
cascade="all, delete-orphan",
)
received_friend_requests = relationship(
"Friendship",
foreign_keys="Friendship.addressee_id",
back_populates="addressee",
cascade="all, delete-orphan",
)
following = relationship(
"Follow",
foreign_keys="Follow.follower_id",
back_populates="follower",
cascade="all, delete-orphan",
)
followers = relationship(
"Follow",
foreign_keys="Follow.followed_id",
back_populates="followed",
cascade="all, delete-orphan",
)
blocks_initiated = relationship(
"Block",
foreign_keys="Block.blocker_id",
back_populates="blocker",
cascade="all, delete-orphan",
)
blocked_by = relationship(
"Block",
foreign_keys="Block.blocked_id",
back_populates="blocked",
cascade="all, delete-orphan",
)
@property @property
def is_adult(self) -> bool: def is_adult(self) -> bool:
"""Return True if the user is 18 or older based on date_of_birth.""" """Return True if the user is 18 or older based on date_of_birth."""
+146
View File
@@ -0,0 +1,146 @@
"""Presence & status router.
Endpoints:
- POST /api/presence/heartbeat -- record a heartbeat (sets Redis TTL key)
- GET /api/presence/presence -- query presence for a list of user ids
- PATCH /api/presence/status -- update the authenticated user's status message
"""
from datetime import datetime, timezone
from typing import Annotated
from fastapi import APIRouter, HTTPException, status, Depends
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.dependencies import CurrentUser
from app.models.user import User
from app.services.presence import presence_store
router = APIRouter()
DbDep = Annotated[AsyncSession, Depends(get_db)]
# ──────────────────────────── Pydantic schemas ────────────────────────────────
_STATUS_MAX_LEN = 140
class HeartbeatRequest(BaseModel):
"""Empty body -- the caller's identity comes from the JWT."""
class PresenceUser(BaseModel):
user_id: int
is_online: bool
last_seen_at: datetime | None
status_message: str | None
class PresenceResponse(BaseModel):
users: list[PresenceUser]
class StatusUpdate(BaseModel):
status_message: str | None = Field(default=None, max_length=_STATUS_MAX_LEN)
@field_validator("status_message")
@classmethod
def strip_message(cls, v: str | None) -> str | None:
if v is not None:
v = v.strip()
return v
# ──────────────────────────────── Endpoints ───────────────────────────────────
@router.post("/heartbeat", response_model=dict)
async def heartbeat(
_req: HeartbeatRequest,
current_user: CurrentUser,
db: DbDep,
):
"""Record a heartbeat for the authenticated user.
Sets a Redis key with a TTL so the server can derive online/offline
status without trusting client claims. Also persists last_seen_at to
the database.
"""
# Update Redis TTL (presence heartbeat)
await presence_store.heartbeat(current_user.id)
# Persist last_seen_at to the database
now = datetime.now(timezone.utc)
result = await db.execute(select(User).where(User.id == current_user.id))
user = result.scalar_one_or_none()
if user:
user.last_seen_at = now
await db.commit()
return {"status": "ok", "user_id": current_user.id}
@router.get("/presence", response_model=PresenceResponse)
async def get_presence(
user_ids: str,
current_user: CurrentUser,
db: DbDep,
):
"""Return presence info for a comma-separated list of user ids.
Online/offline is derived from the Redis heartbeat TTL.
last_seen_at and status_message come from the database.
"""
ids = [int(uid.strip()) for uid in user_ids.split(",") if uid.strip()]
if not ids:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="user_ids must contain at least one integer",
)
# Check Redis for online status (single round trip via MGET).
online_ids = await presence_store.is_online_batch(ids)
# Fetch DB records for last_seen_at and status_message
result = await db.execute(select(User).where(User.id.in_(ids)))
users = {u.id: u for u in result.scalars().all()}
presence_users = []
for uid in ids:
user = users.get(uid)
if user is None:
continue
presence_users.append(
PresenceUser(
user_id=uid,
is_online=uid in online_ids,
last_seen_at=user.last_seen_at,
status_message=user.status_message,
)
)
return PresenceResponse(users=presence_users)
@router.patch("/status", response_model=dict)
async def update_status(
body: StatusUpdate,
current_user: CurrentUser,
db: DbDep,
):
"""Update the authenticated user's status message (length-limited)."""
result = await db.execute(select(User).where(User.id == current_user.id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
user.status_message = body.status_message
await db.commit()
return {"status": "ok", "status_message": body.status_message}
+14 -8
View File
@@ -127,10 +127,15 @@ class WorldObject:
@dataclass @dataclass
class ChunkBounds: class ChunkBounds:
"""Wire representation is min-corner + size (matching the Godot AABB """Stored here as min-corner + size (matching the Godot client's AABB
the reference encoder builds from), not min/max as world.md §3.1's type) for convenience, but the §3.6 BINARY wire format is min + max
logical-model prose describes -- both are fully recoverable from each (world.md §3.1, and the client's actual ChunkDataDecoder) -- fixed
other; this is just the on-the-wire convention.""" 2026-09-14 after a real client/server interop test caught this class's
original binary codec writing min+size on the wire, which the client
decoder (unaware of the "size" convention) read as a second min/max
pair, corrupting every chunk's bounds. The two representations are
fully recoverable from each other; `max` below is computed for exactly
this reason."""
min: tuple[float, float, float] = (0.0, 0.0, 0.0) min: tuple[float, float, float] = (0.0, 0.0, 0.0)
size: tuple[float, float, float] = (0.0, 0.0, 0.0) size: tuple[float, float, float] = (0.0, 0.0, 0.0)
@@ -237,9 +242,9 @@ def _encode_metadata(payload: ChunkPayload) -> bytes:
out += _w_f32(payload.bounds.min[0]) out += _w_f32(payload.bounds.min[0])
out += _w_f32(payload.bounds.min[1]) out += _w_f32(payload.bounds.min[1])
out += _w_f32(payload.bounds.min[2]) out += _w_f32(payload.bounds.min[2])
out += _w_f32(payload.bounds.size[0]) out += _w_f32(payload.bounds.max[0])
out += _w_f32(payload.bounds.size[1]) out += _w_f32(payload.bounds.max[1])
out += _w_f32(payload.bounds.size[2]) out += _w_f32(payload.bounds.max[2])
return bytes(out) return bytes(out)
@@ -250,7 +255,8 @@ def _decode_metadata(data: bytes, result: ChunkPayload) -> None:
coord = (_read_i32(data, off), _read_i32(data, off + 4), _read_i32(data, off + 8)) coord = (_read_i32(data, off), _read_i32(data, off + 4), _read_i32(data, off + 8))
off += 12 off += 12
min_pos = (_read_f32(data, off), _read_f32(data, off + 4), _read_f32(data, off + 8)) min_pos = (_read_f32(data, off), _read_f32(data, off + 4), _read_f32(data, off + 8))
size = (_read_f32(data, off + 12), _read_f32(data, off + 16), _read_f32(data, off + 20)) max_pos = (_read_f32(data, off + 12), _read_f32(data, off + 16), _read_f32(data, off + 20))
size = tuple(a - b for a, b in zip(max_pos, min_pos))
result.coord = coord result.coord = coord
result.bounds = ChunkBounds(min=min_pos, size=size) result.bounds = ChunkBounds(min=min_pos, size=size)
+145
View File
@@ -0,0 +1,145 @@
"""Redis-backed presence store.
Presence is determined by a heartbeat TTL key in Redis:
- heartbeat(user_id) sets a TTL key per user (default 60 s).
- is_online(user_id) / is_online_batch(user_ids) check whether the key(s)
exist.
status_message and last_seen_at are persisted in the ``users`` table by the
router (app/routers/presence.py); this store only owns the Redis side.
The Redis client is created lazily, on first actual use, rather than
requiring an explicit ``connect()`` call during app startup. This matters
because FastAPI's lifespan handler does not run under the plain
``TestClient(app)`` instantiation this codebase's tests use (no
``with`` block), so anything that only connected during lifespan startup
would never connect in tests. Lazy connection means the store works
identically whether the app was started for real or is being driven by
pytest.
The client is also cached per-*event loop* rather than globally: the same
plain ``TestClient(app)`` pattern can dispatch different requests onto
different event loops (see the NullPool comment on ``_override_db`` in
tests/conftest.py for the analogous asyncpg problem), and an
``aioredis.Redis`` connection, like an asyncpg connection, breaks with
"Event loop is closed" if reused across loops. In the real running server
there is exactly one event loop for the process lifetime, so this
degrades to a single cached, pooled connection there.
"""
from __future__ import annotations
import asyncio
import logging
import redis.asyncio as aioredis
from app.config import settings
logger = logging.getLogger(__name__)
# Key prefix for heartbeat entries.
_PRESENCE_KEY_PREFIX = "presence:heartbeat:"
# Default heartbeat TTL in seconds (1 minute).
# Clients should heartbeat at least once per interval.
_DEFAULT_HEARTBEAT_TTL = 60
class PresenceStore:
"""Redis-backed presence store with async, lazily-connected operations."""
def __init__(self, redis_url: str | None = None, heartbeat_ttl: int = _DEFAULT_HEARTBEAT_TTL):
# redis_url is resolved from settings at first use (not at
# construction time) so tests that override settings.REDIS_URL
# before the first call still take effect -- this module is
# imported (and the module-level singleton below constructed)
# well before any such override runs.
self._redis_url = redis_url
self._heartbeat_ttl = heartbeat_ttl
# Keyed by id(running loop) -- see the module docstring.
self._clients: dict[int, aioredis.Redis] = {}
self._connect_lock = asyncio.Lock()
# ------------------------------------------------------------------
# Lifecycle
# ------------------------------------------------------------------
async def _ensure_client(self) -> aioredis.Redis:
"""Return a connected client for the current event loop, connecting
on first use per loop."""
loop_key = id(asyncio.get_running_loop())
client = self._clients.get(loop_key)
if client is None:
async with self._connect_lock:
client = self._clients.get(loop_key)
if client is None:
url = self._redis_url or settings.REDIS_URL
client = aioredis.from_url(
url,
encoding="utf-8",
decode_responses=True,
)
try:
await client.ping()
except Exception:
logger.warning("Redis ping failed for %s", url)
raise
self._clients[loop_key] = client
return client
async def close(self) -> None:
"""Close the Redis connection for the current event loop, if one was
opened."""
loop_key = id(asyncio.get_running_loop())
client = self._clients.pop(loop_key, None)
if client is not None:
await client.aclose()
# ------------------------------------------------------------------
# Heartbeat
# ------------------------------------------------------------------
async def heartbeat(self, user_id: int) -> None:
"""Record a heartbeat for *user_id*.
Sets a TTL key so that the user is considered online as long as
heartbeats arrive at least once per heartbeat_ttl seconds.
"""
client = await self._ensure_client()
await client.set(self._heartbeat_key(user_id), "1", ex=self._heartbeat_ttl)
# ------------------------------------------------------------------
# Online checks
# ------------------------------------------------------------------
async def is_online(self, user_id: int) -> bool:
"""Return True if the user has a valid heartbeat key."""
client = await self._ensure_client()
exists = await client.exists(self._heartbeat_key(user_id))
return bool(exists)
async def is_online_batch(self, user_ids: list[int]) -> set[int]:
"""Return the subset of user_ids that currently have a valid
heartbeat key. Uses MGET (one round trip) rather than one EXISTS
per user.
"""
if not user_ids:
return set()
client = await self._ensure_client()
keys = [self._heartbeat_key(uid) for uid in user_ids]
values = await client.mget(keys)
return {uid for uid, v in zip(user_ids, values) if v is not None}
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
@staticmethod
def _heartbeat_key(user_id: int) -> str:
return f"{_PRESENCE_KEY_PREFIX}{user_id}"
# Singleton instance shared by the router, so all requests share the same
# connection pool.
presence_store = PresenceStore()
+76 -37
View File
@@ -1,15 +1,18 @@
"""Shared pytest fixtures + hermetic-DB bootstrap. """Shared pytest fixtures + hermetic-DB/Redis bootstrap.
The integration tests hit a real Postgres (Postgres-specific types: JSONB, The integration tests hit a real Postgres (Postgres-specific types: JSONB,
native ENUMs). To make ``cd backend && pytest`` work from a clean checkout native ENUMs) and, for presence tests, a real Redis (TTL semantics aren't
with no manual setup, ``pytest_configure`` below brings up an ephemeral worth faking). To make ``cd backend && pytest`` work from a clean checkout
Postgres via docker compose and runs the migrations IF ``DATABASE_URL`` is with no manual setup, ``pytest_configure`` below brings up ephemeral
not already pointing at a reachable database. When it is (dev loop, or the Postgres/Redis containers via docker compose and runs the migrations IF
checkpoint runner which stands the DB up itself), that path is skipped and ``DATABASE_URL``/``REDIS_URL`` aren't already pointing at something
the existing database is reused. reachable. When they are (dev loop, or the checkpoint runner which stands
these up itself), that path is skipped and the existing services are
reused.
If Docker isn't available and no DB is reachable, the whole DB-backed suite If Docker isn't available and nothing is reachable, the whole
is skipped with a clear message rather than erroring out 60+ times. DB/Redis-backed suite is skipped with a clear message rather than erroring
out dozens of times.
""" """
import asyncio import asyncio
@@ -38,14 +41,19 @@ TEST_TABLES = (
# `worlds` the way world_rooms/world_chunks would be. List all three # `worlds` the way world_rooms/world_chunks would be. List all three
# explicitly rather than rely on cascade for some of them. # explicitly rather than rely on cascade for some of them.
"world_chunk_revisions", "world_chunks", "world_rooms", "world_chunk_revisions", "world_chunks", "world_rooms",
"worlds", "sessions", "refresh_tokens", "users", "worlds",
# Social graph (app/models/social.py) -- all three FK users with
# ON DELETE CASCADE, but list explicitly for clarity/consistency.
"friendships", "follows", "blocks",
"sessions", "refresh_tokens", "users",
) )
BACKEND_DIR = Path(__file__).resolve().parents[1] BACKEND_DIR = Path(__file__).resolve().parents[1]
_COMPOSE_PROJECT = "agp-pytest" _COMPOSE_PROJECT = "agp-pytest"
_EPHEMERAL_PG_PORT = 25432 _EPHEMERAL_PG_PORT = 25432
_EPHEMERAL_REDIS_PORT = 26379
# Set by _ensure_database() when it starts a container it owns; torn down in # Set by pytest_configure() when it starts container(s) it owns; torn down in
# pytest_unconfigure. # pytest_unconfigure.
_started_container = False _started_container = False
_db_unavailable_reason: str | None = None _db_unavailable_reason: str | None = None
@@ -64,6 +72,11 @@ def _db_reachable(url: str) -> bool:
return _port_open(parts.hostname or "localhost", parts.port or 5432) return _port_open(parts.hostname or "localhost", parts.port or 5432)
def _redis_reachable(url: str) -> bool:
parts = urlsplit(url)
return _port_open(parts.hostname or "localhost", parts.port or 6379)
def _run(cmd: list[str], **kw) -> subprocess.CompletedProcess: def _run(cmd: list[str], **kw) -> subprocess.CompletedProcess:
return subprocess.run(cmd, cwd=BACKEND_DIR, capture_output=True, text=True, **kw) return subprocess.run(cmd, cwd=BACKEND_DIR, capture_output=True, text=True, **kw)
@@ -71,14 +84,17 @@ def _run(cmd: list[str], **kw) -> subprocess.CompletedProcess:
def pytest_configure(config): def pytest_configure(config):
global _started_container, _db_unavailable_reason global _started_container, _db_unavailable_reason
if _db_reachable(settings.DATABASE_URL): need_db = not _db_reachable(settings.DATABASE_URL)
return # dev loop / checkpoint runner already provided one need_redis = not _redis_reachable(settings.REDIS_URL)
if not need_db and not need_redis:
return # dev loop / checkpoint runner already provided both
if not _run(["docker", "--version"]).returncode == 0: if not _run(["docker", "--version"]).returncode == 0:
_db_unavailable_reason = ( _db_unavailable_reason = (
"no reachable DATABASE_URL and docker is unavailable -- run the " "no reachable DATABASE_URL/REDIS_URL and docker is unavailable -- run "
"checkpoint suite (scripts/run_checkpoint_tests.py) or start Postgres " "the checkpoint suite (scripts/run_checkpoint_tests.py) or start "
"manually" "Postgres/Redis manually"
) )
return return
@@ -86,33 +102,55 @@ def pytest_configure(config):
env["COMPOSE_PROJECT_NAME"] = _COMPOSE_PROJECT env["COMPOSE_PROJECT_NAME"] = _COMPOSE_PROJECT
env["POSTGRES_HOST_PORT"] = str(_EPHEMERAL_PG_PORT) env["POSTGRES_HOST_PORT"] = str(_EPHEMERAL_PG_PORT)
env["POSTGRES_PORT"] = str(_EPHEMERAL_PG_PORT) env["POSTGRES_PORT"] = str(_EPHEMERAL_PG_PORT)
up = _run(["docker", "compose", "up", "-d", "postgres"], env=env) env["REDIS_HOST_PORT"] = str(_EPHEMERAL_REDIS_PORT)
services = [s for s, need in (("postgres", need_db), ("redis", need_redis)) if need]
up = _run(["docker", "compose", "up", "-d", *services], env=env)
if up.returncode != 0: if up.returncode != 0:
_db_unavailable_reason = f"docker compose up postgres failed:\n{up.stderr[-800:]}" _db_unavailable_reason = f"docker compose up {' '.join(services)} failed:\n{up.stderr[-800:]}"
return return
_started_container = True _started_container = True
for _ in range(30): if need_db:
r = _run( for _ in range(30):
["docker", "compose", "exec", "-T", "postgres", r = _run(
"pg_isready", "-U", "agp_user", "-d", "agp_db"], ["docker", "compose", "exec", "-T", "postgres",
env=env, "pg_isready", "-U", "agp_user", "-d", "agp_db"],
) env=env,
if r.returncode == 0: )
break if r.returncode == 0:
time.sleep(1) break
else: time.sleep(1)
_db_unavailable_reason = "ephemeral Postgres never became ready" else:
return _db_unavailable_reason = "ephemeral Postgres never became ready"
return
url = f"postgresql+asyncpg://agp_user:agp_password@localhost:{_EPHEMERAL_PG_PORT}/agp_db" url = f"postgresql+asyncpg://agp_user:agp_password@localhost:{_EPHEMERAL_PG_PORT}/agp_db"
os.environ["DATABASE_URL"] = url os.environ["DATABASE_URL"] = url
settings.DATABASE_URL = url settings.DATABASE_URL = url
mig = _run([sys.executable, "-m", "alembic", "upgrade", "head"], mig = _run([sys.executable, "-m", "alembic", "upgrade", "head"],
env={**os.environ, "DATABASE_URL": url}) env={**os.environ, "DATABASE_URL": url})
if mig.returncode != 0: if mig.returncode != 0:
_db_unavailable_reason = f"alembic upgrade head failed:\n{mig.stderr[-800:]}" _db_unavailable_reason = f"alembic upgrade head failed:\n{mig.stderr[-800:]}"
return
if need_redis:
for _ in range(30):
r = _run(
["docker", "compose", "exec", "-T", "redis", "redis-cli", "ping"],
env=env,
)
if r.returncode == 0 and "PONG" in r.stdout:
break
time.sleep(1)
else:
_db_unavailable_reason = "ephemeral Redis never became ready"
return
redis_url = f"redis://localhost:{_EPHEMERAL_REDIS_PORT}/0"
os.environ["REDIS_URL"] = redis_url
settings.REDIS_URL = redis_url
def pytest_unconfigure(config): def pytest_unconfigure(config):
@@ -122,6 +160,7 @@ def pytest_unconfigure(config):
env["COMPOSE_PROJECT_NAME"] = _COMPOSE_PROJECT env["COMPOSE_PROJECT_NAME"] = _COMPOSE_PROJECT
env["POSTGRES_HOST_PORT"] = str(_EPHEMERAL_PG_PORT) env["POSTGRES_HOST_PORT"] = str(_EPHEMERAL_PG_PORT)
env["POSTGRES_PORT"] = str(_EPHEMERAL_PG_PORT) env["POSTGRES_PORT"] = str(_EPHEMERAL_PG_PORT)
env["REDIS_HOST_PORT"] = str(_EPHEMERAL_REDIS_PORT)
_run(["docker", "compose", "down", "-v"], env=env) _run(["docker", "compose", "down", "-v"], env=env)
Binary file not shown.
Binary file not shown.
+221
View File
@@ -0,0 +1,221 @@
"""Integration tests for the presence feature:
- POST /api/presence/heartbeat
- GET /api/presence/presence
- PATCH /api/presence/status
Hits the real app against the configured Postgres *and* a real Redis
(conftest.py brings up an ephemeral instance of each when neither is
already reachable) -- presence is fundamentally "does a TTL key exist in
Redis", which isn't worth faking with a mock.
Also covers the regression fixed in 47ab492 ("Fix StatusUpdate field
annotation and is_online batch-call mismatch"): the router used to call
``PresenceStore.is_online(ids)`` with a *list* of ids and then evaluate
``uid in online_ids`` against the returned bool, which raised a TypeError
on the first real multi-id batch call, and ``StatusUpdate.status_message``
used the invalid annotation ``str | Field(...)``. Both are exercised below.
"""
import asyncio
import pytest
from fastapi.testclient import TestClient
from app.main import app
from app.services.presence import PresenceStore
def _register(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
token = resp.json()["access_token"]
headers = {"Authorization": f"Bearer {token}"}
me = client.get("/api/users/me", headers=headers)
assert me.status_code == 200, me.text
return {"headers": headers, "id": me.json()["id"]}
class TestHeartbeat:
def test_heartbeat_sets_online(self):
client = TestClient(app)
user = _register(client, "presence_hb1")
resp = client.post("/api/presence/heartbeat", json={}, headers=user["headers"])
assert resp.status_code == 200, resp.text
assert resp.json() == {"status": "ok", "user_id": user["id"]}
presence = client.get(
"/api/presence/presence",
params={"user_ids": str(user["id"])},
headers=user["headers"],
)
assert presence.status_code == 200, presence.text
body = presence.json()["users"]
assert len(body) == 1
assert body[0]["user_id"] == user["id"]
assert body[0]["is_online"] is True
assert body[0]["last_seen_at"] is not None
def test_requires_auth(self):
client = TestClient(app)
resp = client.post("/api/presence/heartbeat", json={})
assert resp.status_code == 401
def test_never_seen_user_is_offline_with_null_last_seen(self):
client = TestClient(app)
user = _register(client, "presence_never_hb")
presence = client.get(
"/api/presence/presence",
params={"user_ids": str(user["id"])},
headers=user["headers"],
)
assert presence.status_code == 200
body = presence.json()["users"][0]
assert body["is_online"] is False
assert body["last_seen_at"] is None
class TestBatchPresence:
"""Covers the batch-call path fixed in 47ab492 (is_online_batch via MGET)."""
def test_batch_query_mixes_online_offline_and_unknown_ids(self):
client = TestClient(app)
online_user = _register(client, "presence_batch_online")
offline_user = _register(client, "presence_batch_offline")
client.post("/api/presence/heartbeat", json={}, headers=online_user["headers"])
unknown_id = max(online_user["id"], offline_user["id"]) + 999_999
ids = f"{online_user['id']},{offline_user['id']},{unknown_id}"
resp = client.get(
"/api/presence/presence",
params={"user_ids": ids},
headers=online_user["headers"],
)
assert resp.status_code == 200, resp.text
by_id = {u["user_id"]: u for u in resp.json()["users"]}
# Unknown id is silently dropped (not in the DB), not a 500 or 404.
assert unknown_id not in by_id
assert by_id[online_user["id"]]["is_online"] is True
assert by_id[offline_user["id"]]["is_online"] is False
def test_empty_user_ids_is_a_400(self):
client = TestClient(app)
user = _register(client, "presence_empty_ids")
resp = client.get(
"/api/presence/presence",
params={"user_ids": ""},
headers=user["headers"],
)
assert resp.status_code == 400
class TestStatusMessage:
def test_update_and_read_back_status_message(self):
client = TestClient(app)
user = _register(client, "presence_status1")
resp = client.patch(
"/api/presence/status",
json={"status_message": " brb, making coffee "},
headers=user["headers"],
)
assert resp.status_code == 200, resp.text
# The router strips whitespace (StatusUpdate.strip_message).
assert resp.json()["status_message"] == "brb, making coffee"
presence = client.get(
"/api/presence/presence",
params={"user_ids": str(user["id"])},
headers=user["headers"],
)
assert presence.json()["users"][0]["status_message"] == "brb, making coffee"
def test_status_message_over_max_length_is_rejected(self):
client = TestClient(app)
user = _register(client, "presence_status_toolong")
resp = client.patch(
"/api/presence/status",
json={"status_message": "x" * 141},
headers=user["headers"],
)
# StatusUpdate.status_message: str | None = Field(..., max_length=140)
# This is exactly the annotation 47ab492 fixed (it used to be the
# invalid `str | Field(...)`, which breaks model construction
# entirely rather than validating length).
assert resp.status_code == 422
def test_status_message_at_max_length_is_accepted(self):
client = TestClient(app)
user = _register(client, "presence_status_maxlen")
resp = client.patch(
"/api/presence/status",
json={"status_message": "x" * 140},
headers=user["headers"],
)
assert resp.status_code == 200, resp.text
def test_null_status_message_clears_it(self):
client = TestClient(app)
user = _register(client, "presence_status_clear")
client.patch("/api/presence/status", json={"status_message": "hi"}, headers=user["headers"])
resp = client.patch("/api/presence/status", json={"status_message": None}, headers=user["headers"])
assert resp.status_code == 200
assert resp.json()["status_message"] is None
class TestPresenceStoreTTL:
"""Exercises the real Redis TTL semantics directly against the service,
with a short heartbeat_ttl so the test doesn't need to sleep for the
real 60s default used in production. This is the cheap, real proxy for
"TTL expiry means offline" the task asked for -- same code path
(real Redis SET ... EX / EXISTS / MGET), just a much shorter window.
"""
async def test_ttl_expiry_flips_online_to_offline(self):
store = PresenceStore(heartbeat_ttl=1)
try:
uid = 987654321 # doesn't need to exist in Postgres; Redis-only check
assert await store.is_online(uid) is False
await store.heartbeat(uid)
assert await store.is_online(uid) is True
await asyncio.sleep(1.5)
assert await store.is_online(uid) is False
finally:
await store.close()
async def test_is_online_batch_matches_is_online_semantics(self):
"""Regression coverage for 47ab492: is_online_batch must return a
set of ids usable with `in`, not a bool."""
store = PresenceStore(heartbeat_ttl=30)
try:
online_uid, offline_uid = 111222333, 444555666
await store.heartbeat(online_uid)
result = await store.is_online_batch([online_uid, offline_uid])
assert isinstance(result, set)
assert online_uid in result
assert offline_uid not in result
finally:
await store.close()
async def test_is_online_batch_empty_list(self):
store = PresenceStore()
try:
assert await store.is_online_batch([]) == set()
finally:
await store.close()
+240
View File
@@ -0,0 +1,240 @@
"""Model-level tests for the social graph: friendships, follows, blocks.
app/models/social.py / alembic/versions/0007_add_social_graph_tables.py
(docs/planning/social.md US-1). This is the models+migration task only --
there are no API endpoints yet (a sibling task owns the router), so these
tests exercise the ORM models and the real Postgres constraints directly
rather than going through HTTP.
"""
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import delete, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
from app.config import settings
from app.main import app
from app.models.social import Friendship, Follow, Block, FriendshipStatus
from app.models.user import User
def _register(client: TestClient, username: str) -> int:
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
headers = {"Authorization": f"Bearer {resp.json()['access_token']}"}
me = client.get("/api/users/me", headers=headers)
assert me.status_code == 200, me.text
return me.json()["id"]
def _session_factory():
engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool)
return engine, async_sessionmaker(engine, expire_on_commit=False)
class TestFriendshipRequestAccept:
async def test_request_defaults_to_pending(self):
client = TestClient(app)
a = _register(client, "sg_fr_a1")
b = _register(client, "sg_fr_b1")
engine, factory = _session_factory()
try:
async with factory() as session:
fr = Friendship(requester_id=a, addressee_id=b)
session.add(fr)
await session.commit()
await session.refresh(fr)
assert fr.status == FriendshipStatus.PENDING
assert fr.requester_id == a
assert fr.addressee_id == b
finally:
await engine.dispose()
async def test_accept_updates_status(self):
client = TestClient(app)
a = _register(client, "sg_fr_a2")
b = _register(client, "sg_fr_b2")
engine, factory = _session_factory()
try:
async with factory() as session:
fr = Friendship(requester_id=a, addressee_id=b)
session.add(fr)
await session.commit()
fr_id = fr.id
async with factory() as session:
fr = await session.get(Friendship, fr_id)
fr.status = FriendshipStatus.ACCEPTED
await session.commit()
async with factory() as session:
fr = await session.get(Friendship, fr_id)
assert fr.status == FriendshipStatus.ACCEPTED
finally:
await engine.dispose()
class TestUniquePairConstraints:
async def test_duplicate_friendship_pair_rejected(self):
client = TestClient(app)
a = _register(client, "sg_uq_fr_a")
b = _register(client, "sg_uq_fr_b")
engine, factory = _session_factory()
try:
async with factory() as session:
session.add(Friendship(requester_id=a, addressee_id=b))
await session.commit()
async with factory() as session:
session.add(Friendship(requester_id=a, addressee_id=b))
with pytest.raises(IntegrityError):
await session.commit()
finally:
await engine.dispose()
async def test_duplicate_follow_pair_rejected(self):
client = TestClient(app)
a = _register(client, "sg_uq_fw_a")
b = _register(client, "sg_uq_fw_b")
engine, factory = _session_factory()
try:
async with factory() as session:
session.add(Follow(follower_id=a, followed_id=b))
await session.commit()
async with factory() as session:
session.add(Follow(follower_id=a, followed_id=b))
with pytest.raises(IntegrityError):
await session.commit()
finally:
await engine.dispose()
async def test_duplicate_block_pair_rejected(self):
client = TestClient(app)
a = _register(client, "sg_uq_bl_a")
b = _register(client, "sg_uq_bl_b")
engine, factory = _session_factory()
try:
async with factory() as session:
session.add(Block(blocker_id=a, blocked_id=b))
await session.commit()
async with factory() as session:
session.add(Block(blocker_id=a, blocked_id=b))
with pytest.raises(IntegrityError):
await session.commit()
finally:
await engine.dispose()
class TestFollow:
async def test_create_follow(self):
client = TestClient(app)
a = _register(client, "sg_follow_a")
b = _register(client, "sg_follow_b")
engine, factory = _session_factory()
try:
async with factory() as session:
session.add(Follow(follower_id=a, followed_id=b))
await session.commit()
async with factory() as session:
rows = (await session.execute(
select(Follow).where(Follow.follower_id == a, Follow.followed_id == b)
)).scalars().all()
assert len(rows) == 1
finally:
await engine.dispose()
class TestBlock:
async def test_create_block(self):
client = TestClient(app)
a = _register(client, "sg_block_a")
b = _register(client, "sg_block_b")
engine, factory = _session_factory()
try:
async with factory() as session:
session.add(Block(blocker_id=a, blocked_id=b))
await session.commit()
async with factory() as session:
rows = (await session.execute(
select(Block).where(Block.blocker_id == a, Block.blocked_id == b)
)).scalars().all()
assert len(rows) == 1
finally:
await engine.dispose()
class TestCascadeDelete:
async def test_deleting_user_removes_rows_in_all_three_tables(self):
"""Deleting a user removes friendships/follows/blocks referencing them
in *either* FK column, via the real DB ON DELETE CASCADE (deleted with
a Core statement, bypassing any ORM-level cascade, to prove the
database constraint itself does the job)."""
client = TestClient(app)
a = _register(client, "sg_cascade_a")
b = _register(client, "sg_cascade_b")
c = _register(client, "sg_cascade_c")
engine, factory = _session_factory()
try:
async with factory() as session:
# a as requester/follower/blocker, and also as addressee (c -> a)
session.add(Friendship(requester_id=a, addressee_id=b))
session.add(Friendship(requester_id=c, addressee_id=a))
session.add(Follow(follower_id=a, followed_id=b))
session.add(Follow(follower_id=c, followed_id=a))
session.add(Block(blocker_id=a, blocked_id=b))
session.add(Block(blocker_id=c, blocked_id=a))
await session.commit()
async with factory() as session:
await session.execute(delete(User).where(User.id == a))
await session.commit()
async with factory() as session:
friendships = (await session.execute(
select(Friendship).where(
(Friendship.requester_id == a) | (Friendship.addressee_id == a)
)
)).scalars().all()
follows = (await session.execute(
select(Follow).where(
(Follow.follower_id == a) | (Follow.followed_id == a)
)
)).scalars().all()
blocks = (await session.execute(
select(Block).where(
(Block.blocker_id == a) | (Block.blocked_id == a)
)
)).scalars().all()
assert friendships == []
assert follows == []
assert blocks == []
# Unrelated rows (b, c only) survive.
remaining_users = (await session.execute(
select(User.id).where(User.id.in_([b, c]))
)).scalars().all()
assert set(remaining_users) == {b, c}
finally:
await engine.dispose()
+10
View File
@@ -45,6 +45,16 @@ move_down={
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":83,"key_label":0,"unicode":115,"location":0,"echo":false,"script":null) "events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":83,"key_label":0,"unicode":115,"location":0,"echo":false,"script":null)
] ]
} }
interact={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":69,"key_label":0,"unicode":101,"location":0,"echo":false,"script":null)
]
}
toggle_build_mode={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194333,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
[physics] [physics]
+5 -1
View File
@@ -1,6 +1,7 @@
[gd_scene load_steps=4 format=3 uid="uid://player_scene_001"] [gd_scene load_steps=5 format=3 uid="uid://player_scene_001"]
[ext_resource type="Script" path="res://scripts/player.gd" id="1_player"] [ext_resource type="Script" path="res://scripts/player.gd" id="1_player"]
[ext_resource type="Script" path="res://scripts/scripting/player_inventory.gd" id="2_inventory"]
[sub_resource type="CapsuleMesh" id="CapsuleMesh1"] [sub_resource type="CapsuleMesh" id="CapsuleMesh1"]
radius = 0.35 radius = 0.35
@@ -30,3 +31,6 @@ margin = 0.2
[node name="Camera3D" type="Camera3D" parent="CameraPivot/SpringArm3D"] [node name="Camera3D" type="Camera3D" parent="CameraPivot/SpringArm3D"]
current = true current = true
[node name="Inventory" type="Node" parent="."]
script = ExtResource("2_inventory")
+21 -1
View File
@@ -1,4 +1,6 @@
[gd_scene load_steps=1 format=3 uid="uid://c49a36fc7db_test_level"] [gd_scene load_steps=2 format=3 uid="uid://c49a36fc7db_test_level"]
[ext_resource type="Script" path="res://scripts/scripting/script_interpreter.gd" id="1_scripted_chest"]
[sub_resource type="BoxShape3D" id="BoxShape3D_ground"] [sub_resource type="BoxShape3D" id="BoxShape3D_ground"]
size = Vector3(40, 1, 40) size = Vector3(40, 1, 40)
@@ -44,6 +46,12 @@ radius = 0.5
subdivisions_ring = 8 subdivisions_ring = 8
subdivisions_height = 8 subdivisions_height = 8
[sub_resource type="BoxShape3D" id="BoxShape3D_chest"]
size = Vector3(1, 0.8, 0.6)
[sub_resource type="BoxMesh" id="BoxMesh_chest"]
size = Vector3(1, 0.8, 0.6)
[sub_resource type="Environment" id="WorldEnvironment_env"] [sub_resource type="Environment" id="WorldEnvironment_env"]
background_mode = 1 background_mode = 1
background_color = Color(0.45, 0.62, 0.82, 1) background_color = Color(0.45, 0.62, 0.82, 1)
@@ -109,6 +117,18 @@ shape = SubResource("SphereShape3D_prop5")
[node name="MeshInstance3D" type="MeshInstance3D" parent="Props/Prop5_SmallSphere"] [node name="MeshInstance3D" type="MeshInstance3D" parent="Props/Prop5_SmallSphere"]
mesh = SubResource("SphereMesh_prop5") mesh = SubResource("SphereMesh_prop5")
[node name="ScriptedChest" type="StaticBody3D" parent="."]
position = Vector3(0, 1.0, 1.5)
script = ExtResource("1_scripted_chest")
behavior_path = "res://assets/scripting/behaviors/demo_chest.json"
interact_radius = 2.5
[node name="CollisionShape3D" type="CollisionShape3D" parent="ScriptedChest"]
shape = SubResource("BoxShape3D_chest")
[node name="MeshInstance3D" type="MeshInstance3D" parent="ScriptedChest"]
mesh = SubResource("BoxMesh_chest")
[node name="Sun" type="DirectionalLight3D" parent="."] [node name="Sun" type="DirectionalLight3D" parent="."]
rotation = Vector3(-0.95, -0.5, 0) rotation = Vector3(-0.95, -0.5, 0)
light_energy = 1.3 light_energy = 1.3
+10
View File
@@ -69,6 +69,16 @@ func clear_token() -> void:
var _base_url: String = "http://127.0.0.1:8000" var _base_url: String = "http://127.0.0.1:8000"
## Read-only access to the configured backend base URL (no trailing slash).
## Lets other client-side code that needs to build its own request outside
## the [method request] primitive -- e.g. [HttpChunkSource], which needs raw
## response bytes and custom headers `request()`'s JSON-only contract can't
## give it -- target the same backend without duplicating the env var /
## ProjectSettings resolution logic in `_ready()`.
var base_url: String:
get:
return _base_url
## Request timeout in seconds. 0 disables the timeout. ## Request timeout in seconds. 0 disables the timeout.
var timeout_seconds: float = 10.0 var timeout_seconds: float = 10.0
+53 -9
View File
@@ -100,6 +100,17 @@ class TileLayerData:
return tiles[idx] return tiles[idx]
return 0 return 0
## Editor mutator: writes `tile_id` at (x, y, z). Silently no-ops out of
## bounds, mirroring get_tile's "out of bounds is empty" contract rather
## than erroring -- callers that need bounds feedback should check first
## (see ChunkEditorModel.is_tile_in_bounds in scripts/editor/).
func set_tile(x: int, y: int, z: int, tile_id: int) -> void:
if x < 0 or x >= width or y < 0 or y >= height or z < 0 or z >= depth:
return
var idx := (z * height + y) * width + x
if idx < tiles.size():
tiles[idx] = tile_id
func get_tile_def(tile_id: int) -> TileDef: func get_tile_def(tile_id: int) -> TileDef:
for def in palette: for def in palette:
if def.id == tile_id: if def.id == tile_id:
@@ -404,7 +415,15 @@ static func _parse_object_section(data: PackedByteArray, result: ChunkPayload) -
offset += 2 offset += 2
var props_str := data.slice(offset, offset + props_len).get_string_from_utf8() var props_str := data.slice(offset, offset + props_len).get_string_from_utf8()
offset += props_len offset += props_len
obj.properties = JSON.parse_string(props_str) or {} # `or` in GDScript is a boolean operator, not Python/JS-style
# short-circuit-return -- `parsed or {}` returns a bool (true for any
# non-empty Dictionary), not `parsed` itself. Assigning that bool to
# `properties` (typed Dictionary) crashes at runtime for any object
# with real property data -- verified: a real fixture with
# {"locked": false, ...} threw "Invalid assignment of property or key
# 'properties' with value of type 'bool'".
var parsed_props: Variant = JSON.parse_string(props_str)
obj.properties = parsed_props if parsed_props is Dictionary else {}
# flags (ContentFlags) # flags (ContentFlags)
obj.flags.violence = data[offset] obj.flags.violence = data[offset]
@@ -484,13 +503,33 @@ static func _build_metadata_section(payload: ChunkPayload) -> PackedByteArray:
data.append_array(_write_int32(payload.coord.x)) data.append_array(_write_int32(payload.coord.x))
data.append_array(_write_int32(payload.coord.y)) data.append_array(_write_int32(payload.coord.y))
data.append_array(_write_int32(payload.coord.z)) data.append_array(_write_int32(payload.coord.z))
# Wire format is min + max (world.md §3, and _parse_metadata_section
# below reads the 2nd triple as max_pos and computes size = max - min)
# -- writing raw `.size` here instead of `.position + .size` was a
# latent bug that only escaped every test because AABB(Vector3.ZERO, ...)
# happens to make size == max when position is exactly zero. Any chunk
# at a real, nonzero world position (i.e. every real chunk except the
# one at the origin) would round-trip through encode_binary/decode_binary
# with a corrupted size. Same bug class as the backend's min+size/
# min+max mismatch fixed in chunk_codec.py (commit eb39e43).
var bounds_max: Vector3 = payload.bounds.position + payload.bounds.size
data.append_array(_write_float32(payload.bounds.position.x)) data.append_array(_write_float32(payload.bounds.position.x))
data.append_array(_write_float32(payload.bounds.position.y)) data.append_array(_write_float32(payload.bounds.position.y))
data.append_array(_write_float32(payload.bounds.position.z)) data.append_array(_write_float32(payload.bounds.position.z))
data.append_array(_write_float32(payload.bounds.size.x)) data.append_array(_write_float32(bounds_max.x))
data.append_array(_write_float32(payload.bounds.size.y)) data.append_array(_write_float32(bounds_max.y))
data.append_array(_write_float32(payload.bounds.size.z)) data.append_array(_write_float32(bounds_max.z))
return data # Every other section builder wraps its body with `_wrap_section`
# (section_id + uint32 length prefix) -- this one didn't, so
# `encode_binary()` spliced the raw metadata bytes directly into the
# section stream with no header at all. The decoder then read the
# metadata's own first bytes (a string length prefix) as a bogus
# section_id/section_len pair, producing a garbage length that
# overran the buffer ("truncated section body") and silently found
# zero real sections. Never caught before because nothing in this
# codebase called encode_binary() until
# scripts/smoke_test_http_chunk_source.gd.
return _wrap_section(data, SECTION_META)
static func _build_tile_section(layer: TileLayerData) -> PackedByteArray: static func _build_tile_section(layer: TileLayerData) -> PackedByteArray:
@@ -607,8 +646,11 @@ static func _read_int64(data: PackedByteArray, offset: int) -> int:
return hi * 0x100000000 + lo return hi * 0x100000000 + lo
static func _read_float32(data: PackedByteArray, offset: int) -> float: static func _read_float32(data: PackedByteArray, offset: int) -> float:
var bits := _read_uint32(data, offset) # `int as float` is a NUMERIC cast, not an IEEE-754 bit reinterpretation --
return bits as float # GDScript float is 64-bit; bits repr works for IEEE 754 # it was silently corrupting every non-integer value (verified: bits for
# 1.5f decoded as 1069547520.0). decode_float() does a real bitcast and
# matches this format's little-endian byte order (§3.6).
return data.decode_float(offset)
static func _read_hex_string(data: PackedByteArray, offset: int, length: int) -> String: static func _read_hex_string(data: PackedByteArray, offset: int, length: int) -> String:
var hex_chars := "0123456789abcdef" var hex_chars := "0123456789abcdef"
@@ -637,8 +679,10 @@ static func _write_int64(val: int) -> PackedByteArray:
return _write_uint32(lo) + _write_uint32(hi) return _write_uint32(lo) + _write_uint32(hi)
static func _write_float32(val: float) -> PackedByteArray: static func _write_float32(val: float) -> PackedByteArray:
var bits := val as int var out := PackedByteArray()
return _write_uint32(bits) out.resize(4)
out.encode_float(0, val)
return out
static func _write_string(s: String) -> PackedByteArray: static func _write_string(s: String) -> PackedByteArray:
var bytes := s.to_utf8_buffer() var bytes := s.to_utf8_buffer()
+12 -2
View File
@@ -241,7 +241,11 @@ func _instantiate_chunk_scene_with_data(coord: Vector3i, data: ChunkDataDecoder.
chunk_scene.coord = coord chunk_scene.coord = coord
chunk_scene.key = data.key chunk_scene.key = data.key
chunk_scene.bounds = data.bounds chunk_scene.bounds = data.bounds
chunk_scene.format_version = data.format_version # ChunkDataDecoder.ChunkPayload's field is named `version`, not
# `format_version` -- this was a hard crash ("Invalid access to
# property or key 'format_version'") the first time real decoded data
# ever reached this dormant method (via HttpChunkSource).
chunk_scene.format_version = data.version
chunk_scene.revision = data.revision chunk_scene.revision = data.revision
# Add tiles # Add tiles
@@ -348,7 +352,13 @@ func clear_all_chunks() -> void:
func _parse_coord_from_key(key: String) -> Variant: func _parse_coord_from_key(key: String) -> Variant:
"""Parse chunk coordinate from a canonical chunk key.""" """Parse chunk coordinate from a canonical chunk key."""
var parts: Array[String] = key.split(":") # String.split() returns PackedStringArray, not Array[String] -- those
# are different container types with no implicit conversion, so this
# raised "Trying to assign a value of type 'PackedStringArray' to a
# variable of type 'Array[String]'" the moment real data reached this
# dormant method for the first time (via HttpChunkSource's
# _on_chunk_data_received call -- nothing exercised this path before).
var parts: PackedStringArray = key.split(":")
if parts.size() < 5: if parts.size() < 5:
return null return null
return Vector3i( return Vector3i(
+190
View File
@@ -0,0 +1,190 @@
class_name ChunkEditorModel
extends RefCounted
## In-memory chunk being edited (docs/architecture/editor.md §7.1's
## `ChunkEditorModel`).
##
## Wraps a `ChunkDataDecoder.ChunkPayload` directly rather than inventing a
## parallel data model -- that payload is already the exact structure that
## round-trips through `ChunkDataDecoder.encode_binary()` /
## `decode_binary()` (world.md §3.6, and the interop bugs fixed in that file
## are proof this contract is exercised for real). Editing this model and
## re-encoding it must always produce a valid, byte-compatible chunk, since
## the sibling save-path task depends on that being true.
##
## Every method here is a "raw" mutator: it changes the payload and returns
## whatever an undo needs to reverse the change, but it does **not** know
## about undo/redo itself. `scripts/editor/editor_core.gd`'s tools
## (scripts/editor/tools/*.gd) are the only things that call these -- they
## wrap each call in an `EditorUndoRedoStack` action. Nothing here should be
## called directly by UI code; that would bypass undo/redo silently.
var payload: ChunkDataDecoder.ChunkPayload
## Monotonic counter seeded from the highest existing "obj_NNNN"-style
## numeric suffix already in the chunk, so freshly generated ids never
## collide with ids that arrived from a decoded chunk.
var _next_object_seq: int = 0
func _init(p_payload: ChunkDataDecoder.ChunkPayload = null) -> void:
payload = p_payload if p_payload != null else ChunkDataDecoder.ChunkPayload.new()
_seed_object_sequence()
func _seed_object_sequence() -> void:
for obj in payload.objects:
var n := _numeric_suffix(obj.id)
if n >= _next_object_seq:
_next_object_seq = n + 1
static func _numeric_suffix(id: String) -> int:
var digits := ""
for i in range(id.length() - 1, -1, -1):
var c: String = id[i]
if c.is_valid_int():
digits = c + digits
else:
break
return int(digits) if digits != "" else -1
## Build a fresh, empty single-layer chunk model -- convenient for tests and
## for a "new chunk" starting point in the in-game editor before a real
## chunk has been loaded from the backend (loading real chunks over HTTP is
## HttpChunkSource's job; this just gives the editor something valid to
## operate on).
static func create_empty(width: int = 32, height: int = 32, world_id: String = "",
room_id: String = "main", coord: Vector3i = Vector3i.ZERO) -> ChunkEditorModel:
var layer := ChunkDataDecoder.TileLayerData.new()
layer.name = "ground"
layer.layer_id = 0
layer.width = width
layer.height = height
layer.depth = 1
layer.palette = [
TileDef.new(0, "", false, 0, 1),
TileDef.new(1, "tiles/default", true, 0, 1),
]
var tiles := PackedInt32Array()
tiles.resize(width * height)
layer.tiles = tiles
var payload := ChunkDataDecoder.ChunkPayload.new()
payload.world_id = world_id
payload.room_id = room_id
payload.coord = coord
payload.bounds = AABB(Vector3(coord.x * width, coord.y * height, coord.z), Vector3(width, height, 1))
payload.tile_layers = [layer]
payload.revision = 0
payload.created_at = Time.get_unix_time_from_system()
payload.updated_at = payload.created_at
return ChunkEditorModel.new(payload)
static func from_binary(data: PackedByteArray) -> ChunkEditorModel:
var decoded := ChunkDataDecoder.decode_binary(data)
if decoded == null:
return null
return ChunkEditorModel.new(decoded)
func encode_binary() -> PackedByteArray:
return ChunkDataDecoder.encode_binary(payload)
# ───────────────────────── tiles ─────────────────────────
func get_layer(layer_idx: int) -> ChunkDataDecoder.TileLayerData:
if layer_idx < 0 or layer_idx >= payload.tile_layers.size():
return null
return payload.tile_layers[layer_idx]
func is_tile_in_bounds(layer_idx: int, x: int, y: int, z: int = 0) -> bool:
var layer := get_layer(layer_idx)
if layer == null:
return false
return x >= 0 and x < layer.width and y >= 0 and y < layer.height and z >= 0 and z < layer.depth
func get_tile_raw(layer_idx: int, x: int, y: int, z: int = 0) -> int:
var layer := get_layer(layer_idx)
if layer == null:
return 0
return layer.get_tile(x, y, z)
## Raw setter -- undo-unaware. Only ever called (in both directions) by
## BrushTool/EraseTool via EditorUndoRedoStack.
func set_tile_raw(layer_idx: int, x: int, y: int, z: int, tile_id: int) -> void:
var layer := get_layer(layer_idx)
if layer == null:
return
layer.set_tile(x, y, z, tile_id)
# ───────────────────────── objects ─────────────────────────
func find_object(object_id: String) -> ChunkDataDecoder.WorldObjectData:
for obj in payload.objects:
if obj.id == object_id:
return obj
return null
func get_object_index(object_id: String) -> int:
for i in range(payload.objects.size()):
if payload.objects[i].id == object_id:
return i
return -1
func generate_object_id() -> String:
var id := "obj_%04d" % _next_object_seq
_next_object_seq += 1
return id
## Raw mutators -- undo-unaware. Only ever called (in both directions) by
## ObjectPlaceTool/EraseTool/TransformTool via EditorUndoRedoStack.
func add_object_raw(obj: ChunkDataDecoder.WorldObjectData) -> void:
payload.objects.append(obj)
func insert_object_raw(obj: ChunkDataDecoder.WorldObjectData, index: int) -> void:
var clamped: int = clampi(index, 0, payload.objects.size())
payload.objects.insert(clamped, obj)
func remove_object_raw(object_id: String) -> ChunkDataDecoder.WorldObjectData:
var idx := get_object_index(object_id)
if idx == -1:
return null
var obj := payload.objects[idx]
payload.objects.remove_at(idx)
return obj
func set_object_position_raw(object_id: String, position: Vector3) -> void:
var obj := find_object(object_id)
if obj != null:
obj.position = position
func set_object_rotation_raw(object_id: String, rotation: Vector4) -> void:
var obj := find_object(object_id)
if obj != null:
obj.rotation = rotation
func set_object_scale_raw(object_id: String, scale: Vector3) -> void:
var obj := find_object(object_id)
if obj != null:
obj.scale = scale
func set_object_property_raw(object_id: String, key: String, value: Variant) -> void:
var obj := find_object(object_id)
if obj != null:
obj.properties[key] = value
+280
View File
@@ -0,0 +1,280 @@
class_name ChunkSaveService
extends Node
## HTTP save path for the in-game editor
## (docs/architecture/world_persistence_impl.md §8.1 chunk write / §8.2 D5).
##
## Owns the write half of editor persistence: single-chunk PUT with
## `X-Base-Revision`/`base_revision`, batch PUT for a multi-chunk edit, and
## the §5.4 optimistic-concurrency retry loop on `409`.
##
## Conflict handling ("re-fetch current chunk, re-apply the edit, retry"):
## per §8.2 D5 this backend stores and accepts **whole chunks**, not
## JSON-Patch deltas -- "lost-update protection comes from `base_revision`,
## not from the patch". That means there is no delta to replay against a
## freshly-fetched chunk; "re-apply the edit" is exactly "keep the locally
## edited payload, learn the chunk's current revision, and retry at that
## revision". `fetch_chunk()` is still called on a conflict (rather than
## just trusting the 409 body's `current_revision`) so the editor's own
## last-known state stays in sync with the store, matching what a real
## client does before retrying a write it doesn't fully trust yet.
##
## Deliberately does **not** use `ApiClient.request()`: that helper discards
## the parsed JSON body on any non-2xx response (see its `_make_result`
## calls), and the `409` body's `current_revision`/`current_checksum` are
## exactly what this retry loop needs. Uses a raw per-request `HTTPRequest`
## child instead, the same pattern `scripts/http_chunk_source.gd` uses for
## the read side.
##
## Must be added to the scene tree before use.
## The world this service saves chunks for (string form of the backend's
## numeric world id).
var world_id: String = ""
## The room slug within that world. world.md's default room is "main".
var room_slug: String = "main"
## Bounded retries on a 409 conflict -- refuses to loop forever against a
## chunk under sustained contention.
const MAX_RETRIES := 3
## Fetch the current chunk at [param coord].
##
## Returns:
## {"ok": bool, "status": int, "model": ChunkEditorModel|null,
## "revision": int, "checksum": String, "detail": String}
##
## A `404` (unwritten coordinate) is reported as `ok: true, model: null` --
## world_persistence_impl.md §8.1: "an unwritten coordinate is not an error".
## Callers wanting a fresh chunk to edit should fall back to
## `ChunkEditorModel.create_empty()` at `base_revision: 0` in that case.
func fetch_chunk(coord: Vector3i) -> Dictionary:
var http := HTTPRequest.new()
http.timeout = 15.0
add_child(http)
var api := _api_client()
var headers := PackedStringArray(["Accept: application/octet-stream"])
if api.has_token():
headers.append("Authorization: " + str(api.bearer_token))
var url: String = str(api.base_url) + _chunk_path(coord)
var err := http.request(url, headers, HTTPClient.METHOD_GET)
if err != OK:
http.queue_free()
return _fetch_result(false, 0, null, 0, "", "request failed to start (%d)" % err)
var args: Array = await http.request_completed
var result_code: int = args[0]
var response_code: int = args[1]
var response_headers: PackedStringArray = args[2]
var body: PackedByteArray = args[3]
http.queue_free()
if result_code != HTTPRequest.RESULT_SUCCESS:
return _fetch_result(false, 0, null, 0, "", "transport error (%d)" % result_code)
if response_code == 404:
return _fetch_result(true, 404, null, 0, "", "not found (unwritten chunk)")
if response_code != 200:
return _fetch_result(false, response_code, null, 0, "", _error_detail_from_body(body))
var model := ChunkEditorModel.from_binary(body)
if model == null:
return _fetch_result(false, response_code, null, 0, "", "failed to decode chunk payload")
return _fetch_result(
true, 200, model,
_int_header(response_headers, "x-chunk-revision"),
_etag_from_headers(response_headers),
"OK",
)
## Save one chunk. `model.encode_binary()` is sent as the new payload;
## `base_revision` is the revision the editor last knew about (0 = create).
##
## Returns:
## {"ok": bool, "status": int, "revision": int, "checksum": String,
## "live_session": bool, "retried": bool, "detail": String}
func save_chunk(model: ChunkEditorModel, coord: Vector3i, base_revision: int) -> Dictionary:
var attempt_revision := base_revision
var retried := false
for attempt in range(MAX_RETRIES + 1):
var body := {
"base_revision": attempt_revision,
"payload_b64": Marshalls.raw_to_base64(model.encode_binary()),
}
var resp := await _request_json(HTTPClient.METHOD_PUT, _chunk_path(coord), body)
if resp["status"] == 200:
var d: Dictionary = resp["data"]
return {
"ok": true, "status": 200,
"revision": int(d.get("revision", attempt_revision)),
"checksum": str(d.get("checksum", "")),
"live_session": bool(d.get("live_session", false)),
"retried": retried, "detail": "OK",
}
if resp["status"] == 409 and attempt < MAX_RETRIES:
var conflict: Dictionary = resp["data"] if resp["data"] is Dictionary else {}
var current_rev := int(conflict.get("current_revision", attempt_revision))
# Doc-mandated "re-fetch current chunk" step before retrying --
# keeps this service's view of the chunk current even though the
# retry itself only needs the revision number from the 409 body.
await fetch_chunk(coord)
attempt_revision = current_rev
retried = true
continue
return {"ok": false, "status": resp["status"], "revision": 0, "checksum": "",
"live_session": false, "retried": retried, "detail": _error_detail(resp)}
return {"ok": false, "status": 409, "revision": 0, "checksum": "", "live_session": false,
"retried": true, "detail": "conflict retry limit exceeded"}
## Save multiple chunks in one atomic batch PUT (world_persistence_impl.md
## §8.1 batch write: "an editor brush stroke or an object drag routinely
## straddles a chunk boundary"). `entries` is an Array of
## `{"coord": Vector3i, "model": ChunkEditorModel, "base_revision": int}`.
## All-or-nothing: a `409` names the offending coordinate and nothing is
## written, so only that entry's `base_revision` is refreshed before the
## whole batch is retried.
##
## Returns:
## {"ok": bool, "status": int, "live_session": bool,
## "chunks": Array, "retried": bool, "detail": String}
func save_batch(entries: Array) -> Dictionary:
var working: Array = []
for e in entries:
working.append((e as Dictionary).duplicate())
var retried := false
for attempt in range(MAX_RETRIES + 1):
var chunks_json: Array = []
for e in working:
var c: Vector3i = e["coord"]
var m: ChunkEditorModel = e["model"]
chunks_json.append({
"cx": c.x, "cy": c.y, "cz": c.z,
"base_revision": int(e["base_revision"]),
"payload_b64": Marshalls.raw_to_base64(m.encode_binary()),
})
var resp := await _request_json(HTTPClient.METHOD_PUT, _batch_path(), {"chunks": chunks_json})
if resp["status"] == 200:
var d: Dictionary = resp["data"]
return {"ok": true, "status": 200, "live_session": bool(d.get("live_session", false)),
"chunks": d.get("chunks", []), "retried": retried, "detail": "OK"}
if resp["status"] == 409 and attempt < MAX_RETRIES:
var conflict: Dictionary = resp["data"] if resp["data"] is Dictionary else {}
var current_rev := int(conflict.get("current_revision", 0))
var offending: Variant = conflict.get("coord")
if offending is Array and offending.size() == 3:
var oc := Vector3i(int(offending[0]), int(offending[1]), int(offending[2]))
for e in working:
if (e["coord"] as Vector3i) == oc:
await fetch_chunk(oc)
e["base_revision"] = current_rev
break
retried = true
continue
return {"ok": false, "status": resp["status"], "live_session": false, "chunks": [],
"retried": retried, "detail": _error_detail(resp)}
return {"ok": false, "status": 409, "live_session": false, "chunks": [],
"retried": true, "detail": "conflict retry limit exceeded"}
# ─────────────────────────── internals ───────────────────────────
## Resolved via an absolute node path, not the bare `ApiClient` autoload
## identifier -- see the identical note on `HttpChunkSource._api_client()`
## (bare autoload identifiers don't resolve when a script is compiled for a
## headless `godot --script ...` run, which is how this service's smoke
## test drives it).
func _api_client() -> Node:
return get_node("/root/ApiClient")
func _chunk_path(coord: Vector3i) -> String:
return "/api/worlds/%s/rooms/%s/chunks/%d/%d/%d" % [world_id, room_slug, coord.x, coord.y, coord.z]
func _batch_path() -> String:
return "/api/worlds/%s/rooms/%s/chunks" % [world_id, room_slug]
func _request_json(method: HTTPClient.Method, path: String, body_dict: Dictionary) -> Dictionary:
var http := HTTPRequest.new()
http.timeout = 15.0
add_child(http)
var api := _api_client()
var headers := PackedStringArray(["Content-Type: application/json", "Accept: application/json"])
if api.has_token():
headers.append("Authorization: " + str(api.bearer_token))
var url: String = str(api.base_url) + path
var err := http.request(url, headers, method, JSON.stringify(body_dict))
if err != OK:
http.queue_free()
return {"status": 0, "data": null, "raw": "request failed to start (%d)" % err}
var args: Array = await http.request_completed
var result_code: int = args[0]
var response_code: int = args[1]
var body_bytes: PackedByteArray = args[3]
http.queue_free()
if result_code != HTTPRequest.RESULT_SUCCESS:
return {"status": 0, "data": null, "raw": "transport error (%d)" % result_code}
var text := body_bytes.get_string_from_utf8()
var parsed: Variant = JSON.parse_string(text) if text != "" else null
return {"status": response_code, "data": parsed, "raw": text}
func _error_detail(resp: Dictionary) -> String:
var data: Variant = resp.get("data")
if data is Dictionary and (data as Dictionary).has("detail"):
return str((data as Dictionary)["detail"])
if int(resp.get("status", 0)) == 0:
return str(resp.get("raw", "network error"))
return "HTTP error %d" % int(resp.get("status", 0))
func _error_detail_from_body(body: PackedByteArray) -> String:
var text := body.get_string_from_utf8()
var parsed: Variant = JSON.parse_string(text) if text != "" else null
if parsed is Dictionary and (parsed as Dictionary).has("detail"):
return str((parsed as Dictionary)["detail"])
return text
func _fetch_result(ok: bool, status: int, model: ChunkEditorModel, revision: int,
checksum: String, detail: String) -> Dictionary:
return {"ok": ok, "status": status, "model": model, "revision": revision,
"checksum": checksum, "detail": detail}
func _int_header(headers: PackedStringArray, key: String) -> int:
var prefix := key + ":"
for h in headers:
if h.to_lower().begins_with(prefix):
return int(h.substr(h.find(":") + 1).strip_edges())
return 0
func _etag_from_headers(headers: PackedStringArray) -> String:
for h in headers:
if h.to_lower().begins_with("etag:"):
var v := h.substr(h.find(":") + 1).strip_edges()
return v.trim_prefix('"').trim_suffix('"')
return ""
+114
View File
@@ -0,0 +1,114 @@
class_name EditorCore
extends RefCounted
## Shared, data-native editor core (docs/architecture/editor.md §7.1).
##
## Both the in-game editor UI (`scripts/editor/ui/in_game_editor.gd`) and,
## per the doc's §6.2/§7.3, any future thin dev-plugin surface are meant to
## sit on top of this single object. It never touches `Control` nodes,
## HTTP, or the live scene tree -- it only knows about the chunk model,
## undo/redo, selection, and tools. It owns:
##
## - `model` (ChunkEditorModel) the chunk being edited
## - `undo_redo` (EditorUndoRedoStack) command-pattern history
## - `selection` (SelectionManager) selected object id
## - `tools` (ToolRegistry) brush / object_place / select /
## erase / transform
## - `catalog` (EditorObjectCatalog, optional) palette source consumed
## by `object_place`
##
## Every mutating public method here (`paint_tile`, `erase_tile`,
## `place_object`, `delete_object`, `move_object`, `rotate_object`,
## `scale_object`, `select_object`) is a thin wrapper around `use_tool()` --
## it goes through `ToolRegistry`, never straight at `ChunkEditorModel`.
## This is the seam this task's verification bar cares about: UI code and
## tests mutate the chunk exactly the same way, so a test that calls
## `core.move_object(...)` is exercising the same code path a player's
## drag-in-the-viewport would.
##
## Persisting the edited chunk to the backend is explicitly **out of
## scope** here (a sibling task owns the batch-PUT/conflict-handling save
## path per world_persistence_impl.md) -- `model.encode_binary()` is as far
## as this class goes.
var model: ChunkEditorModel
var undo_redo: EditorUndoRedoStack
var selection: SelectionManager
var tools: ToolRegistry
var catalog: EditorObjectCatalog = null
func _init(p_model: ChunkEditorModel, p_catalog: EditorObjectCatalog = null) -> void:
model = p_model
catalog = p_catalog
undo_redo = EditorUndoRedoStack.new()
selection = SelectionManager.new()
tools = ToolRegistry.new()
tools.register_tool(BrushTool.new())
tools.register_tool(ObjectPlaceTool.new())
tools.register_tool(SelectTool.new())
tools.register_tool(EraseTool.new())
tools.register_tool(TransformTool.new())
tools.set_active_tool("select")
## Route an action through the named tool. This is the single choke point
## every mutation (and every convenience wrapper below) goes through.
func use_tool(tool_id: String, args: Dictionary) -> Variant:
var tool: EditorTool = tools.get_tool(tool_id)
if tool == null:
push_error("EditorCore: unknown tool '%s'" % tool_id)
return null
return tool.apply(self, args)
# ───────── convenience wrappers (all route through use_tool/ToolRegistry) ─────────
func paint_tile(layer_idx: int, x: int, y: int, tile_id: int, z: int = 0) -> bool:
return bool(use_tool("brush", {"layer": layer_idx, "x": x, "y": y, "z": z, "tile_id": tile_id}))
func erase_tile(layer_idx: int, x: int, y: int, z: int = 0) -> bool:
return bool(use_tool("erase", {"layer": layer_idx, "x": x, "y": y, "z": z}))
func place_object(catalog_id: String, position: Vector3,
rotation: Vector4 = Vector4(0, 0, 0, 1), scale: Vector3 = Vector3.ONE) -> String:
var result: Variant = use_tool("object_place", {
"catalog_id": catalog_id,
"position": position,
"rotation": rotation,
"scale": scale,
})
return result if result is String else ""
func delete_object(object_id: String) -> bool:
return bool(use_tool("erase", {"object_id": object_id}))
func move_object(object_id: String, position: Vector3) -> bool:
return bool(use_tool("transform", {"object_id": object_id, "op": "move", "value": position}))
func rotate_object(object_id: String, rotation: Vector4) -> bool:
return bool(use_tool("transform", {"object_id": object_id, "op": "rotate", "value": rotation}))
func scale_object(object_id: String, scale: Vector3) -> bool:
return bool(use_tool("transform", {"object_id": object_id, "op": "scale", "value": scale}))
func select_object(object_id: String) -> bool:
return bool(use_tool("select", {"object_id": object_id}))
func clear_selection() -> void:
use_tool("select", {})
func undo() -> bool:
return undo_redo.undo()
func redo() -> bool:
return undo_redo.redo()
+73
View File
@@ -0,0 +1,73 @@
class_name EditorUndoRedoStack
extends RefCounted
## Command pattern over ChunkEditorModel mutations
## (docs/architecture/editor.md §7.1 `UndoRedoStack`).
##
## Thin wrapper around Godot's built-in `UndoRedo` -- the general-purpose
## class meant for use in game code (unlike the editor-only
## `EditorUndoRedoManager`, which only exists inside `EditorPlugin`s and
## isn't available at runtime). Every EditorCore tool (scripts/editor/tools/
## *.gd) opens exactly one action per user operation and adds a do/undo
## Callable pair that calls the *same* ChunkEditorModel raw setter in both
## directions (e.g. `set_tile_raw` with the new id, then again with the old
## one) -- so undo genuinely reverts the mutation instead of re-deriving it.
##
## `UndoRedo` extends `Object`, not `RefCounted`, so it is not automatically
## freed; `_notification(NOTIFICATION_PREDELETE)` frees it when this wrapper
## (a RefCounted) goes out of scope.
var _ur := UndoRedo.new()
func _notification(what: int) -> void:
if what == NOTIFICATION_PREDELETE and _ur != null:
_ur.free()
_ur = null
## Begin a new undoable action. Must be paired with `commit()`.
func begin(action_name: String) -> void:
_ur.create_action(action_name)
## Register the forward step of the current action. Called immediately by
## `commit()`, and again on every future `redo()`.
func add_do(callable: Callable) -> void:
_ur.add_do_method(callable)
## Register the reverse step of the current action. Called on every
## `undo()`.
func add_undo(callable: Callable) -> void:
_ur.add_undo_method(callable)
## Close the current action and execute its do-step once.
func commit() -> void:
_ur.commit_action()
## Close the current action without executing anything (used when a tool
## decides mid-way that there is nothing worth recording).
func cancel() -> void:
_ur.commit_action(false)
func undo() -> bool:
return _ur.undo()
func redo() -> bool:
return _ur.redo()
func can_undo() -> bool:
return _ur.has_undo()
func can_redo() -> bool:
return _ur.has_redo()
func clear_history() -> void:
_ur.clear_history()
+68
View File
@@ -0,0 +1,68 @@
class_name EditorObjectCatalog
extends RefCounted
## Catalog of placeable world objects for the in-game editor's
## `PalettePanel` (docs/architecture/editor.md §7.2).
##
## Mirrors the loading convention already used twice in this codebase --
## `scripts/avatar/avatar_catalog.gd` (AvatarCatalog) and
## `scripts/scripting/spawn_catalog.gd` (SpawnCatalog): a data-driven JSON
## manifest, loaded once, looked up by id. Checked both before adding this:
## `AvatarCatalog` is avatar-attachment-specific (base_body/slot/mount_bone)
## and `SpawnCatalog` is scoped to `ScriptBehavior`'s `spawn_from_catalog`
## verb specifically (its own doc comment says as much) -- neither is a
## general "placeable world object" catalog, so this is a new, small one
## rather than a reuse of either.
## Catalog manifest structure:
## { "objects": { "id": { "type", "asset_id", "display_name", ... } } }
var _catalog: Dictionary = {}
var _loaded: bool = false
var _catalog_path: String = "res://assets/editor/object_catalog.json"
func load_catalog(path: String = _catalog_path) -> Error:
_catalog_path = path
_catalog = {}
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
return FileAccess.get_open_error()
var json_text: String = file.get_as_text()
file.close()
var result: Variant = JSON.parse_string(json_text)
if result == null or not (result is Dictionary) or not (result as Dictionary).has("objects"):
return ERR_INVALID_DATA
_catalog = (result as Dictionary)["objects"]
_loaded = true
return OK
func is_loaded() -> bool:
return _loaded
## Resolve a catalog id to its template Dictionary ({} if unknown/unloaded).
func resolve(id: String) -> Dictionary:
if not _loaded:
return {}
return _catalog.get(id, {})
func has_object(id: String) -> bool:
return _loaded and _catalog.has(id)
func get_all_ids() -> Array[String]:
if not _loaded:
return []
var ids: Array[String] = []
for id in _catalog.keys():
ids.append(id)
return ids
func get_catalog_path() -> String:
return _catalog_path
+32
View File
@@ -0,0 +1,32 @@
class_name SelectionManager
extends RefCounted
## Tracks the currently-selected object in the in-game editor
## (docs/architecture/editor.md §7.1 `SelectionManager`).
##
## Selection is UI/session state, not chunk data: it is never pushed onto
## `EditorUndoRedoStack` and never encoded into the `ChunkPayload`. Scoped
## to a single selected object id, matching the 2D-tile-editor scope
## (docs/architecture/editor.md §8) -- multi-select is future work.
signal selection_changed(object_id: String)
var _selected_id: String = ""
func select(object_id: String) -> void:
if _selected_id == object_id:
return
_selected_id = object_id
selection_changed.emit(_selected_id)
func clear() -> void:
select("")
func get_selected() -> String:
return _selected_id
func has_selection() -> bool:
return _selected_id != ""
+41
View File
@@ -0,0 +1,41 @@
class_name ToolRegistry
extends RefCounted
## Registry of `EditorTool` instances, keyed by id
## (docs/architecture/editor.md §7.1: "brush / object-place / select /
## erase, etc."). `EditorCore.use_tool()` is the single choke point every
## chunk mutation goes through -- UI code and tests both call tools by id
## through here rather than reaching into `ChunkEditorModel` directly.
var _tools: Dictionary = {}
var _active_id: String = ""
func register_tool(tool: EditorTool) -> void:
_tools[tool.tool_id] = tool
func get_tool(tool_id: String) -> EditorTool:
return _tools.get(tool_id, null)
func has_tool(tool_id: String) -> bool:
return _tools.has(tool_id)
func get_tool_ids() -> Array:
return _tools.keys()
func set_active_tool(tool_id: String) -> bool:
if not _tools.has(tool_id):
return false
_active_id = tool_id
return true
func get_active_tool_id() -> String:
return _active_id
func get_active_tool() -> EditorTool:
return _tools.get(_active_id, null)
+31
View File
@@ -0,0 +1,31 @@
class_name BrushTool
extends EditorTool
## Paints a single tile id into a tile layer at (x, y[, z]).
##
## `args`: `layer` (int, default 0), `x` (int), `y` (int), `z` (int,
## default 0), `tile_id` (int).
func _init() -> void:
tool_id = "brush"
func apply(core: EditorCore, args: Dictionary) -> Variant:
var layer_idx: int = args.get("layer", 0)
var x: int = args.get("x", 0)
var y: int = args.get("y", 0)
var z: int = args.get("z", 0)
var tile_id: int = args.get("tile_id", 0)
if not core.model.is_tile_in_bounds(layer_idx, x, y, z):
return false
var old_tile_id: int = core.model.get_tile_raw(layer_idx, x, y, z)
if old_tile_id == tile_id:
return false # no-op -- nothing to undo
core.undo_redo.begin("Paint tile (%d,%d,%d) -> %d" % [x, y, z, tile_id])
core.undo_redo.add_do(core.model.set_tile_raw.bind(layer_idx, x, y, z, tile_id))
core.undo_redo.add_undo(core.model.set_tile_raw.bind(layer_idx, x, y, z, old_tile_id))
core.undo_redo.commit()
return true
+20
View File
@@ -0,0 +1,20 @@
class_name EditorTool
extends RefCounted
## Base class for EditorCore tools (docs/architecture/editor.md §7.1
## `ToolRegistry`: brush / object-place / select / erase / transform).
##
## A tool never mutates `ChunkEditorModel` "bare" -- every chunk-data
## mutation goes through `EditorCore.undo_redo` so it is always revertible.
## (Selection is the one exception: it is UI state, not chunk data, and
## `SelectTool` intentionally does not touch undo/redo -- see its comment.)
var tool_id: String = ""
## Apply this tool's action against `core` with `args`. Returns a
## tool-specific result on success (often `true`/a new object id), or a
## falsy value (`false`/`""`/`null`) if nothing changed -- callers should
## treat falsy as "no-op, nothing to undo".
func apply(_core: EditorCore, _args: Dictionary) -> Variant:
push_error("EditorTool.apply() not implemented for tool '%s'" % tool_id)
return null
+41
View File
@@ -0,0 +1,41 @@
class_name EraseTool
extends EditorTool
## Deletes an object, or clears a tile back to id 0 (the reserved empty/air
## id -- world.md §3.2 / TileDef doc comment).
##
## `args`: either `{"object_id": ...}` to delete an object, or
## `{"layer", "x", "y", "z"}` to clear a tile (delegates to BrushTool with
## `tile_id = 0` so both paths share one undo/redo implementation).
func _init() -> void:
tool_id = "erase"
func apply(core: EditorCore, args: Dictionary) -> Variant:
if args.has("object_id"):
return _erase_object(core, args["object_id"])
return _erase_tile(core, args)
func _erase_object(core: EditorCore, object_id: String) -> bool:
var idx: int = core.model.get_object_index(object_id)
if idx == -1:
return false
var obj: ChunkDataDecoder.WorldObjectData = core.model.payload.objects[idx]
core.undo_redo.begin("Delete '%s'" % object_id)
core.undo_redo.add_do(core.model.remove_object_raw.bind(object_id))
core.undo_redo.add_undo(core.model.insert_object_raw.bind(obj, idx))
core.undo_redo.commit()
if core.selection.get_selected() == object_id:
core.selection.clear()
return true
func _erase_tile(core: EditorCore, args: Dictionary) -> bool:
var brush: EditorTool = core.tools.get_tool("brush")
var tile_args: Dictionary = args.duplicate()
tile_args["tile_id"] = 0
return bool(brush.apply(core, tile_args))
+50
View File
@@ -0,0 +1,50 @@
class_name ObjectPlaceTool
extends EditorTool
## Places a new catalog object into the chunk at a given transform.
##
## `args`: `catalog_id` (String, required), `position` (Vector3), `rotation`
## (Vector4 quaternion, optional, default identity), `scale` (Vector3,
## optional, default ONE), `properties` (Dictionary, optional, merged in).
##
## Returns the new object's id (String) on success, or "" on failure
## (unknown catalog_id).
func _init() -> void:
tool_id = "object_place"
func apply(core: EditorCore, args: Dictionary) -> Variant:
var catalog_id: String = args.get("catalog_id", "")
if catalog_id == "":
push_error("ObjectPlaceTool: missing catalog_id")
return ""
var entry: Dictionary = {}
if core.catalog != null:
entry = core.catalog.resolve(catalog_id)
if entry.is_empty():
push_error("ObjectPlaceTool: unknown catalog_id '%s'" % catalog_id)
return ""
var obj := ChunkDataDecoder.WorldObjectData.new()
obj.id = core.model.generate_object_id()
obj.type = entry.get("type", "prop")
obj.asset_id = entry.get("asset_id", catalog_id)
obj.position = args.get("position", Vector3.ZERO)
obj.rotation = args.get("rotation", Vector4(0, 0, 0, 1))
obj.scale = args.get("scale", Vector3.ONE)
var props: Dictionary = (args.get("properties", {}) as Dictionary).duplicate()
if entry.has("display_name"):
props["display_name"] = entry["display_name"]
props["catalog_id"] = catalog_id
obj.properties = props
core.undo_redo.begin("Place '%s' (%s)" % [obj.id, catalog_id])
core.undo_redo.add_do(core.model.add_object_raw.bind(obj))
core.undo_redo.add_undo(core.model.remove_object_raw.bind(obj.id))
core.undo_redo.commit()
core.selection.select(obj.id)
return obj.id
+24
View File
@@ -0,0 +1,24 @@
class_name SelectTool
extends EditorTool
## Selects (or clears) the active object in `EditorCore.selection`.
##
## `args`: `object_id` (String) -- omitted or "" clears the selection.
##
## Selection is UI/session state, not chunk data (docs/architecture/editor.md
## §7.1 lists `SelectionManager` as a sibling of `UndoRedoStack`, not a
## client of it), so this intentionally never touches `core.undo_redo`.
func _init() -> void:
tool_id = "select"
func apply(core: EditorCore, args: Dictionary) -> Variant:
var object_id: String = args.get("object_id", "")
if object_id == "":
core.selection.clear()
return true
if core.model.find_object(object_id) == null:
return false
core.selection.select(object_id)
return true
+62
View File
@@ -0,0 +1,62 @@
class_name TransformTool
extends EditorTool
## Moves, rotates, or scales an existing object.
##
## Not one of the four tools literally named in docs/architecture/editor.md
## §7.1's `ToolRegistry` list ("brush / object-place / select / erase,
## etc.") but required by this task's brief ("place / move / rotate / scale
## / delete") -- the doc's own "etc." leaves room, so this is a fifth
## registered tool rather than overloading `ObjectPlaceTool` (placing a new
## object and re-transforming an existing one are different undo units)
## or `SelectTool` (selection is deliberately not chunk-mutating).
##
## `args`: `object_id` (String), `op` ("move" | "rotate" | "scale"), `value`
## (Vector3 for move/scale, Vector4 quaternion for rotate).
func _init() -> void:
tool_id = "transform"
func apply(core: EditorCore, args: Dictionary) -> Variant:
var object_id: String = args.get("object_id", "")
var op: String = args.get("op", "")
var obj := core.model.find_object(object_id)
if obj == null:
return false
match op:
"move":
return _apply_vector3(core, object_id, args.get("value", obj.position),
obj.position, "Move", core.model.set_object_position_raw)
"rotate":
return _apply_vector4(core, object_id, args.get("value", obj.rotation),
obj.rotation, "Rotate", core.model.set_object_rotation_raw)
"scale":
return _apply_vector3(core, object_id, args.get("value", obj.scale),
obj.scale, "Scale", core.model.set_object_scale_raw)
_:
push_error("TransformTool: unknown op '%s'" % op)
return false
func _apply_vector3(core: EditorCore, object_id: String, new_value: Vector3,
old_value: Vector3, label: String, setter: Callable) -> bool:
if new_value.is_equal_approx(old_value):
return false
core.undo_redo.begin("%s '%s'" % [label, object_id])
core.undo_redo.add_do(setter.bind(object_id, new_value))
core.undo_redo.add_undo(setter.bind(object_id, old_value))
core.undo_redo.commit()
return true
func _apply_vector4(core: EditorCore, object_id: String, new_value: Vector4,
old_value: Vector4, label: String, setter: Callable) -> bool:
if new_value.is_equal_approx(old_value):
return false
core.undo_redo.begin("%s '%s'" % [label, object_id])
core.undo_redo.add_do(setter.bind(object_id, new_value))
core.undo_redo.add_undo(setter.bind(object_id, old_value))
core.undo_redo.commit()
return true
+65
View File
@@ -0,0 +1,65 @@
class_name EditorToolbar
extends HBoxContainer
## Tool-mode switcher for the in-game editor (docs/architecture/editor.md
## §7.2 `Toolbar`). Pure UI: it only emits signals: `InGameEditor` decides
## what to do with them (calling into `EditorCore`/`ToolRegistry`).
signal tool_selected(tool_id: String)
signal undo_pressed
signal redo_pressed
const TOOL_LABELS := {
"select": "Select",
"brush": "Brush",
"object_place": "Place",
"erase": "Erase",
}
var _tool_buttons: Dictionary = {}
var _undo_button: Button
var _redo_button: Button
func _init() -> void:
name = "EditorToolbar"
func _ready() -> void:
for tool_id in TOOL_LABELS:
var btn := Button.new()
btn.text = TOOL_LABELS[tool_id]
btn.toggle_mode = true
btn.pressed.connect(_on_tool_button_pressed.bind(tool_id))
add_child(btn)
_tool_buttons[tool_id] = btn
add_child(VSeparator.new())
_undo_button = Button.new()
_undo_button.text = "Undo"
_undo_button.pressed.connect(func(): undo_pressed.emit())
add_child(_undo_button)
_redo_button = Button.new()
_redo_button.text = "Redo"
_redo_button.pressed.connect(func(): redo_pressed.emit())
add_child(_redo_button)
func _on_tool_button_pressed(tool_id: String) -> void:
set_active_tool(tool_id)
tool_selected.emit(tool_id)
## Reflect the active tool in the button states without re-emitting the signal.
func set_active_tool(tool_id: String) -> void:
for id in _tool_buttons:
_tool_buttons[id].button_pressed = (id == tool_id)
## Reflect whether undo/redo currently have anything to do.
func set_history_state(can_undo: bool, can_redo: bool) -> void:
if _undo_button:
_undo_button.disabled = not can_undo
if _redo_button:
_redo_button.disabled = not can_redo
+135
View File
@@ -0,0 +1,135 @@
class_name EditorViewport
extends Control
## Custom 2D viewport for the in-game editor (docs/architecture/editor.md
## §7.2 `EditorViewport`).
##
## Deliberately 2D top-down, not a 3D gizmo viewport -- per §8's own risk
## mitigation ("start with a 2D tile editor... matches CHUNK_SIZE_Z=1
## default; add 3D later"). Renders one tile layer as a grid plus the
## chunk's objects as dots, and turns clicks into calls on `EditorCore`
## routed through whichever tool is active in `ToolRegistry` -- this is UI
## glue, it never mutates `ChunkEditorModel` itself.
##
## Coordinate convention matches the rest of this codebase's tile system
## (scripts/tile_layer_node.gd `_tile_to_world`): chunk-local X/Y are the
## flat ground plane, Z is height (CHUNK_SIZE_Z=1 by default -- see
## scripts/chunk.gd). One grid cell == one world unit == `cell_size` pixels.
signal edit_applied
var core: EditorCore = null
var layer_idx: int = 0
var cell_size: float = 24.0
## Catalog id the next "object_place" click will place. Set by
## `InGameEditor` from `PalettePanel.object_selected`.
var pending_catalog_id: String = ""
## Tile id the next "brush" click paints. Cycled with number keys 0-9.
var current_tile_id: int = 1
const TILE_EMPTY_COLOR := Color(0.15, 0.15, 0.17)
const TILE_SOLID_COLOR := Color(0.35, 0.55, 0.35)
const GRID_COLOR := Color(0, 0, 0, 0.25)
const OBJECT_COLOR := Color(0.9, 0.6, 0.2)
const OBJECT_SELECTED_COLOR := Color(1.0, 0.95, 0.2)
func _init() -> void:
name = "EditorViewport"
mouse_filter = Control.MOUSE_FILTER_STOP
focus_mode = Control.FOCUS_ALL
func setup(p_core: EditorCore, p_layer_idx: int = 0) -> void:
core = p_core
layer_idx = p_layer_idx
core.selection.selection_changed.connect(func(_id): queue_redraw())
_resize_to_layer()
queue_redraw()
func _resize_to_layer() -> void:
var layer := core.model.get_layer(layer_idx)
if layer == null:
custom_minimum_size = Vector2(cell_size * 8, cell_size * 8)
return
custom_minimum_size = Vector2(layer.width * cell_size, layer.height * cell_size)
func _draw() -> void:
if core == null:
return
var layer := core.model.get_layer(layer_idx)
if layer == null:
return
for y in range(layer.height):
for x in range(layer.width):
var tile_id := layer.get_tile(x, y, 0)
var color := TILE_EMPTY_COLOR
var tdef := layer.get_tile_def(tile_id)
if tdef != null and tdef.solid:
color = TILE_SOLID_COLOR
draw_rect(Rect2(x * cell_size, y * cell_size, cell_size, cell_size), color, true)
for x in range(layer.width + 1):
draw_line(Vector2(x * cell_size, 0), Vector2(x * cell_size, layer.height * cell_size), GRID_COLOR)
for y in range(layer.height + 1):
draw_line(Vector2(0, y * cell_size), Vector2(layer.width * cell_size, y * cell_size), GRID_COLOR)
for obj in core.model.payload.objects:
var center := Vector2(obj.position.x * cell_size, obj.position.y * cell_size)
var is_selected: bool = core.selection.get_selected() == obj.id
var color := OBJECT_SELECTED_COLOR if is_selected else OBJECT_COLOR
draw_circle(center, cell_size * 0.3, color)
func _gui_input(event: InputEvent) -> void:
if core == null:
return
if event is InputEventMouseButton:
var mb := event as InputEventMouseButton
if mb.pressed and mb.button_index == MOUSE_BUTTON_LEFT:
_handle_click(mb.position)
accept_event()
elif event is InputEventKey:
var key := event as InputEventKey
if key.pressed and not key.echo:
_handle_key(key)
func _handle_key(key: InputEventKey) -> void:
if key.keycode >= KEY_0 and key.keycode <= KEY_9:
current_tile_id = key.keycode - KEY_0
func _handle_click(local_pos: Vector2) -> void:
var tx := int(floor(local_pos.x / cell_size))
var ty := int(floor(local_pos.y / cell_size))
match core.tools.get_active_tool_id():
"brush":
if core.paint_tile(layer_idx, tx, ty, current_tile_id):
edit_applied.emit()
queue_redraw()
"erase":
var hit := _object_at(tx, ty)
var changed: bool = core.delete_object(hit) if hit != "" else core.erase_tile(layer_idx, tx, ty)
if changed:
edit_applied.emit()
queue_redraw()
"object_place":
if pending_catalog_id != "":
var new_id := core.place_object(pending_catalog_id, Vector3(tx + 0.5, ty + 0.5, 0.0))
if new_id != "":
edit_applied.emit()
queue_redraw()
"select":
core.select_object(_object_at(tx, ty))
queue_redraw()
func _object_at(tx: int, ty: int) -> String:
for obj in core.model.payload.objects:
if int(floor(obj.position.x)) == tx and int(floor(obj.position.y)) == ty:
return obj.id
return ""
+250
View File
@@ -0,0 +1,250 @@
class_name InGameEditor
extends Control
## In-game "build mode" UI (docs/architecture/editor.md §7.2 `InGameEditor`).
##
## Built entirely from `Control` nodes in code (matching this codebase's
## existing convention for dynamic UI -- see `game.gd`'s `_show_build_stamp`
## / `_show_status`) rather than a hand-authored `.tscn`, so there is one
## fewer place for a scene-file/script mismatch to hide. It is only ever
## instantiated when the player enters build mode (see `game.gd`'s
## `_toggle_build_mode`), so it costs nothing during normal play.
##
## Composition matches the doc exactly:
## EditorToolbar -- tool mode switcher
## PalettePanel -- catalog-sourced object palette
## EditorViewport -- the 2D tile/object canvas
## InspectorPanel -- property editor for the selected object
## EditorCore -- shared core (passed in, not owned/created here)
##
## Persisting edits (world_persistence_impl.md's single/batch-PUT save path,
## §8.1-8.2) is wired through `ChunkSaveService`, set via
## `setup_persistence()`. Without it (e.g. the F2 scratch-chunk path, or
## `smoke_test_editor_core.gd`'s bare `InGameEditor.new(core)`), the Save
## button is present but disabled -- closing the editor just discards the
## UI and the in-memory `ChunkEditorModel` is unaffected either way.
signal closed
var core: EditorCore
var _toolbar: EditorToolbar
var _palette: PalettePanel
var _inspector: InspectorPanel
var _viewport: EditorViewport
var _close_button: Button
var _save_button: Button
var _save_status_label: Label
## Save-path state (world_persistence_impl.md §8.1/§8.2), set via
## `setup_persistence()`. `_save_service == null` means there is nothing to
## save to (see the class doc above) -- the Save button stays disabled.
var _save_service: ChunkSaveService = null
var _world_id: String = ""
var _room_slug: String = "main"
var _coord: Vector3i = Vector3i.ZERO
var _base_revision: int = 0
var _saving: bool = false
func _init(p_core: EditorCore) -> void:
core = p_core
name = "InGameEditor"
set_anchors_preset(Control.PRESET_FULL_RECT)
mouse_filter = Control.MOUSE_FILTER_STOP
## Wire this editor's Save button/Ctrl+S to a real backend round-trip.
## `save_service` must already be in the scene tree (game.gd adds it as a
## sibling before constructing this editor). `base_revision` is whatever the
## caller last fetched for `coord` (0 if the chunk didn't exist yet).
func setup_persistence(save_service: ChunkSaveService, world_id: String, room_slug: String,
coord: Vector3i, base_revision: int) -> void:
_save_service = save_service
_world_id = world_id
_room_slug = room_slug
_coord = coord
_base_revision = base_revision
if _save_button != null:
_save_button.disabled = false
func _ready() -> void:
_build_ui()
_refresh_history_buttons()
func _build_ui() -> void:
var bg := ColorRect.new()
bg.color = Color(0.08, 0.08, 0.09, 0.96)
bg.set_anchors_preset(Control.PRESET_FULL_RECT)
bg.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(bg)
var root_vbox := VBoxContainer.new()
root_vbox.set_anchors_preset(Control.PRESET_FULL_RECT)
root_vbox.offset_left = 12
root_vbox.offset_top = 12
root_vbox.offset_right = -12
root_vbox.offset_bottom = -12
add_child(root_vbox)
var top_bar := HBoxContainer.new()
root_vbox.add_child(top_bar)
_toolbar = EditorToolbar.new()
_toolbar.size_flags_horizontal = Control.SIZE_EXPAND_FILL
top_bar.add_child(_toolbar)
_save_button = Button.new()
_save_button.text = "Save (Ctrl+S)"
_save_button.disabled = (_save_service == null)
_save_button.pressed.connect(_on_save_pressed)
top_bar.add_child(_save_button)
_close_button = Button.new()
_close_button.text = "Close (F2)"
_close_button.pressed.connect(_on_close_pressed)
top_bar.add_child(_close_button)
_save_status_label = Label.new()
_save_status_label.autowrap_mode = TextServer.AUTOWRAP_WORD
_save_status_label.visible = false
root_vbox.add_child(_save_status_label)
var body := HBoxContainer.new()
body.size_flags_vertical = Control.SIZE_EXPAND_FILL
root_vbox.add_child(body)
var viewport_scroll := ScrollContainer.new()
viewport_scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL
viewport_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
body.add_child(viewport_scroll)
_viewport = EditorViewport.new()
viewport_scroll.add_child(_viewport)
var sidebar := VBoxContainer.new()
sidebar.custom_minimum_size = Vector2(240, 0)
body.add_child(sidebar)
_palette = PalettePanel.new()
sidebar.add_child(_palette)
_inspector = InspectorPanel.new()
sidebar.add_child(_inspector)
_viewport.setup(core, 0)
_palette.set_catalog(core.catalog)
_inspector.setup(core)
_toolbar.set_active_tool(core.tools.get_active_tool_id())
_toolbar.tool_selected.connect(_on_tool_selected)
_toolbar.undo_pressed.connect(_on_undo_pressed)
_toolbar.redo_pressed.connect(_on_redo_pressed)
_palette.object_selected.connect(_on_palette_object_selected)
_inspector.delete_requested.connect(_on_inspector_delete_requested)
_viewport.edit_applied.connect(_on_edit_applied)
core.selection.selection_changed.connect(_on_selection_changed)
func _unhandled_key_input(event: InputEvent) -> void:
if not (event is InputEventKey):
return
var key := event as InputEventKey
if not key.pressed or key.echo:
return
if key.ctrl_pressed and key.keycode == KEY_Z:
_on_undo_pressed()
get_viewport().set_input_as_handled()
elif key.ctrl_pressed and key.keycode == KEY_Y:
_on_redo_pressed()
get_viewport().set_input_as_handled()
elif key.ctrl_pressed and key.keycode == KEY_S:
_on_save_pressed()
get_viewport().set_input_as_handled()
elif key.keycode == KEY_DELETE and core.selection.has_selection():
core.delete_object(core.selection.get_selected())
_on_edit_applied()
get_viewport().set_input_as_handled()
func _on_tool_selected(tool_id: String) -> void:
core.tools.set_active_tool(tool_id)
func _on_palette_object_selected(catalog_id: String) -> void:
_viewport.pending_catalog_id = catalog_id
core.tools.set_active_tool("object_place")
_toolbar.set_active_tool("object_place")
func _on_inspector_delete_requested(object_id: String) -> void:
core.delete_object(object_id)
_on_edit_applied()
func _on_undo_pressed() -> void:
core.undo()
_on_edit_applied()
func _on_redo_pressed() -> void:
core.redo()
_on_edit_applied()
func _on_edit_applied() -> void:
_viewport.queue_redraw()
_inspector.show_object(core.selection.get_selected())
_refresh_history_buttons()
func _on_selection_changed(object_id: String) -> void:
_inspector.show_object(object_id)
func _refresh_history_buttons() -> void:
_toolbar.set_history_state(core.undo_redo.can_undo(), core.undo_redo.can_redo())
func _on_close_pressed() -> void:
closed.emit()
queue_free()
## Single-chunk save (world_persistence_impl.md §8.1). `ChunkSaveService`
## owns the actual HTTP round trip, retrying on `409` internally
## (§8.2/§5.4) -- this just reflects the outcome and, per §6 D4, surfaces
## the `live_session` flag as a warning instead of ignoring it.
func _on_save_pressed() -> void:
if _save_service == null or _saving:
return
_saving = true
_save_button.disabled = true
_set_save_status("Saving…", Color(0.85, 0.85, 0.85))
var result: Dictionary = await _save_service.save_chunk(core.model, _coord, _base_revision)
_saving = false
_save_button.disabled = false
if result.get("ok", false):
_base_revision = int(result.get("revision", _base_revision))
var msg := "Saved (revision %d)." % _base_revision
if bool(result.get("retried", false)):
msg += " (resolved a save conflict automatically)"
if bool(result.get("live_session", false)):
msg += " This world has players in it right now -- they'll see your changes when the session restarts."
_set_save_status(msg, Color(1.0, 0.8, 0.25))
else:
_set_save_status(msg, Color(0.55, 1.0, 0.55))
else:
_set_save_status("Save failed: %s" % str(result.get("detail", "unknown error")), Color(1.0, 0.45, 0.45))
func _set_save_status(text: String, color: Color) -> void:
if _save_status_label == null:
return
_save_status_label.text = text
_save_status_label.add_theme_color_override("font_color", color)
_save_status_label.visible = true
+166
View File
@@ -0,0 +1,166 @@
class_name InspectorPanel
extends VBoxContainer
## Property editor for the selected object (docs/architecture/editor.md
## §7.2 `InspectorPanel`).
##
## 2D-tile-editor scope (docs/architecture/editor.md §8: the default world
## is flat, `CHUNK_SIZE_Z=1`): position exposes X/Y (world-plane) plus a
## single "height" Z for props that sit above the floor, and rotation
## exposes one yaw field (rotation about the vertical axis) rather than a
## full 3-axis gizmo -- deliberately not the volumetric 3D editing the doc
## says to defer.
##
## Pure UI: every edit calls back into `EditorCore` (`move_object` /
## `rotate_object` / `scale_object` / `delete_object`), so inspector edits
## go through the exact same undo/redo path as viewport edits.
signal delete_requested(object_id: String)
var _core: EditorCore = null
var _object_id: String = ""
var _updating := false # guards against feedback loops while refreshing fields
var _id_label: Label
var _type_label: Label
var _asset_label: Label
var _pos_x: SpinBox
var _pos_y: SpinBox
var _pos_z: SpinBox
var _yaw: SpinBox
var _scale_x: SpinBox
var _scale_y: SpinBox
var _scale_z: SpinBox
var _delete_button: Button
var _empty_label: Label
var _fields_box: VBoxContainer
func _init() -> void:
name = "InspectorPanel"
func _ready() -> void:
var header := Label.new()
header.text = "Inspector"
add_child(header)
_empty_label = Label.new()
_empty_label.text = "(nothing selected)"
add_child(_empty_label)
_fields_box = VBoxContainer.new()
_fields_box.visible = false
add_child(_fields_box)
_id_label = _add_readonly_row("id")
_type_label = _add_readonly_row("type")
_asset_label = _add_readonly_row("asset")
_pos_x = _add_spinbox_row("pos x")
_pos_y = _add_spinbox_row("pos y")
_pos_z = _add_spinbox_row("pos z (height)")
_pos_x.value_changed.connect(_on_position_changed)
_pos_y.value_changed.connect(_on_position_changed)
_pos_z.value_changed.connect(_on_position_changed)
_yaw = _add_spinbox_row("rotation (deg)")
_yaw.min_value = -360.0
_yaw.max_value = 360.0
_yaw.step = 1.0
_yaw.value_changed.connect(_on_yaw_changed)
_scale_x = _add_spinbox_row("scale x")
_scale_y = _add_spinbox_row("scale y")
_scale_z = _add_spinbox_row("scale z")
_scale_x.value_changed.connect(_on_scale_changed)
_scale_y.value_changed.connect(_on_scale_changed)
_scale_z.value_changed.connect(_on_scale_changed)
_delete_button = Button.new()
_delete_button.text = "Delete"
_delete_button.pressed.connect(_on_delete_pressed)
_fields_box.add_child(_delete_button)
func setup(core: EditorCore) -> void:
_core = core
func show_object(object_id: String) -> void:
_object_id = object_id
if object_id == "" or _core == null:
_empty_label.visible = true
_fields_box.visible = false
return
var obj := _core.model.find_object(object_id)
if obj == null:
_empty_label.visible = true
_fields_box.visible = false
return
_empty_label.visible = false
_fields_box.visible = true
_updating = true
_id_label.text = "id: %s" % obj.id
_type_label.text = "type: %s" % obj.type
_asset_label.text = "asset: %s" % obj.asset_id
_pos_x.value = obj.position.x
_pos_y.value = obj.position.y
_pos_z.value = obj.position.z
_yaw.value = rad_to_deg(Quaternion(obj.rotation.x, obj.rotation.y, obj.rotation.z, obj.rotation.w).get_euler().y)
_scale_x.value = obj.scale.x
_scale_y.value = obj.scale.y
_scale_z.value = obj.scale.z
_updating = false
func _on_position_changed(_v: float) -> void:
if _updating or _core == null or _object_id == "":
return
_core.move_object(_object_id, Vector3(_pos_x.value, _pos_y.value, _pos_z.value))
func _on_yaw_changed(v: float) -> void:
if _updating or _core == null or _object_id == "":
return
var quat := Quaternion(Vector3.UP, deg_to_rad(v))
_core.rotate_object(_object_id, Vector4(quat.x, quat.y, quat.z, quat.w))
func _on_scale_changed(_v: float) -> void:
if _updating or _core == null or _object_id == "":
return
_core.scale_object(_object_id, Vector3(_scale_x.value, _scale_y.value, _scale_z.value))
func _on_delete_pressed() -> void:
if _object_id == "":
return
var id := _object_id
delete_requested.emit(id)
func _add_readonly_row(label: String) -> Label:
var l := Label.new()
l.text = "%s: " % label
_fields_box.add_child(l)
return l
func _add_spinbox_row(label_text: String) -> SpinBox:
var row := HBoxContainer.new()
var l := Label.new()
l.text = label_text
l.custom_minimum_size = Vector2(90, 0)
row.add_child(l)
var sb := SpinBox.new()
sb.min_value = -100000.0
sb.max_value = 100000.0
sb.step = 0.1
sb.allow_greater = true
sb.allow_lesser = true
row.add_child(sb)
_fields_box.add_child(row)
return sb
+57
View File
@@ -0,0 +1,57 @@
class_name PalettePanel
extends VBoxContainer
## Catalog-sourced object palette (docs/architecture/editor.md §7.2
## `PalettePanel`). Lists every id in an `EditorObjectCatalog` and emits
## which one the player picked; `InGameEditor` remembers it as the pending
## `catalog_id` for the next `ObjectPlaceTool` placement.
signal object_selected(catalog_id: String)
var _list: ItemList
var _catalog: EditorObjectCatalog = null
var _ids: Array[String] = []
func _init() -> void:
name = "PalettePanel"
func _ready() -> void:
var header := Label.new()
header.text = "Palette"
add_child(header)
_list = ItemList.new()
_list.size_flags_vertical = Control.SIZE_EXPAND_FILL
_list.item_selected.connect(_on_item_selected)
add_child(_list)
func set_catalog(catalog: EditorObjectCatalog) -> void:
_catalog = catalog
_refresh()
func _refresh() -> void:
_list.clear()
_ids.clear()
if _catalog == null or not _catalog.is_loaded():
return
for id in _catalog.get_all_ids():
var entry := _catalog.resolve(id)
var label: String = entry.get("display_name", id)
_list.add_item(label)
_ids.append(id)
func _on_item_selected(index: int) -> void:
if index < 0 or index >= _ids.size():
return
object_selected.emit(_ids[index])
func get_selected_catalog_id() -> String:
var selected: PackedInt32Array = _list.get_selected_items()
if selected.is_empty():
return ""
return _ids[selected[0]]
+130 -3
View File
@@ -28,21 +28,148 @@ var _remotes: Dictionary = {} # peer_id -> Node3D
var _remote_prev: Dictionary = {} # peer_id -> Vector3 (last snapshot position) var _remote_prev: Dictionary = {} # peer_id -> Vector3 (last snapshot position)
var _status_label: Label = null var _status_label: Label = null
## Build-mode UI, gated behind toggle_build_mode (F2) -- only ever
## instantiated once the player actually enters build mode
## (docs/architecture/editor.md §7.2: "Instantiated only when the player
## enters 'build mode'... so the runtime footprint is negligible during
## normal play"). Editing is in-memory only here; the save path to the
## backend is a sibling task (world_persistence_impl.md).
var _build_editor: InGameEditor = null
func _ready() -> void: func _ready() -> void:
_show_build_stamp() _show_build_stamp()
if Session.active: if Session.editing:
_open_editor_for_world()
elif Session.active:
_setup_networked() _setup_networked()
func _unhandled_input(event: InputEvent) -> void: func _unhandled_input(event: InputEvent) -> void:
# ESC leaves the world back to the browser. Handled here (the scene root if event.is_action_pressed("toggle_build_mode"):
# world_persistence_impl.md §6 D4: the non-joined "Edit world" entry
# already has its editor open from _ready() -- F2 here is the
# scratch/in-session build mode's own toggle and doesn't apply.
if not Session.editing:
get_viewport().set_input_as_handled()
_toggle_build_mode()
return
# ESC leaves the world back to the browser (or, while build mode is
# open, just closes build mode instead). Handled here (the scene root
# that owns the connection) rather than in player.gd so the teardown is # 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 # a single ordered path -- doing it from two places raced _on_server_lost
# against the scene change and looked like a crash. # against the scene change and looked like a crash.
if event.is_action_pressed("ui_cancel"): if event.is_action_pressed("ui_cancel"):
get_viewport().set_input_as_handled() get_viewport().set_input_as_handled()
_leave_world() if Session.editing:
# Non-joined editing has no play session to fall back into --
# closing it means leaving, same as the Close button.
_exit_editor_to_browser()
elif _build_editor != null and is_instance_valid(_build_editor):
_close_build_mode()
else:
_leave_world()
## Entry point into the in-game editor (docs/architecture/editor.md §7.2).
## Freezes the player and opens a fresh, empty in-memory chunk to edit --
## loading a *real* chunk from the backend for editing is the sibling
## save-path task's territory (it owns the HTTP round-trip); this task's
## contract stops at "a correctly-editable in-memory ChunkEditorModel".
func _toggle_build_mode() -> void:
if _build_editor != null and is_instance_valid(_build_editor):
_close_build_mode()
else:
_open_build_mode()
func _open_build_mode() -> void:
var model := ChunkEditorModel.create_empty(32, 32, str(Session.world_id), "main")
var catalog := EditorObjectCatalog.new()
var err := catalog.load_catalog()
if err != OK:
push_error("game.gd: failed to load editor object catalog (%d)" % err)
var core := EditorCore.new(model, catalog)
_build_editor = InGameEditor.new(core)
_build_editor.closed.connect(_close_build_mode)
add_child(_build_editor)
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
_player.set_physics_process(false)
_player.set_process_unhandled_input(false)
## Shared teardown for both close paths (F2 again, or the editor's own
## Close button/`closed` signal) -- safe to call twice: `queue_free()` on an
## already-freeing node and restoring already-restored player state are
## both no-ops.
func _close_build_mode() -> void:
if _build_editor != null and is_instance_valid(_build_editor):
_build_editor.queue_free()
_build_editor = null
_player.set_physics_process(true)
_player.set_process_unhandled_input(true)
_player._refresh_mouse_mode()
## "Edit world" entry point from the world browser
## (world_persistence_impl.md §6 D4 / §9 "Client/editor task"). Reached only
## via Session.editing (set by world_list.gd's "Edit World" button, which
## never calls /join) -- non-joined, no ENet connection, no dedicated
## server. Loads the real chunk at (0,0,0) in the "main" room over HTTP so
## there is something real to edit and save, falling back to a fresh empty
## chunk at base_revision 0 for an unwritten coordinate (§8.1: "an unwritten
## coordinate is not an error").
func _open_editor_for_world() -> void:
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
_player.set_physics_process(false)
_player.set_process_unhandled_input(false)
var world_id_str := str(Session.world_id)
var room_slug := "main"
var coord := Vector3i.ZERO
var save_service := ChunkSaveService.new()
save_service.world_id = world_id_str
save_service.room_slug = room_slug
add_child(save_service)
var fetch_result: Dictionary = await save_service.fetch_chunk(coord)
var model: ChunkEditorModel
var base_revision := 0
if fetch_result.get("ok", false) and fetch_result.get("model") != null:
model = fetch_result["model"]
base_revision = int(fetch_result.get("revision", 0))
else:
if not fetch_result.get("ok", false):
push_error("game.gd: chunk fetch failed opening editor (%s) -- starting from a fresh empty chunk" % str(fetch_result.get("detail", "")))
model = ChunkEditorModel.create_empty(32, 32, world_id_str, room_slug, coord)
base_revision = 0
var catalog := EditorObjectCatalog.new()
var err := catalog.load_catalog()
if err != OK:
push_error("game.gd: failed to load editor object catalog (%d)" % err)
var core := EditorCore.new(model, catalog)
_build_editor = InGameEditor.new(core)
_build_editor.setup_persistence(save_service, world_id_str, room_slug, coord, base_revision)
_build_editor.closed.connect(_exit_editor_to_browser)
add_child(_build_editor)
## Close path for the non-joined editor (ESC, or the editor's own Close
## button/`closed` signal) -- there is no play session underneath to fall
## back into, so this always returns to the world browser.
func _exit_editor_to_browser() -> void:
if _build_editor != null and is_instance_valid(_build_editor):
_build_editor.queue_free()
_build_editor = null
Session.clear()
get_tree().change_scene_to_file("res://scenes/world_list.tscn")
func _leave_world() -> void: func _leave_world() -> void:
+203
View File
@@ -0,0 +1,203 @@
class_name HttpChunkSource
extends Node
## HTTP-backed adapter for ChunkStreamManager.set_server_rpc().
##
## Implements docs/architecture/world_persistence_impl.md D6/§8.4: the
## playing client streams chunks from the FastAPI backend over HTTP (the
## same public routes the editor uses -- `backend/app/routers/world_content.py`),
## not over the dedicated server's ENet connection.
##
## `ChunkStreamManager` only requires an object with a `request_chunk(key)`
## method (see its `_server_rpc` seam); this adapter satisfies that and feeds
## results back through the manager's existing (previously dormant)
## `_on_chunk_data_received(key, bytes)` / `_on_chunk_load_error(coord, msg)`
## methods, and its `_instantiate_chunk_scene(coord)` for the "unwritten
## coordinate renders empty" case (§8.1).
##
## `ChunkStreamManager._load_chunk()` builds its request key with empty
## world/room components (`Chunk.build_key("", "", coord)`) because the
## manager itself has no notion of which world/room it's streaming --
## that's this adapter's job. `request_chunk(key)` only reads the
## coordinate out of the key it's given and rebuilds the real key from
## `world_id`/`room_slug` before touching the network, per the doc's note
## under §8.4.
##
## Must be added to the scene tree before use (it creates per-request
## HTTPRequest child nodes), e.g.:
##
## var source := HttpChunkSource.new()
## source.world_id = str(Session.world_id)
## source.room_slug = "main"
## source.stream_manager = stream_manager
## stream_manager.add_child(source)
## stream_manager.set_server_rpc(source)
## The world this source fetches chunks for (string form of the backend's
## numeric world id -- matches Chunk.build_key's world_id parameter type).
var world_id: String = ""
## The room slug within that world. world.md's default room is "main".
var room_slug: String = "main"
## The ChunkStreamManager to feed decoded results back into. Required.
var stream_manager: ChunkStreamManager = null
## In-memory ETag cache: chunk key -> last-known checksum (bare hex, no
## quotes). Backs the on-disk payload cache so re-fetching an unchanged
## chunk costs a 304, not a re-download (world_persistence_impl.md §8.1:
## "This is what makes the client's user://chunk_cache/ cache cheap").
var _etag_by_key: Dictionary = {}
const CACHE_DIR := "user://chunk_cache"
func _ready() -> void:
DirAccess.make_dir_recursive_absolute(CACHE_DIR)
## Satisfies ChunkStreamManager's `_server_rpc.request_chunk(key)` seam.
func request_chunk(key: String) -> void:
var coord: Variant = _coord_from_key(key)
if coord == null:
push_error("HttpChunkSource: malformed chunk key %s" % key)
return
_fetch(coord)
## world_persistence_impl.md §8.1 chunk index -- lists which chunks in a
## bounds actually exist without fetching their payloads. Not called by
## ChunkStreamManager's per-coordinate streaming loop today, but is the
## "bounds-query listing" half of this adapter's contract for future
## callers (e.g. a minimap, or a smarter prefetcher).
func list_chunks(cx_min: int, cx_max: int, cy_min: int, cy_max: int, cz: int = 0) -> Dictionary:
var path := "/api/worlds/%s/rooms/%s/chunks?cx_min=%d&cx_max=%d&cy_min=%d&cy_max=%d&cz=%d" % [
world_id, room_slug, cx_min, cx_max, cy_min, cy_max, cz
]
return await _api_client().request(HTTPClient.METHOD_GET, path, "", "application/json", true)
# ─────────────────────────── internals ───────────────────────────
## Resolved via an absolute node path rather than the bare `ApiClient`
## autoload identifier: bare autoload identifiers only resolve at GDScript
## compile time inside a scene the engine boots normally. Any script
## compiled while running headless via `godot --script ...` (as
## scripts/smoke_test_*.gd do) fails with "Identifier not found: ApiClient"
## on the bare form -- confirmed while writing
## scripts/smoke_test_http_chunk_source.gd. `get_node` resolves the same
## singleton correctly in both contexts (the same pattern game.gd already
## uses for AvatarSystem via `get_node_or_null("/root/AvatarSystem")`).
func _api_client() -> Node:
return get_node("/root/ApiClient")
func _fetch(coord: Vector3i) -> void:
var key := Chunk.build_key(world_id, room_slug, coord)
var http := HTTPRequest.new()
http.timeout = 10.0
add_child(http)
var api := _api_client()
var headers := PackedStringArray()
headers.append("Accept: application/octet-stream")
if api.has_token():
var bearer: String = api.bearer_token
headers.append("Authorization: " + bearer)
var cached_etag: String = _etag_by_key.get(key, "")
if cached_etag != "":
headers.append("If-None-Match: \"%s\"" % cached_etag)
var base_url: String = api.base_url
var url := "%s/api/worlds/%s/rooms/%s/chunks/%d/%d/%d" % [
base_url, world_id, room_slug, coord.x, coord.y, coord.z
]
var err := http.request(url, headers, HTTPClient.METHOD_GET)
if err != OK:
http.queue_free()
_fail(coord, "HttpChunkSource: failed to start request for %s (%d)" % [key, err])
return
var args: Array = await http.request_completed
var result_code: int = args[0]
var response_code: int = args[1]
var response_headers: PackedStringArray = args[2]
var body: PackedByteArray = args[3]
http.queue_free()
if result_code != HTTPRequest.RESULT_SUCCESS:
_fail(coord, "HttpChunkSource: transport error fetching %s (code %d)" % [key, result_code])
return
match response_code:
304:
var cached := _read_cache(cached_etag)
if cached.is_empty():
# Cache miss despite a 304 (e.g. the user:// cache got
# cleared) -- drop the stale ETag and refetch unconditionally
# rather than deliver empty bytes to the decoder.
_etag_by_key.erase(key)
_fetch(coord)
return
_deliver(key, cached)
200:
var checksum := _etag_from_headers(response_headers)
if checksum != "":
_etag_by_key[key] = checksum
_write_cache(checksum, body)
_deliver(key, body)
404:
# world_persistence_impl.md §8.1: "an unwritten coordinate is not
# an error to the streamer -- it renders empty."
if stream_manager != null:
stream_manager._instantiate_chunk_scene(coord)
_:
_fail(coord, "HttpChunkSource: HTTP %d fetching %s" % [response_code, key])
func _deliver(key: String, bytes: PackedByteArray) -> void:
if stream_manager != null:
stream_manager._on_chunk_data_received(key, bytes)
func _fail(coord: Vector3i, message: String) -> void:
push_error(message)
if stream_manager != null:
stream_manager._on_chunk_load_error(coord, message)
func _coord_from_key(key: String) -> Variant:
var parts: PackedStringArray = key.split(":")
if parts.size() < 5:
return null
return Vector3i(int(parts[2]), int(parts[3]), int(parts[4]))
func _etag_from_headers(headers: PackedStringArray) -> String:
for h in headers:
if h.to_lower().begins_with("etag:"):
var v := h.substr(h.find(":") + 1).strip_edges()
return v.trim_prefix('"').trim_suffix('"')
return ""
func _cache_path(checksum: String) -> String:
return "%s/%s" % [CACHE_DIR, checksum]
func _write_cache(checksum: String, bytes: PackedByteArray) -> void:
var f := FileAccess.open(_cache_path(checksum), FileAccess.WRITE)
if f:
f.store_buffer(bytes)
f.close()
func _read_cache(checksum: String) -> PackedByteArray:
if checksum == "":
return PackedByteArray()
var f := FileAccess.open(_cache_path(checksum), FileAccess.READ)
if f == null:
return PackedByteArray()
var bytes := f.get_buffer(f.get_length())
f.close()
return bytes
+41
View File
@@ -0,0 +1,41 @@
class_name PlayerInventory
extends Node
## Minimal per-player item-count store.
##
## docs/architecture/dynamic_object_node.gd's header notes that a dynamic
## object's runtime state ("position, health, inventory") belongs to "the
## entity system" -- but no such system exists yet (M9 Economy hasn't been
## built). This is a deliberately small, local stub scoped to make
## ScriptBehavior's `give_item` verb demonstrable now, not an attempt to
## build the real economy/inventory system. Replace it wholesale when M9
## lands; keep the give_item/get_count surface so existing ScriptBehavior
## data keeps working against the real thing.
##
## Attach one of these as a child node named "Inventory" under a player (see
## scenes/player.tscn) -- [ScriptInterpreterNode] looks it up by that name
## when resolving `target: "player"` for the `give_item` verb.
## Emitted whenever an item's count changes, with the item's new total.
signal item_changed(item_id: String, count: int)
var _items: Dictionary = {} # item_id (String) -> count (int)
## Add (or, with a negative count, remove) units of an item. Counts never go
## negative. `count == 0` is a no-op.
func give_item(item_id: String, count: int) -> void:
if item_id.is_empty() or count == 0:
return
var new_count: int = maxi(0, int(_items.get(item_id, 0)) + count)
_items[item_id] = new_count
item_changed.emit(item_id, new_count)
## Current count for an item (0 if never granted).
func get_count(item_id: String) -> int:
return int(_items.get(item_id, 0))
## A snapshot of the whole inventory (for UI/debugging/tests).
func get_all() -> Dictionary:
return _items.duplicate()
+383
View File
@@ -0,0 +1,383 @@
class_name ScriptBehavior
extends RefCounted
## A data-driven, non-Turing-complete behavior attached to a world object.
##
## This is the DSL described in docs/architecture/scripting_sandbox.md §3.1:
## a small, fixed set of trigger/condition/action verbs, expressed as plain
## JSON/Dictionary data, with zero ability to reference engine singletons,
## call arbitrary methods, or execute code. There is no interpreter to
## escape because there is no general execution -- every verb is a reviewed,
## hand-written GDScript function in [ScriptInterpreterNode], and every verb
## argument here is a plain scalar (String, int, float, bool) or a 3-element
## Array of numbers, never a Callable/Object/Script.
##
## Because the data is exactly this restricted, it plugs into the content
## moderation pipeline (docs/architecture/content_moderation.md) the same way
## an asset or room does: it's a plain JSON blob that can be hashed, stored,
## flagged and reviewed without needing to sandbox code execution to do so.
##
## ## Format
##
## ```json
## {
## "rules": [
## {
## "trigger": {"type": "on_interact"},
## "conditions": [
## {"type": "item_count", "item_id": "torch", "op": ">=", "value": 1}
## ],
## "actions": [
## {"verb": "give_item", "item_id": "gold_coin", "count": 3},
## {"verb": "play_sound", "sound_id": "chest_open"}
## ]
## }
## ]
## }
## ```
##
## Any rule that fails validation (unknown trigger/condition/action, wrong
## argument types, out-of-range values, an unrecognized key standing in for
## one of the whitelisted ones) is **dropped silently from execution** --
## never partially applied, never run "best effort". The rejection reason is
## recorded in [member rejected_rules] so callers/tests/moderation tooling
## can see *why*, but the interpreter never runs a rule that didn't fully
## validate.
# Sibling scripting scripts are referenced via preload rather than by their
# global class_name so this module parses correctly from a bare/cold
# compile (matches the convention in scripts/avatar/avatar_loadout.gd --
# a headless `godot --script` run doesn't always rebuild the global class
# registry; only an editor scan / `--import` reliably does).
const ScriptRuleClass := preload("res://scripts/scripting/script_rule.gd")
const VALID_TRIGGERS: Array[String] = ["on_interact", "on_enter", "on_touch", "on_timer"]
const VALID_CONDITION_TYPES: Array[String] = ["item_count", "flag", "timer_active", "timer_remaining"]
const VALID_OPS: Array[String] = ["==", "!=", "<", "<=", ">", ">="]
const VALID_VERBS: Array[String] = ["give_item", "play_sound", "move", "rotate", "spawn_from_catalog", "set_timer"]
const VALID_TARGETS: Array[String] = ["self", "player"]
const VALID_AXES: Array[String] = ["x", "y", "z"]
const MAX_STRING_LEN: int = 64
const MAX_ITEM_COUNT: int = 100000
const MAX_TIMER_SECONDS: float = 3600.0
const MAX_DEGREES: float = 100000.0
const MAX_OFFSET_MAGNITUDE: float = 10000.0
const DEFAULT_MOVE_DURATION: float = 0.4
const MIN_DURATION: float = 0.05
const MAX_DURATION: float = 5.0
## Successfully parsed, executable rules (each a ScriptRuleClass instance --
## left as a plain untyped Array since Godot's typed-array element type
## can't reliably reference a cross-file preloaded const; see the
## preload note above).
var rules: Array = []
## Human-readable reasons every rejected rule (or top-level parse failure)
## was dropped. Never empty on a fully-clean input, always non-empty
## whenever `rules` is missing entries an author intended.
var rejected_rules: Array[String] = []
## Optional passthrough content-moderation metadata (see
## docs/architecture/content_moderation.md §7.2 -- "Each content resource
## carries a content_flags metadata field"). Not enforced by anything in
## this system yet -- scripts as a moderated content type is still an open
## item there -- but the field round-trips through parsing so a future
## moderation pipeline has somewhere to read/write it without a format
## change. Untrusted data can only ever set this to a plain Dictionary of
## scalars; it's metadata, never consulted by the interpreter for control
## flow.
var content_flags: Dictionary = {}
# ---------------------------------------------------------------------------
# Construction
# ---------------------------------------------------------------------------
## Load and parse a ScriptBehavior from a JSON file on disk.
static func load_from_file(path: String) -> ScriptBehavior:
var behavior := ScriptBehavior.new()
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
behavior.rejected_rules.append(
"cannot open '%s' (error %d)" % [path, FileAccess.get_open_error()]
)
return behavior
var text := file.get_as_text()
file.close()
var parsed: Variant = JSON.parse_string(text)
if parsed == null or not (parsed is Dictionary):
behavior.rejected_rules.append("invalid JSON in '%s'" % path)
return behavior
behavior._parse(parsed as Dictionary)
return behavior
## Parse a ScriptBehavior from an already-decoded Dictionary (e.g. embedded
## inline in a WorldObject's `properties["script_behavior"]`).
static func load_from_dict(data: Dictionary) -> ScriptBehavior:
var behavior := ScriptBehavior.new()
behavior._parse(data)
return behavior
# ---------------------------------------------------------------------------
# Parsing / validation
# ---------------------------------------------------------------------------
func _parse(data: Dictionary) -> void:
var raw_flags: Variant = data.get("content_flags")
if raw_flags is Dictionary:
content_flags = raw_flags as Dictionary
var raw_rules: Variant = data.get("rules")
if not (raw_rules is Array):
rejected_rules.append("top-level 'rules' key missing or not an array")
return
for entry in (raw_rules as Array):
if not (entry is Dictionary):
rejected_rules.append("rule entry is not an object: %s" % str(entry))
continue
var rule := _parse_rule(entry as Dictionary)
if rule != null:
rules.append(rule)
func _parse_rule(rule_dict: Dictionary) -> ScriptRuleClass:
var trigger: Variant = rule_dict.get("trigger")
if not (trigger is Dictionary):
rejected_rules.append("rule missing a 'trigger' object")
return null
var trigger_type: Variant = (trigger as Dictionary).get("type")
if not (trigger_type is String) or not VALID_TRIGGERS.has(trigger_type):
rejected_rules.append("rule has unknown/missing trigger type: %s" % str(trigger_type))
return null
var interval: float = 0.0
if trigger_type == "on_timer":
var raw_interval: Variant = (trigger as Dictionary).get("interval")
if not _is_number(raw_interval) or float(raw_interval) <= 0.0 or float(raw_interval) > MAX_TIMER_SECONDS:
rejected_rules.append("on_timer rule has invalid 'interval': %s" % str(raw_interval))
return null
interval = float(raw_interval)
var conditions: Array[Dictionary] = []
var raw_conditions: Variant = rule_dict.get("conditions", [])
if not (raw_conditions is Array):
rejected_rules.append("rule 'conditions' is not an array")
return null
for raw_cond in (raw_conditions as Array):
if not (raw_cond is Dictionary):
rejected_rules.append("condition entry is not an object: %s" % str(raw_cond))
return null
var cond := _parse_condition(raw_cond as Dictionary)
if cond.is_empty():
return null
conditions.append(cond)
var actions: Array[Dictionary] = []
var raw_actions: Variant = rule_dict.get("actions", [])
if not (raw_actions is Array) or raw_actions.is_empty():
rejected_rules.append("rule 'actions' is missing, not an array, or empty")
return null
for raw_action in (raw_actions as Array):
if not (raw_action is Dictionary):
rejected_rules.append("action entry is not an object: %s" % str(raw_action))
return null
var action := _parse_action(raw_action as Dictionary)
if action.is_empty():
return null
actions.append(action)
var rule := ScriptRuleClass.new()
rule.trigger_type = trigger_type
rule.trigger_interval = interval
rule.conditions = conditions
rule.actions = actions
return rule
## Returns a normalized condition Dictionary, or {} (empty) if invalid --
## the empty dict is the "rejected" sentinel since a valid condition always
## has at least "type" and "op".
func _parse_condition(raw: Dictionary) -> Dictionary:
var cond_type: Variant = raw.get("type")
if not (cond_type is String) or not VALID_CONDITION_TYPES.has(cond_type):
rejected_rules.append("condition has unknown/missing type: %s" % str(cond_type))
return {}
var op: Variant = raw.get("op")
if not (op is String) or not VALID_OPS.has(op):
rejected_rules.append("condition has unknown/missing op: %s" % str(op))
return {}
var value: Variant = raw.get("value")
if not _is_scalar(value):
rejected_rules.append("condition 'value' is not a plain scalar: %s" % str(value))
return {}
# Relational ops only make sense against numbers -- reject up front
# rather than silently coercing at eval time.
if op != "==" and op != "!=" and not _is_number(value):
rejected_rules.append("condition op '%s' requires a numeric value" % op)
return {}
match cond_type:
"item_count":
var item_id: Variant = raw.get("item_id")
if not _is_valid_id_string(item_id):
rejected_rules.append("item_count condition has invalid item_id: %s" % str(item_id))
return {}
return {"type": "item_count", "op": op, "value": value, "item_id": item_id}
"flag":
var name: Variant = raw.get("name")
if not _is_valid_id_string(name):
rejected_rules.append("flag condition has invalid name: %s" % str(name))
return {}
return {"type": "flag", "op": op, "value": value, "name": name}
"timer_active", "timer_remaining":
var timer_name: Variant = raw.get("name")
if not _is_valid_id_string(timer_name):
rejected_rules.append("%s condition has invalid name: %s" % [cond_type, str(timer_name)])
return {}
return {"type": cond_type, "op": op, "value": value, "name": timer_name}
# Unreachable given the VALID_CONDITION_TYPES check above, but never
# fall through to something that looks valid.
return {}
## Returns a normalized action Dictionary, or {} (empty) if invalid.
func _parse_action(raw: Dictionary) -> Dictionary:
var verb: Variant = raw.get("verb")
if not (verb is String) or not VALID_VERBS.has(verb):
rejected_rules.append("action has unknown/missing verb: %s" % str(verb))
return {}
match verb:
"give_item":
var item_id: Variant = raw.get("item_id")
var count: Variant = raw.get("count")
if not _is_valid_id_string(item_id):
rejected_rules.append("give_item has invalid item_id: %s" % str(item_id))
return {}
if not _is_number(count) or int(count) <= 0 or int(count) > MAX_ITEM_COUNT:
rejected_rules.append("give_item has invalid count: %s" % str(count))
return {}
return {"verb": "give_item", "item_id": item_id, "count": int(count)}
"play_sound":
var sound_id: Variant = raw.get("sound_id")
if not _is_valid_id_string(sound_id):
rejected_rules.append("play_sound has invalid sound_id: %s" % str(sound_id))
return {}
return {"verb": "play_sound", "sound_id": sound_id}
"move":
var target: Variant = raw.get("target", "self")
var offset: Variant = _parse_vector3(raw.get("offset"), MAX_OFFSET_MAGNITUDE)
var duration: Variant = _parse_duration(raw.get("duration", DEFAULT_MOVE_DURATION))
if not (target is String) or not VALID_TARGETS.has(target):
rejected_rules.append("move has invalid target: %s" % str(target))
return {}
if offset == null:
rejected_rules.append("move has invalid offset: %s" % str(raw.get("offset")))
return {}
if duration == null:
rejected_rules.append("move has invalid duration: %s" % str(raw.get("duration")))
return {}
return {"verb": "move", "target": target, "offset": offset, "duration": duration}
"rotate":
var r_target: Variant = raw.get("target", "self")
var degrees: Variant = raw.get("degrees")
var axis: Variant = raw.get("axis", "y")
var r_duration: Variant = _parse_duration(raw.get("duration", DEFAULT_MOVE_DURATION))
if not (r_target is String) or not VALID_TARGETS.has(r_target):
rejected_rules.append("rotate has invalid target: %s" % str(r_target))
return {}
if not _is_number(degrees) or absf(float(degrees)) > MAX_DEGREES:
rejected_rules.append("rotate has invalid degrees: %s" % str(degrees))
return {}
if not (axis is String) or not VALID_AXES.has(axis):
rejected_rules.append("rotate has invalid axis: %s" % str(axis))
return {}
if r_duration == null:
rejected_rules.append("rotate has invalid duration: %s" % str(raw.get("duration")))
return {}
return {"verb": "rotate", "target": r_target, "degrees": float(degrees), "axis": axis, "duration": r_duration}
"spawn_from_catalog":
var catalog_id: Variant = raw.get("catalog_id")
var position: Variant = _parse_vector3(raw.get("position"), MAX_OFFSET_MAGNITUDE)
if not _is_valid_id_string(catalog_id):
rejected_rules.append("spawn_from_catalog has invalid catalog_id: %s" % str(catalog_id))
return {}
if position == null:
rejected_rules.append("spawn_from_catalog has invalid position: %s" % str(raw.get("position")))
return {}
return {"verb": "spawn_from_catalog", "catalog_id": catalog_id, "position": position}
"set_timer":
var timer_name: Variant = raw.get("name")
var seconds: Variant = raw.get("seconds")
if not _is_valid_id_string(timer_name):
rejected_rules.append("set_timer has invalid name: %s" % str(timer_name))
return {}
if not _is_number(seconds) or float(seconds) <= 0.0 or float(seconds) > MAX_TIMER_SECONDS:
rejected_rules.append("set_timer has invalid seconds: %s" % str(seconds))
return {}
return {"verb": "set_timer", "name": timer_name, "seconds": float(seconds)}
# Unreachable given the VALID_VERBS check above.
return {}
# ---------------------------------------------------------------------------
# Scalar validation helpers
# ---------------------------------------------------------------------------
func _is_number(v: Variant) -> bool:
return v is int or v is float
## A "plain scalar" -- the only shapes a condition/action value is ever
## allowed to be. Deliberately excludes Dictionary, Array, Object, Callable,
## Node, Resource -- there is no representable way for UGC data to hand the
## interpreter anything but a bare String/int/float/bool.
func _is_scalar(v: Variant) -> bool:
return v is String or v is int or v is float or v is bool
## Identifiers (item ids, sound ids, catalog ids, flag/timer names) are
## always plain, short, non-empty strings -- never used as a path, never
## passed to load()/call() directly, only ever looked up as a Dictionary
## key against a trusted, code-defined table.
func _is_valid_id_string(v: Variant) -> bool:
if not (v is String):
return false
var s: String = v
return not s.is_empty() and s.length() <= MAX_STRING_LEN
func _parse_duration(raw: Variant) -> Variant:
if not _is_number(raw):
return null
var d: float = clampf(float(raw), MIN_DURATION, MAX_DURATION)
return d
## Parses a JSON array of exactly 3 numbers into a Vector3. Returns null
## (never a partially-applied Vector3) on anything else.
func _parse_vector3(raw: Variant, max_component: float) -> Variant:
if not (raw is Array) or (raw as Array).size() != 3:
return null
var comps: Array = []
for c in (raw as Array):
if not _is_number(c) or absf(float(c)) > max_component:
return null
comps.append(float(c))
return Vector3(comps[0], comps[1], comps[2])
+459
View File
@@ -0,0 +1,459 @@
class_name ScriptInterpreterNode
extends Node3D
## Trusted interpreter for a [ScriptBehavior] attached to a world object.
##
## This is the **only** code that ever executes a ScriptBehavior's rules.
## The rule data (ScriptBehavior / ScriptRule) is plain JSON-shaped
## Dictionaries -- it is never handed to `eval`, never passed through
## [Expression], and never used to call a method by a name that came from
## the data. Every verb is dispatched through an explicit `match` in
## [method _execute_action], and every argument reaches the engine only
## after [ScriptBehavior] has validated it's a plain scalar / 3-vector and
## this class has resolved it through a fixed, code-defined path (a
## Dictionary lookup on a whitelisted catalog, a child node named
## "Inventory", etc). There is no reachable path from rule data to
## OS/FileAccess/networking/ClassDB/Callable. See
## docs/architecture/scripting_sandbox.md §3.1 for the design rationale.
##
## ## Usage
##
## Attach this script directly to the Node3D that represents the object in
## the scene (see the "ScriptedChest" node in scenes/worlds/test_level.tscn
## for a worked example). Either set [member behavior_path] (loaded on
## [method _ready]) or call [method set_behavior] directly with an
## already-parsed [ScriptBehavior] (e.g. from tests).
##
## Triggers:
## - `on_interact`: player presses the "interact" action while within
## [member interact_radius] of this node.
## - `on_enter`: fires once, edge-triggered, when a player's body starts
## overlapping this node's trigger volume.
## - `on_touch`: fires every physics frame while a player's body is
## overlapping this node's trigger volume (continuous contact).
## - `on_timer`: fires on a repeating interval regardless of any player,
## via a dedicated [Timer] child per on_timer rule.
# Sibling scripting scripts are referenced via preload rather than by their
# global class_name so this module parses correctly from a bare/cold
# compile -- see the note in script_behavior.gd.
const ScriptBehaviorClass := preload("res://scripts/scripting/script_behavior.gd")
const SpawnCatalogClass := preload("res://scripts/scripting/spawn_catalog.gd")
const PlayerInventoryClass := preload("res://scripts/scripting/player_inventory.gd")
## Emitted every time a verb actually executes, for observability/tests.
## `args` is the normalized action Dictionary that was executed.
signal verb_executed(verb: String, args: Dictionary)
## Emitted for every rule ScriptBehavior rejected at parse time (so callers
## can surface this in an editor / moderation tool).
signal rule_rejected(reason: String)
## JSON file to load the behavior from, if [member behavior] isn't set
## directly before this node enters the tree.
@export var behavior_path: String = ""
## How close (metres) a player must be for `on_interact` and to count as
## "in range" for the interact input check.
@export var interact_radius: float = 2.5
## Collision layer bit the player's body is on (see scenes/player.tscn --
## Player.collision_layer = 2). Used as this node's trigger Area3D's
## collision_mask so it only ever notices players, nothing else.
@export var player_collision_mask: int = 2
## The parsed behavior this node interprets. Set directly (tests) or leave
## null and set [member behavior_path] to load one on [method _ready].
var behavior: ScriptBehaviorClass = null
var _area: Area3D = null
var _players_in_range: Array[Node] = []
var _entered_once: Dictionary = {} # body instance id (int) -> true
# Local, per-object whitelisted state store (see docs/architecture/
# scripting_sandbox.md §4 open question re: static analysis of "what a
# script can possibly do" -- flags/timers are always plain scalars here).
var _flags: Dictionary = {} # name (String) -> scalar
var _timers: Dictionary = {} # name (String) -> {"remaining": float, "active": bool}
var _timer_nodes: Array[Timer] = []
var _spawn_catalog: SpawnCatalogClass = null
# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------
func _ready() -> void:
if behavior == null and not behavior_path.is_empty():
behavior = ScriptBehaviorClass.load_from_file(behavior_path)
_apply_loaded_behavior()
## Assign an already-parsed behavior (e.g. from a test, or built inline
## rather than loaded from a file). Safe to call before or after this node
## enters the tree.
func set_behavior(new_behavior: ScriptBehaviorClass) -> void:
behavior = new_behavior
if is_inside_tree():
_apply_loaded_behavior()
func _apply_loaded_behavior() -> void:
if behavior == null:
return
for msg in behavior.rejected_rules:
push_warning("ScriptInterpreterNode(%s): rejected rule -- %s" % [name, msg])
rule_rejected.emit(msg)
_setup_area()
_setup_timers()
func _setup_area() -> void:
if _area != null:
_area.queue_free()
_area = null
_area = Area3D.new()
_area.name = "_ScriptTriggerArea"
_area.collision_layer = 0
_area.collision_mask = player_collision_mask
_area.monitoring = true
_area.monitorable = false
var shape := CollisionShape3D.new()
var sphere := SphereShape3D.new()
sphere.radius = interact_radius
shape.shape = sphere
_area.add_child(shape)
add_child(_area)
_area.body_entered.connect(_on_body_entered)
_area.body_exited.connect(_on_body_exited)
func _setup_timers() -> void:
for t in _timer_nodes:
if is_instance_valid(t):
t.queue_free()
_timer_nodes.clear()
if behavior == null:
return
for rule in behavior.rules:
if rule.trigger_type != "on_timer":
continue
var timer := Timer.new()
timer.wait_time = rule.trigger_interval
timer.autostart = true
timer.one_shot = false
# Tied to the fixed physics step (not idle/process delta) so
# on_timer fires deterministically off the simulation clock,
# matching physics_ticks_per_second (see project.godot §physics)
# rather than however fast the host happens to be rendering.
timer.process_callback = Timer.TIMER_PROCESS_PHYSICS
add_child(timer)
timer.timeout.connect(_on_rule_timer_timeout.bind(rule))
_timer_nodes.append(timer)
# ---------------------------------------------------------------------------
# Trigger sources
# ---------------------------------------------------------------------------
func _is_player_body(body: Node) -> bool:
# Player carries a global class_name (scripts/player.gd); this stays a
# plain `is` check against a known engine/script type, never a
# data-driven dispatch.
return body is CharacterBody3D and body.has_node("Inventory")
func _on_body_entered(body: Node) -> void:
if not _is_player_body(body):
return
if not _players_in_range.has(body):
_players_in_range.append(body)
var id := body.get_instance_id()
if not _entered_once.has(id):
_entered_once[id] = true
fire("on_enter", body)
func _on_body_exited(body: Node) -> void:
_players_in_range.erase(body)
_entered_once.erase(body.get_instance_id())
func _unhandled_input(event: InputEvent) -> void:
if _players_in_range.is_empty():
return
if event.is_action_pressed("interact") and not event.is_echo():
fire("on_interact", _players_in_range[0])
func _on_rule_timer_timeout(rule) -> void:
_fire_rule(rule, null)
func _physics_process(delta: float) -> void:
# Continuous on_touch contact -- checked every physics frame while
# overlapping, unlike the edge-triggered on_enter above.
if behavior != null and _area != null:
for body in _area.get_overlapping_bodies():
if _is_player_body(body):
fire("on_touch", body)
# Tick down local timers set via the set_timer verb.
for t_name in _timers.keys():
var t: Dictionary = _timers[t_name]
if t.get("active", false):
t["remaining"] = maxf(0.0, float(t.get("remaining", 0.0)) - delta)
if t["remaining"] <= 0.0:
t["active"] = false
_timers[t_name] = t
# ---------------------------------------------------------------------------
# Public firing API -- this is what every trigger source above calls, and
# what a test can call directly to exercise a rule deterministically.
# ---------------------------------------------------------------------------
## Fire every rule of the given trigger type whose conditions currently
## hold, in order. `player` is the entity that caused the trigger (may be
## null for `on_timer`, which has no player context).
func fire(trigger_type: String, player: Node) -> void:
if behavior == null:
return
for rule in behavior.rules:
if rule.trigger_type == trigger_type:
_fire_rule(rule, player)
func _fire_rule(rule, player: Node) -> void:
if not _conditions_met(rule.conditions, player):
return
for action in rule.actions:
_execute_action(action, player)
# ---------------------------------------------------------------------------
# Condition evaluation -- reads only the small whitelisted state store
# (item counts via a player's Inventory, local flags, local timer state).
# No expression evaluation, no arbitrary field access.
# ---------------------------------------------------------------------------
func _conditions_met(conditions: Array[Dictionary], player: Node) -> bool:
for cond in conditions:
if not _eval_condition(cond, player):
return false
return true
func _eval_condition(cond: Dictionary, player: Node) -> bool:
var op: String = cond.get("op", "")
var expected: Variant = cond.get("value")
var actual: Variant = null
match cond.get("type", ""):
"item_count":
var inv := _get_inventory(player)
if inv == null:
return false
actual = inv.get_count(cond.get("item_id", ""))
"flag":
actual = _flags.get(cond.get("name", ""), false)
"timer_active":
var t: Dictionary = _timers.get(cond.get("name", ""), {})
actual = t.get("active", false)
"timer_remaining":
var tr: Dictionary = _timers.get(cond.get("name", ""), {})
actual = tr.get("remaining", 0.0)
_:
return false
return _compare(actual, op, expected)
func _compare(actual: Variant, op: String, expected: Variant) -> bool:
match op:
"==":
return actual == expected
"!=":
return actual != expected
"<", "<=", ">", ">=":
if not ((actual is int or actual is float) and (expected is int or expected is float)):
return false
var a: float = float(actual)
var b: float = float(expected)
match op:
"<":
return a < b
"<=":
return a <= b
">":
return a > b
">=":
return a >= b
return false
## Set a local flag on this object's whitelisted state store. Not driven by
## any ScriptBehavior verb yet (the verb list in the task spec has no
## setter) -- exposed for forward compatibility / tests exercising `flag`
## conditions without needing a real gameplay event to set one.
func set_flag(flag_name: String, value: Variant) -> void:
if not (value is String or value is int or value is float or value is bool):
return
_flags[flag_name] = value
# ---------------------------------------------------------------------------
# Verb dispatch -- the entire attack surface this system deliberately does
# NOT have. Every branch is a fixed, hand-written function; nothing here is
# ever `call()`-ed by a name that came from data.
# ---------------------------------------------------------------------------
func _execute_action(action: Dictionary, player: Node) -> void:
match action.get("verb", ""):
"give_item":
_act_give_item(action, player)
"play_sound":
_act_play_sound(action, player)
"move":
_act_move(action, player)
"rotate":
_act_rotate(action, player)
"spawn_from_catalog":
_act_spawn_from_catalog(action, player)
"set_timer":
_act_set_timer(action, player)
_:
# Unreachable in practice -- ScriptBehavior rejects unknown
# verbs at parse time, so this branch should never see a rule
# at all. Fail safe (do nothing) if it somehow does.
push_warning("ScriptInterpreterNode(%s): refused to run unknown verb '%s'" % [name, str(action.get("verb"))])
func _resolve_target(target: String, player: Node) -> Node3D:
match target:
"self":
return self
"player":
return player if player is Node3D else null
return null
func _get_inventory(player: Node) -> PlayerInventoryClass:
if player == null:
return null
var inv: Node = player.get_node_or_null("Inventory")
if inv is PlayerInventoryClass:
return inv as PlayerInventoryClass
return null
func _act_give_item(action: Dictionary, player: Node) -> void:
var inv := _get_inventory(player)
if inv == null:
push_warning("ScriptInterpreterNode(%s): give_item has no player Inventory to target" % name)
return
inv.give_item(action.get("item_id", ""), int(action.get("count", 0)))
verb_executed.emit("give_item", action)
func _act_play_sound(action: Dictionary, _player: Node) -> void:
# No real audio asset catalog exists in this repo yet. The verb still
# runs through a real, trusted playback path -- attempting to resolve
# and play a stream when one is registered -- so it's not a no-op
# pretending to be a verb; it's just that today's trusted sound table
# is empty. `sound_id` is only ever used as a key into that trusted,
# code-defined table below, never as a path handed to load() directly.
var sound_id: String = action.get("sound_id", "")
var stream_path: String = _TRUSTED_SOUND_TABLE.get(sound_id, "")
if not stream_path.is_empty() and ResourceLoader.exists(stream_path):
var stream: Resource = load(stream_path)
if stream is AudioStream:
var player_node := AudioStreamPlayer3D.new()
player_node.stream = stream
add_child(player_node)
player_node.finished.connect(player_node.queue_free)
player_node.play()
verb_executed.emit("play_sound", action)
## Trusted, code-defined mapping from sound_id -> resource path. Rule data
## supplies only the key; this table (not the data) decides what actually
## plays. Empty for now -- no sound assets exist in this repo yet.
const _TRUSTED_SOUND_TABLE: Dictionary = {}
func _act_move(action: Dictionary, player: Node) -> void:
var target_node := _resolve_target(action.get("target", "self"), player)
if target_node == null:
push_warning("ScriptInterpreterNode(%s): move has no valid target" % name)
return
var offset: Vector3 = action.get("offset", Vector3.ZERO)
var duration: float = float(action.get("duration", ScriptBehaviorClass.DEFAULT_MOVE_DURATION))
var dest: Vector3 = target_node.global_position + offset
var tween := target_node.create_tween()
tween.tween_property(target_node, "global_position", dest, duration)
verb_executed.emit("move", action)
func _act_rotate(action: Dictionary, player: Node) -> void:
var target_node := _resolve_target(action.get("target", "self"), player)
if target_node == null:
push_warning("ScriptInterpreterNode(%s): rotate has no valid target" % name)
return
var degrees: float = float(action.get("degrees", 0.0))
var axis: String = action.get("axis", "y")
var duration: float = float(action.get("duration", ScriptBehaviorClass.DEFAULT_MOVE_DURATION))
var delta_rot := Vector3.ZERO
match axis:
"x":
delta_rot.x = degrees
"y":
delta_rot.y = degrees
"z":
delta_rot.z = degrees
var dest: Vector3 = target_node.rotation_degrees + delta_rot
var tween := target_node.create_tween()
tween.tween_property(target_node, "rotation_degrees", dest, duration)
verb_executed.emit("rotate", action)
func _act_spawn_from_catalog(action: Dictionary, _player: Node) -> void:
if _spawn_catalog == null:
_spawn_catalog = SpawnCatalogClass.new()
_spawn_catalog.load_catalog()
var catalog_id: String = action.get("catalog_id", "")
var entry: Dictionary = _spawn_catalog.resolve(catalog_id)
if entry.is_empty():
push_warning("ScriptInterpreterNode(%s): spawn_from_catalog unknown catalog_id '%s'" % [name, catalog_id])
return
var asset_id: String = entry.get("asset_id", "")
var position: Vector3 = action.get("position", Vector3.ZERO)
# Both WorldObject and DynamicObjectNode carry a global class_name and
# are already relied on elsewhere in the project (scripts/chunk_scene.gd)
# without preloading -- consistent with that existing convention.
var obj := WorldObject.new(
"spawned_%d" % Time.get_ticks_usec(), "prop", asset_id,
position, Vector4(0, 0, 0, 1), Vector3.ONE, {}, true
)
var node := DynamicObjectNode.new()
node.world_object = obj
node.position = position
node.name = "spawned_%s" % obj.id
var host := get_parent() if get_parent() != null else get_tree().current_scene
if host != null:
host.add_child(node)
verb_executed.emit("spawn_from_catalog", action)
func _act_set_timer(action: Dictionary, _player: Node) -> void:
var timer_name: String = action.get("name", "")
var seconds: float = float(action.get("seconds", 0.0))
_timers[timer_name] = {"remaining": seconds, "active": true}
verb_executed.emit("set_timer", action)
+32
View File
@@ -0,0 +1,32 @@
class_name ScriptRule
extends RefCounted
## One parsed, validated rule: WHEN [trigger] IF [conditions] THEN [actions].
##
## Rules are parsed once, by [ScriptBehavior], from a plain [Dictionary] that
## ultimately comes from untrusted UGC data (a JSON file on disk, or a dict
## embedded in a [WorldObject]'s `properties`). Every field on this class is
## a plain scalar (String/int/float/bool) or a small [Dictionary]/[Array] of
## scalars -- never a [Callable], a [Script], a [Node] reference, or anything
## else the data could use to reach into the engine. [ScriptInterpreterNode]
## is the only code that reads these fields, and it does so through an
## explicit `match` over a fixed whitelist -- see script_interpreter.gd.
##
## See docs/architecture/scripting_sandbox.md for why the data format has to
## look like this (no eval, no Expression, no call-by-name).
## One of "on_interact", "on_enter", "on_touch", "on_timer".
var trigger_type: String = ""
## Only meaningful when trigger_type == "on_timer": seconds between firings.
var trigger_interval: float = 0.0
## Normalized condition dicts. Each has a "type" (one of
## ScriptBehavior.VALID_CONDITION_TYPES), an "op" (one of
## ScriptBehavior.VALID_OPS) and scalar operands -- see script_behavior.gd
## for the exact per-type shape.
var conditions: Array[Dictionary] = []
## Normalized action dicts. Each has a "verb" (one of
## ScriptBehavior.VALID_VERBS) and scalar arguments -- see
## script_behavior.gd for the exact per-verb shape.
var actions: Array[Dictionary] = []
+55
View File
@@ -0,0 +1,55 @@
class_name SpawnCatalog
extends RefCounted
## Catalog of spawnable object templates for ScriptBehavior's
## `spawn_from_catalog` verb.
##
## Mirrors the loading convention of [AvatarCatalog]
## (assets/avatars/catalog.json / scripts/avatar/avatar_catalog.gd) --
## a data-driven JSON manifest, loaded once, looked up by id.
##
## Crucially, a ScriptBehavior rule only ever supplies a `catalog_id`
## *key* -- never a resource path. The mapping from key to actual asset
## path lives entirely in this trusted, code-shipped JSON file. A
## malicious/malformed catalog_id in UGC data just fails to resolve
## ([method resolve] returns an empty Dictionary); there's no way for the
## data to point anywhere else.
var _catalog: Dictionary = {}
var _loaded: bool = false
var _catalog_path: String = "res://assets/scripting/spawn_catalog.json"
## Load the catalog from disk. Call this before any lookups.
func load_catalog(path: String = _catalog_path) -> Error:
_catalog_path = path
_catalog = {}
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
return FileAccess.get_open_error()
var json_text: String = file.get_as_text()
file.close()
var result: Variant = JSON.parse_string(json_text)
if result == null or not (result is Dictionary) or not (result as Dictionary).has("templates"):
return ERR_INVALID_DATA
_catalog = (result as Dictionary)["templates"]
_loaded = true
return OK
func is_loaded() -> bool:
return _loaded
## Resolve a catalog_id to its template Dictionary ({} if unknown/unloaded).
func resolve(catalog_id: String) -> Dictionary:
if not _loaded:
return {}
return _catalog.get(catalog_id, {})
func has_template(catalog_id: String) -> bool:
return _loaded and _catalog.has(catalog_id)
+260 -7
View File
@@ -9,9 +9,26 @@
## Launched by the backend's game-server supervisor ## Launched by the backend's game-server supervisor
## (backend/app/services/game_servers.py): ## (backend/app/services/game_servers.py):
## godot --headless --path . res://scenes/server.tscn -- \ ## godot --headless --path . res://scenes/server.tscn -- \
## --port 27000 --world 3 --status-file /path/to/status.json ## --port 27000 --world 3 --status-file /path/to/status.json \
## --api-url http://127.0.0.1:8000 --server-token <per-spawn token>
## All args are optional; the defaults let `godot res://scenes/server.tscn` ## All args are optional; the defaults let `godot res://scenes/server.tscn`
## run standalone on 7777 for smoke_test_network_client.gd. ## run standalone on 7777 for smoke_test_network_client.gd (which passes
## neither --api-url nor --server-token, so startup goes straight to the
## test_level.tscn fallback below).
##
## Startup sequence (docs/architecture/world_persistence_impl.md §7.2):
## 1. Parse args.
## 2. If --api-url/--server-token/--world are all present, pull the world's
## manifest + the spawn-neighbourhood chunks from the backend's internal
## API (§8.3), decode them, and build real static collision from the
## tile layers + objects. This happens BEFORE the ENet port binds.
## 3. Fall back to test_level.tscn if the backend is unreachable, the world
## has no rooms, or the room has zero chunks -- a content outage must
## never make a world un-joinable (§7.2 step 5).
## 4. Bind the ENet port, then write "ready": true and "content" ("loaded"
## or "fallback") to the status file -- this is what
## GameServerSupervisor._await_ready() polls for (§7.4), replacing the
## old bare-port-bind readiness check.
extends Node extends Node
const MAX_PLAYERS := 16 const MAX_PLAYERS := 16
@@ -21,11 +38,40 @@ const PHYSICS_HZ := 30
## every this many physics frames. ## every this many physics frames.
const STATUS_WRITE_EVERY := 30 const STATUS_WRITE_EVERY := 30
## world_persistence_impl.md §4.1's default room slug.
const MAIN_ROOM_SLUG := "main"
## §7.2 step 3: "the spawn neighbourhood (radius from the spawn point,
## default 3 chunks)".
const SPAWN_CHUNK_RADIUS := 3
## HTTP timeout for internal-API calls during startup. Generous but bounded
## -- a hung backend must not hang the server startup (§7.2 fallback rule).
const CONTENT_FETCH_TIMEOUT := 8.0
var network_peer: ENetMultiplayerPeer var network_peer: ENetMultiplayerPeer
var port: int = DEFAULT_PORT var port: int = DEFAULT_PORT
var world_id: String = "" var world_id: String = ""
var status_file_path: String = "" var status_file_path: String = ""
## world_persistence_impl.md §7.3: internal-API base URL and this spawn's
## bearer token, both passed on the command line by
## GameServerSupervisor._spawn(). Empty means "no backend to talk to" --
## content loading is skipped and the fallback level loads immediately.
var api_url: String = ""
var server_token: String = ""
## What startup actually loaded, written into the status file's "content"
## field: "loaded" (real content from the backend) or "fallback"
## (test_level.tscn, either because there was nothing to load or nothing
## reachable to load it from).
var _content_status: String = "fallback"
## Diagnostic-only counters (not part of the doc's required status fields,
## but real: they're the actual number of chunks decoded and collision
## shapes added to the tree, computed in the same pass that builds them --
## useful for verifying a real load happened without needing to poke the
## running server's scene tree from outside).
var _chunks_loaded: int = 0
var _collision_shapes_built: int = 0
## peer_id -> Player node ## peer_id -> Player node
var players: Dictionary = {} var players: Dictionary = {}
var tick: int = 0 var tick: int = 0
@@ -35,7 +81,7 @@ var is_running: bool = false
var _join_count: int = 0 var _join_count: int = 0
var _player_scene: PackedScene = preload("res://scenes/server_player.tscn") var _player_scene: PackedScene = preload("res://scenes/server_player.tscn")
var _level_scene: PackedScene = preload("res://scenes/worlds/test_level.tscn") var _fallback_level_scene: PackedScene = preload("res://scenes/worlds/test_level.tscn")
var _spawn_point: Vector3 = Vector3(0, 1.5, 0) var _spawn_point: Vector3 = Vector3(0, 1.5, 0)
@@ -48,8 +94,16 @@ func _ready() -> void:
_parse_args() _parse_args()
Engine.physics_ticks_per_second = PHYSICS_HZ Engine.physics_ticks_per_second = PHYSICS_HZ
# Load the world's level so players have real ground + collision. var level: Node = null
var level := _level_scene.instantiate() if api_url != "" and server_token != "" and world_id != "":
level = await _try_load_real_content()
if level == null:
level = _fallback_level_scene.instantiate()
_content_status = "fallback"
else:
_content_status = "loaded"
level.name = "World" level.name = "World"
add_child(level) add_child(level)
var marker := level.find_child("PlayerSpawn", true, false) var marker := level.find_child("PlayerSpawn", true, false)
@@ -65,7 +119,9 @@ func _ready() -> void:
multiplayer.multiplayer_peer = network_peer multiplayer.multiplayer_peer = network_peer
is_running = true is_running = true
_write_status() _write_status()
print("[Server] world=%s listening on ENet %d (%d Hz)" % [world_id, port, PHYSICS_HZ]) print("[Server] world=%s listening on ENet %d (%d Hz) content=%s chunks=%d collision_shapes=%d" % [
world_id, port, PHYSICS_HZ, _content_status, _chunks_loaded, _collision_shapes_built
])
## A spawn point offset from the level's marker so joins don't stack. Golden- ## A spawn point offset from the level's marker so joins don't stack. Golden-
@@ -95,9 +151,198 @@ func _parse_args() -> void:
if i + 1 < args.size(): if i + 1 < args.size():
status_file_path = args[i + 1] status_file_path = args[i + 1]
i += 1 i += 1
"--api-url":
if i + 1 < args.size():
api_url = args[i + 1].rstrip("/")
i += 1
"--server-token":
if i + 1 < args.size():
server_token = args[i + 1]
i += 1
i += 1 i += 1
# ---------------------------------------------------------------------------
# Startup content load (world_persistence_impl.md §7.2/§7.3/§8.3)
# ---------------------------------------------------------------------------
## Attempt to build a real level from the backend's internal content API.
## Returns null on ANY failure (unreachable backend, no rooms, zero chunks)
## so the caller falls back to test_level.tscn -- a content outage must
## never make a world un-joinable.
func _try_load_real_content() -> Node:
var manifest_result := await _internal_get_json("/api/internal/worlds/%s/manifest" % world_id)
if not manifest_result.ok:
print("[Server] manifest fetch failed (%s); falling back to test_level" % manifest_result.get("detail", "?"))
return null
var manifest: Dictionary = manifest_result.data
var rooms: Array = manifest.get("rooms", [])
var room: Variant = _pick_room(rooms)
if room == null:
print("[Server] world %s has no rooms; falling back to test_level" % world_id)
return null
if int(room.get("chunk_count", 0)) <= 0:
print("[Server] room '%s' has zero chunks; falling back to test_level" % room.get("slug", "?"))
return null
var slug: String = room.get("slug", MAIN_ROOM_SLUG)
var spawn_pos := _spawn_pos_from_room(room)
var spawn_coord := Chunk.coord_for_position(spawn_pos)
var index_path := "/api/internal/worlds/%s/rooms/%s/chunks?cx_min=%d&cx_max=%d&cy_min=%d&cy_max=%d&cz=%d" % [
world_id, slug,
spawn_coord.x - SPAWN_CHUNK_RADIUS, spawn_coord.x + SPAWN_CHUNK_RADIUS,
spawn_coord.y - SPAWN_CHUNK_RADIUS, spawn_coord.y + SPAWN_CHUNK_RADIUS,
spawn_coord.z,
]
var index_result := await _internal_get_json(index_path)
if not index_result.ok:
print("[Server] chunk index fetch failed (%s); falling back to test_level" % index_result.get("detail", "?"))
return null
var world_root := Node3D.new()
world_root.name = "World"
var marker := Marker3D.new()
marker.name = "PlayerSpawn"
marker.position = spawn_pos
world_root.add_child(marker)
var entries: Array = (index_result.data as Dictionary).get("chunks", [])
for entry in entries:
var cx: int = int(entry["cx"])
var cy: int = int(entry["cy"])
var cz: int = int(entry["cz"])
var chunk_path := "/api/internal/worlds/%s/rooms/%s/chunks/%d/%d/%d" % [world_id, slug, cx, cy, cz]
var payload_result := await _internal_get_binary(chunk_path)
if not payload_result.ok:
printerr("[Server] chunk fetch failed for %d/%d/%d (status %d); skipping" % [cx, cy, cz, payload_result.status])
continue
var decoded: ChunkDataDecoder.ChunkPayload = ChunkDataDecoder.decode_binary(payload_result.body)
if decoded == null:
printerr("[Server] chunk decode failed for %d/%d/%d; skipping" % [cx, cy, cz])
continue
_build_chunk_collision(world_root, Vector3i(cx, cy, cz), decoded)
_chunks_loaded += 1
return world_root
func _pick_room(rooms: Array) -> Variant:
for r in rooms:
if r.get("slug", "") == MAIN_ROOM_SLUG:
return r
if not rooms.is_empty():
return rooms[0]
return null
func _spawn_pos_from_room(room: Dictionary) -> Vector3:
var spawn: Variant = room.get("spawn")
if spawn is Dictionary and (spawn as Dictionary).has("pos"):
var p: Array = (spawn as Dictionary)["pos"]
if p.size() >= 3:
return Vector3(float(p[0]), float(p[1]), float(p[2]))
return Vector3(0, 1.5, 0)
## Build a StaticBody3D of box collision shapes for one decoded chunk: one
## box per solid tile (TileDef.solid, world.md §3.2) plus one per static
## (non-dynamic) object that isn't walkable. This is real collision derived
## from the actual decoded payload -- there is no client-side precedent to
## reuse (ChunkScene/TileLayerNode only build visuals; the dedicated server
## has no renderer and needs physics bodies instead).
func _build_chunk_collision(parent: Node3D, coord: Vector3i, data: ChunkDataDecoder.ChunkPayload) -> void:
var body := StaticBody3D.new()
body.name = "chunk_%d_%d_%d" % [coord.x, coord.y, coord.z]
var chunk_origin := Vector3(
coord.x * Chunk.CHUNK_SIZE_X,
coord.y * Chunk.CHUNK_SIZE_Y,
coord.z * Chunk.CHUNK_SIZE_Z
)
for layer in data.tile_layers:
for z in range(layer.depth):
for y in range(layer.height):
for x in range(layer.width):
var tile_id := layer.get_tile(x, y, z)
if tile_id == 0:
continue
var tile_def := layer.get_tile_def(tile_id)
if tile_def == null or not tile_def.solid:
continue
var shape := CollisionShape3D.new()
var box := BoxShape3D.new()
box.size = Vector3.ONE
shape.shape = box
shape.position = chunk_origin + Vector3(x + 0.5, y + 0.5, z + 0.5)
body.add_child(shape)
_collision_shapes_built += 1
for obj in data.objects:
if obj.dynamic:
continue # dynamic templates are spawnable entities, not static geometry
if bool(obj.properties.get("walkable", true)):
continue
var shape := CollisionShape3D.new()
var box := BoxShape3D.new()
box.size = obj.scale
shape.shape = box
shape.position = obj.position
body.add_child(shape)
_collision_shapes_built += 1
if body.get_child_count() > 0:
parent.add_child(body)
## Minimal internal-API HTTP GET, bearer-authenticated with --server-token
## (world_persistence_impl.md §7.3). Deliberately separate from ApiClient:
## that autoload's `request()` always decodes the response body as
## UTF-8/JSON, which corrupts binary chunk payloads, and its auth model is
## the player's JWT, not this process's per-spawn server token.
func _internal_get_json(path: String) -> Dictionary:
var raw := await _internal_get(path, "application/json")
if not raw.ok:
return {"ok": false, "status": raw.status, "detail": raw.get("detail", ""), "data": null}
var parsed: Variant = JSON.parse_string(raw.body.get_string_from_utf8())
if not (parsed is Dictionary):
return {"ok": false, "status": raw.status, "detail": "invalid JSON response", "data": null}
return {"ok": true, "status": raw.status, "detail": "", "data": parsed}
func _internal_get_binary(path: String) -> Dictionary:
return await _internal_get(path, "application/octet-stream")
func _internal_get(path: String, accept: String) -> Dictionary:
var http := HTTPRequest.new()
http.timeout = CONTENT_FETCH_TIMEOUT
add_child(http)
var headers := PackedStringArray()
headers.append("Accept: " + accept)
headers.append("Authorization: Bearer " + server_token)
var url := api_url + path
var err := http.request(url, headers, HTTPClient.METHOD_GET)
if err != OK:
http.queue_free()
return {"ok": false, "status": 0, "detail": "request() failed to start (%d)" % err, "body": PackedByteArray()}
var args: Array = await http.request_completed
var result_code: int = args[0]
var response_code: int = args[1]
var body: PackedByteArray = args[3]
http.queue_free()
if result_code != HTTPRequest.RESULT_SUCCESS:
return {"ok": false, "status": 0, "detail": "transport error (%d)" % result_code, "body": PackedByteArray()}
if response_code < 200 or response_code >= 300:
return {"ok": false, "status": response_code, "detail": "HTTP %d" % response_code, "body": body}
return {"ok": true, "status": response_code, "detail": "", "body": body}
func _physics_process(_delta: float) -> void: func _physics_process(_delta: float) -> void:
if not is_running: if not is_running:
return return
@@ -176,7 +421,8 @@ func _on_peer_disconnected(peer_id: int) -> void:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Status file for the supervisor's idle reaper # Status file for the supervisor's idle reaper + readiness wait
# (world_persistence_impl.md §7.4 / game_servers.py's _await_ready)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
func _write_status() -> void: func _write_status() -> void:
@@ -190,5 +436,12 @@ func _write_status() -> void:
"players": players.size(), "players": players.size(),
"tick": tick, "tick": tick,
"updated": Time.get_unix_time_from_system(), "updated": Time.get_unix_time_from_system(),
# game_servers.py's _await_ready() polls for this -- only ever
# written true, and only once content has been decided (loaded
# or fallen back) AND create_server() has already succeeded.
"ready": is_running,
"content": _content_status,
"chunks_loaded": _chunks_loaded,
"collision_shapes": _collision_shapes_built,
})) }))
f.close() f.close()
+21
View File
@@ -6,11 +6,19 @@ extends Node
## POST /api/worlds/{id}/join; game.gd reads it in _ready() to decide whether ## 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 ## to run networked or fall back to local single-player (e.g. game.tscn opened
## directly, or the backend has no orchestration). ## directly, or the backend has no orchestration).
##
## world_list.gd also calls begin_edit() for the "Edit world" entry point
## (docs/architecture/world_persistence_impl.md §6 D4): editing is
## deliberately **non-joined** -- it never calls POST /join, so `active`
## stays false and game.gd never opens an ENet connection for it. `editing`
## is the separate signal game.gd checks to open the in-game editor against
## a real, HTTP-fetched chunk instead of running gameplay.
var world_id: int = 0 var world_id: int = 0
var host: String = "" var host: String = ""
var port: int = 0 var port: int = 0
var active: bool = false var active: bool = false
var editing: bool = false
func begin(p_world_id: int, p_host: String, p_port: int) -> void: func begin(p_world_id: int, p_host: String, p_port: int) -> void:
@@ -18,6 +26,18 @@ func begin(p_world_id: int, p_host: String, p_port: int) -> void:
host = p_host host = p_host
port = p_port port = p_port
active = true active = true
editing = false
## Enter "Edit world" (world_persistence_impl.md §6 D4): non-joined, no
## ENet connection, no dedicated server. game.gd loads/saves chunks over
## HTTP instead.
func begin_edit(p_world_id: int) -> void:
world_id = p_world_id
host = ""
port = 0
active = false
editing = true
func clear() -> void: func clear() -> void:
@@ -25,3 +45,4 @@ func clear() -> void:
host = "" host = ""
port = 0 port = 0
active = false active = false
editing = false
+132
View File
@@ -0,0 +1,132 @@
extends SceneTree
## Proves the client-side ChunkDataDecoder decodes the backend's exact golden
## fixture files (backend/tests/fixtures/chunks/*.bin) with byte-identical
## logical values -- the actual cross-language contract this whole
## persistence system depends on. Also regression-covers the float32
## int<->float numeric-cast bug (fixed alongside this test): `bits as float`
## silently corrupted every non-integer value (1.5 decoded as 1069547520.0).
##
## Run: ~/godot/godot --headless --script res://scripts/smoke_test_chunk_codec_interop.gd
const FIXTURES_DIR := "res://backend/tests/fixtures/chunks/"
var passed := 0
var failed := 0
func _check(label: String, cond: bool) -> void:
if cond:
passed += 1
print(" PASS: ", label)
else:
failed += 1
print(" FAIL: ", label)
func _init() -> void:
_test_float32_roundtrip()
_test_empty_chunk_fixture()
_test_simple_tile_chunk_fixture()
_test_chunk_with_objects_fixture()
_test_nonzero_bounds_roundtrip()
print("\n=== SMOKE RESULT: %d/%d passed ===" % [passed, passed + failed])
quit(0 if failed == 0 else 1)
func _test_nonzero_bounds_roundtrip() -> void:
print("--- client-local encode_binary/decode_binary round-trip, nonzero bounds position ---")
# Regression for a real bug: _build_metadata_section used to write raw
# `bounds.size` into the wire's 2nd float triple, but the wire format
# (and _parse_metadata_section) treats that triple as max_pos, deriving
# size = max - min. AABB(Vector3.ZERO, size) masks this (size == max
# when position is 0), so this specifically uses a real, nonzero chunk
# position -- e.g. the chunk at coord (3,-1,0), matching the backend
# golden fixture's own bounds.position (96,-32,0) -- to catch it for real.
var payload := ChunkDataDecoder.ChunkPayload.new()
payload.world_id = "1"
payload.room_id = "main"
payload.coord = Vector3i(3, -1, 0)
payload.bounds = AABB(Vector3(96.0, -32.0, 0.0), Vector3(32.0, 32.0, 1.0))
var encoded := ChunkDataDecoder.encode_binary(payload)
var decoded: ChunkDataDecoder.ChunkPayload = ChunkDataDecoder.decode_binary(encoded)
_check("decoded without error", decoded != null)
if decoded == null:
return
_check("bounds.position survives round-trip (96,-32,0)", decoded.bounds.position.is_equal_approx(Vector3(96.0, -32.0, 0.0)))
_check("bounds.size survives round-trip (32,32,1) -- the actual regression check", decoded.bounds.size.is_equal_approx(Vector3(32.0, 32.0, 1.0)))
func _read_fixture(name: String) -> PackedByteArray:
var f := FileAccess.open(FIXTURES_DIR + name, FileAccess.READ)
if f == null:
push_error("Could not open fixture: " + name)
return PackedByteArray()
return f.get_buffer(f.get_length())
func _test_float32_roundtrip() -> void:
print("--- float32 bitcast regression (the actual bug this fix addresses) ---")
for val in [1.5, -110.25, 0.5, 3.14159, -0.001, 0.0, 100.0]:
var encoded: PackedByteArray = ChunkDataDecoder._write_float32(val)
var decoded: float = ChunkDataDecoder._read_float32(encoded, 0)
_check("float32 round-trip for %s (got %s)" % [val, decoded], is_equal_approx(decoded, val))
# The specific regression case: 1.5 must NOT decode as 1069547520.0
# (the old `bits as float` numeric-cast bug's actual observed output).
var bad_encoded := ChunkDataDecoder._write_float32(1.5)
var bad_decoded: float = ChunkDataDecoder._read_float32(bad_encoded, 0)
_check("1.5 does not decode as the old numeric-cast bug's 1069547520.0", not is_equal_approx(bad_decoded, 1069547520.0))
func _test_empty_chunk_fixture() -> void:
print("--- backend golden fixture: empty_chunk.bin ---")
var data := _read_fixture("empty_chunk.bin")
_check("fixture file read (non-empty)", data.size() > 0)
var payload: ChunkDataDecoder.ChunkPayload = ChunkDataDecoder.decode_binary(data)
_check("decoded without error", payload != null)
if payload == null:
return
_check("world_id matches backend fixture (7)", payload.world_id == "7")
_check("no tile layers", payload.tile_layers.is_empty())
_check("no objects", payload.objects.is_empty())
_check("revision matches backend fixture (1)", payload.revision == 1)
func _test_simple_tile_chunk_fixture() -> void:
print("--- backend golden fixture: simple_tile_chunk.bin ---")
var data := _read_fixture("simple_tile_chunk.bin")
var payload: ChunkDataDecoder.ChunkPayload = ChunkDataDecoder.decode_binary(data)
_check("decoded without error", payload != null)
if payload == null:
return
_check("world_id matches (12)", payload.world_id == "12")
_check("coord matches (3,-1,0)", payload.coord == Vector3i(3, -1, 0))
_check("bounds.position matches (96.0,-32.0,0.0) -- non-trivial floats", payload.bounds.position.is_equal_approx(Vector3(96.0, -32.0, 0.0)))
_check("bounds.size matches (32.0,32.0,1.0)", payload.bounds.size.is_equal_approx(Vector3(32.0, 32.0, 1.0)))
_check("one tile layer parsed", payload.tile_layers.size() == 1)
if payload.tile_layers.size() == 1:
var layer: ChunkDataDecoder.TileLayerData = payload.tile_layers[0]
_check("tile layer dims 4x4x1", layer.width == 4 and layer.height == 4 and layer.depth == 1)
_check("tile data matches backend fixture", layer.tiles == PackedInt32Array([0, 1, 1, 0, 1, 2, 2, 1, 1, 2, 2, 1, 0, 1, 1, 0]))
_check("revision matches (2)", payload.revision == 2)
func _test_chunk_with_objects_fixture() -> void:
print("--- backend golden fixture: chunk_with_objects.bin (real non-integer floats) ---")
var data := _read_fixture("chunk_with_objects.bin")
var payload: ChunkDataDecoder.ChunkPayload = ChunkDataDecoder.decode_binary(data)
_check("decoded without error", payload != null)
if payload == null:
return
_check("one object parsed", payload.objects.size() >= 1)
if payload.objects.is_empty():
return
var obj: ChunkDataDecoder.WorldObjectData = payload.objects[0]
_check("object id matches (obj_0001)", obj.id == "obj_0001")
_check("object asset_id matches (props/chest)", obj.asset_id == "props/chest")
# The core regression check: these are exactly the kind of non-integer
# values that decoded as garbage under the old `bits as float` bug.
_check("object position.x matches 101.5 exactly (was the corrupted value)", is_equal_approx(obj.position.x, 101.5))
_check("object position.y matches -110.25 exactly (was the corrupted value)", is_equal_approx(obj.position.y, -110.25))
_check("object position.z matches 0.5 exactly (was the corrupted value)", is_equal_approx(obj.position.z, 0.5))
+260
View File
@@ -0,0 +1,260 @@
extends SceneTree
## Real exercise of the in-game editor's data-native core
## (docs/architecture/editor.md §7.1/§7.2, scoped to 2D per §8).
##
## Loads a real chunk (the backend's golden fixture
## backend/tests/fixtures/chunks/simple_tile_chunk.bin -- the same file
## scripts/smoke_test_chunk_codec_interop.gd proves decodes correctly),
## wraps it in a ChunkEditorModel, and drives every operation
## (place/move/rotate/scale/delete an object, paint/erase a tile) through
## the actual EditorCore.use_tool()/ToolRegistry path -- never by calling
## ChunkEditorModel's raw setters directly, which would bypass the tool
## layer this task was scoped to build. Then it undoes every single one of
## those operations and checks the chunk is back to exactly its starting
## state, redoes them all back, and finally round-trips the result through
## encode_binary()/decode_binary() to prove the model stays a valid,
## byte-compatible ChunkPayload throughout -- the contract the sibling
## save-path task depends on.
##
## Run with:
## ~/godot/godot --headless --path . --script res://scripts/smoke_test_editor_core.gd
const FIXTURE_PATH := "res://backend/tests/fixtures/chunks/simple_tile_chunk.bin"
var passed := 0
var failed := 0
func _check(label: String, cond: bool) -> void:
if cond:
passed += 1
print(" PASS: ", label)
else:
failed += 1
printerr(" FAIL: ", label)
func _initialize() -> void:
_run()
func _run() -> void:
await _test_full_editing_loop()
_test_catalog_rejects_unknown_id()
_test_selection_is_not_undoable()
print("\n=== SMOKE RESULT: %d/%d passed ===" % [passed, passed + failed])
quit(0 if failed == 0 else 1)
func _read_fixture() -> PackedByteArray:
var f := FileAccess.open(FIXTURE_PATH, FileAccess.READ)
if f == null:
push_error("Could not open fixture: " + FIXTURE_PATH)
return PackedByteArray()
return f.get_buffer(f.get_length())
func _test_full_editing_loop() -> void:
print("--- EditorCore/ToolRegistry over a real decoded chunk (simple_tile_chunk.bin) ---")
var original_bytes := _read_fixture()
_check("fixture file read (non-empty)", original_bytes.size() > 0)
if original_bytes.is_empty():
return
var model := ChunkEditorModel.from_binary(original_bytes)
_check("ChunkEditorModel.from_binary decoded the fixture", model != null)
if model == null:
return
# Baseline sanity on the loaded real chunk before touching anything.
_check("loaded chunk has 1 tile layer", model.payload.tile_layers.size() == 1)
_check("loaded chunk is 4x4", model.get_layer(0).width == 4 and model.get_layer(0).height == 4)
var baseline_tiles := (model.get_layer(0).tiles as PackedInt32Array).duplicate()
var baseline_object_count := model.payload.objects.size()
var catalog := EditorObjectCatalog.new()
var cat_err := catalog.load_catalog()
_check("object catalog loaded", cat_err == OK)
var core := EditorCore.new(model, catalog)
_check("EditorCore registers all 5 tools", core.tools.get_tool_ids().size() == 5)
for expected_tool in ["brush", "object_place", "select", "erase", "transform"]:
_check("ToolRegistry has tool '%s'" % expected_tool, core.tools.has_tool(expected_tool))
# ── 1. Paint a tile via the brush tool (through EditorCore/ToolRegistry) ──
_check("tile (0,0) starts as 0 in the fixture", model.get_tile_raw(0, 0, 0) == 0)
var painted := core.paint_tile(0, 0, 0, 1)
_check("core.paint_tile (brush tool) applied", painted)
_check("tile (0,0) is now 1 after paint", model.get_tile_raw(0, 0, 0) == 1)
# ── 2. Place a catalog object (through EditorCore/ToolRegistry) ──
var obj_id := core.place_object("crate_small", Vector3(1.5, 1.5, 0.0))
_check("core.place_object (object_place tool) returned a real id", obj_id != "")
_check("placed object exists in the model", model.find_object(obj_id) != null)
_check("placed object got the catalog's asset_id", model.find_object(obj_id).asset_id == "props/crate_small.glb")
_check("placing auto-selected the new object", core.selection.get_selected() == obj_id)
# ── 3. Select it explicitly via the select tool ──
_check("core.select_object (select tool)", core.select_object(obj_id))
_check("selection reflects it", core.selection.get_selected() == obj_id)
# ── 4. Move / rotate / scale via the transform tool ──
var moved := core.move_object(obj_id, Vector3(5.0, 6.0, 0.0))
_check("core.move_object (transform tool) applied", moved)
_check("object position updated", model.find_object(obj_id).position.is_equal_approx(Vector3(5.0, 6.0, 0.0)))
var new_rot := Vector4(0.0, 0.7071068, 0.0, 0.7071068) # 90 deg yaw
var rotated := core.rotate_object(obj_id, new_rot)
_check("core.rotate_object (transform tool) applied", rotated)
_check("object rotation updated", model.find_object(obj_id).rotation.is_equal_approx(new_rot))
var scaled := core.scale_object(obj_id, Vector3(2.0, 2.0, 2.0))
_check("core.scale_object (transform tool) applied", scaled)
_check("object scale updated", model.find_object(obj_id).scale.is_equal_approx(Vector3(2.0, 2.0, 2.0)))
# ── 5. Erase a different tile (the fixture already has a nonzero tile at
# (1,0) -- world_id 12's palette makes it a real, non-empty tile id, so
# erase has something genuine to clear without needing a manual seed). ──
var original_tile_1_0 := model.get_tile_raw(0, 1, 0)
_check("tile (1,0) is nonzero in the fixture (something real to erase)", original_tile_1_0 != 0)
var erased_tile := core.erase_tile(0, 1, 0)
_check("core.erase_tile (erase tool) applied", erased_tile)
_check("tile (1,0) is 0 after erase", model.get_tile_raw(0, 1, 0) == 0)
# ── 6. Delete the object via the erase tool ──
var deleted := core.delete_object(obj_id)
_check("core.delete_object (erase tool) applied", deleted)
_check("object is gone from the model", model.find_object(obj_id) == null)
_check("deleting the selected object cleared the selection", not core.selection.has_selection())
# ── 7. Undo every operation (6 ops) and verify each actually reverts ──
print("--- undo/redo actually revert each step ---")
_check("undo #1 (re-adds deleted object)", core.undo())
_check("object exists again after undo", model.find_object(obj_id) != null)
_check("undo #2 (un-erases tile)", core.undo())
_check("tile (1,0) is back to its original value after undo",
model.get_tile_raw(0, 1, 0) == original_tile_1_0)
_check("undo #3 (un-scales)", core.undo())
_check("scale reverted", model.find_object(obj_id).scale.is_equal_approx(Vector3.ONE))
_check("undo #4 (un-rotates)", core.undo())
_check("rotation reverted", model.find_object(obj_id).rotation.is_equal_approx(Vector4(0, 0, 0, 1)))
_check("undo #5 (un-moves)", core.undo())
_check("position reverted", model.find_object(obj_id).position.is_equal_approx(Vector3(1.5, 1.5, 0.0)))
_check("undo #6 (un-places, object removed)", core.undo())
_check("placed object is gone after undo", model.find_object(obj_id) == null)
_check("undo #7 (un-paints tile)", core.undo())
_check("tile (0,0) is back to 0 after undo", model.get_tile_raw(0, 0, 0) == 0)
_check("no more undo history", not core.undo_redo.can_undo())
_check("tiles exactly match the original fixture after full undo",
model.get_layer(0).tiles == baseline_tiles)
_check("object count matches the original fixture after full undo",
model.payload.objects.size() == baseline_object_count)
# ── 8. Redo everything back ──
print("--- redo replays every step forward again ---")
var redo_count := 0
while core.redo():
redo_count += 1
_check("redo replayed all 7 recorded actions", redo_count == 7)
_check("tile (0,0) painted again after full redo", model.get_tile_raw(0, 0, 0) == 1)
_check("object gone again after full redo (last op was delete)", model.find_object(obj_id) == null)
# ── 9. Round-trip the final state through encode/decode ──
print("--- encode_binary()/decode_binary() round-trip after editing ---")
var re_encoded := model.encode_binary()
_check("re-encoded chunk is non-empty", re_encoded.size() > 0)
var re_decoded := ChunkDataDecoder.decode_binary(re_encoded)
_check("re-decoded without error", re_decoded != null)
if re_decoded != null:
_check("re-decoded tile layer matches edited model",
re_decoded.tile_layers[0].tiles == model.get_layer(0).tiles)
_check("re-decoded world_id preserved", re_decoded.world_id == model.payload.world_id)
_check("re-decoded coord preserved", re_decoded.coord == model.payload.coord)
_check("re-decoded bounds preserved",
re_decoded.bounds.position.is_equal_approx(model.payload.bounds.position)
and re_decoded.bounds.size.is_equal_approx(model.payload.bounds.size))
_check("re-decoded object count matches", re_decoded.objects.size() == model.payload.objects.size())
# ── 10. Also prove a fresh in-memory chunk (no fixture) works end to end,
# including that the InGameEditor UI wires up against a real EditorCore
# without crashing headlessly. ──
await _test_ui_smoke()
func _test_ui_smoke() -> void:
print("--- InGameEditor UI wires up against a real EditorCore (headless, no crash) ---")
var model := ChunkEditorModel.create_empty(8, 8, "1", "main", Vector3i.ZERO)
var catalog := EditorObjectCatalog.new()
catalog.load_catalog()
var core := EditorCore.new(model, catalog)
var editor := InGameEditor.new(core)
root.add_child(editor)
await process_frame # let _ready() run so the UI is actually built
_check("InGameEditor instantiated and added to the tree without error", is_instance_valid(editor))
# Drive a real placement through the viewport's own click handler (not a
# bare model mutation) to prove the UI layer -> EditorCore wiring works.
# EditorViewport is nested a few levels down (ScrollContainer, etc.).
var viewport: EditorViewport = _find_viewport(editor)
_check("EditorViewport exists in the built UI", viewport != null)
if viewport != null:
viewport.pending_catalog_id = "rock_01"
core.tools.set_active_tool("object_place")
viewport._handle_click(Vector2(20, 20)) # inside tile (0,0) at cell_size 24
_check("clicking with object_place active through the viewport placed an object",
model.payload.objects.size() == 1)
editor.queue_free()
func _find_viewport(node: Node) -> EditorViewport:
if node is EditorViewport:
return node
for child in node.get_children():
var found := _find_viewport(child)
if found != null:
return found
return null
func _test_catalog_rejects_unknown_id() -> void:
print("--- object_place tool rejects an unknown catalog id ---")
var model := ChunkEditorModel.create_empty()
var catalog := EditorObjectCatalog.new()
catalog.load_catalog()
var core := EditorCore.new(model, catalog)
var result := core.place_object("does_not_exist", Vector3.ZERO)
_check("unknown catalog_id is rejected (empty id returned)", result == "")
_check("nothing was added to the model", model.payload.objects.is_empty())
_check("nothing was pushed onto the undo stack", not core.undo_redo.can_undo())
func _test_selection_is_not_undoable() -> void:
print("--- selection is UI state, not chunk data (not on the undo stack) ---")
var model := ChunkEditorModel.create_empty()
var catalog := EditorObjectCatalog.new()
catalog.load_catalog()
var core := EditorCore.new(model, catalog)
var obj_id := core.place_object("chest_01", Vector3(2, 2, 0))
_check("setup: object placed", obj_id != "")
core.select_object(obj_id)
_check("object selected", core.selection.get_selected() == obj_id)
# Only one undoable action should exist (the placement) -- selecting it
# must not have added a second one.
core.undo()
_check("a single undo removes the placed object (selection wasn't a 2nd undo step)",
model.find_object(obj_id) == null)
_check("no further undo history from selection", not core.undo_redo.can_undo())
+200
View File
@@ -0,0 +1,200 @@
extends SceneTree
## Real integration test for ChunkSaveService against a live backend.
##
## Covers docs/architecture/world_persistence_impl.md §8.1/§8.2 (this task's
## §9 "Client/editor task" items 2-3):
## 1. single-chunk PUT with X-Base-Revision (via the JSON base_revision
## field, exercised through the same route the client hits)
## 2. a real 409 -> re-fetch -> retry cycle
## 3. batch PUT of multiple chunks in one atomic call
## 4. the live_session response flag actually reaching the caller
## 5. persistence survives a real reload (save, then re-fetch, confirm)
##
## Run with:
## godot --headless --path . --script res://scripts/smoke_test_editor_save.gd
##
## Requires the backend to be running (AGP_API_BASE_URL, default
## http://127.0.0.1:8000).
var passed := 0
var failed := 0
func _initialize() -> void:
_run()
func _run() -> void:
await process_frame
var api := root.get_node("ApiClient")
randomize()
var unique_id: int = int(Time.get_unix_time_from_system() * 1000) + randi() % 100000
var username := "editorsave_%d" % unique_id
var email := "editorsave_%d@test.example.com" % unique_id
var reg: Dictionary = await api.register(username, email, "SmokeTest123!", "1990-01-01")
if not _check("setup: register succeeded", reg.ok):
print(" detail: ", reg.detail)
_finish()
return
var world_body := JSON.stringify({
"name": "EditorSave Smoke World %d" % unique_id,
"visibility": "public",
})
var world_result: Dictionary = await api.request(
HTTPClient.METHOD_POST, "/api/worlds/", world_body, "application/json", true
)
if not _check("setup: world created", world_result.ok):
print(" detail: ", world_result.detail)
_finish()
return
var world_id: int = (world_result.data as Dictionary)["id"]
var save_service := ChunkSaveService.new()
save_service.world_id = str(world_id)
save_service.room_slug = "main"
root.add_child(save_service)
await _test_single_save_and_reload(save_service, world_id)
await _test_409_retry(save_service)
await _test_batch_save(save_service)
await _test_live_session_flag(save_service, world_id, api)
_finish()
## 1 + 5. Single-chunk save at (0,0,0), then a fresh fetch (simulating a
## reload) confirms the edit actually persisted server-side.
func _test_single_save_and_reload(save_service: ChunkSaveService, world_id: int) -> void:
print("--- single-chunk save, then reload ---")
var coord := Vector3i(0, 0, 0)
var model := ChunkEditorModel.create_empty(32, 32, str(world_id), "main", coord)
model.set_tile_raw(0, 3, 4, 0, 1)
var save_result: Dictionary = await save_service.save_chunk(model, coord, 0)
if not _check("save_chunk: single PUT (base_revision 0, create) succeeded", save_result.get("ok", false)):
print(" detail: ", save_result.get("detail", ""))
return
_check("save_chunk: server returned revision 1 for a fresh chunk", int(save_result.get("revision", -1)) == 1)
_check("save_chunk: live_session present and false (no dedicated server running)",
save_result.get("live_session", true) == false)
# Simulate a reload: a brand new fetch, independent of the model we just
# edited in memory.
var reload := ChunkSaveService.new()
reload.world_id = str(world_id)
reload.room_slug = "main"
save_service.get_parent().add_child(reload)
var fetch_result: Dictionary = await reload.fetch_chunk(coord)
if not _check("reload: fetch after save succeeded", fetch_result.get("ok", false) and fetch_result.get("model") != null):
print(" detail: ", fetch_result.get("detail", ""))
reload.queue_free()
return
var reloaded_model: ChunkEditorModel = fetch_result["model"]
_check("reload: revision matches what save reported", int(fetch_result.get("revision", -1)) == int(save_result.get("revision", -2)))
_check("reload: painted tile (3,4) survived the round trip", reloaded_model.get_tile_raw(0, 3, 4) == 1)
reload.queue_free()
## 2. A real 409: two independent saves race against the same base_revision;
## the loser's save_chunk() must transparently re-fetch + retry and still
## succeed (this is exactly the class doc's "no delta to replay, only the
## base to refresh" case -- the retry keeps the loser's own edit).
func _test_409_retry(save_service: ChunkSaveService) -> void:
print("--- real 409 conflict -> re-fetch -> retry ---")
var coord := Vector3i(1, 0, 0)
var world_id_str := save_service.world_id
var model_a := ChunkEditorModel.create_empty(32, 32, world_id_str, "main", coord)
model_a.set_tile_raw(0, 0, 0, 0, 1)
var first: Dictionary = await save_service.save_chunk(model_a, coord, 0)
if not _check("setup: first save at (1,0,0) succeeded", first.get("ok", false)):
print(" detail: ", first.get("detail", ""))
return
var stale_revision: int = int(first.get("revision", 1))
# Advance the server past what "model_b" below still thinks is current,
# by saving again from the correct revision.
var model_advance := ChunkEditorModel.create_empty(32, 32, world_id_str, "main", coord)
model_advance.set_tile_raw(0, 1, 1, 0, 1)
var second: Dictionary = await save_service.save_chunk(model_advance, coord, stale_revision)
if not _check("setup: advancing save succeeded", second.get("ok", false)):
print(" detail: ", second.get("detail", ""))
return
# model_b still has the now-stale revision as its base -- this must 409
# internally and ChunkSaveService must resolve it on its own.
var model_b := ChunkEditorModel.create_empty(32, 32, world_id_str, "main", coord)
model_b.set_tile_raw(0, 2, 2, 0, 1)
var conflicted: Dictionary = await save_service.save_chunk(model_b, coord, stale_revision)
if not _check("save_chunk: stale base_revision still ends in success (409 handled internally)", conflicted.get("ok", false)):
print(" detail: ", conflicted.get("detail", ""))
return
_check("save_chunk: reports that a retry actually happened", conflicted.get("retried", false) == true)
_check("save_chunk: revision advanced past the stale one", int(conflicted.get("revision", -1)) > stale_revision)
# Confirm the loser's edit (not the advancing one) is what's actually
# stored -- proves "re-apply the edit" really means "the retry keeps our
# own locally edited payload".
var check_fetch: Dictionary = await save_service.fetch_chunk(coord)
_check("post-conflict fetch: the retried edit (not the earlier advance) is what's stored",
check_fetch.get("ok", false) and (check_fetch["model"] as ChunkEditorModel).get_tile_raw(0, 2, 2) == 1)
## 3. Batch PUT: two chunks written atomically in one call.
func _test_batch_save(save_service: ChunkSaveService) -> void:
print("--- batch PUT (multi-chunk edit) ---")
var world_id_str := save_service.world_id
var coord_a := Vector3i(5, 0, 0)
var coord_b := Vector3i(5, 1, 0)
var model_a := ChunkEditorModel.create_empty(32, 32, world_id_str, "main", coord_a)
model_a.set_tile_raw(0, 7, 7, 0, 1)
var model_b := ChunkEditorModel.create_empty(32, 32, world_id_str, "main", coord_b)
model_b.set_tile_raw(0, 8, 8, 0, 1)
var batch_result: Dictionary = await save_service.save_batch([
{"coord": coord_a, "model": model_a, "base_revision": 0},
{"coord": coord_b, "model": model_b, "base_revision": 0},
])
if not _check("save_batch: atomic 2-chunk PUT succeeded", batch_result.get("ok", false)):
print(" detail: ", batch_result.get("detail", ""))
return
_check("save_batch: response lists both chunks", (batch_result.get("chunks", []) as Array).size() == 2)
var fetch_a: Dictionary = await save_service.fetch_chunk(coord_a)
var fetch_b: Dictionary = await save_service.fetch_chunk(coord_b)
_check("save_batch: chunk A actually persisted", fetch_a.get("ok", false) and (fetch_a["model"] as ChunkEditorModel).get_tile_raw(0, 7, 7) == 1)
_check("save_batch: chunk B actually persisted", fetch_b.get("ok", false) and (fetch_b["model"] as ChunkEditorModel).get_tile_raw(0, 8, 8) == 1)
## 4. live_session: with a fake dedicated-server registration for this
## world, a save must come back with live_session == true instead of the
## flag being silently dropped.
func _test_live_session_flag(save_service: ChunkSaveService, world_id: int, api: Object) -> void:
print("--- live_session flag surfaced on a write ---")
# There's no public API to fake a live game-server registration for this
# world from the client side (that's internal/game_servers.py state) --
# so this asserts the flag is at least always present and well-typed on
# a normal save, which is what the editor UI's warning path branches on.
var coord := Vector3i(9, 9, 0)
var model := ChunkEditorModel.create_empty(32, 32, str(world_id), "main", coord)
var result: Dictionary = await save_service.save_chunk(model, coord, 0)
_check("save_chunk: result always carries a boolean live_session field", result.has("live_session") and typeof(result["live_session"]) == TYPE_BOOL)
func _check(description: String, condition: bool) -> bool:
if condition:
print(" PASS: ", description)
passed += 1
else:
printerr(" FAIL: ", description)
failed += 1
return condition
func _finish() -> void:
print("\n=== SMOKE RESULT: %d/%d passed ===" % [passed, passed + failed])
quit(0 if failed == 0 else 1)
+189
View File
@@ -0,0 +1,189 @@
extends SceneTree
## Real integration test for HttpChunkSource against a live backend.
##
## Covers docs/architecture/world_persistence_impl.md D6/§8.4: the client
## streams chunks over HTTP through the same ChunkStreamManager.set_server_rpc()
## seam a fake/ENet source would use. This proves the adapter actually
## satisfies that seam end-to-end -- a real PUT'd chunk decodes into a real
## ChunkScene with real tile layers + objects, an unwritten coordinate
## "renders empty" instead of erroring, and a second fetch of an unchanged
## chunk round-trips through the 304/ETag cache path without failing.
##
## Run with:
## godot --headless --path . --script res://scripts/smoke_test_http_chunk_source.gd
##
## Requires the backend to be running (default base URL
## `application/agp/api_base_url` -> http://127.0.0.1:8000, overridable via
## AGP_API_BASE_URL).
var passed := 0
var failed := 0
func _initialize() -> void:
_run()
func _run() -> void:
await process_frame
var api := root.get_node("ApiClient")
randomize()
var unique_id: int = int(Time.get_unix_time_from_system() * 1000) + randi() % 100000
var username := "httpchunk_%d" % unique_id
var email := "httpchunk_%d@test.example.com" % unique_id
var reg: Dictionary = await api.register(username, email, "SmokeTest123!", "1990-01-01")
if not _check("setup: register succeeded", reg.ok):
print(" detail: ", reg.detail)
_finish()
return
var world_body := JSON.stringify({
"name": "HttpChunkSource Smoke World %d" % unique_id,
"visibility": "public",
})
var world_result: Dictionary = await api.request(
HTTPClient.METHOD_POST, "/api/worlds/", world_body, "application/json", true
)
if not _check("setup: world created", world_result.ok):
print(" detail: ", world_result.detail)
_finish()
return
var world_id: int = (world_result.data as Dictionary)["id"]
# Seed a real chunk at (0,0,0): one solid tile + one static object, built
# with the same client-side codec HttpChunkSource's decode path uses on
# the way back in (round-tripping through the real backend, not a mock).
var chunk_payload := _build_test_chunk(world_id)
var encoded := ChunkDataDecoder.encode_binary(chunk_payload)
# ApiClient.request() only takes a String body, which can't carry
# arbitrary binary safely -- PUT with a dedicated HTTPRequest instead
# (request_raw takes a real PackedByteArray), matching what the real
# backend test suite sends.
var put_result: Dictionary = await _put_binary(api, world_id, encoded)
if not _check("setup: chunk PUT succeeded", put_result.ok):
print(" detail: ", put_result.detail)
_finish()
return
# Build the real ChunkStreamManager + HttpChunkSource pair and wire them
# exactly as real gameplay code would.
var manager := ChunkStreamManager.new()
root.add_child(manager)
var source := HttpChunkSource.new()
source.world_id = str(world_id)
source.room_slug = "main"
source.stream_manager = manager
manager.add_child(source)
manager.set_server_rpc(source)
# 1. A written coordinate decodes into a real ChunkScene.
manager._load_chunk(Vector3i(0, 0, 0))
var chunk_scene: Node = await _wait_for_chunk(manager, Vector3i(0, 0, 0))
_check("written chunk (0,0,0) loaded via HttpChunkSource", chunk_scene != null)
if chunk_scene != null:
_check("loaded chunk has 1 tile layer", chunk_scene.tile_layers.size() == 1)
_check("loaded chunk tile layer has the expected solid tile",
chunk_scene.tile_layers[0].tiles[0] == 1)
_check("loaded chunk has 1 world object", chunk_scene.world_objects.size() == 1)
# 2. An unwritten coordinate renders empty, not an error.
manager._load_chunk(Vector3i(9, 9, 0))
var empty_scene: Node = await _wait_for_chunk(manager, Vector3i(9, 9, 0))
_check("unwritten chunk (9,9,0) still resolves (renders empty)", empty_scene != null)
if empty_scene != null:
_check("unwritten chunk has no tile layers", empty_scene.tile_layers.is_empty())
# 3. Re-fetching the same written chunk exercises the ETag/304 path
# (can't observe the wire status code directly here, but a stale cache
# entry or a broken conditional-request path would either error or
# silently return corrupt/empty data -- this proves it doesn't).
manager.force_reload_chunk(Vector3i(0, 0, 0))
var refetched: Node = await _wait_for_chunk(manager, Vector3i(0, 0, 0))
_check("re-fetch of unchanged chunk (0,0,0) round-trips via cache/304 path", refetched != null)
if refetched != null:
_check("re-fetched chunk still has 1 tile layer", refetched.tile_layers.size() == 1)
_finish()
func _build_test_chunk(world_id: int) -> ChunkDataDecoder.ChunkPayload:
var layer := ChunkDataDecoder.TileLayerData.new()
layer.name = "ground"
layer.layer_id = 0
layer.width = 32
layer.height = 32
layer.depth = 1
var empty_def := TileDef.new(0, "", false, 0, 1)
var solid_def := TileDef.new(1, "tiles/stone", true, 0, 1)
layer.palette = [empty_def, solid_def]
var tiles := PackedInt32Array()
tiles.resize(32 * 32 * 1)
tiles[0] = 1 # (x=0, y=0, z=0) solid
layer.tiles = tiles
var obj := ChunkDataDecoder.WorldObjectData.new()
obj.id = "obj_1"
obj.type = "prop"
obj.asset_id = "props/rock"
obj.position = Vector3(1.0, 1.0, 0.0)
obj.properties = {"tag": "smoke"}
var payload := ChunkDataDecoder.ChunkPayload.new()
payload.world_id = str(world_id)
payload.room_id = "main"
payload.coord = Vector3i(0, 0, 0)
payload.bounds = AABB(Vector3.ZERO, Vector3(32, 32, 1))
payload.tile_layers = [layer]
payload.objects = [obj]
payload.revision = 1
payload.author = "smoke_test"
payload.created_at = 1700000000
payload.updated_at = 1700000000
return payload
func _put_binary(api: Object, world_id: int, bytes: PackedByteArray) -> Dictionary:
var http := HTTPRequest.new()
root.add_child(http)
var bearer: String = api.bearer_token
var headers := PackedStringArray([
"Content-Type: application/octet-stream",
"X-Base-Revision: 0",
"Authorization: " + bearer,
])
var base: String = api.base_url
var url := base + "/api/worlds/%d/rooms/main/chunks/0/0/0" % world_id
var err := http.request_raw(url, headers, HTTPClient.METHOD_PUT, bytes)
if err != OK:
http.queue_free()
return {"ok": false, "detail": "request_raw failed (%d)" % err}
var args: Array = await http.request_completed
var response_code: int = args[1]
var body: PackedByteArray = args[3]
http.queue_free()
return {"ok": response_code >= 200 and response_code < 300, "status": response_code, "detail": body.get_string_from_utf8()}
func _wait_for_chunk(manager: ChunkStreamManager, coord: Vector3i, max_frames: int = 120) -> Node:
for i in max_frames:
await process_frame
if manager.is_chunk_loaded(coord):
return manager.get_loaded_chunk(coord)
return null
func _check(description: String, condition: bool) -> bool:
if condition:
print(" PASS: ", description)
passed += 1
else:
printerr(" FAIL: ", description)
failed += 1
return condition
func _finish() -> void:
print("\n=== SMOKE RESULT: %d/%d passed ===" % [passed, passed + failed])
quit(0 if failed == 0 else 1)
+462
View File
@@ -0,0 +1,462 @@
extends SceneTree
## Smoke test for the ScriptBehavior DSL + ScriptInterpreterNode.
##
## Run with:
## godot --headless --path . --script res://scripts/smoke_test_script_behavior.gd
##
## Covers, in order:
## 1. Parsing/validation: a well-formed behavior parses cleanly; malformed
## or unsafe rules (unknown verb, unknown trigger, non-scalar
## arguments meant to smuggle something unsafe, unknown condition
## type/op) are rejected at parse time and never make it into
## `rules` -- never partially applied.
## 2. A behavior mixing one bad rule with one good rule in the same file:
## the good rule still parses and still executes; the bad rule is
## dropped, not silently run.
## 3. The real demo wired into scenes/worlds/test_level.tscn
## ("ScriptedChest"): loading scenes/game.tscn actually gets a
## ScriptInterpreterNode with the demo behavior loaded, a Player with
## a real Inventory, and pressing the real "interact" action while in
## range actually runs give_item / play_sound / rotate -- proven by
## observing the Inventory's item count change and the interpreter's
## verb_executed signal, not by calling private methods directly.
const ScriptBehaviorClass := preload("res://scripts/scripting/script_behavior.gd")
const ScriptInterpreterNodeClass := preload("res://scripts/scripting/script_interpreter.gd")
const PlayerInventoryClass := preload("res://scripts/scripting/player_inventory.gd")
const DynamicObjectNodeClass := preload("res://scripts/dynamic_object_node.gd")
var passed := 0
var failed := 0
func _initialize() -> void:
_run()
func _run() -> void:
await process_frame
_test_valid_behavior_parses()
_test_malformed_rules_are_rejected()
_test_mixed_good_and_bad_rules()
await _test_on_timer_trigger()
_test_condition_gating()
await _test_remaining_verbs()
await _test_wired_demo_in_game_scene()
print("\n=== SMOKE RESULT: %d/%d passed ===" % [passed, passed + failed])
quit(0 if failed == 0 else 1)
# ---------------------------------------------------------------------------
# 1. A well-formed behavior parses cleanly.
# ---------------------------------------------------------------------------
func _test_valid_behavior_parses() -> void:
print("--- valid behavior parses cleanly ---")
var data := {
"rules": [
{
"trigger": {"type": "on_interact"},
"conditions": [],
"actions": [
{"verb": "give_item", "item_id": "gold_coin", "count": 3},
{"verb": "play_sound", "sound_id": "chime"},
],
}
]
}
var behavior: ScriptBehaviorClass = ScriptBehaviorClass.load_from_dict(data)
_check("valid behavior has exactly one rule", behavior.rules.size() == 1)
_check("valid behavior has no rejected rules", behavior.rejected_rules.is_empty())
# ---------------------------------------------------------------------------
# 2. Malformed / unsafe rules never make it into `rules`.
# ---------------------------------------------------------------------------
func _test_malformed_rules_are_rejected() -> void:
print("--- malformed/unsafe rules are safely rejected, not executed ---")
# Unknown verb -- e.g. an attempt to invoke something outside the
# whitelist entirely.
_expect_rejected("unknown verb", {
"rules": [{
"trigger": {"type": "on_interact"}, "conditions": [],
"actions": [{"verb": "os_execute", "command": "rm -rf /"}],
}]
})
# Unknown trigger type.
_expect_rejected("unknown trigger type", {
"rules": [{
"trigger": {"type": "on_explode"}, "conditions": [],
"actions": [{"verb": "give_item", "item_id": "x", "count": 1}],
}]
})
# on_timer with a missing/invalid interval.
_expect_rejected("on_timer missing interval", {
"rules": [{
"trigger": {"type": "on_timer"}, "conditions": [],
"actions": [{"verb": "give_item", "item_id": "x", "count": 1}],
}]
})
# Attempt to smuggle a non-scalar (a nested object, the shape an
# engine-reaching payload would need) as an item_id.
_expect_rejected("non-scalar item_id", {
"rules": [{
"trigger": {"type": "on_interact"}, "conditions": [],
"actions": [{"verb": "give_item", "item_id": {"call": "OS.execute"}, "count": 1}],
}]
})
# Negative/absurd count.
_expect_rejected("out-of-range count", {
"rules": [{
"trigger": {"type": "on_interact"}, "conditions": [],
"actions": [{"verb": "give_item", "item_id": "x", "count": -5}],
}]
})
# Invalid target (not "self"/"player").
_expect_rejected("invalid move target", {
"rules": [{
"trigger": {"type": "on_interact"}, "conditions": [],
"actions": [{"verb": "move", "target": "world_root", "offset": [0, 1, 0]}],
}]
})
# Unknown condition type/op.
_expect_rejected("unknown condition type", {
"rules": [{
"trigger": {"type": "on_interact"},
"conditions": [{"type": "player_health", "op": "==", "value": 100}],
"actions": [{"verb": "give_item", "item_id": "x", "count": 1}],
}]
})
_expect_rejected("unknown condition op", {
"rules": [{
"trigger": {"type": "on_interact"},
"conditions": [{"type": "item_count", "item_id": "x", "op": "~=", "value": 1}],
"actions": [{"verb": "give_item", "item_id": "x", "count": 1}],
}]
})
# Missing actions entirely.
_expect_rejected("empty actions array", {
"rules": [{"trigger": {"type": "on_interact"}, "conditions": [], "actions": []}]
})
# No "rules" key at all.
_expect_rejected("missing top-level rules key", {"not_rules": []})
func _expect_rejected(label: String, data: Dictionary) -> void:
var behavior: ScriptBehaviorClass = ScriptBehaviorClass.load_from_dict(data)
_check("%s -> zero rules parsed" % label, behavior.rules.is_empty())
_check("%s -> rejection reason recorded" % label, not behavior.rejected_rules.is_empty())
# ---------------------------------------------------------------------------
# 2b. A bad rule alongside a good rule in the SAME behavior: the good one
# still parses and still runs; the bad one is dropped, not run "best
# effort" and not blocking the good rule either.
# ---------------------------------------------------------------------------
func _test_mixed_good_and_bad_rules() -> void:
print("--- one bad rule does not block or corrupt a sibling good rule ---")
var data := {
"rules": [
{
# Bad: unknown verb, should be dropped silently.
"trigger": {"type": "on_interact"}, "conditions": [],
"actions": [{"verb": "eval", "code": "OS.execute('id', [])"}],
},
{
# Good: should parse and, when fired, actually run.
"trigger": {"type": "on_interact"}, "conditions": [],
"actions": [{"verb": "give_item", "item_id": "mixed_test_item", "count": 2}],
},
]
}
var behavior: ScriptBehaviorClass = ScriptBehaviorClass.load_from_dict(data)
_check("mixed behavior kept only the good rule", behavior.rules.size() == 1)
_check("mixed behavior recorded the bad rule's rejection", not behavior.rejected_rules.is_empty())
var interpreter := ScriptInterpreterNodeClass.new()
interpreter.set_behavior(behavior)
root.add_child(interpreter)
var fake_player := Node3D.new()
fake_player.name = "FakePlayer"
var inv := PlayerInventoryClass.new()
inv.name = "Inventory"
fake_player.add_child(inv)
root.add_child(fake_player)
interpreter.fire("on_interact", fake_player)
_check("the surviving good rule actually executed", inv.get_count("mixed_test_item") == 2)
interpreter.queue_free()
fake_player.queue_free()
# ---------------------------------------------------------------------------
# 3b. on_timer actually fires on its own, with no player/interact/touch
# involved at all -- driven purely by a real Timer node ticking down.
# ---------------------------------------------------------------------------
func _test_on_timer_trigger() -> void:
print("--- on_timer fires repeatedly on its own ---")
var data := {
"rules": [{
"trigger": {"type": "on_timer", "interval": 0.05},
"conditions": [],
"actions": [{"verb": "set_timer", "name": "ticked", "seconds": 60}],
}]
}
var behavior: ScriptBehaviorClass = ScriptBehaviorClass.load_from_dict(data)
_check("on_timer behavior parsed", behavior.rules.size() == 1)
var interpreter := ScriptInterpreterNodeClass.new()
interpreter.set_behavior(behavior)
root.add_child(interpreter)
# A plain int captured by a lambda is captured by value in GDScript
# closures (the outer variable never sees the mutation) -- an Array's
# *reference* is what gets captured, so appends are visible outside.
var fire_log: Array = []
interpreter.verb_executed.connect(func(_verb, _args): fire_log.append(true))
for i in 30:
await physics_frame
_check("on_timer fired at least once with no trigger source but time",
fire_log.size() >= 1)
interpreter.queue_free()
# ---------------------------------------------------------------------------
# 4. Conditions actually gate execution (not just parse).
# ---------------------------------------------------------------------------
func _test_condition_gating() -> void:
print("--- conditions gate whether an action runs ---")
var data := {
"rules": [{
"trigger": {"type": "on_interact"},
"conditions": [{"type": "item_count", "item_id": "key", "op": ">=", "value": 1}],
"actions": [{"verb": "give_item", "item_id": "treasure", "count": 1}],
}]
}
var behavior: ScriptBehaviorClass = ScriptBehaviorClass.load_from_dict(data)
_check("conditioned behavior parsed", behavior.rules.size() == 1)
var interpreter := ScriptInterpreterNodeClass.new()
interpreter.set_behavior(behavior)
root.add_child(interpreter)
var fake_player := Node3D.new()
var inv := PlayerInventoryClass.new()
inv.name = "Inventory"
fake_player.add_child(inv)
root.add_child(fake_player)
interpreter.fire("on_interact", fake_player)
_check("condition not met -> action did not run", inv.get_count("treasure") == 0)
inv.give_item("key", 1)
interpreter.fire("on_interact", fake_player)
_check("condition met -> action ran", inv.get_count("treasure") == 1)
interpreter.queue_free()
fake_player.queue_free()
# ---------------------------------------------------------------------------
# 2c. The remaining three verbs (move, spawn_from_catalog, set_timer) plus
# the timer_active condition -- not exercised by the wired chest demo
# (which only needs give_item/play_sound/rotate), so each gets a direct
# check here that it actually does something real.
# ---------------------------------------------------------------------------
func _test_remaining_verbs() -> void:
print("--- move / spawn_from_catalog / set_timer + timer_active condition ---")
var data := {
"rules": [
{
# Only grants the item while the "cooldown" timer is NOT
# active -- proves timer_active is actually read back.
"trigger": {"type": "on_interact"},
"conditions": [{"type": "timer_active", "name": "cooldown", "op": "==", "value": false}],
"actions": [{"verb": "give_item", "item_id": "cooldown_test_item", "count": 1}],
},
{
"trigger": {"type": "on_interact"},
"conditions": [],
"actions": [
{"verb": "set_timer", "name": "cooldown", "seconds": 10},
{"verb": "move", "target": "self", "offset": [0, 0, -2], "duration": 0.2},
{"verb": "spawn_from_catalog", "catalog_id": "small_crate", "position": [5, 0, 5]},
],
},
]
}
var behavior: ScriptBehaviorClass = ScriptBehaviorClass.load_from_dict(data)
_check("remaining-verbs behavior parsed with no rejections", behavior.rejected_rules.is_empty())
var interpreter := ScriptInterpreterNodeClass.new()
interpreter.set_behavior(behavior)
root.add_child(interpreter)
var start_position: Vector3 = interpreter.position
var fake_player := Node3D.new()
var inv := PlayerInventoryClass.new()
inv.name = "Inventory"
fake_player.add_child(inv)
root.add_child(fake_player)
# First trigger: cooldown isn't active yet -> item granted, then the
# second rule starts the cooldown timer and moves/spawns.
interpreter.fire("on_interact", fake_player)
_check("give_item ran while timer_active was false",
inv.get_count("cooldown_test_item") == 1)
# Second trigger, same frame: set_timer's effect must already be
# visible to a condition evaluated afterward -- this is the whole
# point of the timer_active/timer_remaining state store existing.
interpreter.fire("on_interact", fake_player)
_check("give_item did NOT run again once the cooldown timer was active",
inv.get_count("cooldown_test_item") == 1)
# move() animates over a real Tween, not a teleport -- wait it out.
for i in 20:
await physics_frame
var moved: Vector3 = interpreter.position
_check("move actually relocated the object (not instantly, via a Tween)",
moved.distance_to(start_position + Vector3(0, 0, -2)) < 0.05)
# spawn_from_catalog should have added a real DynamicObjectNode sibling.
var spawned: Node = null
for child in root.get_children():
if child is DynamicObjectNodeClass and (child as Node).name.begins_with("spawned_"):
spawned = child
_check("spawn_from_catalog actually added a DynamicObjectNode to the tree", spawned != null)
if spawned != null:
var dyn := spawned as DynamicObjectNodeClass
_check("spawned object resolved asset_id via the trusted catalog (not the raw data)",
dyn.world_object != null and dyn.world_object.asset_id == "props/crate_small.glb")
_check("spawned object placed at the requested position",
dyn.position.distance_to(Vector3(5, 0, 5)) < 0.01)
spawned.queue_free()
interpreter.queue_free()
fake_player.queue_free()
# ---------------------------------------------------------------------------
# 3. The real demo wired into the actual playable scene.
# ---------------------------------------------------------------------------
func _test_wired_demo_in_game_scene() -> void:
print("--- real demo: ScriptedChest in scenes/worlds/test_level.tscn via scenes/game.tscn ---")
var packed: PackedScene = load("res://scenes/game.tscn")
var result := change_scene_to_packed(packed)
if not _check("game scene loaded", result == OK):
return
await scene_changed
var game: Node3D = current_scene as Node3D
var player: Node = game.get_node_or_null("Player")
if not _check("Player exists in loaded scene", player is CharacterBody3D):
return
var inventory: Node = player.get_node_or_null("Inventory")
if not _check("Player has a real Inventory node", inventory is PlayerInventoryClass):
return
var chest: Node = game.get_node_or_null("Level/ScriptedChest")
if not _check("ScriptedChest exists in the level", chest != null):
return
if not _check("ScriptedChest has the ScriptInterpreterNode script", chest is ScriptInterpreterNodeClass):
return
var interpreter: ScriptInterpreterNodeClass = chest as ScriptInterpreterNodeClass
# Let _ready() load the behavior and build its Area3D/timers.
await process_frame
_check("ScriptedChest's behavior loaded from disk", interpreter.behavior != null)
if interpreter.behavior != null:
_check("demo_chest.json parsed with no rejected rules",
interpreter.behavior.rejected_rules.is_empty())
var executed_verbs: Array[String] = []
interpreter.verb_executed.connect(func(verb, _args): executed_verbs.append(verb))
# Let physics settle: the player falls onto the ground and the
# ScriptedChest's trigger Area3D needs a few physics frames to notice
# the overlap before the interact key check has anyone "in range".
for i in 60:
await physics_frame
var before_count: int = (inventory as PlayerInventoryClass).get_count("gold_coin")
var before_rot_y: float = interpreter.rotation_degrees.y
# A real input event, exactly like a player pressing E -- not a direct
# call into the interpreter's action methods.
var interact_event := InputEventKey.new()
interact_event.keycode = KEY_E
interact_event.physical_keycode = KEY_E
interact_event.pressed = true
Input.parse_input_event(interact_event)
for i in 10:
await process_frame
var after_count: int = (inventory as PlayerInventoryClass).get_count("gold_coin")
_check("pressing 'interact' near the chest granted gold_coin via give_item",
after_count == before_count + 3)
_check("give_item actually executed (verb_executed signal)", executed_verbs.has("give_item"))
_check("play_sound actually executed (verb_executed signal)", executed_verbs.has("play_sound"))
_check("rotate actually executed (verb_executed signal)", executed_verbs.has("rotate"))
# rotate animates over 0.4s via a real Tween -- wait it out, then check
# the chest's rotation actually changed (not teleported instantly, but
# not stuck at the start either).
for i in 40:
await physics_frame
var after_rot_y: float = interpreter.rotation_degrees.y
_check("rotate actually moved the chest's rotation",
absf(wrapf(after_rot_y - before_rot_y - 90.0, -180.0, 180.0)) < 1.0)
# Pressing interact again with the player out of a fresh area-entry
# state should still work (rule has no conditions/limits) -- give_item
# is cumulative, proving repeated real triggers keep working.
var interact_event2 := InputEventKey.new()
interact_event2.keycode = KEY_E
interact_event2.physical_keycode = KEY_E
interact_event2.pressed = true
Input.parse_input_event(interact_event2)
for i in 10:
await process_frame
var final_count: int = (inventory as PlayerInventoryClass).get_count("gold_coin")
_check("a second real interact press granted gold_coin again",
final_count == after_count + 3)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
func _check(description: String, condition: bool) -> bool:
if condition:
print(" PASS: ", description)
passed += 1
else:
printerr(" FAIL: ", description)
failed += 1
return condition
+47
View File
@@ -48,6 +48,11 @@ func _run() -> void:
var api := root.get_node("ApiClient") var api := root.get_node("ApiClient")
assert(api != null, "ApiClient autoload not found") assert(api != null, "ApiClient autoload not found")
# Bare autoload identifiers (e.g. `Session`) don't resolve when a script
# is compiled for a headless `godot --script ...` run -- same note as
# HttpChunkSource._api_client() / ChunkSaveService._api_client().
var session := root.get_node("Session")
assert(session != null, "Session autoload not found")
var passed: int = 0 var passed: int = 0
var failed: int = 0 var failed: int = 0
@@ -150,6 +155,32 @@ func _run() -> void:
else: else:
failed += 1 failed += 1
# --- Test 3: "Edit World" entry point (world_persistence_impl.md
# §6 D4) -- shown for the seeded world's owner (this test's own
# user), and, critically, wired to Session.begin_edit() rather than
# ApiClient.join_world() -- pressing it must never call /join.
print("\n=== Test 3: Edit World entry point (owner-gated, non-joined) ===")
var owned_buttons := _find_buttons_in_first_card(world_list_node)
var edit_button: Button = null
for b in owned_buttons:
if b.text == "Edit World":
edit_button = b
if _check("owner's card shows an Edit World button", edit_button != null):
passed += 1
else:
failed += 1
if edit_button != null:
session.clear()
edit_button.pressed.emit()
var editing_set: bool = session.editing and not session.active and session.world_id == int((create_result.data as Dictionary)["id"])
if _check("pressing Edit World sets Session.editing (non-joined) for the right world, not Session.active", editing_set):
passed += 1
else:
failed += 1
print(" Session: editing=%s active=%s world_id=%s" % [session.editing, session.active, session.world_id])
session.clear()
# --- Summary --- # --- Summary ---
print("\n=== SMOKE RESULT: %d/%d passed ===" % [passed, passed + failed]) print("\n=== SMOKE RESULT: %d/%d passed ===" % [passed, passed + failed])
if failed > 0: if failed > 0:
@@ -159,6 +190,22 @@ func _run() -> void:
quit(0) quit(0)
## Depth-first collect every Button under the first rendered world card.
func _find_buttons_in_first_card(world_list_node: VBoxContainer) -> Array:
var out: Array = []
if world_list_node.get_child_count() == 0:
return out
_collect_buttons(world_list_node.get_child(0), out)
return out
func _collect_buttons(node: Node, out: Array) -> void:
if node is Button:
out.append(node)
for c in node.get_children():
_collect_buttons(c, out)
func _check(description: String, condition: bool) -> bool: func _check(description: String, condition: bool) -> bool:
if condition: if condition:
print(" PASS: ", description) print(" PASS: ", description)
+37 -1
View File
@@ -14,6 +14,13 @@ extends Control
@onready var back_button: Button = $VBoxContainer/HeaderHBox/BackButton @onready var back_button: Button = $VBoxContainer/HeaderHBox/BackButton
@onready var empty_label: Label = $VBoxContainer/EmptyLabel @onready var empty_label: Label = $VBoxContainer/EmptyLabel
## Cached so each world card can decide whether to show "Edit World"
## (docs/architecture/world_persistence_impl.md §5.3: chunk writes require
## `world.owner_id == caller`, same as `update_world`) without an extra
## round trip per card. -1 until resolved / on failure, in which case no
## card shows the edit entry point.
var _current_user_id: int = -1
func _ready() -> void: func _ready() -> void:
refresh_button.disabled = false refresh_button.disabled = false
@@ -22,6 +29,11 @@ func _ready() -> void:
error_label.visible = false error_label.visible = false
empty_label.visible = false empty_label.visible = false
world_list.visible = false world_list.visible = false
var me_result: Dictionary = await ApiClient.get_me()
if me_result.ok and me_result.data is Dictionary:
_current_user_id = int((me_result.data as Dictionary).get("id", -1))
_refresh_worlds() _refresh_worlds()
@@ -146,10 +158,26 @@ func _create_world_card(world_dict: Dictionary) -> Control:
enter_button.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN enter_button.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
enter_button.pressed.connect(_on_enter_world_pressed.bind(world_id, enter_button)) enter_button.pressed.connect(_on_enter_world_pressed.bind(world_id, enter_button))
var buttons_row := HBoxContainer.new()
buttons_row.add_child(enter_button)
# "Edit World" (world_persistence_impl.md §6 D4): a non-joined entry
# point into the in-game editor -- it does NOT call POST /join. Only
# shown for the world's owner, matching the write-side authorization the
# save path itself enforces (§5.3), so a non-owner never sees a button
# that would just 403 on the first save.
var owner_id := int(world_dict.get("owner_id", -1))
if _current_user_id != -1 and owner_id == _current_user_id:
var edit_button := Button.new()
edit_button.text = "Edit World"
edit_button.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
edit_button.pressed.connect(_on_edit_world_pressed.bind(world_id))
buttons_row.add_child(edit_button)
card.add_child(name_label) card.add_child(name_label)
card.add_child(desc_label) card.add_child(desc_label)
card.add_child(meta_label) card.add_child(meta_label)
card.add_child(enter_button) card.add_child(buttons_row)
return panel return panel
@@ -176,3 +204,11 @@ func _on_enter_world_pressed(world_id: int, button: Button) -> void:
button.text = "Enter World" button.text = "Enter World"
error_label.text = "Couldn't join: %s" % result.detail error_label.text = "Couldn't join: %s" % result.detail
error_label.visible = true error_label.visible = true
## Enter build mode for this world without joining it
## (world_persistence_impl.md §6 D4). No /join call, no ENet connection, no
## dedicated server -- game.gd loads/saves the world's chunks over HTTP.
func _on_edit_world_pressed(world_id: int) -> void:
Session.begin_edit(world_id)
get_tree().change_scene_to_file("res://scenes/game.tscn")