feat(scripting): Lua (MoonSharp) content-scripting layer — migrate rat, sword, bow #193

Merged
marco merged 11 commits from feat/scripting-lua into main 2026-07-24 18:56:19 +02:00
Owner

Summary

Adds the Lua (MoonSharp) content-scripting layer (#183, Phase 1) and uses it to migrate the rat, sword and bow out of C# into Lua — proving the whole mechanism end to end. The goal set for this work was developer experience: content authored in content/scripts/*.lua, hot-reloadable, with editor autocomplete and mistakes caught as early as possible.

What's in it:

  • IsoMmo.Scripting — engine-agnostic host (references only Shared + MoonSharp): compiles/sandboxes scripts, derives each script's FQN from its path (mobiles/npc/rat.luaMobiles.NPC.Rat), exposes curated verbs (say/spawn/getFlag/setFlag), dispatches the onUse trigger under an instruction budget with error isolation + quarantine, and hot-reloads compile-fresh-then-swap. Clean Api/Content/Hosting namespaces.
  • GameServer wiringWorldScriptContext : IScriptContext realises the verbs over World (id + DTO boundary, server-authoritative); scripted content bridges into ItemCatalog (now an instance) + CreatureRegistry. Scripts are loaded at startup and a broken set is FATAL; a runtime handler failure is isolated + logged. /reloadscripts GM command.
  • Migrationcontent/scripts/mobiles/npc/rat.lua, items/weapons/sword.lua, items/weapons/bow.lua; the C# Rat/Sword/Bow classes are removed. Unit tests use C# test-doubles (the one documented test-only exception to "content in Lua").
  • DX / no-drift — the Lua API is described by [Script*] attributes on the C# contract; content/scripts/api.d.lua is generated from them (just gen-lua-defs) and the host validates every table against the same reflected model, so editor types and runtime acceptance share one source. A CI drift-check + a lua-lint gate (luacheck + lua-language-server) run over the scripts. Dialogue is typed: say(npc, Messages.Creatures.RatSqueak).
  • Protocol v13UseMobile intent + MobileSaid broadcast; client renders a speech bubble over any mobile.
  • Docs: docs/scripting.md, a new ## Content scripting (Lua) HARD GATE in CLAUDE.md (+ scoped src/IsoMmo.Scripting/CLAUDE.md and README.md), .vscode/ shared dev setup.

The critical-design-review (with the Invariants Check + the dialogue-vs-catalog resolution) is recorded on #183.

Screenshots / recording

Captured by piloting the client via the debug harness from a fresh DB (docs/debug-harness.md).

A scripted NPC talks — double-click the rat → its Lua onUse runs → catalogued MobileSaid bubble:

The rat squeaks when double-clicked

login test
/spawn rat            # via chat → GM command → target a tile
# double-click the rat sprite

(The rat normally wanders; for a stable frame the shot was taken with the wander cadence turned up via a runtime env override — the onUse → talk behaviour is unaffected.)

The migrated Lua weapons — /give sword + /give bow → real UO art in the backpack (13 st = 6 + 7):

Sword and bow in the backpack

login test
/give sword
/give bow
open bag

How it was tested

  • Unit (IsoMmo.Scripting, fake IScriptContext) — instruction-budget aborts a while true; syntax error does NOT swap (old handlers keep running); handler exception isolated + quarantine after N; trigger routing; FQN-from-path; schema-driven field validation (unknown field rejected); typed Messages.*; the generated-api.d.lua drift-check.
  • GameServer gameplay (vs World)onUse makes the rat talk to AoI observers; setFlag survives a serialize/restore round-trip; off-map spawn from a script rejected (authority holds); C#/Lua kind collision is fatal.
  • Integration (WebSocket / real startup) — scripted NPC speaks on UseMobile end to end; the server refuses to start on a deliberately broken script.
  • Lua lintluacheck + lua-language-server --check run locally against the real tools: the committed scripts pass; a deliberate type error fails (exit 1).
  • Gatesjust lint (CSharpier + build, zero warnings), whole-solution build (client + tools), dotnet test all green (536 tests), just check-docs clean.
  • Manual — the harness flow above from a fresh DB.

Checklist

  • just lint passes (CSharpier + analyzers, zero warnings)
  • just test is green
  • The whole solution builds (client and tools included)
  • Multi-platform preserved (MoonSharp is pure-managed; luacheck/lua-language-server are dev/CI-only)
  • Tests added/updated for this change
  • Linked the related issue (#183) and its Definition of Done is met
## Summary Adds the **Lua (MoonSharp) content-scripting layer** (#183, Phase 1) and uses it to **migrate the rat, sword and bow out of C# into Lua** — proving the whole mechanism end to end. The goal set for this work was developer experience: content authored in `content/scripts/*.lua`, hot-reloadable, with editor autocomplete and mistakes caught as early as possible. What's in it: - **`IsoMmo.Scripting`** — engine-agnostic host (references only `Shared` + MoonSharp): compiles/sandboxes scripts, derives each script's FQN from its **path** (`mobiles/npc/rat.lua` → `Mobiles.NPC.Rat`), exposes curated verbs (`say`/`spawn`/`getFlag`/`setFlag`), dispatches the `onUse` trigger under an **instruction budget** with **error isolation + quarantine**, and **hot-reloads** compile-fresh-then-swap. Clean `Api`/`Content`/`Hosting` namespaces. - **GameServer wiring** — `WorldScriptContext : IScriptContext` realises the verbs over `World` (id + DTO boundary, server-authoritative); scripted content bridges into `ItemCatalog` (now an instance) + `CreatureRegistry`. Scripts are loaded at startup and a broken set is **FATAL**; a runtime handler failure is isolated + logged. `/reloadscripts` GM command. - **Migration** — `content/scripts/mobiles/npc/rat.lua`, `items/weapons/sword.lua`, `items/weapons/bow.lua`; the C# `Rat`/`Sword`/`Bow` classes are **removed**. Unit tests use C# test-doubles (the one documented test-only exception to "content in Lua"). - **DX / no-drift** — the Lua API is described by `[Script*]` attributes on the C# contract; `content/scripts/api.d.lua` is **generated** from them (`just gen-lua-defs`) and the host validates every table against the **same** reflected model, so editor types and runtime acceptance share one source. A CI drift-check + a **`lua-lint`** gate (luacheck + lua-language-server) run over the scripts. Dialogue is typed: `say(npc, Messages.Creatures.RatSqueak)`. - **Protocol v13** — `UseMobile` intent + `MobileSaid` broadcast; client renders a speech bubble over any mobile. - Docs: `docs/scripting.md`, a new `## Content scripting (Lua)` HARD GATE in `CLAUDE.md` (+ scoped `src/IsoMmo.Scripting/CLAUDE.md` and `README.md`), `.vscode/` shared dev setup. The `critical-design-review` (with the Invariants Check + the dialogue-vs-catalog resolution) is recorded on #183. ## Screenshots / recording Captured by piloting the client via the debug harness from a **fresh DB** (`docs/debug-harness.md`). **A scripted NPC talks — double-click the rat → its Lua `onUse` runs → catalogued `MobileSaid` bubble:** ![The rat squeaks when double-clicked](https://git.homelab.devncode.it/attachments/1ac05e61-ae14-47e9-b78c-6ff5a04885df) ``` login test /spawn rat # via chat → GM command → target a tile # double-click the rat sprite ``` (The rat normally wanders; for a stable frame the shot was taken with the wander cadence turned up via a runtime env override — the `onUse` → talk behaviour is unaffected.) **The migrated Lua weapons — `/give sword` + `/give bow` → real UO art in the backpack (13 st = 6 + 7):** ![Sword and bow in the backpack](https://git.homelab.devncode.it/attachments/fa747a49-fe93-4200-b86c-6188101481e0) ``` login test /give sword /give bow open bag ``` ## How it was tested - **Unit (`IsoMmo.Scripting`, fake `IScriptContext`)** — instruction-budget aborts a `while true`; syntax error does NOT swap (old handlers keep running); handler exception isolated + quarantine after N; trigger routing; FQN-from-path; schema-driven field validation (unknown field rejected); typed `Messages.*`; the generated-`api.d.lua` drift-check. - **GameServer gameplay (vs `World`)** — `onUse` makes the rat talk to AoI observers; `setFlag` survives a serialize/restore round-trip; off-map spawn from a script rejected (authority holds); C#/Lua kind collision is fatal. - **Integration (WebSocket / real startup)** — scripted NPC speaks on `UseMobile` end to end; the server **refuses to start** on a deliberately broken script. - **Lua lint** — `luacheck` + `lua-language-server --check` run locally against the real tools: the committed scripts pass; a deliberate type error fails (exit 1). - **Gates** — `just lint` (CSharpier + build, zero warnings), whole-solution build (client + tools), `dotnet test` all green (536 tests), `just check-docs` clean. - **Manual** — the harness flow above from a fresh DB. ## Checklist - [x] `just lint` passes (CSharpier + analyzers, zero warnings) - [x] `just test` is green - [x] The whole solution builds (client and tools included) - [x] Multi-platform preserved (MoonSharp is pure-managed; luacheck/lua-language-server are dev/CI-only) - [x] Tests added/updated for this change - [x] Linked the related issue (#183) and its Definition of Done is met
Engine-agnostic scripting spine (references only Shared + MoonSharp):
ScriptHost compiles content .lua into a sandboxed MoonSharp runtime, derives
each script's FQN from its path (Mobiles.NPC.Rat), exposes curated verbs
(say/spawn/getFlag/setFlag) backed by IScriptContext, and dispatches triggers
(onUse) under an instruction budget with error isolation + quarantine.
Hot-reload is compile-fresh-then-swap. Clean Api/Content/Hosting namespaces.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- WorldScriptContext realises IScriptContext over World (id+DTO boundary,
  server-authoritative); ScriptRuntime facade; scripted content bridges into
  ItemCatalog (now an instance) + CreatureRegistry (scripted factories).
- ScriptedWeapon/ScriptedCreature build from Lua defs; AI archetypes + enums
  resolved at startup (fatal on a bad name). Scripts loaded at startup FATAL on
  failure; runtime failures isolated + logged.
- Protocol v13: UseMobile intent + MobileSaid broadcast; new script/rat cues.
- /reloadscripts GM command (compile-fresh-then-swap off the sim thread).
- Quest flags on PlayerMobile (persisted v4).
- Content: rat/sword/bow migrated to content/scripts (C# classes removed);
  unit tests use TestContent doubles (the documented test-only exception).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- ScriptedWorldTests (public surface): onUse makes the NPC talk to AoI observers,
  setFlag survives serialize/restore, off-map spawn rejected, kind collision fatal.
- Integration: scripted NPC speaks on UseMobile end-to-end (WebSocket); server
  refuses to start on a broken script (fatal startup).
- Typed dialogue: Messages.<Category>.<Name> table generated from [ScriptMessage]
  on SystemMessageId; ScriptHost injects it, unit-tested.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Lua type definitions (content/scripts/api.d.lua) are now GENERATED by reflecting
the decorated contract — [ScriptVerb]/[ScriptHandle] on IScriptContext, [ScriptField]/
[ScriptInlineFields]/[ScriptRegistration] on the def records, [ScriptHandler] on
ScriptTrigger, [ScriptMessage] on SystemMessageId — instead of a hand-written mirror.
The host validates each registration table against the SAME reflected model, so the
editor types and what the runtime accepts share one source and cannot drift. A
drift-check test (run in CI) fails if the committed file is stale; regenerate with
`just gen-lua-defs`. Adds .luacheckrc + lua-lint recipe.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Promote the speech-bubble feature from Player up to the Mobile base, so a
  scripted creature (Creature) shows a dialogue bubble too; Player unchanged.
- ServerMessageDispatcher: MobileSaid -> mob.ShowSpeech(catalog-formatted text).
- GameInputController: double-clicking a mobile sends UseMobile (the server
  validates + replies with MobileSaid); falls back to ground-item Use.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- docs/scripting.md: model, namespacing gate, authoring, verbs/flags/dialogue,
  the generated-API contract, validation stages, operating.
- Root CLAUDE.md: new 'Content is authored in Lua' HARD GATE + Design-checklist
  bullet + Architecture entry + Content-scripting section.
- src/IsoMmo.Scripting/{README,CLAUDE}.md: VSCode setup, generated types, layer gates.
- .vscode/{extensions,settings}.json: shared dotnet+Lua dev setup across machines.
- gm-commands.md: /reloadscripts row + scripted spawn kinds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- scripts/lua-lint.sh: luacheck + lua-language-server --check over content/scripts,
  both exit non-zero on a finding (verified). Wired as `just lua-lint` + a CI `lua`
  job (installs both tools). Catches undefined globals, arity, wrong signatures, type
  and nil errors before deploy.
- .luacheckrc declares the sandbox globals + disables unused-args (event handlers).
- Fix the generated api.d.lua Messages literal so it doesn't self-trip missing-fields.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
chore(scripting): editor-formatted rat.lua + shared .vscode debug/build config
Some checks failed
ci / Lua content lint (pull_request) Failing after 7s
ci / Lint & Test (pull_request) Successful in 2m12s
f83ab6384a
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ci(scripting): make the Lua lint job runner-portable (no sudo/apt)
All checks were successful
ci / Lua content lint (pull_request) Successful in 7s
ci / Lint & Test (pull_request) Successful in 1m46s
58018d6da0
The Forgejo runner has no sudo/apt. Install only lua-language-server (self-contained
linux-x64 binary) for the CI gate — it checks the content scripts against the generated
api.d.lua (undefined globals, signatures, type/nil). luacheck stays a local-dev extra
(lua-lint.sh skips it when absent).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ci(trial): try lunarmodules/luacheck marketplace action on the Forgejo runner
All checks were successful
ci / Lua content lint (pull_request) Successful in 10s
ci / luacheck (marketplace action trial) (pull_request) Successful in 13s
ci / Lint & Test (pull_request) Successful in 1m48s
80637edecd
ci(scripting): use the lunarmodules/luacheck marketplace action
All checks were successful
ci / Lua content lint (pull_request) Successful in 10s
ci / Lint & Test (pull_request) Successful in 2m0s
c8b85d2f32
The Forgejo runner resolves + runs marketplace actions, so use the official luacheck
action instead of a local install; keep lua-language-server as the portable binary for
the type-check (no standard action, no apt on the runner).
marco merged commit 0362c30627 into main 2026-07-24 18:56:19 +02:00
marco deleted branch feat/scripting-lua 2026-07-24 18:56:19 +02:00
Sign in to join this conversation.
No reviewers
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!193
No description provided.