feat(scripting): Lua (MoonSharp) content-scripting layer for NPCs & quests — design + phase 1 #183

Closed
opened 2026-07-24 13:57:58 +02:00 by marco · 4 comments
Owner

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 (.scp trigger 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

  • Author NPC behaviour/dialogue and quest logic in Lua, no C# edits, no recompile.
  • Hot-reload in seconds (edit → reload → test).
  • The scripting surface is a curated, versioned contract; the core stays authoritative and closed.
  • Cross-platform (server runs Win/macOS/Linux — hard requirement).

Non-goals (for now)

  • Open/community modding on a live server (adversarial sandbox, quotas, review). Out of scope.
  • Client-side scripting (that's the debug-harness ClientApi seam, separate).
  • Build-time world authoring (scripts/genmap.py already covers the "escript"-style offline tooling; a richer authoring API is a later, separate track).
  • Scripting spells (deferred — delicate) and commands (stay C#). Items ARE in scope now (see the HARD GATE + roadmap).

Language: Lua via MoonSharp

MoonSharp (pure managed C#) — chosen over alternatives:

  • vs C# hot-compile (ModernUO-style): still code, needs the SDK, painful/unsafe to sandbox, needs recompile. Rejected.
  • vs NLua (native Lua): native dependency → risks the multi-platform hard requirement. Rejected.
  • vs a bespoke DSL (Sphere .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:

  1. Author-time / build-time (primary). A generated EmmyLua .d.lua definition file, produced from the C# IScriptContext and kept in sync automatically, drives lua-language-server in strict diagnostics mode → autocomplete, hover-docs, and signature/type/nil diagnostics in the editor. A CI gate runs luacheck (lint: undefined globals, arity, unused) + lua-language-server --check (type diagnostics against the defs) over content/scripts/ and fails the build on a wrong signature or a type error before it can deploy.
  2. Runtime net (for what static analysis can't catch). Argument-validating bindings that raise a descriptive error with file:line; error isolation (a bad script is disabled + logged, the tick survives); a smoke-check at load/reload (handlers are functions, required fields present).
  3. Tests.

Honest caveat: lua-language-server is 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.

  1. Author-time (editor): lua-language-server + the generated .d.lua — live diagnostics. Fast feedback, not a gate.
  2. CI/CD build — primary gate, heavy. luacheck + lua-language-server --check (signature/type diagnostics) + compile every script + shape-check (every register/events.on well-formed, handlers are functions, required fields present, referenced kinds/events resolvable) over content/scripts/. Any failure fails the build — a broken script never deploys.
  3. Server startup — fail-fast gate, lightweight. Before the server opens its socket / starts the sim, it compiles all scripts + runs the shape-check (NOT the heavy type-analysis — that stays in CI). A broken script set is FATAL: the server refuses to come online (non-zero exit + clear log), exactly as a malformed world.map already fails fast at startup. This is the safety net for anything that bypassed CI (manual deploy, content drift). A broken script = an invalid server.
  4. Runtime /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.
  5. Runtime execution bug (a live handler throws / exceeds its instruction budget) — isolated + reported, NEVER fatal. The failing invocation is aborted and isolated; the tick and the rest of the world continue. It is reported so the author sees it immediately (DX-first): a structured log (script · kind · trigger · line · message) plus an in-session notice to admins/authors. After N consecutive failures that handler is quarantined (disabled until the next reload), reported once, to avoid spam. Distinct from a compile error — this is code that loads but misbehaves at runtime.

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.Scripting must NOT reference IsoMmo.GameServer (else a dependency cycle, since the core hosts the scripting host while scripts call the core). Resolve by inverting through an abstraction:

IsoMmo.Shared  ←  IsoMmo.Scripting  (MoonSharp, ScriptHost, IScriptContext, trigger/DTO types)
      ↑                    ↑
      └────────  IsoMmo.GameServer  (WorldScriptContext : IScriptContext, wiring)
  • IsoMmo.Scripting (new project; references only IsoMmo.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…).
    • Trigger/event types + any DTOs scripts see.
    • No game logic, no World. Unit-testable in isolation with a fake IScriptContext (mirrors the IsoMmo.Client.Core split).
  • IsoMmo.GameServer (references IsoMmo.Scripting) — the concrete side:
    • WorldScriptContext : IScriptContext — realises the verbs over World. This is the real "facade to the core"; it lives with the core because it needs World. Every mutation goes through existing World validation → server stays authoritative even if a script asks for nonsense.
    • Wiring: register the host at startup, raise trigger events from the sim, marshal /reload onto the sim thread.

The context traffics in ids + DTOs, not live entities

ctx.Say(npcId, "..."), never mobile.Say(...). Exposing Mobile/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 on World. 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.Client does not reference IsoMmo.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

  1. Engine raises a trigger (onUse, onDeath, onQuestStep, …) with ids/DTOs.
  2. ScriptHost dispatches to the registered Lua handler for that (kind, trigger), under the instruction budget + a try/catch.
  3. The handler calls IScriptContext methods (ids).
  4. WorldScriptContext validates + mutates World on the sim thread.

Sandbox & single-threaded execution

  • No file/OS/network exposed to Lua (mistake-avoidance): only the curated IScriptContext.
  • Instruction budget per handler invocation (MoonSharp interrupt) → a while true from a GM cannot freeze the world; over-budget = abort that call + log.
  • Scripts run on the simulation thread (World is single-threaded, no new lock).
  • Error isolation: a handler exception is caught, that script is disabled + logged with a readable line number; the tick survives (mirrors the resilient WorldTick).

Hot-reload (first-class requirement)

  • Reload on the sim thread, between ticks (Simulation.InvokeAsync) — atomic, no half-loaded state.
  • Compile-fresh-then-swap: load new .lua into a fresh MoonSharp Script; 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.
  • State vs code: reload replaces functions, never state. Quest state lives on the entity (persisted), so the world is untouched by a reload; live NPCs pick up new handlers on their next trigger. The IScriptContext must force state onto the entity (no hidden Lua globals/tables for durable state).
  • Cancel pending script-scheduled timers/coroutines from the old code on reload.
  • Trigger: a GM command (/reloadscripts) first — deterministic, sim-thread, like /save. A FileSystemWatcher auto-reload is dev-only, a later nicety (debounce + cross-platform quirks + atomic-save races).

State & persistence

  • Quest state = flags on PlayerMobile (a per-player owned sub-graph, our existing save model) → survives relog and hot-reload; no cross-player references.
  • Set/read only via 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) an onTick/think cadence 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):

-- content/scripts/npc/village_elder.lua
register("village_elder", {
  onUse = function(npc, player)
    if getFlag(player, "elder_greeted") == "1" then
      say(npc, "Back already, traveller?")
    else
      setFlag(player, "elder_greeted", "1")
      say(npc, "Welcome to our village.")
    end
  end,
})

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:

  • (a) Script-owned dialogue — simple, but bypasses the catalog/localization path; or
  • (b) Catalogued dialogue — coherent with the invariant, but an author must touch two places.
    Recommendation to pressure-test in the review: (a) with dialogue treated as a distinct authored-content category (documented exception, like the biome tint), keeping SystemMessageId for 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 new docs/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 from IScriptContext (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 checklist bullet in CLAUDE.md:

Content is authored in Lua, not C# (HARD GATE). Once the scripting layer ships, a new NPC, quest, dialogue, or item — any behaviour the IScriptContext can express — is a Lua script in content/scripts/, never a new C# type in the core. The C# side is the engine + primitives: if the scripting API can't express what you need, you extend the API (a new IScriptContext verb/trigger, with its generated .d.lua entry + docs) — you do not hand-author a specific NPC/quest in C#. About to write a C# BaseCreature subclass for "a goblin that…"? STOP — that's content → Lua. This specialises the existing "extend by type" invariant for scriptable content. Engine mechanics the API doesn't cover (sim, protocol, combat/movement/persistence pipelines) stay C#.

Scope of the gate: NPCs / quests / dialogue / items — items as loadable data-definitions + onUse behaviour (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

  • Phase 1 — the spine (this issue's DoD). IsoMmo.Scripting project (ScriptHost + IScriptContext + trigger types); WorldScriptContext in GameServer; one trigger onUse; ~4 verbs (say, spawn, getFlag, setFlag); load from content/scripts/; hot-reload via /reloadscripts; instruction budget + error isolation. Proves the whole mechanism with a tiny surface.
  • Phase 2 — behaviour & breadth. onTick/think AI cadence, more triggers (onDeath, onEnterTile, onQuestStep), a richer IScriptContext, timers/schedule.
  • Phase 3 — items as content. Item definitions as loadable data (name/graphic/weight/stack/slot/base stats) + onUse behaviour hooks in Lua (potions/scrolls/keys/food); split data-definition vs behaviour. The engine keeps reading item data cheaply (carry weight, equip stats).
  • Phase 4 — ergonomics. File-watcher auto-reload (dev-only), Lua handle wrappers, authoring/debug tooling, docs & examples.

Verification / testing plan

  • IsoMmo.Scripting unit tests (fake IScriptContext): instruction budget aborts a while 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.
  • GameServer gameplay tests (against World): onUse handler makes a scripted NPC talk; setFlag persists across a serialize/restore round-trip; a script asking for an illegal action is rejected by World (authority holds).
  • Manual (debug harness): /spawn a 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.Scripting exists, references only IsoMmo.Shared + MoonSharp (no reference to IsoMmo.GameServer); GameServer references it and implements WorldScriptContext : IScriptContext.

  • A committed content/scripts/**.lua with an onUse handler makes a /spawned scripted NPC send a line to the clicking player (demonstrated via the debug harness, screenshots attached).

  • getFlag/setFlag set quest state on the player entity that survives a serialize/restore round-trip (unit-tested against World).

  • A script action that World would reject (e.g. spawn off-map) is refused server-side; the client cannot influence script execution (scripts are server-only committed content).

  • /reloadscripts reloads on the sim thread with compile-fresh-then-swap: a syntax error keeps the previous scripts running and logs the error (unit-tested in IsoMmo.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.md documents the model (contract, triggers, hot-reload, sandbox, how to add a script) and the dialogue-vs-catalog decision is recorded.

  • The critical-design-review was run and its Invariants Check (incl. the dialogue-vs-catalog resolution) is recorded on this issue before implementation.

  • A generated .d.lua definition file from IScriptContext gives autocomplete + hover-docs + signature/type diagnostics in lua-language-server, and is regenerated in the build so it never drifts from the C# contract.

  • A CI gate (luacheck + lua-language-server --check over content/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 /reloadscripts keeps the last-good scripts running. All unit/integration tested.

Scope checklist (what to build — separate from the DoD)

  • New IsoMmo.Scripting project + .slnx wiring + MoonSharp package (pinned version in Directory.Build.props).
  • ScriptHost, IScriptContext, trigger/DTO types, dispatch, instruction-budget, error isolation, hot-reload (compile-fresh-then-swap).
  • WorldScriptContext + startup wiring + /reloadscripts GM command + trigger emission (onUse).
  • content/scripts/ loaded + copied to output; a sample NPC script.
  • Bound options (script dir, instruction budget) on GameOptions.
  • Tests (Scripting unit + GameServer gameplay) + docs/scripting.md.
**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 (`.scp` trigger 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 - Author NPC behaviour/dialogue and quest logic in **Lua**, no C# edits, no recompile. - **Hot-reload** in seconds (edit → reload → test). - The scripting surface is a **curated, versioned contract**; the core stays authoritative and closed. - Cross-platform (server runs Win/macOS/Linux — hard requirement). ## Non-goals (for now) - Open/community modding on a live server (adversarial sandbox, quotas, review). Out of scope. - Client-side scripting (that's the debug-harness `ClientApi` seam, separate). - Build-time world authoring (`scripts/genmap.py` already covers the "escript"-style offline tooling; a richer authoring API is a later, separate track). - Scripting **spells** (deferred — delicate) and **commands** (stay C#). Items ARE in scope now (see the HARD GATE + roadmap). ## Language: Lua via MoonSharp **MoonSharp** (pure managed C#) — chosen over alternatives: - vs **C# hot-compile (ModernUO-style)**: still *code*, needs the SDK, painful/unsafe to sandbox, needs recompile. Rejected. - vs **NLua** (native Lua): native dependency → risks the multi-platform hard requirement. Rejected. - vs a **bespoke DSL** (Sphere `.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: 1. **Author-time / build-time (primary).** A **generated EmmyLua `.d.lua` definition file**, produced from the C# `IScriptContext` and kept in sync automatically, drives **`lua-language-server` in strict diagnostics mode** → autocomplete, hover-docs, and signature/type/nil diagnostics *in the editor*. A **CI gate** runs `luacheck` (lint: undefined globals, arity, unused) + `lua-language-server --check` (type diagnostics against the defs) over `content/scripts/` and **fails the build** on a wrong signature or a type error before it can deploy. 2. **Runtime net (for what static analysis can't catch).** Argument-validating bindings that raise a **descriptive error with file:line**; **error isolation** (a bad script is disabled + logged, the tick survives); a **smoke-check at load/reload** (handlers are functions, required fields present). 3. **Tests.** Honest caveat: `lua-language-server` is *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. 1. **Author-time (editor):** `lua-language-server` + the generated `.d.lua` — live diagnostics. Fast feedback, not a gate. 2. **CI/CD build — primary gate, heavy.** `luacheck` + `lua-language-server --check` (signature/type diagnostics) + **compile every script** + **shape-check** (every `register`/`events.on` well-formed, handlers are functions, required fields present, referenced kinds/events resolvable) over `content/scripts/`. **Any failure fails the build — a broken script never deploys.** 3. **Server startup — fail-fast gate, lightweight.** Before the server opens its socket / starts the sim, it **compiles all scripts + runs the shape-check** (NOT the heavy type-analysis — that stays in CI). **A broken script set is FATAL: the server refuses to come online** (non-zero exit + clear log), exactly as a malformed `world.map` already fails fast at startup. This is the safety net for anything that bypassed CI (manual deploy, content drift). *A broken script = an invalid server.* 4. **Runtime `/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. 5. **Runtime execution bug (a live handler throws / exceeds its instruction budget) — isolated + reported, NEVER fatal.** The failing invocation is aborted and isolated; the tick and the rest of the world continue. It is **reported so the author sees it immediately** (DX-first): a structured log (script · kind · trigger · line · message) **plus an in-session notice to admins/authors**. After N consecutive failures that handler is **quarantined** (disabled until the next reload), reported once, to avoid spam. Distinct from a compile error — this is code that *loads* but misbehaves at runtime. 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.Scripting` must NOT reference `IsoMmo.GameServer`** (else a dependency cycle, since the core hosts the scripting host while scripts call the core). Resolve by inverting through an abstraction: ``` IsoMmo.Shared ← IsoMmo.Scripting (MoonSharp, ScriptHost, IScriptContext, trigger/DTO types) ↑ ↑ └──────── IsoMmo.GameServer (WorldScriptContext : IScriptContext, wiring) ``` - **`IsoMmo.Scripting`** (new project; references only `IsoMmo.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`…). - Trigger/event types + any DTOs scripts see. - **No game logic, no `World`.** Unit-testable in isolation with a fake `IScriptContext` (mirrors the `IsoMmo.Client.Core` split). - **`IsoMmo.GameServer`** (references `IsoMmo.Scripting`) — the concrete side: - `WorldScriptContext : IScriptContext` — realises the verbs **over `World`**. This is the real "facade to the core"; it lives *with* the core because it needs `World`. Every mutation goes through existing `World` validation → **server stays authoritative** even if a script asks for nonsense. - Wiring: register the host at startup, raise trigger events from the sim, marshal `/reload` onto the sim thread. ### The context traffics in ids + DTOs, not live entities `ctx.Say(npcId, "...")`, never `mobile.Say(...)`. Exposing `Mobile`/`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 on `World`. 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.Client` does not reference `IsoMmo.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 1. Engine raises a trigger (`onUse`, `onDeath`, `onQuestStep`, …) with ids/DTOs. 2. `ScriptHost` dispatches to the registered Lua handler for that (kind, trigger), under the instruction budget + a try/catch. 3. The handler calls `IScriptContext` methods (ids). 4. `WorldScriptContext` validates + mutates `World` on the sim thread. ## Sandbox & single-threaded execution - **No file/OS/network** exposed to Lua (mistake-avoidance): only the curated `IScriptContext`. - **Instruction budget** per handler invocation (MoonSharp interrupt) → a `while true` from a GM cannot freeze the world; over-budget = abort that call + log. - Scripts run **on the simulation thread** (`World` is single-threaded, no new lock). - **Error isolation**: a handler exception is caught, that script is disabled + logged with a readable line number; the tick survives (mirrors the resilient `WorldTick`). ## Hot-reload (first-class requirement) - **Reload on the sim thread**, between ticks (`Simulation.InvokeAsync`) — atomic, no half-loaded state. - **Compile-fresh-then-swap**: load new `.lua` into a *fresh* MoonSharp `Script`; 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. - **State vs code**: reload replaces *functions*, never *state*. Quest state lives **on the entity** (persisted), so the world is untouched by a reload; live NPCs pick up new handlers on their next trigger. The `IScriptContext` must *force* state onto the entity (no hidden Lua globals/tables for durable state). - **Cancel pending script-scheduled timers/coroutines** from the old code on reload. - **Trigger**: a GM command (`/reloadscripts`) first — deterministic, sim-thread, like `/save`. A `FileSystemWatcher` auto-reload is **dev-only, a later nicety** (debounce + cross-platform quirks + atomic-save races). ## State & persistence - Quest state = **flags on `PlayerMobile`** (a per-player owned sub-graph, our existing save model) → survives relog *and* hot-reload; no cross-player references. - Set/read only via `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) an `onTick`/`think` cadence 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): ```lua -- content/scripts/npc/village_elder.lua register("village_elder", { onUse = function(npc, player) if getFlag(player, "elder_greeted") == "1" then say(npc, "Back already, traveller?") else setFlag(player, "elder_greeted", "1") say(npc, "Welcome to our village.") end end, }) ``` ## 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: - (a) **Script-owned dialogue** — simple, but bypasses the catalog/localization path; or - (b) **Catalogued dialogue** — coherent with the invariant, but an author must touch two places. Recommendation to pressure-test in the review: (a) with dialogue treated as a distinct *authored-content* category (documented exception, like the biome tint), keeping `SystemMessageId` for 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 new `docs/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 from `IScriptContext`** (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 checklist` bullet in `CLAUDE.md`:** > **Content is authored in Lua, not C# (HARD GATE).** Once the scripting layer ships, a new **NPC, quest, dialogue, or item** — any behaviour the `IScriptContext` can express — is a **Lua script** in `content/scripts/`, never a new C# type in the core. The C# side is the **engine + primitives**: if the scripting API can't express what you need, you extend the *API* (a new `IScriptContext` verb/trigger, with its generated `.d.lua` entry + docs) — you do **not** hand-author a specific NPC/quest in C#. About to write a C# `BaseCreature` subclass for "a goblin that…"? STOP — that's content → Lua. This *specialises* the existing "extend by type" invariant for scriptable content. Engine mechanics the API doesn't cover (sim, protocol, combat/movement/persistence pipelines) stay C#. Scope of the gate: **NPCs / quests / dialogue / items** — items as **loadable data-definitions + `onUse` behaviour** (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 - **Phase 1 — the spine (this issue's DoD).** `IsoMmo.Scripting` project (ScriptHost + `IScriptContext` + trigger types); `WorldScriptContext` in GameServer; **one trigger `onUse`**; ~4 verbs (`say`, `spawn`, `getFlag`, `setFlag`); load from `content/scripts/`; **hot-reload via `/reloadscripts`**; instruction budget + error isolation. Proves the whole mechanism with a tiny surface. - **Phase 2 — behaviour & breadth.** `onTick`/`think` AI cadence, more triggers (`onDeath`, `onEnterTile`, `onQuestStep`), a richer `IScriptContext`, timers/`schedule`. - **Phase 3 — items as content.** Item definitions as loadable data (name/graphic/weight/stack/slot/base stats) + `onUse` behaviour hooks in Lua (potions/scrolls/keys/food); split data-definition vs behaviour. The engine keeps reading item *data* cheaply (carry weight, equip stats). - **Phase 4 — ergonomics.** File-watcher auto-reload (dev-only), Lua handle wrappers, authoring/debug tooling, docs & examples. ## Verification / testing plan - **`IsoMmo.Scripting` unit tests (fake `IScriptContext`)**: instruction budget aborts a `while 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. - **GameServer gameplay tests (against `World`)**: `onUse` handler makes a scripted NPC talk; `setFlag` persists across a serialize/restore round-trip; a script asking for an illegal action is rejected by `World` (authority holds). - **Manual (debug harness)**: `/spawn` a 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.Scripting` exists, references only `IsoMmo.Shared` + MoonSharp (no reference to `IsoMmo.GameServer`); `GameServer` references it and implements `WorldScriptContext : IScriptContext`. - [ ] A committed `content/scripts/**.lua` with an `onUse` handler makes a `/spawn`ed scripted NPC send a line to the clicking player (demonstrated via the debug harness, screenshots attached). - [ ] `getFlag`/`setFlag` set quest state on the player entity that **survives a serialize/restore round-trip** (unit-tested against `World`). - [ ] A script action that `World` would reject (e.g. spawn off-map) is refused server-side; the client cannot influence script execution (scripts are server-only committed content). - [ ] `/reloadscripts` reloads on the sim thread with **compile-fresh-then-swap**: a syntax error keeps the previous scripts running and logs the error (unit-tested in `IsoMmo.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.md` documents the model (contract, triggers, hot-reload, sandbox, how to add a script) and the dialogue-vs-catalog decision is recorded. - [ ] The `critical-design-review` was run and its Invariants Check (incl. the dialogue-vs-catalog resolution) is recorded on this issue before implementation. - [ ] A **generated `.d.lua` definition file** from `IScriptContext` gives autocomplete + hover-docs + signature/type diagnostics in `lua-language-server`, and is regenerated in the build so it never drifts from the C# contract. - [ ] A **CI gate** (`luacheck` + `lua-language-server --check` over `content/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 `/reloadscripts` keeps the last-good scripts running. All unit/integration tested. ## Scope checklist (what to build — separate from the DoD) - New `IsoMmo.Scripting` project + `.slnx` wiring + MoonSharp package (pinned version in `Directory.Build.props`). - `ScriptHost`, `IScriptContext`, trigger/DTO types, dispatch, instruction-budget, error isolation, hot-reload (compile-fresh-then-swap). - `WorldScriptContext` + startup wiring + `/reloadscripts` GM command + trigger emission (`onUse`). - `content/scripts/` loaded + copied to output; a sample NPC script. - Bound options (script dir, instruction budget) on `GameOptions`. - Tests (Scripting unit + GameServer gameplay) + `docs/scripting.md`.
Author
Owner

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:

  1. NPC dialogue via PlayerSaid (NPC is a mobile speaking in AoI) → no protocol bump in Phase 1.
  2. Compile off-thread, swap on-thread on reload → no .lua file IO on the sim/tick thread.
  3. Bump PlayerMobile's version int to persist quest flags (per-entity versioning).
  4. Add a new "Scripting" invariant/section to CLAUDE.md + the documented dialogue exception.

Invariants Check (every line of CLAUDE.md § Design checklist)

  1. Scope ✓ — only the NPC/quest scripting layer (scenario A); open modding / client-scripting / world-authoring / item-spell scripting out of scope.
  2. Server-authoritative ✓ — every verb goes WorldScriptContextWorld (validated); context by id, not results; scripts are committed content, never client-supplied; client-reachable triggers resolve ids server-side.
  3. GM authorization ✓ — /reloadscripts gated by the JWT admin claim (CanExecute), not a client flag.
  4. Identity model N/A.
  5. Protocol versioned ✓ (with choice) — reuse PlayerSaid → no bump; a future dialogue-choice UI would be a new wire shape + bump (out of scope).
  6. String catalog ✗→RESOLVED — NPC dialogue = authored-content category (documented exception, like the biome tint), delivered via PlayerSaid; SystemMessageId/catalog stay for system cues. Requires a CLAUDE.md exception line.
  7. Single-threaded sim ✓ — host on the sim thread, no new lock, instruction budget; reload marshaled; (refine: compile off-thread).
  8. World.cs HARD GATE ✓ — WorldScriptContext verbs delegate to existing World/components; no logic in World.cs.
  9. Screen HARD GATE (client) N/A — server-only; NPC speech reuses existing PlayerSaid rendering.
  10. Client engine-independence N/A — no client logic in Phase 1.
  11. Gameplay/Networking separation ✓ — scripting is gameplay (context over World); networking untouched; host knows nothing of networking.
  12. Act on the instance ✓ — quest flags on PlayerMobile (authoritative per-entity state), not a side-collection keyed by id.
  13. Extend by type, not switch ✓ — a Lua register("kind", {...}) generalises subclass+catalog; dispatch = registry lookup, not switch(kind); C# extend-by-type unchanged.
  14. Server-paced actions N/A (Phase 1) — a future schedule must advance on ticks (server-paced).
  15. Persistence (GameServer) ✓ (refine) — quest flags in PlayerMobile self-serialization (version-int bump, no central switch); per-player sub-graph, no cross-player refs; .lua read only at startup / explicit reload, never in the tick loop (compile off-thread, swap on-thread).
  16. Persistence (Auth) N/A.
  17. Process separation ✓ — GameServer-only; Auth + JWT contract intact.
  18. Typed options ✓ — script dir + instruction budget as GameOptions fields, one default; no IConfiguration in the host.
  19. Broadcasts absolute / AoI / transient ✓ — NPC speech via PlayerSaid, AoI-scoped from the NPC's authoritative position; no new broadcast in Phase 1.
  20. Multi-platform ✓ — MoonSharp pure managed (Win/macOS/Linux); native NLua rejected for this reason.
  21. Assets required N/A — no new rendered content/art.
  22. Asset naming (HARD GATE) N/A — no pack/tiledata assets.
  23. ModernUO as reference ✓ — ModernUO=C# scripts, Sphere=.scp triggers; we diverge to sandboxed Lua for iteration+safety while keeping server-authority. Justified.
  24. Docs & DoD same change ✓ — requires 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 via World; 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.Scripting unit (fake context): budget aborts while true; syntax error does NOT swap; reload cancels timers; handler exception isolated; trigger routing correct.
  • GameServer gameplay (vs World): onUse NPC speaks; setFlag survives serialize/restore; illegal action rejected by World.
  • Manual (debug harness): scripted NPC talks on click; flag persists on relog; edit .lua + /reloadscripts → live without restart; syntax error → world stays up, logged. Screenshots attached.

Decision resolved

  • NPC dialogue → (a) script-owned content via 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).
  • Minor: instruction budget per-invocation to start.

Implementation remains gated on owner confirmation of (a); no code until then.

## 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: 1. **NPC dialogue via `PlayerSaid`** (NPC is a mobile speaking in AoI) → **no protocol bump** in Phase 1. 2. **Compile off-thread, swap on-thread** on reload → no `.lua` file IO on the sim/tick thread. 3. **Bump `PlayerMobile`'s version int** to persist quest flags (per-entity versioning). 4. Add a new **"Scripting" invariant/section to `CLAUDE.md`** + the documented dialogue exception. ### Invariants Check (every line of CLAUDE.md § Design checklist) 1. Scope ✓ — only the NPC/quest scripting layer (scenario A); open modding / client-scripting / world-authoring / item-spell scripting out of scope. 2. Server-authoritative ✓ — every verb goes `WorldScriptContext`→`World` (validated); context by id, not results; scripts are committed content, never client-supplied; client-reachable triggers resolve ids server-side. 3. GM authorization ✓ — `/reloadscripts` gated by the JWT `admin` claim (`CanExecute`), not a client flag. 4. Identity model N/A. 5. Protocol versioned ✓ (with choice) — reuse `PlayerSaid` → no bump; a future dialogue-choice UI would be a new wire shape + bump (out of scope). 6. String catalog ✗→**RESOLVED** — NPC dialogue = authored-content category (documented exception, like the biome tint), delivered via `PlayerSaid`; `SystemMessageId`/catalog stay for system cues. Requires a CLAUDE.md exception line. 7. Single-threaded sim ✓ — host on the sim thread, no new lock, instruction budget; reload marshaled; (refine: compile off-thread). 8. World.cs HARD GATE ✓ — `WorldScriptContext` verbs delegate to existing `World`/components; no logic in `World.cs`. 9. Screen HARD GATE (client) N/A — server-only; NPC speech reuses existing `PlayerSaid` rendering. 10. Client engine-independence N/A — no client logic in Phase 1. 11. Gameplay/Networking separation ✓ — scripting is gameplay (context over `World`); networking untouched; host knows nothing of networking. 12. Act on the instance ✓ — quest flags on `PlayerMobile` (authoritative per-entity state), not a side-collection keyed by id. 13. Extend by type, not switch ✓ — a Lua `register("kind", {...})` generalises subclass+catalog; **dispatch = registry lookup**, not `switch(kind)`; C# extend-by-type unchanged. 14. Server-paced actions N/A (Phase 1) — a future `schedule` must advance on ticks (server-paced). 15. Persistence (GameServer) ✓ (refine) — quest flags in `PlayerMobile` self-serialization (**version-int bump**, no central switch); per-player sub-graph, no cross-player refs; **`.lua` read only at startup / explicit reload, never in the tick loop** (compile off-thread, swap on-thread). 16. Persistence (Auth) N/A. 17. Process separation ✓ — GameServer-only; Auth + JWT contract intact. 18. Typed options ✓ — script dir + instruction budget as `GameOptions` fields, one default; no `IConfiguration` in the host. 19. Broadcasts absolute / AoI / transient ✓ — NPC speech via `PlayerSaid`, AoI-scoped from the NPC's authoritative position; no new broadcast in Phase 1. 20. Multi-platform ✓ — MoonSharp pure managed (Win/macOS/Linux); native `NLua` rejected for this reason. 21. Assets required N/A — no new rendered content/art. 22. Asset naming (HARD GATE) N/A — no pack/tiledata assets. 23. ModernUO as reference ✓ — ModernUO=C# scripts, Sphere=`.scp` triggers; we diverge to sandboxed Lua for iteration+safety while keeping server-authority. Justified. 24. Docs & DoD same change ✓ — requires `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 via `World`; 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.Scripting` unit (fake context): budget aborts `while true`; syntax error does NOT swap; reload cancels timers; handler exception isolated; trigger routing correct. - GameServer gameplay (vs `World`): `onUse` NPC speaks; `setFlag` survives serialize/restore; illegal action rejected by `World`. - Manual (debug harness): scripted NPC talks on click; flag persists on relog; edit `.lua` + `/reloadscripts` → live without restart; syntax error → world stays up, logged. Screenshots attached. ### Decision resolved - **NPC dialogue → (a) script-owned content via `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)._ - Minor: instruction budget **per-invocation** to start. **Implementation remains gated** on owner confirmation of (a); no code until then.
Author
Owner

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, bow to Lua — not just add a demo NPC; (b) a new catalogued wire message MobileNotice is needed so a scripted NPC can talk. No blocking , no HARD GATE tripped.

Owner decisions taken

  1. True migration — the C# Rat / Sword / Bow classes are removed; their sole definition becomes the .lua file. Catalogs merge residual C# kinds (Greatsword/Armor/Ankh/Coin/Stone/Dummy) + Lua kinds with a fatal collision-check.
  2. Dialogue = catalogued (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 in SystemMessageCatalog (localizable). Wire carries MobileNotice(mobileId, SystemMessageId, args) — no raw dialogue strings on the wire. String-catalog invariant stays fully satisfied.
  3. Full build-time DX gate in this PR: generated .d.lua from IScriptContext (with a CI drift-check) + luacheck + lua-language-server --check over content/scripts/.

Namespacing (HARD GATE, new) — filesystem tree is the namespace

Path (lowercase) → FQN (PascalCase, acronym map npc→NPC):

content/scripts/mobiles/npc/rat.lua      -> Mobiles.NPC.Rat
content/scripts/items/weapons/sword.lua  -> Items.Weapons.Sword
content/scripts/items/weapons/bow.lua    -> Items.Weapons.Bow

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)

  1. Scope — ✓ (owner-requested migration of rat+sword+bow = Phase-1 spine + data-def layer; declared expansion, not a new system).
  2. Server-authoritative — ✓ Lua asks by id via IScriptContext; WorldScriptContext validates on World (off-map spawn rejected, range, walkability). Scripts are committed content, never client input.
  3. GM authorization — ✓ /reloadscripts is a GmCommand (CanExecute => IsAdmin, JWT admin claim).
  4. Identity model — N/A (no account/char/login change).
  5. Protocol versioned — ✓ add MobileNotice in Shared/Protocol + bump ProtocolVersion.Current same change; script defs never cross the wire.
  6. String catalog — ✓ NPC dialogue is catalogued (SystemMessageId + client template); say takes a message id; /reloadscripts feedback is a SystemMessageId too.
  7. Single-threaded sim — ✓ ScriptHost runs on the sim thread, no new lock; reload IO marshalled via Simulation.InvokeAsync; MoonSharp instruction-budget interrupt.
  8. World.cs HARD GATE — ✓ WorldScriptContext calls existing delegating World methods; no logic added to World.cs.
  9. Screen HARD GATE (client) — ✓ MobileNotice bubble handled in ServerMessageDispatcher/ClientWorld/WorldRenderer, not accumulated in GameScreen.
  10. Client engine-independence — ✓ bubble placement/timing in Client.Core (unit-tested), draw glue in Client.
  11. Gameplay/Networking separation — ✓ dispatch + defs in Gameplay/+IsoMmo.Scripting; MobileNotice transport in Networking/; meet in GameSessionHandler/WorldTick.
  12. Act on the instance — ✓ quest flags are fields on PlayerMobile via an intrinsic setter exposed through IScriptContext; no side-collection; no durable state in Lua globals.
  13. Extend by type, not switch — ✓ a new kind = a new auto-discovered .lua file (reflection analog to ItemCatalog/CreatureRegistry); AI archetypes are an auto-discovered BaseAi registry. No switch added.
  14. Server-paced actions — N/A (no new timed/channeled action; casting stays out of scope).
  15. Persistence (GameServer) — ✓ quest flags on the player sub-graph (bump PlayerMobile version); scripted creatures persist by FQN (bump CreatureSerializer version + branch); no tick-loop IO (scripts loaded at startup/reload off-tick); no cross-player refs; GM-placed & persisted, no seeding/respawn.
  16. Persistence (Auth) — N/A (Auth untouched).
  17. Process separation — ✓ scripting is GameServer-only; Auth untouched; JWT contract unchanged.
  18. Typed options — ✓ GameOptions.ScriptsPath + GameOptions.ScriptInstructionBudget, defaults in one place, bound like the rest.
  19. Broadcasts absolute / AoI — ✓ MobileNotice carries the mobile id (position already reconciled via MobileState); it is a transient event sent only to observers within AoiRadius at emit time (not global, not reconciled).
  20. Multi-platform — ✓ MoonSharp is pure-managed; luacheck/lua-language-server are dev/CI-only, not runtime deps.
  21. Assets required — N/A (no new asset; rat/sword/bow reuse existing art).
  22. Asset naming (HARD GATE) — N/A (no new art id). Note: a twin script-naming HARD GATE (namespaced FQN, never a bare generic) is added to CLAUDE.md ## Design checklist this change.
  23. ModernUO as reference — ✓ ModernUO uses hot-compiled C# scripts + per-type reaction hooks; we diverge to sandboxed Lua (fast iteration, multi-platform) but keep the per-type-hook + data-def model. Documented in docs/scripting.md.
  24. Docs & DoD in the same change — ✓ new docs/scripting.md (model + generated API + dialogue decision) + new CLAUDE.md HARD GATE + its ## Design checklist bullet, all in this change.

No , no HARD GATE tripped.

Verification plan (preview)

  • IsoMmo.Scripting unit (fake IScriptContext): instruction-budget aborts while 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.
  • GameServer gameplay (vs World): onUse makes the rat talk; setFlag survives a serialize/restore round-trip; off-map spawn from a script rejected (authority holds); Lua sword/bow equip arms the Mobile with declared stats (melee vs range+projectile) and combat is unchanged; C#/Lua kind collision is fatal; startup is FATAL with a broken script.
  • WebSocket (CreateWebSocketClient): MobileNotice delivered only to observers within AoI.
  • Trust boundary (must cover): /reloadscripts from a non-admin rejected; off-map spawn from a script rejected without tearing the tick.
  • Screenshots (visible change → required), fresh DB via the debug harness: (1) /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.
## 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`, `bow` to Lua — not just add a demo NPC; (b) a new catalogued wire message `MobileNotice` is needed so a scripted NPC can talk. No blocking `✗`, no HARD GATE tripped. ### Owner decisions taken 1. **True migration** — the C# `Rat` / `Sword` / `Bow` classes are **removed**; their sole definition becomes the `.lua` file. Catalogs merge residual C# kinds (Greatsword/Armor/Ankh/Coin/Stone/Dummy) + Lua kinds with a fatal collision-check. 2. **Dialogue = catalogued (`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 in `SystemMessageCatalog` (localizable). Wire carries `MobileNotice(mobileId, SystemMessageId, args)` — no raw dialogue strings on the wire. String-catalog invariant stays fully satisfied. 3. **Full build-time DX gate in this PR**: generated `.d.lua` from `IScriptContext` (with a CI drift-check) + `luacheck` + `lua-language-server --check` over `content/scripts/`. ### Namespacing (HARD GATE, new) — filesystem tree *is* the namespace Path (lowercase) → FQN (PascalCase, acronym map `npc→NPC`): ``` content/scripts/mobiles/npc/rat.lua -> Mobiles.NPC.Rat content/scripts/items/weapons/sword.lua -> Items.Weapons.Sword content/scripts/items/weapons/bow.lua -> Items.Weapons.Bow ``` 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`) 1. **Scope** — ✓ (owner-requested migration of rat+sword+bow = Phase-1 spine + data-def layer; declared expansion, not a new system). 2. **Server-authoritative** — ✓ Lua asks by id via `IScriptContext`; `WorldScriptContext` validates on `World` (off-map spawn rejected, range, walkability). Scripts are committed content, never client input. 3. **GM authorization** — ✓ `/reloadscripts` is a `GmCommand` (`CanExecute => IsAdmin`, JWT `admin` claim). 4. **Identity model** — N/A (no account/char/login change). 5. **Protocol versioned** — ✓ add `MobileNotice` in `Shared/Protocol` + bump `ProtocolVersion.Current` same change; script defs never cross the wire. 6. **String catalog** — ✓ NPC dialogue is catalogued (`SystemMessageId` + client template); `say` takes a message id; `/reloadscripts` feedback is a `SystemMessageId` too. 7. **Single-threaded sim** — ✓ `ScriptHost` runs on the sim thread, no new lock; reload IO marshalled via `Simulation.InvokeAsync`; MoonSharp instruction-budget interrupt. 8. **World.cs HARD GATE** — ✓ `WorldScriptContext` calls existing delegating `World` methods; no logic added to `World.cs`. 9. **Screen HARD GATE (client)** — ✓ `MobileNotice` bubble handled in `ServerMessageDispatcher`/`ClientWorld`/`WorldRenderer`, not accumulated in `GameScreen`. 10. **Client engine-independence** — ✓ bubble placement/timing in `Client.Core` (unit-tested), draw glue in `Client`. 11. **Gameplay/Networking separation** — ✓ dispatch + defs in `Gameplay/`+`IsoMmo.Scripting`; `MobileNotice` transport in `Networking/`; meet in `GameSessionHandler`/`WorldTick`. 12. **Act on the instance** — ✓ quest flags are fields on `PlayerMobile` via an intrinsic setter exposed through `IScriptContext`; no side-collection; no durable state in Lua globals. 13. **Extend by type, not switch** — ✓ a new kind = a new auto-discovered `.lua` file (reflection analog to `ItemCatalog`/`CreatureRegistry`); AI archetypes are an auto-discovered `BaseAi` registry. No switch added. 14. **Server-paced actions** — N/A (no new timed/channeled action; casting stays out of scope). 15. **Persistence (GameServer)** — ✓ quest flags on the player sub-graph (bump `PlayerMobile` version); scripted creatures persist by FQN (bump `CreatureSerializer` version + branch); no tick-loop IO (scripts loaded at startup/reload off-tick); no cross-player refs; GM-placed & persisted, no seeding/respawn. 16. **Persistence (Auth)** — N/A (Auth untouched). 17. **Process separation** — ✓ scripting is GameServer-only; Auth untouched; JWT contract unchanged. 18. **Typed options** — ✓ `GameOptions.ScriptsPath` + `GameOptions.ScriptInstructionBudget`, defaults in one place, bound like the rest. 19. **Broadcasts absolute / AoI** — ✓ `MobileNotice` carries the mobile id (position already reconciled via `MobileState`); it is a **transient event** sent only to observers within `AoiRadius` at emit time (not global, not reconciled). 20. **Multi-platform** — ✓ MoonSharp is pure-managed; luacheck/lua-language-server are dev/CI-only, not runtime deps. 21. **Assets required** — N/A (no new asset; rat/sword/bow reuse existing art). 22. **Asset naming (HARD GATE)** — N/A (no new art id). *Note:* a twin **script-naming HARD GATE** (namespaced FQN, never a bare generic) is added to CLAUDE.md `## Design checklist` this change. 23. **ModernUO as reference** — ✓ ModernUO uses hot-compiled C# scripts + per-type reaction hooks; we diverge to sandboxed Lua (fast iteration, multi-platform) but keep the per-type-hook + data-def model. Documented in `docs/scripting.md`. 24. **Docs & DoD in the same change** — ✓ new `docs/scripting.md` (model + generated API + dialogue decision) + new `CLAUDE.md` HARD GATE + its `## Design checklist` bullet, all in this change. **No `✗`, no HARD GATE tripped.** ### Verification plan (preview) - **`IsoMmo.Scripting` unit (fake `IScriptContext`)**: instruction-budget aborts `while 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. - **GameServer gameplay (vs `World`)**: `onUse` makes the rat talk; `setFlag` survives a serialize/restore round-trip; off-map spawn from a script rejected (authority holds); Lua sword/bow equip arms the `Mobile` with declared stats (melee vs range+projectile) and combat is unchanged; C#/Lua kind collision is fatal; startup is FATAL with a broken script. - **WebSocket (`CreateWebSocketClient`)**: `MobileNotice` delivered only to observers within AoI. - **Trust boundary (must cover)**: `/reloadscripts` from a non-admin rejected; off-map spawn from a script rejected without tearing the tick. - **Screenshots (visible change → required)**, fresh DB via the debug harness: (1) `/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.
Author
Owner

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), the luacheck + lua-language-server gate, /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 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), the `luacheck` + `lua-language-server` gate, `/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.
Author
Owner

Phase 1 shipped in #193 (merged to main). The design + Phase-1 spine are done: the IsoMmo.Scripting host, WorldScriptContext, onUse/say/spawn/getFlag/setFlag, hot-reload (/reloadscripts), instruction-budget + error isolation, the generated-from-contract api.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:

  • #195 — Phase 2: onTick/think, onDeath, quests, timers
  • #196 — Phase 3: items as content (non-weapon data-defs + onUse)
  • #197 — Phase 4: ergonomics (file-watcher auto-reload, handle methods, tooling)

Scripting spells stays deferred (a future decision).

**Phase 1 shipped in #193** (merged to `main`). The design + Phase-1 spine are done: the `IsoMmo.Scripting` host, `WorldScriptContext`, `onUse`/say/spawn/getFlag/setFlag, hot-reload (`/reloadscripts`), instruction-budget + error isolation, the generated-from-contract `api.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: - #195 — Phase 2: onTick/think, onDeath, quests, timers - #196 — Phase 3: items as content (non-weapon data-defs + onUse) - #197 — Phase 4: ergonomics (file-watcher auto-reload, handle methods, tooling) Scripting **spells** stays deferred (a future decision).
marco closed this issue 2026-07-24 19:01:45 +02:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
marco/IsoMmo#183
No description provided.