Compare commits

..

1 Commits

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y2FESCaMXeJpC9gSctiEeD
2026-09-14 19:19:10 +00:00
14 changed files with 667 additions and 1489 deletions
@@ -1,17 +0,0 @@
{
"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
@@ -1,8 +0,0 @@
{
"templates": {
"small_crate": {
"asset_id": "props/crate_small.glb",
"display_name": "Small Crate"
}
}
}
+5
View File
@@ -4,6 +4,11 @@
> **Owner:** Platform team
> **Related:** World chunk data format & persistence, server authority + client
> prediction, content moderation & age verification, avatar component system
> **Note:** the recommendation here stands, but edits do **not** ride the
> `request_chunk_edit` ENet RPC (§4.2, §7.4). Per
> [`world_persistence_impl.md`](world_persistence_impl.md) the editor saves
> chunks over HTTP to the FastAPI backend, which is the sole writer; the
> validation/moderation boundary moves with it.
> **Godot version:** 4.6.2 stable
## 1. Overview
+5 -1
View File
@@ -1,6 +1,10 @@
# World Chunk Data Format & Persistence Architecture
> **Status:** Proposed
> **Status:** Proposed. The chunk format (§2, §3) is normative and implemented
> on the client. **§4 (SQLite schema) and §5 (write ownership / write-behind)
> are superseded by [`world_persistence_impl.md`](world_persistence_impl.md)**,
> which stores chunks in the existing Postgres backend and makes the FastAPI
> process — not the per-world Godot server — the sole writer.
> **Owner:** Platform team
> **Related:** Server authority + client prediction, avatar component system, content moderation & age verification, backend auth (JWT)
+655
View File
@@ -0,0 +1,655 @@
# World Persistence: Server-Side Implementation Architecture
> **Status:** Accepted (design). Implementation is split across two follow-on
> tasks (backend endpoints; editor wiring).
> **Owner:** Platform team
> **Supersedes:** the *storage engine* and *write ownership* parts of
> [`world.md`](world.md) §4 and §5. Everything else in `world.md` — the chunk
> logical model, the JSON and binary encodings, checksums, revisions, content
> flags — stands unchanged and is normative.
> **Related:** [`world.md`](world.md) (chunk format), [`editor.md`](editor.md)
> (authoring surface), [`networking.md`](networking.md) (ENet sim transport),
> [`content_moderation.md`](content_moderation.md) (flags & gating).
## 1. Why this document exists
`world.md` was written during the client-side chunk-streaming work (M2). It
assumes **one** server that owns **one** embedded SQLite file and is both the
authoritative simulation and the authoritative store. That is not the system we
built. What actually exists today is:
| Process | Lifetime | Owns | Code |
|---|---|---|---|
| FastAPI backend | always on, single worker | Postgres (users, worlds, assets, avatar loadouts), game-server orchestration | `backend/app/` |
| Godot dedicated server, one per world | **spawned on join, reaped ~5 min after the last join when empty** | nothing durable; a 30 Hz in-memory player sim | `scripts/server.gd`, spawned by `backend/app/services/game_servers.py` |
| Godot client | player session | local chunk streaming, the in-game editor (planned) | `scripts/chunk_stream_manager.gd`, `scripts/chunk_data_decoder.gd` |
Nothing server-side has ever stored or served a real chunk. `server.gd`
`preload()`s `res://scenes/worlds/test_level.tscn` for **every** world and uses
`world_id` only for logging and the status file. `ChunkStreamManager` has a
`set_server_rpc()` seam that no code calls. The backend's `World` model is
metadata only (`id`, `name`, `description`, `owner_id`, `visibility`).
This document decides where chunk content lives, who may write it, how the
dedicated server gets it, and the exact API surface — so the two implementation
tasks that follow have no architectural decisions left to make.
---
## 2. Decisions at a glance
| # | Question | Decision |
|---|---|---|
| D1 | Where does chunk data live at rest? | **Postgres**, in the existing backend database. Not a per-world SQLite file. |
| D2 | Who writes it? | **The FastAPI backend, exclusively.** The editor writes through it over HTTP with optimistic concurrency. The dedicated Godot server never writes world content. |
| D3 | How does the dedicated server get content? | **HTTP pull from the backend at startup**, over a loopback-only `/api/internal` route authenticated by a per-spawn token the supervisor mints. Lazy fetch as players roam. |
| D4 | Edit while a live session exists? | **Out of scope this milestone, with a defined seam.** The editor is a non-joined client mode; writes always succeed and the response flags `live_session` so the UI can say "players see this when the session restarts". |
| D5 | Edit granularity on the wire | **Full-chunk PUT with `base_revision`**, not RFC-6902 deltas. |
| D6 | Client chunk transport | **HTTP from the backend**, not ENet from the dedicated server. The `set_server_rpc()` seam is satisfied by an HTTP-backed adapter. |
---
## 3. D1 — Chunk data lives in Postgres
### 3.1 Decision
World content (rooms, chunk payloads, chunk revisions) is stored in the
**existing Postgres database**, alongside `worlds`, in new tables created by an
Alembic migration. `world.md` §4's `world.db` SQLite file is **not** built.
The chunk **payload stays exactly what `world.md` §3.6 specifies**: the 48-byte
header + sectioned binary body, checksummed with SHA-256. It moves from a
SQLite `BLOB` to a Postgres `BYTEA`. Nothing about the format changes, so
`scripts/chunk_data_decoder.gd` remains correct and the format spec stays the
single contract.
### 3.2 Reasoning
1. **SQLite's premise does not hold here.** `world.md` §4 justifies SQLite as
"ideal for a server that owns the world store". No process in this system
owns a world store: the only process that could plausibly hold the file (the
dedicated Godot server) is *ephemeral* — reaped when idle, `SIGTERM`'d on
backend shutdown, `SIGKILL`'d 10 s later if it doesn't exit, and orphaned
entirely if the backend dies. Meanwhile the editor must write when no such
process exists. So the file would have to be shared by two processes with
independent lifetimes, which is precisely the workload SQLite's
single-writer/file-lock model handles worst.
2. **Postgres is already a hard dependency; SQLite would be a second one.** The
backend cannot start without Postgres. Adding a per-world SQLite file adds a
second storage engine, a second migration system (`world.md` §4.8 specifies a
hand-rolled `meta.schema_version` runner *next to* the Alembic we already
have), a second backup story, and a second consistency story.
3. **Referential integrity with metadata we already have.** `worlds` rows carry
`owner_id` and `visibility`, and every authorization rule for content derives
from them. In one database this is a join and a foreign key with
`ON DELETE CASCADE`; across two engines, deleting a world silently orphans a
file on disk and every ownership check becomes a cross-store read.
4. **Concurrency is the normal case, not the edge case.** Two creators editing
one world, or an editor writing while the backend serves chunks to a running
dedicated server, are ordinary. Postgres gives row-level MVCC and real
transactions for the read-modify-write in §5.4; SQLite gives a database-level
write lock and `SQLITE_BUSY`.
5. **The repo already has the precedent and the plumbing.** `avatar_loadouts`
(migration `0004`) stores a client-shaped document in Postgres JSONB rather
than inventing a side store, and `backend/app/database.py` already provides
the async session factory this code will use.
### 3.3 What we give up, and why it's acceptable
- **Zero-config embedding.** Irrelevant — the backend already requires a
configured Postgres.
- **`world.md` §5.5 write-behind + the `dirty` column.** Deliberately dropped
(§4.4). It existed to amortize disk I/O for a process that owned the file. Our
writes are human-rate editor saves, not sim-rate; each one is a single
transaction and is durable when the HTTP response returns. Dropping it also
deletes the entire "unclean shutdown recovery" scan in §5.5 — there is no
window in which committed content can be lost.
- **Sharding by `world_id` (`world.md` §8).** Postgres partitioning by
`world_id` remains available on the same table shape if a single table ever
becomes a bottleneck. At ~2–8 KB per chunk, 10 000 chunks is under 100 MB.
---
## 4. Schema (Alembic migration `0005_add_world_content_tables`)
New model module: `backend/app/models/world_content.py`, registered in
`backend/app/models/__init__.py`. `backend/app/models/world.py` is left alone
except for one added relationship, so nothing PC1/PC2 depends on moves.
Conventions follow the existing models: `Column(...)` declarative style,
`Integer` surrogate PKs, `DateTime(timezone=True)` with `server_default=func.now()`,
JSONB for document-shaped fields (as `avatar_loadout.py` does).
### 4.1 `world_rooms`
```python
class WorldRoom(Base):
__tablename__ = "world_rooms"
id = Column(Integer, primary_key=True, index=True)
world_id = Column(Integer, ForeignKey("worlds.id", ondelete="CASCADE"),
nullable=False, index=True)
slug = Column(String(64), nullable=False) # url-safe; "main" by default
name = Column(String(255), nullable=False)
# Chunk grid config. world.md §2.2 puts this on the world; it lives on the
# room so a world can later hold rooms with different grids, and so this
# migration is purely additive (the existing `worlds` table is untouched).
grid = Column(JSONB, nullable=False,
server_default='{"size": [32, 32, 1], "origin": [0, 0, 0]}')
bounds = Column(JSONB, nullable=True) # {"min":[x,y,z], "max":[x,y,z]}; null = unbounded
spawn = Column(JSONB, nullable=True) # {"pos":[x,y,z], "yaw": 0.0}
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(),
onupdate=func.now(), nullable=False)
__table_args__ = (UniqueConstraint("world_id", "slug", name="uq_world_rooms_world_slug"),)
```
`World` gains
`rooms = relationship("WorldRoom", back_populates="world", cascade="all, delete-orphan")`.
Every world gets a `main` room: created in `create_world`, and lazily created on
first room read/write for worlds that predate the migration. `grid` defaults
match `Chunk.CHUNK_SIZE_*` in `scripts/chunk.gd`, which is the client's source
of truth for the same constants.
### 4.2 `world_chunks`
```python
class WorldChunk(Base):
__tablename__ = "world_chunks"
id = Column(Integer, primary_key=True, index=True)
world_id = Column(Integer, ForeignKey("worlds.id", ondelete="CASCADE"),
nullable=False, index=True) # denormalized: world-scoped queries
room_id = Column(Integer, ForeignKey("world_rooms.id", ondelete="CASCADE"),
nullable=False, index=True)
chunk_key = Column(String(160), nullable=False, unique=True) # see §4.5
cx = Column(Integer, nullable=False)
cy = Column(Integer, nullable=False)
cz = Column(Integer, nullable=False, server_default="0")
format_version = Column(Integer, nullable=False, server_default="1")
payload = Column(LargeBinary, nullable=False) # world.md §3.6 bytes, BYTEA
payload_len = Column(Integer, nullable=False)
checksum = Column(String(64), nullable=False) # SHA-256 hex of payload body
revision = Column(Integer, nullable=False, server_default="1")
content_flags = Column(JSONB, nullable=False, server_default="{}") # world.md §3.4
is_mature = Column(Boolean, nullable=False, server_default="false")
updated_by_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(),
onupdate=func.now(), nullable=False)
__table_args__ = (
UniqueConstraint("room_id", "cx", "cy", "cz", name="uq_world_chunks_coord"),
Index("ix_world_chunks_room_coord", "room_id", "cx", "cy", "cz"),
Index("ix_world_chunks_mature", "world_id", postgresql_where=text("is_mature")),
)
```
Deltas from `world.md` §4.4:
- **Surrogate `id` PK + unique coordinate constraint**, with `chunk_key` kept as
a unique denormalized column. The key string is the client-facing contract
(`Chunk.build_key`) and is embedded in the payload, so storing it gives a
single-index lookup by key; the coordinate constraint is what actually
enforces one chunk per cell.
- **`dirty` removed** — see §3.3.
- **Content flags folded into the chunk row as JSONB + an indexed `is_mature`
boolean**, instead of `world.md` §4.6's separate 11-column `content_flags`
table. The flags are a document the client already produces in exactly this
shape (`ChunkDataDecoder.ContentFlags.to_dict()`), the only query we anticipate
is "is this mature", and folding them in removes a second write per save and
the `object_id = ''` sentinel hack that §4.6 needed to work around SQLite's
NULL-in-primary-key behaviour (Postgres has the same NULL semantics, so the
hack would have carried over).
- **`world.md` §4.5 `chunk_objects` is deferred, not dropped.** It is a derived
index that exists to answer queries ("which chunks contain a door?") that no
code asks yet, and maintaining it means a delete-and-reinsert of every object
row on every save. It is fully rebuildable from `payload`, so it can be added
in its own migration the day a query needs it, with a backfill. Do **not**
build it in `0005`.
### 4.3 `world_chunk_revisions`
```python
class WorldChunkRevision(Base):
__tablename__ = "world_chunk_revisions"
id = Column(Integer, primary_key=True, index=True)
chunk_key = Column(String(160), nullable=False, index=True) # deliberately NOT a FK
world_id = Column(Integer, nullable=False, index=True) # also not a FK, same reason
revision = Column(Integer, nullable=False)
action = Column(String(16), nullable=False) # 'create' | 'update' | 'delete'
actor_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
checksum = Column(String(64), nullable=False)
payload = Column(LargeBinary, nullable=True) # full payload at this revision
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
```
Kept from `world.md` §4.7 including its two design points: the full payload is
stored (rollback needs it) and `chunk_key`/`world_id` are **not** foreign keys,
so deleting a chunk or a world preserves the audit trail.
**Retention (new, required):** this table grows without bound — a creator
dragging a brush can produce hundreds of revisions per minute. The
implementation must ship a retention policy from day one: keep the newest
`WORLD_CHUNK_REVISION_KEEP` (default 20) payloads per `chunk_key` and null out
`payload` on older rows (keeping the metadata row for the audit trail), pruned
in the same transaction as the write. A `created_at` index supports a later
time-based prune.
### 4.4 Config additions (`backend/app/config.py`)
```
WORLD_CHUNK_MAX_PAYLOAD_BYTES: int = 1_048_576 # 1 MiB; a dense 32×32 uint16 layer is 2 KiB
WORLD_CHUNK_MAX_OBJECTS: int = 512
WORLD_CHUNK_MAX_BATCH: int = 64
WORLD_CHUNK_REVISION_KEEP: int = 20
GAME_SERVER_INTERNAL_API_URL: str = "http://127.0.0.1:8000"
```
### 4.5 Chunk key format
`world.md` §2.3 specifies `"{world_id}:{room_id}:{cx}:{cy}:{cz}"` and illustrates
it with `w_0001:r_0001`. Those ids were illustrative. The real key uses the
backend's identifiers:
```
"{world.id}:{room.slug}:{cx}:{cy}:{cz}" e.g. "12:main:3:-1:0"
```
The room **slug** rather than its numeric id, so a key is human-readable in logs
and stable if rooms are ever re-imported. `Chunk.build_key()` in
`scripts/chunk.gd` already produces this shape unmodified.
---
## 5. D2 — The FastAPI backend is the only writer
### 5.1 Decision
The in-game editor (`editor.md` §7.2) submits edits over **HTTP to the FastAPI
backend**, which validates, versions, and persists them. The dedicated Godot
server process **never writes world content** and does not sit in the edit path.
This preserves `world.md`'s and `editor.md`'s actual invariant — *clients never
touch storage; they submit edits to a server-authoritative tier that validates
them* — while identifying that tier as the always-on backend rather than the
ephemeral sim process. `editor.md` §4.2's claim that edits ride the same
`request_chunk_edit` RPC as gameplay is the one part of that doc this design
changes; the moderation and validation properties it derives from that claim are
unaffected, because the validation boundary moves with the writer.
### 5.2 Reasoning
1. **Nothing that can vanish in 300 seconds may own the write path.** The reaper
in `game_servers.py` `stop()`s an idle server with `SIGTERM` and escalates to
`SIGKILL` after 10 s; `supervisor.shutdown()` does the same to every server on
backend shutdown; and if the backend dies uncleanly the servers are orphaned
with no one to flush them. Because the sim process holds no unsaved world
content in this design, **no reap, crash, or kill can ever lose world data** —
that is the single strongest practical reason for this split.
2. **Editing must work with no dedicated server running.** A creator opening
their world to build should not have to spin up a 30 Hz sim process, burn a
port from the 100-port range, and hold it for 5 minutes past their last save.
3. **`server.gd` has no database access, no credentials, and no HTTP client.**
Routing writes through it means either shipping Postgres credentials into a
process spawned per join, or building an ENet→HTTP proxy inside the sim loop.
Both are strictly worse than the editor calling the backend directly.
4. **Authorization already lives in the backend.** `CurrentUser`, the
`owner_id` check, and `WorldVisibility` are all there. The dedicated server
has no user identities at all — it knows peer ids, not accounts.
### 5.3 Authorization
- **Read** a chunk: the same rule as `GET /api/worlds/{id}` — the world is
`PUBLIC`, or the caller owns it. Otherwise 404 (not 403), matching the
existing routers' deliberate non-disclosure.
- **Write** a chunk: caller must be `world.owner_id`, matching `update_world`;
403 otherwise. Collaborator/role-based editing is future work and slots in at
this one check.
- **Mature gating hook:** `is_mature` chunks are where `content_moderation.md`'s
age gate attaches on the read path. Specify the column and the index now; the
gate itself lands with that doc's work.
### 5.4 Concurrency
Optimistic concurrency exactly as `world.md` §5.4, expressed over HTTP:
1. Reader gets `revision` (header `X-Chunk-Revision`, or the JSON field).
2. Writer sends `base_revision`.
3. The service does `SELECT ... FOR UPDATE` on the chunk row, compares
`base_revision` to `revision`, and either commits at `revision + 1` or returns
**409** with `current_revision` and `current_checksum` so the editor can
re-fetch and re-apply.
4. `base_revision: 0` means "create; fail with 409 if it already exists".
### 5.5 Validation (the backend must not trust the client)
The write path decodes the submitted payload server-side before storing it. This
requires a **Python port of the `world.md` §3.6 codec** —
`backend/app/services/chunk_codec.py`, mirroring `scripts/chunk_data_decoder.gd`.
It checks: magic `AGPC`, `format_version <= 1`, declared vs. actual
`payload_len`, **recomputed** SHA-256 (the header's checksum is advisory), tile
layer dims equal the room's `grid.size`, tile indices in palette range, object
count ≤ `WORLD_CHUNK_MAX_OBJECTS`, object positions inside the chunk's bounds,
`chunk_key` in the payload matching the URL, and string-field length limits
(`world.md` §7). `content_flags` and `is_mature` are **recomputed** from the
payload's flag sections, never taken from the request body.
**Drift control (required):** the two codecs must be pinned by golden files —
fixture chunks under `tests/fixtures/chunks/` encoded by one implementation and
decoded byte-identically by the other, asserted in both the pytest suite and the
Godot headless suite. Without this the format has two implementations and no
contract test, which is exactly the failure `world.md` §5.2 was trying to avoid
by having one shared implementation.
---
## 6. D4 — Editing versus a live session (scope boundary)
**In scope for this milestone:**
- The editor is entered as **"Edit world" from the world browser**: a non-joined
client mode. It does **not** call `POST /api/worlds/{id}/join`, does not open
an ENet connection, and does not spawn a dedicated server. It loads chunks over
HTTP, edits them locally, and saves them over HTTP.
- Writes always succeed regardless of whether a session is live. The write
response carries `live_session: bool` (from `supervisor.debug_state()`), so the
editor can surface *"this world has players in it right now — they'll see your
changes when the session restarts."*
- A dedicated server started **after** a write sees the new content, because it
loads from the store at startup (§7).
**Explicitly out of scope, deferred:**
- Build-in-place while joined to a live session.
- `chunk_invalidated` push to connected clients (`world.md` §5.3) and hot-reload
of the dedicated server's collision world.
- Live collaborative editing / cursors (`editor.md` §9).
**The seam that makes the deferred work cheap:** the manifest endpoint (§7.2)
returns a monotonic `content_revision` for the world, bumped on every chunk
write. Hot reload is then: the dedicated server polls the manifest on a timer
(or the backend pushes to it over the same internal channel), re-fetches the
chunks whose revision changed, and rebuilds those collision bodies. Nothing in
this design has to change to add it.
**Why the editor must not join:** if it joined, the sim process would enforce
collision against content it loaded at spawn while the creator edits a diverging
local copy — players would walk through new walls and bounce off deleted ones,
with no error anywhere. Making build mode non-joined turns a silent correctness
bug into an explicit product boundary.
---
## 7. D3 — What the dedicated server does at startup
### 7.1 Decision
`server.gd` pulls its world's content from the backend over **HTTP** at startup,
authenticated by a **per-spawn token** the supervisor mints, and **does not bind
the ENet port until content is loaded**.
Rejected alternative — *supervisor writes a whole-world snapshot file at spawn
and passes `--world-file`*: attractive (no new auth surface, no HTTP client in
the sim process), but it makes spawn latency proportional to world size, gives
the server no way to fetch a chunk lazily as players roam beyond the preloaded
set, and still needs a separate channel for the hot-reload work above. Rejected —
*give the Godot process a Postgres connection*: there is no PG driver in
GDScript, and it would put database credentials in a process spawned per join.
### 7.2 Startup sequence
1. `_parse_args()` gains `--api-url` and `--server-token` (§7.3).
2. `GET /api/internal/worlds/{world_id}/manifest` → rooms, per-room `grid`,
`bounds`, `spawn`, `chunk_count`, and the world's `content_revision`.
3. `GET /api/internal/worlds/{world_id}/rooms/{slug}/chunks?` bounds query for
the spawn neighbourhood (radius from the spawn point, default 3 chunks), then
fetch each payload; decode with the existing `ChunkDataDecoder` and build
static collision from the tile layers and objects.
4. **Then** `network_peer.create_server(port)`, then write the status file.
5. **Fallback:** if the backend is unreachable, the world has no rooms, or the
room has zero chunks, load `test_level.tscn` as today and record
`"content": "fallback"` in the status file. A content outage must never make
a world un-joinable, and this keeps every PC3 flow working before any world
has authored content.
During the session the server may lazily fetch chunks as players approach
unloaded coordinates, through the same internal route. It **never writes**.
Dynamic entity runtime state (positions, health) stays in memory and out of
chunks, exactly as `world.md` §3.3 already specifies. Shutdown flushes nothing.
### 7.3 Internal authentication
`GameServerSupervisor._spawn()` generates `token = secrets.token_urlsafe(32)`,
stores it on the `_WorldServer` dataclass, and passes it on the command line
alongside the existing `--port/--world/--status-file`. A FastAPI dependency
`require_game_server` resolves `Authorization: Bearer <token>` against
`supervisor.validate_token(world_id, token)` — a dict lookup, since the
supervisor is an in-process singleton. Properties this gives us:
- A token grants access to **one** world and **dies with its process** (the
reaper's `del self._servers[wid]` invalidates it).
- No long-lived shared secret in config or on disk.
- It reuses the **already documented** single-worker constraint in
`game_servers.py`; it does not add a new one.
Rejected alternative — *let the dedicated server use the public routes with a
service user account*: the public routes enforce per-user visibility and
ownership, which a service identity would have to be exempted from, and it would
pollute `updated_by_id`/audit rows with a fake user. A separate `/api/internal`
prefix keeps the two auth models apart and can be blocked at the reverse proxy.
### 7.4 Lifecycle changes this requires in `game_servers.py`
These are real, load-bearing findings from the current implementation:
- **`_await_port()` is the wrong readiness signal now.** It returns as soon as
the UDP port is bound, and the port is bound before content exists today. With
content loading it must await a `"ready": true` field that `server.gd` writes
to the status file *after* content load + `create_server`. Otherwise the
backend hands a client an address for a server that is still fetching chunks.
- **`_await_port()` fails open.** After its 8 s deadline it returns normally,
with no error — `ensure_server` then reports success for a server that may
never have started. Content loading makes a slow start more likely, so the
readiness wait needs a real timeout error (503 to the joining client) and a
longer, configurable deadline.
- **The status file is the only server→backend channel**, and `_spawn` currently
passes no credentials at all — hence §7.3.
- **`player_count()` returns 0 when the status file is missing or unparseable**,
so a live server whose status write fails can be reaped after the idle timeout.
Harmless for data (the sim owns nothing durable — the point of D2) but worth a
comment where the fallback lives.
- **No crash restart** (documented in the module docstring): if a server dies,
the next join respawns it and it reloads content from the store. With D2 this
is now genuinely safe rather than merely tolerated.
---
## 8. API surface
All paths are under the existing `worlds` router prefix (`/api/worlds`) unless
noted. New router module: `backend/app/routers/world_content.py`, mounted at
`/api/worlds`; internal routes in `backend/app/routers/internal.py` at
`/api/internal`.
### 8.1 Public (editor & player client; JWT `CurrentUser`)
| Method | Path | Purpose |
|---|---|---|
| `GET` | `/api/worlds/{world_id}/rooms` | List rooms + grid config |
| `POST` | `/api/worlds/{world_id}/rooms` | Create a room (owner) |
| `GET` | `/api/worlds/{world_id}/rooms/{slug}/chunks` | Chunk **index** over a bounds query |
| `GET` | `/api/worlds/{world_id}/rooms/{slug}/chunks/{cx}/{cy}/{cz}` | Fetch one chunk |
| `PUT` | `/api/worlds/{world_id}/rooms/{slug}/chunks/{cx}/{cy}/{cz}` | Write one chunk |
| `PUT` | `/api/worlds/{world_id}/rooms/{slug}/chunks` | Write up to `WORLD_CHUNK_MAX_BATCH` chunks atomically |
| `DELETE` | `/api/worlds/{world_id}/rooms/{slug}/chunks/{cx}/{cy}/{cz}` | Delete one chunk |
| `GET` | `/api/worlds/{world_id}/rooms/{slug}/chunks/{cx}/{cy}/{cz}/revisions` | Revision history (owner) |
| `POST` | `/api/worlds/{world_id}/rooms/{slug}/chunks/{cx}/{cy}/{cz}/rollback` | Restore a revision (owner) |
**Room list** — `GET .../rooms` → `200`
```json
[{ "id": 7, "slug": "main", "name": "Main",
"grid": { "size": [32, 32, 1], "origin": [0, 0, 0] },
"bounds": null,
"spawn": { "pos": [0, 1.5, 0], "yaw": 0.0 },
"chunk_count": 41 }]
```
**Chunk index** — `GET .../chunks?cx_min=&cx_max=&cy_min=&cy_max=&cz=0` → `200`.
Payloads are *not* included; this is `world.md` §5.1's `list_chunks(bounds)` and
lets a streamer decide what to fetch and skip anything already cached by
checksum.
```json
{ "room": "main", "content_revision": 118,
"chunks": [{ "key": "12:main:0:0:0", "cx": 0, "cy": 0, "cz": 0,
"revision": 4, "checksum": "9f86…", "payload_len": 2344,
"is_mature": false, "updated_at": "2026-09-14T10:02:11Z" }] }
```
Bounds are capped (default 32×32 chunks per query) and return `422` beyond that.
**Chunk fetch** — `GET .../chunks/{cx}/{cy}/{cz}`, content-negotiated:
- `Accept: application/octet-stream` (default for the game client) → `200` with
the raw `world.md` §3.6 bytes, `ETag: "<checksum>"`,
`X-Chunk-Revision: <n>`, `X-Chunk-Key: <key>`.
- `Accept: application/json` (tooling, tests, the editor's debug view) → the
§3.5 JSON encoding, decoded server-side from the payload. JSON is derived, never
stored, so there is no dual-write drift (`world.md` §4.4).
- `If-None-Match: "<checksum>"` → `304`. This is what makes the client's
`user://chunk_cache/` cache cheap and is why the checksum is the ETag.
- `404` if the chunk does not exist (an unwritten coordinate is not an error to
the streamer — it renders empty).
**Chunk write** — `PUT .../chunks/{cx}/{cy}/{cz}`
```
Content-Type: application/octet-stream
X-Base-Revision: 4 # 0 = create
<binary payload>
```
or, for clients that prefer JSON:
`{"base_revision": 4, "payload_b64": "…"}`.
Responses:
| Code | Body / meaning |
|---|---|
| `200` | `{"key", "revision", "checksum", "payload_len", "updated_at", "is_mature", "live_session"}` |
| `409` | `{"detail": "revision conflict", "current_revision": 6, "current_checksum": "…"}` |
| `403` | not the world owner |
| `404` | world or room not found / not visible |
| `413` | payload over `WORLD_CHUNK_MAX_PAYLOAD_BYTES` |
| `422` | decode/validation failure (`{"detail", "reason"}`, e.g. `checksum_mismatch`, `layer_dims`, `object_out_of_bounds`) |
**Batch write** — `PUT .../chunks` with
`{"chunks": [{"cx","cy","cz","base_revision","payload_b64"}, …]}`. Applied in
**one transaction, all-or-nothing**: if any entry conflicts, nothing is written
and the response is `409` naming the offending coordinate. Justification: an
editor brush stroke or an object drag routinely straddles a chunk boundary, and
without a batch the editor either issues N round trips or leaves half of a user
action persisted. Capped at `WORLD_CHUNK_MAX_BATCH` chunks and 8 MB.
### 8.2 D5 — Full-chunk PUT, not JSON-Patch deltas
`world.md` §5.4 specifies RFC-6902 deltas against `base_revision`. This design
sends **whole chunks** instead.
- A 32×32×1 chunk is ~2 KB of dense tile indices plus its objects — a few KB.
The bandwidth a patch saves is negligible at editor rates.
- The stored form is an opaque binary blob. Patching it means the backend must
decode → apply a JSON-Patch → re-encode → re-checksum, i.e. implement and test
a *second* mutation format on top of the codec, with its own failure modes, and
keep it in sync with the client's patch generator. That is the same dual-path
drift risk `world.md` §5.2 exists to prevent.
- Lost-update protection comes from `base_revision`, not from the patch, so
nothing is given up on correctness.
Delta encoding stays available as a pure bandwidth optimization later —
`world.md` §8 already files it as future work.
### 8.3 Internal (dedicated server; `require_game_server` token)
| Method | Path | Purpose |
|---|---|---|
| `GET` | `/api/internal/worlds/{world_id}/manifest` | Rooms, grids, spawn, `content_revision`, chunk counts |
| `GET` | `/api/internal/worlds/{world_id}/rooms/{slug}/chunks` | Bounds index (same shape as §8.1) |
| `GET` | `/api/internal/worlds/{world_id}/rooms/{slug}/chunks/{cx}/{cy}/{cz}` | Binary payload |
Read-only by construction — there is no internal write route, and adding one
would violate D2. The token is world-scoped, so a path whose `world_id` differs
from the token's world is `403`. These routes skip the per-user visibility check
(the sim process has no user) and apply no mature gating (gating is a client/user
concern; the server still needs collision).
### 8.4 D6 — The client streams chunks over HTTP, not over ENet
`world.md` §5.3 routes `request_chunk` / `chunk_data` over the game's ENet
connection. This design has the playing client fetch chunks from the **backend
over HTTP**, the same routes the editor uses.
- The dedicated server has no content the backend doesn't have; proxying bytes
through it adds a hop and puts multi-megabyte reliable transfers in contention
with the 30 Hz snapshot broadcast on the same ENet peer.
- HTTP gives caching for free — `ETag`/`If-None-Match` on the checksum, and a
local `user://chunk_cache/<checksum>` that survives sessions.
- `ApiClient` (`scripts/api_client.gd`) already carries the JWT and the result
contract; no new transport code.
**This does not require rewriting `ChunkStreamManager`.** It already calls
through a `set_server_rpc()` seam that only needs an object with
`request_chunk(key)`. The editor-wiring task supplies an `HttpChunkSource` that
implements that method against `ApiClient` and feeds
`_on_chunk_data_received(key, bytes)` — which also finally gives that dormant
seam a caller. Note the existing `_load_chunk()` builds its key with empty
world/room ids (`Chunk.build_key("", "", coord)`); the adapter must supply the
real world id and room slug.
---
## 9. What each follow-on task owns
**Backend task**
- `backend/app/models/world_content.py` + `0005_add_world_content_tables`
- `backend/app/services/chunk_codec.py` (Python port of `world.md` §3.6) and the
golden-file fixtures shared with the Godot suite
- `backend/app/services/world_store.py` (the `world.md` §5.1 service interface,
minus `flush_dirty`/`rebuild_index`, over Postgres)
- `backend/app/routers/world_content.py`, `backend/app/routers/internal.py`
- `game_servers.py`: per-spawn token, `--api-url`/`--server-token` args,
readiness-from-status-file instead of `_await_port` (§7.4)
- `create_world` creates the `main` room
**Client/editor task**
- `HttpChunkSource` adapter → `ChunkStreamManager.set_server_rpc()`
- Editor save path (single + batch PUT), 409 conflict re-fetch-and-reapply,
`live_session` warning UI
- "Edit world" entry from the world browser that does **not** join
- `server.gd`: arg parsing, manifest + chunk fetch before `create_server`,
collision build from decoded chunks, `test_level.tscn` fallback,
`"ready"`/`"content"` fields in the status file
---
## 10. Open questions (deliberately not decided here)
- **Hot reload / build-in-place** while a session is live (§6) — the
`content_revision` seam is in place for it.
- **Collaborator permissions** beyond `owner_id` — one check in §5.3.
- **Mature-content gating** on the read path — column and index exist; the
policy belongs to `content_moderation.md`.
- **`world_chunk_objects`** derived index — spec'd in `world.md` §4.5, build it
when a query needs it (§4.2).
- **Payload compression** — the §3.6 header reserves a flags bitmask; Postgres
TOAST already compresses `BYTEA`, so measure before adding a second layer.
- **Chunk streaming for very large worlds** — the bounds-query cap in §8.1 is a
guardrail, not a scaling plan.
-5
View File
@@ -45,11 +45,6 @@ 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)
]
}
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)
]
}
[physics]
+1 -5
View File
@@ -1,7 +1,6 @@
[gd_scene load_steps=5 format=3 uid="uid://player_scene_001"]
[gd_scene load_steps=4 format=3 uid="uid://player_scene_001"]
[ext_resource type="Script" path="res://scripts/player.gd" id="1_player"]
[ext_resource type="Script" path="res://scripts/scripting/player_inventory.gd" id="2_inventory"]
[sub_resource type="CapsuleMesh" id="CapsuleMesh1"]
radius = 0.35
@@ -31,6 +30,3 @@ margin = 0.2
[node name="Camera3D" type="Camera3D" parent="CameraPivot/SpringArm3D"]
current = true
[node name="Inventory" type="Node" parent="."]
script = ExtResource("2_inventory")
+1 -21
View File
@@ -1,6 +1,4 @@
[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"]
[gd_scene load_steps=1 format=3 uid="uid://c49a36fc7db_test_level"]
[sub_resource type="BoxShape3D" id="BoxShape3D_ground"]
size = Vector3(40, 1, 40)
@@ -46,12 +44,6 @@ radius = 0.5
subdivisions_ring = 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"]
background_mode = 1
background_color = Color(0.45, 0.62, 0.82, 1)
@@ -117,18 +109,6 @@ shape = SubResource("SphereShape3D_prop5")
[node name="MeshInstance3D" type="MeshInstance3D" parent="Props/Prop5_SmallSphere"]
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="."]
rotation = Vector3(-0.95, -0.5, 0)
light_energy = 1.3
-41
View File
@@ -1,41 +0,0 @@
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
@@ -1,383 +0,0 @@
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
@@ -1,459 +0,0 @@
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
@@ -1,32 +0,0 @@
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
@@ -1,55 +0,0 @@
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)
-462
View File
@@ -1,462 +0,0 @@
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