Implements the DSL that docs/architecture/scripting_sandbox.md recommends
as the primary (and, for now, sufficient) approach in place of running
untrusted GDScript in-process: a fixed set of trigger/condition/action
verbs, expressed as plain JSON data, interpreted by trusted GDScript with
zero ability to reference engine singletons, call arbitrary methods, or
execute code.
- ScriptBehavior (scripts/scripting/script_behavior.gd): parses and
validates rules from JSON/Dictionary data. Every trigger, condition
type/op, and verb is checked against an explicit whitelist at parse
time; malformed or unknown entries are dropped and recorded in
rejected_rules, never partially executed. Carries an optional
content_flags passthrough so this data type isn't hostile to future
moderation-queue integration (docs/architecture/content_moderation.md).
- ScriptInterpreterNode (scripts/scripting/script_interpreter.gd): the
only code that executes rules. Triggers: on_interact (proximity +
"interact" input action), on_enter (edge-triggered Area3D overlap),
on_touch (continuous overlap), on_timer (physics-clocked Timer). Verbs:
give_item, play_sound, move, rotate, spawn_from_catalog, set_timer --
each a fixed, hand-written function; verb dispatch is an explicit
match(), never call()-by-name from data. move/rotate animate via Tween
rather than teleporting.
- PlayerInventory (scripts/scripting/player_inventory.gd): a small
per-player item-count stub for give_item to target, since no real
inventory/entity-state system exists yet (M9 Economy). Attached to
scenes/player.tscn as "Inventory".
- SpawnCatalog (scripts/scripting/spawn_catalog.gd): trusted catalog_id ->
asset_id lookup for spawn_from_catalog, mirroring the AvatarCatalog
data-driven-JSON convention. Rule data only ever supplies a catalog_id
key, never a resource path.
- Wired a real demo ("ScriptedChest") into scenes/worlds/test_level.tscn
with an on_interact rule (give_item + play_sound + rotate), plus an
"interact" input action (project.godot).
- scripts/smoke_test_script_behavior.gd: exercises every trigger type,
every verb, and every condition type against the real interpreter and
the wired demo scene, and proves unknown/malformed/unsafe rules
(unknown verb, unknown trigger, non-scalar smuggled arguments, etc.)
are rejected at parse time and never run -- 50/50 checks pass.
All players spawned at the exact same PlayerSpawn point and were on the
same collision layer, so two overlapping CharacterBody3Ds triggered a
physics depenetration shove that could launch one through the 1-unit-thick
floor.
- server.gd: golden-angle spawn spread -- each join lands ~1m+ from the
others, growing outward, never exactly repeating.
- server_player.tscn + player.tscn: players on collision layer 2, mask 1
(world only) -- players no longer collide with each other at all
(fine for a social game; also removes the shove).
- player_spawner.gd + player.gd: hard safety net -- if y < -10 (any
tunnel-through), snap back to the spawn instead of falling forever.
Verified: 3 server players spawn 1.15m+ apart, none fall, all rest on the
floor. 2p multiplayer + network_client smokes green.
Client-side -- rebuild the exe.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
- SpringArm3D was colliding with the player's own capsule (same layer) ->
camera yanked up against the head. add_excluded_object(self) in _ready +
gentler default pitch, shorter arm, lower pivot.
- AvatarSystem.build_current_avatar() is now self-sufficient: loads the
catalog itself and falls back to a bare default loadout if login didn't
prime one. So the in-world player always shows an avatar, never the
capsule, as long as the assets are packed (they are now).
- New AvatarSystem.assemble_avatar(): builds a standalone avatar with a
throwaway loader, touching no shared state -- so it's safe to call once
for the local player AND once per remote peer. (load_avatar()'s shared
loader reset()s and freed the previous avatar on every call.)
- game.gd: remote players render as the assembled avatar too (shared default
look, tinted per-peer, name tag) instead of a plain capsule.
Client-side -- rebuild the exe.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
Two real bugs from the last change, both only visible in a real run:
1. Controls reversed: the third-person SpringArm3D had a 180° Y rotation
that put the camera IN FRONT of the player looking back -- a mirror, so
W read as backward and mouse pitch as inverted. SpringArm3D extends its
child along +Z, not -Z; with no rotation the camera sits behind the
player (+Z world) and looks -Z straight at them. Verified the vectors
headless: camera behind, W decreases Z, mouse-up looks up.
2. Bare capsule instead of an avatar in the .exe: avatar_catalog.gd reads
assets/avatars/catalog.json with FileAccess.open(), but the export used
`all_resources` + empty include_filter, which does NOT pack plain .json
files. ensure_catalog() failed -> no loadout -> build_current_avatar()
returned null -> capsule stayed visible. From source it worked because
res:// is the real filesystem. Fixed: include_filter="*.json" in both
export presets (verified catalog.json + the .glb pack are now in the
exported .pck).
- scripts/verify_export_contents.gd + a checkpoint-runner step: export a
pack and assert the non-resource runtime files are inside it. This is
the guard for the "works from source, broken in the .exe" class.
Client-side -- rebuild the exe.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
You couldn't see your own character because the camera was first-person
(inside the avatar's head), and the customize screen's 3D preview rendered
nothing.
- player.tscn: camera now hangs off a SpringArm3D on a CameraPivot, 4 m
behind + above the player, pulled in on collision. player.gd: yaw still
turns the body, pitch orbits the arm (starts tilted down to frame the
avatar).
- avatar_customization_ui.gd: the preview SubViewport had own_world_3d=false,
so it shared the Control-only main world -- no scenario, nothing rendered.
Set own_world_3d=true, add a WorldEnvironment with ambient fill + a
key/fill light pair, make the camera current, tweak framing.
- smoke_test_player: assert the third-person SpringArm rig (18/18).
- smoke_test_enter_world: wait longer for the scene change -- a real
POST /join spawns a dedicated server, which is slow (9/9).
Client-side change -- rebuild the exe to get it.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ