feat(scripting): Lua (MoonSharp) content-scripting layer for NPCs & quests — design + phase 1 #183
Labels
No labels
alpha:wave-0
alpha:wave-1
alpha:wave-2
alpha:wave-3
area:assets
area:combat
area:ecology
area:infra
area:render
area:scripting
area:ui
area:world
enhancement
epic
migration
post-alpha
roadmap
tech-debt
type:bug
type:chore
type:design
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
marco/IsoMmo#183
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Design spec (agreed in a brainstorming session). This is the design; implementation is gated on a
critical-design-review(see "Open decision" + "Invariants to verify"). Reference: ModernUO (C# scripts) and Sphere (.scptrigger scripts) — we diverge toward a sandboxed Lua layer for fast iteration while keeping server-authority.Motivation
Give trusted content authors (the owner + a few GMs) a way to script NPCs and quests without touching or recompiling the C# core. The core (sim, protocol, rendering) stays a closed engine; content is authored in a lightweight scripting language, hot-reloadable in seconds. This is the "wombat/escript" two-layer idea from UO (a low-level primitive API + a high-level scripting language on top), re-derived for our architecture.
Primary focus — developer experience (DX)
The target user of this system is the content developer/scripter, not the player. Success is measured by DX — autocomplete, hover-docs, mistakes caught as early as possible (ideally at build time), fast hot-reload, an ergonomic API — not by player-facing polish. This reweights every choice below toward the author's experience.
Trust model — Scenario A (decided)
Authors are trusted (project team / GMs); scripts are committed content in the repo, deployed with the server, never uploaded at runtime. So the sandbox is a safety net against mistakes (infinite loops, exceptions, accidental state corruption), NOT an adversarial jail. No per-author quotas, no review pipeline, no defense against malicious bytecode. (Open community modding = a different, much larger threat model — explicitly out of scope.)
Goals
Non-goals (for now)
ClientApiseam, separate).scripts/genmap.pyalready covers the "escript"-style offline tooling; a richer authoring API is a later, separate track).Language: Lua via MoonSharp
MoonSharp (pure managed C#) — chosen over alternatives:
.scp-like): large parser/VM cost, weak tooling. Rejected.MoonSharp gives: approachable language, hot-reload, an instruction-count interrupt (halt runaway loops), restricted globals (no file/OS), all in one cross-platform managed package.
Language & build-time safety — Lua/MoonSharp (DECIDED)
Decided: Lua on MoonSharp. Evaluated against TypeScript/Jint, Teal, WASM (AssemblyScript/Rust), Luau, and C#/Roslyn; Lua won on runtime simplicity + zero-build instant hot-reload while staying pure-managed (multi-platform, no native deps) with a built-in sandbox + instruction interrupt.
Lua is dynamically typed, so — given the DX focus — the hard requirement is to catch author mistakes (wrong signatures,
int + string, undefined globals) as early as possible, ideally at BUILD time, not at runtime. Three layers, build-time first:.d.luadefinition file, produced from the C#IScriptContextand kept in sync automatically, driveslua-language-serverin strict diagnostics mode → autocomplete, hover-docs, and signature/type/nil diagnostics in the editor. A CI gate runsluacheck(lint: undefined globals, arity, unused) +lua-language-server --check(type diagnostics against the defs) overcontent/scripts/and fails the build on a wrong signature or a type error before it can deploy.Honest caveat:
lua-language-serveris gradual typing — it catches wrong signatures, wrong arg types vs the defs, undefined globals and many nil errors, but not everything Lua permits (e.g. numeric-string coercion"0" + 1). The residual is caught by the runtime net (graceful degradation, never a crash) and tests. Documented fallback if the residual proves too costly: Teal (a statically-typed dialect of Lua that transpiles to Lua, runs on the same MoonSharp runtime) — adds real static types at the cost of a transpile step.Validation stages — a broken script means an invalid server
Catch every error in the earliest stage that can catch it; nothing broken reaches an online server. Strict before online, resilient once online.
lua-language-server+ the generated.d.lua— live diagnostics. Fast feedback, not a gate.luacheck+lua-language-server --check(signature/type diagnostics) + compile every script + shape-check (everyregister/events.onwell-formed, handlers are functions, required fields present, referenced kinds/events resolvable) overcontent/scripts/. Any failure fails the build — a broken script never deploys.world.mapalready fails fast at startup. This is the safety net for anything that bypassed CI (manual deploy, content drift). A broken script = an invalid server./reloadscripts(already online) — graceful, NOT fatal. Compile-fresh-then-swap: a bad edit keeps the last-good scripts running + logs; a typo during a live session never takes the shard down.Symmetry: before online (CI + startup) a broken script ⇒ the server is invalid (fatal); once online, a script bug ⇒ isolated + reported, never fatal.
Split: heavy static analysis in CI · light compile + shape-check at startup · graceful/isolated at runtime.
Architecture — two layers, one-way dependencies
The key constraint:
IsoMmo.Scriptingmust NOT referenceIsoMmo.GameServer(else a dependency cycle, since the core hosts the scripting host while scripts call the core). Resolve by inverting through an abstraction:IsoMmo.Scripting(new project; references onlyIsoMmo.Shared+ MoonSharp) — engine-agnostic scripting infrastructure:ScriptHost— load/compile.lua, sandbox, instruction-budget, hot-reload, trigger dispatch.IScriptContext— the abstract contract of verbs Lua may call (Say,Spawn,GiveItem,GetFlag/SetFlag, tile/Z queries,Schedule…).World. Unit-testable in isolation with a fakeIScriptContext(mirrors theIsoMmo.Client.Coresplit).IsoMmo.GameServer(referencesIsoMmo.Scripting) — the concrete side:WorldScriptContext : IScriptContext— realises the verbs overWorld. This is the real "facade to the core"; it lives with the core because it needsWorld. Every mutation goes through existingWorldvalidation → server stays authoritative even if a script asks for nonsense./reloadonto the sim thread.The context traffics in ids + DTOs, not live entities
ctx.Say(npcId, "..."), nevermobile.Say(...). ExposingMobile/PlayerMobile(GameServer types) to Scripting would recreate the cycle. Passing Guid + simple records keeps the boundary clean AND reinforces server-authority: a script asks by id, the concrete context validates and acts onWorld. Thin Lua-side handle wrappers can add ergonomics on top.Scripts are 100% server-side (boundary)
Scripts run only in the GameServer (sim thread). They never run on, are never sent to, and never reference the client;
IsoMmo.Clientdoes not referenceIsoMmo.Scripting. Player-facing effects (e.g. dialogue) are produced through existing server-authoritative broadcasts (PlayerSaid, …): the client receives an ordinary server message and renders it unaware a script was involved. Scripting is just another server-side producer of the same validated broadcasts — the client stays dumb and authoritative-by-the-server.Data flow
onUse,onDeath,onQuestStep, …) with ids/DTOs.ScriptHostdispatches to the registered Lua handler for that (kind, trigger), under the instruction budget + a try/catch.IScriptContextmethods (ids).WorldScriptContextvalidates + mutatesWorldon the sim thread.Sandbox & single-threaded execution
IScriptContext.while truefrom a GM cannot freeze the world; over-budget = abort that call + log.Worldis single-threaded, no new lock).WorldTick).Hot-reload (first-class requirement)
Simulation.InvokeAsync) — atomic, no half-loaded state..luainto a fresh MoonSharpScript; swap in only if it compiles clean. A syntax error leaves the old (working) scripts running + logs — never take the world down for a typo.IScriptContextmust force state onto the entity (no hidden Lua globals/tables for durable state)./reloadscripts) first — deterministic, sim-thread, like/save. AFileSystemWatcherauto-reload is dev-only, a later nicety (debounce + cross-platform quirks + atomic-save races).State & persistence
PlayerMobile(a per-player owned sub-graph, our existing save model) → survives relog and hot-reload; no cross-player references.IScriptContext(server-validated). Changing a quest's shape (renaming a flag) mid-playthrough is content versioning, not a hot-reload concern.Trigger model
Sphere-style event hooks routed to Lua handlers registered per content "kind":
onSpawn,onUse/onClick,onDeath,onEnterTile,onQuestStep, and (Phase 2) anonTick/thinkcadence for continuous AI. A scripted NPC generalises the existing "per-type reaction hooks on the entity" direction (Mobile.OnHit): a kind becomes definable in Lua instead of a C# subclass.Illustrative (Phase 1):
Open design decision — MUST resolve in the critical-design-review (blocks implementation)
NPC dialogue is player-facing text, but our invariant is "no loose string literals — everything via
SystemMessageId+ catalog" (built for system messages, localizable). Author-written dialogue is content (like the map), not system messaging. Decide:Recommendation to pressure-test in the review: (a) with dialogue treated as a distinct authored-content category (documented exception, like the biome tint), keeping
SystemMessageIdfor true system cues.Invariants to verify in the critical-design-review
Server-authoritative (scripts act via the validated facade, ids not results) · single-thread sim (host on the sim thread, no new lock, instruction budget) · World facade / no logic in
World.cs(verbs delegate to components) · extend-by-type (Lua-defined kinds generalise subclass+catalog — reconcile with the "no switch" rule) · string catalog (the open decision above) · persistence (quest flags on the entity, per-player sub-graph, no tick-loop IO for script load) · typed options (script dir, instruction budget as bound options) · multi-platform (MoonSharp pure managed) · process separation (scripting is GameServer-only; Auth untouched) · docs & DoD in the same change (a newdocs/scripting.md).Ships with the implementation: docs + a new CLAUDE.md HARD GATE
Two deliverables land in the same change as the code (per "docs & DoD in the same change"):
1.
docs/scripting.md— the scripting language + API documentation (a DX pillar): an authoring guide with worked examples (an NPC, a quest), a full API reference generated fromIScriptContext(so it never drifts from the code), how to add/reload a script, and the validation stages above.2. A new HARD GATE + its
## Design checklistbullet inCLAUDE.md:Scope of the gate: NPCs / quests / dialogue / items — items as loadable data-definitions +
onUsebehaviour (potions/scrolls/keys/food), split data-vs-behaviour. Spells stay C# and are deferred (delicate: casting/mana/pacing is authority- and timing-critical — a future decision). Creature-AI archetypes and loot/spawners are future candidates too.Phased plan
IsoMmo.Scriptingproject (ScriptHost +IScriptContext+ trigger types);WorldScriptContextin GameServer; one triggeronUse; ~4 verbs (say,spawn,getFlag,setFlag); load fromcontent/scripts/; hot-reload via/reloadscripts; instruction budget + error isolation. Proves the whole mechanism with a tiny surface.onTick/thinkAI cadence, more triggers (onDeath,onEnterTile,onQuestStep), a richerIScriptContext, timers/schedule.onUsebehaviour hooks in Lua (potions/scrolls/keys/food); split data-definition vs behaviour. The engine keeps reading item data cheaply (carry weight, equip stats).Verification / testing plan
IsoMmo.Scriptingunit tests (fakeIScriptContext): instruction budget aborts awhile true; a syntax error does NOT swap (old handlers still run); reload cancels pending timers; a handler exception is isolated (dispatch continues); trigger routing hits the right handler.World):onUsehandler makes a scripted NPC talk;setFlagpersists across a serialize/restore round-trip; a script asking for an illegal action is rejected byWorld(authority holds)./spawna scripted NPC, click it → it talks; set a flag, relog → flag persists; edit the.lua,/reloadscripts→ new behaviour live without restart; introduce a syntax error → world keeps running, error logged.Definition of Done (Phase 1)
Base DoD applies on top.
IsoMmo.Scriptingexists, references onlyIsoMmo.Shared+ MoonSharp (no reference toIsoMmo.GameServer);GameServerreferences it and implementsWorldScriptContext : IScriptContext.A committed
content/scripts/**.luawith anonUsehandler makes a/spawned scripted NPC send a line to the clicking player (demonstrated via the debug harness, screenshots attached).getFlag/setFlagset quest state on the player entity that survives a serialize/restore round-trip (unit-tested againstWorld).A script action that
Worldwould reject (e.g. spawn off-map) is refused server-side; the client cannot influence script execution (scripts are server-only committed content)./reloadscriptsreloads on the sim thread with compile-fresh-then-swap: a syntax error keeps the previous scripts running and logs the error (unit-tested inIsoMmo.Scripting); a valid edit takes effect without a server restart (harness-demonstrated).An infinite-loop handler is aborted by the instruction budget without freezing the tick (unit-tested).
A handler exception is isolated: that script is disabled + logged, the tick loop survives (unit-tested).
docs/scripting.mddocuments the model (contract, triggers, hot-reload, sandbox, how to add a script) and the dialogue-vs-catalog decision is recorded.The
critical-design-reviewwas run and its Invariants Check (incl. the dialogue-vs-catalog resolution) is recorded on this issue before implementation.A generated
.d.luadefinition file fromIScriptContextgives autocomplete + hover-docs + signature/type diagnostics inlua-language-server, and is regenerated in the build so it never drifts from the C# contract.A CI gate (
luacheck+lua-language-server --checkovercontent/scripts/) fails the build on a wrong signature / undefined global / type error — author mistakes are caught at build time, not at runtime.Bindings validate their arguments and raise a readable error with file:line; a script error is isolated (that script disabled + logged, the tick survives) — unit-tested.
The server refuses to start (fatal, non-zero exit + clear log) if any script fails to compile or fails the startup shape-check — a broken script = an invalid server (verified: a deliberately broken script blocks startup).
A runtime handler exception / instruction-budget overrun is isolated: the tick and the world keep running, and it is reported (structured log + in-session admin notice); after N consecutive failures the handler is quarantined until reload. A bad
/reloadscriptskeeps the last-good scripts running. All unit/integration tested.Scope checklist (what to build — separate from the DoD)
IsoMmo.Scriptingproject +.slnxwiring + MoonSharp package (pinned version inDirectory.Build.props).ScriptHost,IScriptContext, trigger/DTO types, dispatch, instruction-budget, error isolation, hot-reload (compile-fresh-then-swap).WorldScriptContext+ startup wiring +/reloadscriptsGM command + trigger emission (onUse).content/scripts/loaded + copied to output; a sample NPC script.GameOptions.docs/scripting.md.Critical design review — verdict: go-with-changes
Solid, dependency graph correct, no HARD GATE tripped. Ships once these 4 changes from the review are adopted:
PlayerSaid(NPC is a mobile speaking in AoI) → no protocol bump in Phase 1..luafile IO on the sim/tick thread.PlayerMobile's version int to persist quest flags (per-entity versioning).CLAUDE.md+ the documented dialogue exception.Invariants Check (every line of CLAUDE.md § Design checklist)
WorldScriptContext→World(validated); context by id, not results; scripts are committed content, never client-supplied; client-reachable triggers resolve ids server-side./reloadscriptsgated by the JWTadminclaim (CanExecute), not a client flag.PlayerSaid→ no bump; a future dialogue-choice UI would be a new wire shape + bump (out of scope).PlayerSaid;SystemMessageId/catalog stay for system cues. Requires a CLAUDE.md exception line.WorldScriptContextverbs delegate to existingWorld/components; no logic inWorld.cs.PlayerSaidrendering.World); networking untouched; host knows nothing of networking.PlayerMobile(authoritative per-entity state), not a side-collection keyed by id.register("kind", {...})generalises subclass+catalog; dispatch = registry lookup, notswitch(kind); C# extend-by-type unchanged.schedulemust advance on ticks (server-paced).PlayerMobileself-serialization (version-int bump, no central switch); per-player sub-graph, no cross-player refs;.luaread only at startup / explicit reload, never in the tick loop (compile off-thread, swap on-thread).GameOptionsfields, one default; noIConfigurationin the host.PlayerSaid, AoI-scoped from the NPC's authoritative position; no new broadcast in Phase 1.NLuarejected for this reason..scptriggers; we diverge to sandboxed Lua for iteration+safety while keeping server-authority. Justified.docs/scripting.md+ the dialogue exception + a new "Scripting" section/invariant in CLAUDE.md; DoD already on this issue.Index sanity check: the design adds a new trust category (scripts = trusted, sandboxed, server-side content) — a durable principle → add a "Scripting" invariant/section to CLAUDE.md at implementation time (no matching bullet yet).
Server-side validation (trust boundary)
Client can trigger scripts via legitimate intents (
onUse= clicking an NPC, validated for a real in-range target, ids resolved server-side) but cannot author/inject them nor pass unvalidated data; every verb re-validates viaWorld; flood is bounded by the instruction budget + existing interaction rate-limit; reload is admin-gated. The budget/isolation unit tests ARE the trust-boundary tests.Verification plan (preview)
IsoMmo.Scriptingunit (fake context): budget abortswhile true; syntax error does NOT swap; reload cancels timers; handler exception isolated; trigger routing correct.World):onUseNPC speaks;setFlagsurvives serialize/restore; illegal action rejected byWorld..lua+/reloadscripts→ live without restart; syntax error → world stays up, logged. Screenshots attached.Decision resolved
PlayerSaid, with a documented CLAUDE.md exception (catalog stays for system cues; script localization, if ever needed, solved at the content layer). Pending owner confirmation of (a).Implementation remains gated on owner confirmation of (a); no code until then.
Critical design review — recorded before implementation (per DoD)
Verdict: go-with-changes. Two changes vs the issue's Phase-1 as written: (a) scope is expanded to a data-definition layer (Phase-2/3 slices) because the owner explicitly asked to migrate the existing
rat,sword,bowto Lua — not just add a demo NPC; (b) a new catalogued wire messageMobileNoticeis needed so a scripted NPC can talk. No blocking✗, no HARD GATE tripped.Owner decisions taken
Rat/Sword/Bowclasses are removed; their sole definition becomes the.luafile. Catalogs merge residual C# kinds (Greatsword/Armor/Ankh/Coin/Stone/Dummy) + Lua kinds with a fatal collision-check.SystemMessageId), not script-owned strings. Implemented with a behavior-vs-wording split: Lua picks which message id and when (say(npc, "RatSqueak", args), hot-reloadable), the wording lives inSystemMessageCatalog(localizable). Wire carriesMobileNotice(mobileId, SystemMessageId, args)— no raw dialogue strings on the wire. String-catalog invariant stays fully satisfied..d.luafromIScriptContext(with a CI drift-check) +luacheck+lua-language-server --checkovercontent/scripts/.Namespacing (HARD GATE, new) — filesystem tree is the namespace
Path (lowercase) → FQN (PascalCase, acronym map
npc→NPC):FQN is derived from the file path (single source of truth, no drift); it is the spawn key + persistence key, never on the wire. The short
Kind/art(critter/sword/bow) stays on the wire so client rendering is unchanged.Invariants Check (walked against CLAUDE.md
## Design checklist)IScriptContext;WorldScriptContextvalidates onWorld(off-map spawn rejected, range, walkability). Scripts are committed content, never client input./reloadscriptsis aGmCommand(CanExecute => IsAdmin, JWTadminclaim).MobileNoticeinShared/Protocol+ bumpProtocolVersion.Currentsame change; script defs never cross the wire.SystemMessageId+ client template);saytakes a message id;/reloadscriptsfeedback is aSystemMessageIdtoo.ScriptHostruns on the sim thread, no new lock; reload IO marshalled viaSimulation.InvokeAsync; MoonSharp instruction-budget interrupt.WorldScriptContextcalls existing delegatingWorldmethods; no logic added toWorld.cs.MobileNoticebubble handled inServerMessageDispatcher/ClientWorld/WorldRenderer, not accumulated inGameScreen.Client.Core(unit-tested), draw glue inClient.Gameplay/+IsoMmo.Scripting;MobileNoticetransport inNetworking/; meet inGameSessionHandler/WorldTick.PlayerMobilevia an intrinsic setter exposed throughIScriptContext; no side-collection; no durable state in Lua globals..luafile (reflection analog toItemCatalog/CreatureRegistry); AI archetypes are an auto-discoveredBaseAiregistry. No switch added.PlayerMobileversion); scripted creatures persist by FQN (bumpCreatureSerializerversion + branch); no tick-loop IO (scripts loaded at startup/reload off-tick); no cross-player refs; GM-placed & persisted, no seeding/respawn.GameOptions.ScriptsPath+GameOptions.ScriptInstructionBudget, defaults in one place, bound like the rest.MobileNoticecarries the mobile id (position already reconciled viaMobileState); it is a transient event sent only to observers withinAoiRadiusat emit time (not global, not reconciled).## Design checklistthis change.docs/scripting.md.docs/scripting.md(model + generated API + dialogue decision) + newCLAUDE.mdHARD GATE + its## Design checklistbullet, all in this change.No
✗, no HARD GATE tripped.Verification plan (preview)
IsoMmo.Scriptingunit (fakeIScriptContext): instruction-budget abortswhile true; syntax error does NOT swap (old handlers run); reload cancels pending timers; handler exception isolated (dispatch continues) + quarantine after N; trigger routing hits the right handler; FQN derived from path; shape-check rejects malformed tables.World):onUsemakes the rat talk;setFlagsurvives a serialize/restore round-trip; off-map spawn from a script rejected (authority holds); Lua sword/bow equip arms theMobilewith declared stats (melee vs range+projectile) and combat is unchanged; C#/Lua kind collision is fatal; startup is FATAL with a broken script.CreateWebSocketClient):MobileNoticedelivered only to observers within AoI./reloadscriptsfrom a non-admin rejected; off-map spawn from a script rejected without tearing the tick./spawn mobiles.npc.rat→ click → dialogue bubble over the rat; (2)/give items.weapons.sword→ equip; (3)/give items.weapons.bow→ equip → projectile; (4) edit.lua+/reloadscripts→ new behavior live without restart.Phase 1 implemented in #193 — and it goes further than the minimal spine: the rat, sword and bow are migrated out of C# into Lua (the C# classes removed), with the generated
api.d.lua(no-drift, reflected from the decorated contract), theluacheck+lua-language-servergate,/reloadscripts, and harness screenshots of a scripted NPC talking + the Lua weapons in-world. The critical-design-review + Invariants Check are recorded above. DoD items covered; ready for review.Phase 1 shipped in #193 (merged to
main). The design + Phase-1 spine are done: theIsoMmo.Scriptinghost,WorldScriptContext,onUse/say/spawn/getFlag/setFlag, hot-reload (/reloadscripts), instruction-budget + error isolation, the generated-from-contractapi.d.lua(no-drift) + luacheck/lua-language-server gate, and the rat/sword/bow migrated out of C# into Lua. The critical-design-review + Invariants Check are recorded above.Closing as done; the deferred phases continue in:
Scripting spells stays deferred (a future decision).