From b94a714c39073f1af8f62c9c6491fc69cd7bfff0 Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Sat, 10 Oct 2026 20:31:00 +0200 Subject: [PATCH 1/9] feat(scripts): bind typed event handlers in TypeScript and Lua --- .luarc.json | 4 + CONTEXT.md | 19 +- Cargo.lock | 1 + crates/lyracore-package-delta/src/script.rs | 30 +- datascripts/bun.lock | 8 + datascripts/runtime-scripts/bindings.ts | 208 +++++++++ datascripts/runtime-scripts/build-scripts.ts | 98 ++--- datascripts/runtime-scripts/event-names.rs | 24 ++ datascripts/runtime-scripts/events.json | 400 ++++++++++++++++++ .../runtime-scripts/generate-events.test.ts | 25 ++ .../runtime-scripts/generate-events.ts | 192 +++++++++ datascripts/runtime-scripts/lua-emit.cjs | 28 +- datascripts/runtime-scripts/package.json | 4 + .../runtime-scripts/runtime-script.base.d.ts | 39 ++ .../runtime-scripts/runtime-script.d.ts | 200 +++++++-- .../runtime-scripts/runtime-script.lua | 224 ++++++++++ datascripts/runtime-scripts/script-ids.ts | 94 ++++ .../tests/fixtures/typed/answer.generated.lua | 11 + datascripts/tests/fixtures/typed/answer.lua | 7 + .../tests/fixtures/typed/ding.generated.lua | 10 + datascripts/tests/fixtures/typed/ding.lua | 5 + .../fixtures/typed/welcome.generated.lua | 12 + datascripts/tests/fixtures/typed/welcome.ts | 5 + datascripts/tests/runtime-scripts.test.ts | 4 +- datascripts/tests/typed-scripts.test.ts | 175 ++++++++ datascripts/tsconfig.json | 3 +- docs/architecture.md | 2 +- docs/module-build.md | 7 +- docs/package-api.md | 31 +- docs/runtime-script-host.md | 15 +- module/Cargo.toml | 5 + module/build.rs | 257 ++++------- module/src/debug/runtime_script.rs | 1 + module/src/hooks.rs | 13 +- module/src/package_test.rs | 66 +++ module/src/runtime_script.rs | 221 ++++++++++ module/src/script_binding.rs | 43 +- module/src/spell/cast/targeting.rs | 1 + packages/README.md | 51 ++- tsconfig.json | 14 + 40 files changed, 2177 insertions(+), 380 deletions(-) create mode 100644 .luarc.json create mode 100644 datascripts/runtime-scripts/bindings.ts create mode 100644 datascripts/runtime-scripts/event-names.rs create mode 100644 datascripts/runtime-scripts/events.json create mode 100644 datascripts/runtime-scripts/generate-events.test.ts create mode 100644 datascripts/runtime-scripts/generate-events.ts create mode 100644 datascripts/runtime-scripts/runtime-script.base.d.ts create mode 100644 datascripts/runtime-scripts/runtime-script.lua create mode 100644 datascripts/runtime-scripts/script-ids.ts create mode 100644 datascripts/tests/fixtures/typed/answer.generated.lua create mode 100644 datascripts/tests/fixtures/typed/answer.lua create mode 100644 datascripts/tests/fixtures/typed/ding.generated.lua create mode 100644 datascripts/tests/fixtures/typed/ding.lua create mode 100644 datascripts/tests/fixtures/typed/welcome.generated.lua create mode 100644 datascripts/tests/fixtures/typed/welcome.ts create mode 100644 datascripts/tests/typed-scripts.test.ts create mode 100644 tsconfig.json diff --git a/.luarc.json b/.luarc.json new file mode 100644 index 000000000..cb6a93318 --- /dev/null +++ b/.luarc.json @@ -0,0 +1,4 @@ +{ + "runtime.version": "Lua 5.3", + "workspace.library": ["datascripts/runtime-scripts/runtime-script.lua"] +} diff --git a/CONTEXT.md b/CONTEXT.md index 268123824..03198b355 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -37,7 +37,7 @@ A World of Warcraft 1.12.1 server. Game state lives in SpacetimeDB; the gateway **R** [Raid](#sharding-and-transfer) · [Raid Quest](#sharding-and-transfer) · [Raid Slot](#sharding-and-transfer) · [Random Property](#random-properties) · [Ranged Posture](#creature-ai) · [Rank Rights](#guilds) · [Ready Check](#sharding-and-transfer) · [Realm](#realm-topology) · [Realm Chat Line](#chat) · [Realm Clock](#world-clock-and-weather) · [Realm Presence](#chat) · [Realm-core](#realm-topology) · [Recorded Revision](#packages) · [Recovery Attempt](#sharding-and-transfer) · [Recovery Scan](#sharding-and-transfer) · [Reference Datascript](#packages) · [Reference Package](#packages) · [Refusal](#gateway-and-module) · [Relay](#gateway-and-module) · [Relay Definition](#realm-topology) · [Relay Run](#realm-topology) · [Returned Mail](#mail) · [Reward Letter](#mail) · [Roster Revision](#sharding-and-transfer) · [Roster Revision Relay](#sharding-and-transfer) · [Route Path](#procs) · [Route Step](#procs) · [Rule State](#creature-ai) · [Runtime Script](#runtime-scripts) · [Runtime Script Host](#runtime-scripts) · [Runtime Script Toolchain](#packages) -**S** [Script Answer](#runtime-scripts) · [Script Artifact](#packages) · [Script Diagnostic](#runtime-scripts) · [Script Directive](#packages) · [Seam](#working-method) · [Seeker](#meeting-stones) · [Self-Resurrection Option](#death-and-resurrection) · [Service Reconciliation](#realm-topology) · [Session](#accounts-characters-and-sessions) · [Session Expiry](#accounts-characters-and-sessions) · [SessionActor](#accounts-characters-and-sessions) · [Sessionless Action Consent](#sharding-and-transfer) · [Settlement](#auctions) · [Shard](#realm-topology) · [Shard Boundary](#sharding-and-transfer) · [Shard Map](#sharding-and-transfer) · [Signature](#guilds) · [Solo Target Claim](#sharding-and-transfer) · [Soulstone](#death-and-resurrection) · [Spatial Claim](#packages) · [Speaker Facts](#chat) · [Spec](#working-method) · [Speech](#chat) · [Spell Cast Event Kind](#procs) · [Staged Effect](#runtime-scripts) · [Standalone Supervisor](#realm-topology) · [Stat Kind](#random-properties) · [Stone Add](#meeting-stones) · [Store](#working-method) · [Strategy](#sharding-and-transfer) · [Subgroup](#sharding-and-transfer) · [Suffix](#random-properties) +**S** [Script Answer](#runtime-scripts) · [Script Artifact](#packages) · [Script Diagnostic](#runtime-scripts) · [Script Directive](#packages) · [Script Identity](#packages) · [Seam](#working-method) · [Seeker](#meeting-stones) · [Self-Resurrection Option](#death-and-resurrection) · [Service Reconciliation](#realm-topology) · [Session](#accounts-characters-and-sessions) · [Session Expiry](#accounts-characters-and-sessions) · [SessionActor](#accounts-characters-and-sessions) · [Sessionless Action Consent](#sharding-and-transfer) · [Settlement](#auctions) · [Shard](#realm-topology) · [Shard Boundary](#sharding-and-transfer) · [Shard Map](#sharding-and-transfer) · [Signature](#guilds) · [Solo Target Claim](#sharding-and-transfer) · [Soulstone](#death-and-resurrection) · [Spatial Claim](#packages) · [Speaker Facts](#chat) · [Spec](#working-method) · [Speech](#chat) · [Spell Cast Event Kind](#procs) · [Staged Effect](#runtime-scripts) · [Standalone Supervisor](#realm-topology) · [Stat Kind](#random-properties) · [Stone Add](#meeting-stones) · [Store](#working-method) · [Strategy](#sharding-and-transfer) · [Subgroup](#sharding-and-transfer) · [Suffix](#random-properties) **T** [Tabard Designer](#guilds) · [Target Icon](#sharding-and-transfer) · [Ticket](#working-method) · [Tracer](#working-method) · [Trade Commit](#trading) · [Trade Session](#trading) · [Transfer](#sharding-and-transfer) · [Transfer Intent](#sharding-and-transfer) · [Transport Loss](#gateway-and-module) · [Triggered Cast](#procs) · [Trust Review](#packages) @@ -1095,7 +1095,8 @@ _Avoid_: apply log, history, audit trail The recorded inputs `packages build` writes next to a source-built artifact, in a sibling file rather than inside it. A Package Delta records its Datascript source tree, generated Module typings, Base Snapshot, authoring library, and pinned Datascript toolchain. A source-built Script -Artifact records its `scripts/` sources, Runtime Script Toolchain, Bun pin, and artifact hash. A +Artifact records its `scripts/` sources, optional `script-ids.json`, Runtime Script Toolchain, +Bun pin, and artifact hash. A source-free prebuilt Script Artifact has no local author inputs or Build Identity; the authoritative checker still parses and traces it. `lyracore packages check` and preflight recompute a present identity against the checkout on disk and refuse, naming the input that changed. @@ -1132,12 +1133,20 @@ _Avoid_: script bundle, script manifest, script delta **Event Binding**: The event a Runtime Script runs for: a name from the Module's hook catalogue, or a Package Event of -the shipping Package. +the shipping Package. A source file declares one binding, such as `events.player.onLogin(welcome)`, +and the toolchain emits an Invocation of that function with the typed event payload. _Avoid_: hook registration, subscription, listener +**Script Identity**: +A Runtime Script's Package and source-file stem, paired with its durable numeric ID in +`script-ids.json`. The Runtime Script Toolchain allocates new IDs and preserves recorded ones, +including entries whose source was removed. Renaming a handler keeps its identity; renaming its +source file creates a new one. An independent scaffold starts with new identities. + **Script Directive**: -A `@key value` comment line at the top of a Runtime Script source, declaring what the file cannot -say in its own code. +A legacy `@key value` comment at the top of a Runtime Script source. Existing directives still +build and preserve their IDs. New source declares its Event Binding in code; the toolchain records +its Script Identity. _Avoid_: annotation, frontmatter, pragma, metadata header **Runtime Script Toolchain**: diff --git a/Cargo.lock b/Cargo.lock index d82e7e3b2..80056e846 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1876,6 +1876,7 @@ dependencies = [ "lyracore-shared", "lyracore-test-support", "piccolo", + "serde", "serde_json", "spacetimedb", ] diff --git a/crates/lyracore-package-delta/src/script.rs b/crates/lyracore-package-delta/src/script.rs index deffb3e3d..450e083e7 100644 --- a/crates/lyracore-package-delta/src/script.rs +++ b/crates/lyracore-package-delta/src/script.rs @@ -80,35 +80,7 @@ pub const SCRIPT_ARTIFACT_KIND: &str = "script"; /// The Script Artifact version this build reads and writes. pub const SCRIPT_VERSION: u64 = 1; -/// Every event a Runtime Script may bind to: the Module's `HOOK_EVENTS` catalogue, in its order. -/// -/// This is the SECOND copy of that catalogue — the first is `HOOK_EVENTS` in `module/build.rs`, -/// which generates the dispatch. A pure crate cannot read the Module's build script, so the copy is -/// held here and the Module asserts the two are identical against the `GAME_HOOK_EVENT_NAMES` its -/// build emits. Adding an event without updating this list fails that test rather than silently -/// refusing every Package that binds to it. -pub const HOOK_EVENT_NAMES: &[&str] = &[ - "on_damage_taken", - "on_death_prevented", - "on_creature_spawn", - "on_levelup", - "on_group_invite", - "on_death", - "on_kill", - "on_aggro", - "on_cast_resolved", - "on_cast_finished", - "on_loot", - "on_quest_accept", - "on_quest_turnin", - "on_login", - "on_character_relocated", - "on_logout", - "on_gossip_select", - "on_creature_death", - "on_hp_threshold", - "on_go_used", -]; +include!("../../../datascripts/runtime-scripts/event-names.rs"); /// Which artifact a file in a Package's generated directory holds. /// diff --git a/datascripts/bun.lock b/datascripts/bun.lock index f20d9239f..79c6a7778 100644 --- a/datascripts/bun.lock +++ b/datascripts/bun.lock @@ -16,14 +16,20 @@ "runtime-scripts": { "name": "lyracore-script-compiler", "dependencies": { + "luaparse": "0.3.1", "typescript": "6.0.2", "typescript-to-lua": "1.37.1", }, + "devDependencies": { + "@types/luaparse": "0.2.13", + }, }, }, "packages": { "@types/bun": ["@types/bun@1.3.7", "", { "dependencies": { "bun-types": "1.3.7" } }, "sha512-lmNuMda+Z9b7tmhA0tohwy8ZWFSnmQm1UDWXtH5r9F7wZCfkeO3Jx7wKQ1EOiKq43yHts7ky6r8SDJQWRNupkA=="], + "@types/luaparse": ["@types/luaparse@0.2.13", "", {}, "sha512-nWs2m9b/zrMcAA2Rs+wTxdIsBkSYMo0+6ko8mpeqS5dhchU3hQN9kZGcSw96n497Vc7kGpDLtSicjdpaUrRAkw=="], + "@types/node": ["@types/node@24.13.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q=="], "@typescript-to-lua/language-extensions": ["@typescript-to-lua/language-extensions@1.19.0", "", {}, "sha512-Os5wOKwviTD4LeqI29N0btYOjokSJ97iCf45EOjIABlb5IwNQy7AE/AqZJobRw3ywHH8+KzJUMkEirWPzh2tUA=="], @@ -86,6 +92,8 @@ "is-core-module": ["is-core-module@2.16.2", "", { "dependencies": { "hasown": "^2.0.3" } }, "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA=="], + "luaparse": ["luaparse@0.3.1", "", { "bin": { "luaparse": "bin/luaparse" } }, "sha512-b21h2bFEbtGXmVqguHogbyrMAA0wOHyp9u/rx+w6Yc9pW1t9YjhGUsp87lYcp7pFRqSWN/PhFkrdIqKEUzRjjQ=="], + "lyracore-script-compiler": ["lyracore-script-compiler@workspace:runtime-scripts"], "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], diff --git a/datascripts/runtime-scripts/bindings.ts b/datascripts/runtime-scripts/bindings.ts new file mode 100644 index 000000000..2c832892d --- /dev/null +++ b/datascripts/runtime-scripts/bindings.ts @@ -0,0 +1,208 @@ +import * as ts from "typescript"; +import * as lua from "luaparse"; +import { EVENTS, type EventDefinition } from "./generate-events.ts"; + +export interface SourceBinding { + readonly event: string; + readonly group: string; + readonly method: string; + readonly priority: number; + readonly enabled: boolean; + readonly definition?: EventDefinition; +} + +function refuse(file: string, message: string): never { + throw new Error(`${file}: ${message}`); +} + +function binding( + file: string, packageName: string, path: string[], localName: string | undefined, + options: Record, +): SourceBinding { + if (path.length !== 3 || path[0] !== "events") refuse(file, "use events..(handler)"); + const group = path[1]!; + const method = path[2]!; + const definition = EVENTS.find((event) => event.registration[0] === group && event.registration[1] === method); + let event = definition?.event; + if (group === "package" && method === "on") { + if (!localName || !/^[a-z][a-z0-9_]*$/.test(localName)) { + refuse(file, "a Package Event needs a literal local name such as \"greeting\""); + } + event = `${packageName}.${localName}`; + } + if (!event) refuse(file, `unknown Event Binding ${path.join(".")}`); + for (const key of Object.keys(options)) { + if (key !== "priority" && key !== "enabled") refuse(file, `unknown Event Binding option ${key}`); + } + const priority = options.priority ?? 0; + const enabled = options.enabled ?? true; + if (typeof priority !== "number" || !Number.isInteger(priority) || priority < -2147483648 || priority > 2147483647) { + refuse(file, "priority must be a literal signed 32-bit integer"); + } + if (typeof enabled !== "boolean") refuse(file, "enabled must be a literal boolean"); + return { event, group, method, priority, enabled, definition }; +} + +function tsPath(expression: ts.Expression): string[] { + if (ts.isIdentifier(expression)) return [expression.text]; + if (ts.isPropertyAccessExpression(expression)) return [...tsPath(expression.expression), expression.name.text]; + return []; +} + +function tsLiteral(file: string, expression: ts.Expression): unknown { + if (expression.kind === ts.SyntaxKind.TrueKeyword) return true; + if (expression.kind === ts.SyntaxKind.FalseKeyword) return false; + if (ts.isNumericLiteral(expression)) return Number(expression.text); + if (ts.isStringLiteral(expression)) return expression.text; + if (ts.isPrefixUnaryExpression(expression) && ts.isNumericLiteral(expression.operand)) { + if (expression.operator === ts.SyntaxKind.MinusToken) return -Number(expression.operand.text); + if (expression.operator === ts.SyntaxKind.PlusToken) return Number(expression.operand.text); + } + return refuse(file, "Event Binding options must be literal values"); +} + +function tsBinding(file: string, source: string, packageName: string): SourceBinding | undefined { + const tree = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true); + const calls = tree.statements.filter(ts.isExpressionStatement) + .map((statement) => statement.expression).filter(ts.isCallExpression) + .filter((call) => tsPath(call.expression)[0] === "events"); + if (calls.length > 1) refuse(file, "each source file has exactly one Event Binding"); + const call = calls[0]; + let root: ts.Node | undefined = call?.expression; + while (root && ts.isPropertyAccessExpression(root)) root = root.expression; + const walk = (node: ts.Node): void => { + if (ts.isIdentifier(node) && node.text === "events" && node !== root) { + refuse(file, "events may only appear in one unconditional top-level Event Binding"); + } + ts.forEachChild(node, walk); + }; + walk(tree); + if (!call) return undefined; + if (ts.isExternalModule(tree)) refuse(file, "a Runtime Script cannot import or export a module"); + const path = tsPath(call.expression); + const own = path[1] === "package" && path[2] === "on"; + const offset = own ? 1 : 0; + const handler = call.arguments[offset]; + if (!handler || !ts.isIdentifier(handler)) refuse(file, "bind a named function declared in this file"); + const declarations = tree.statements.filter(ts.isFunctionDeclaration) + .filter((fn) => fn.name?.text === handler.text && fn.body); + if (declarations.length !== 1 || declarations[0]!.parameters.length !== 1) { + refuse(file, "the bound function must declare one event parameter"); + } + if (call.arguments.length < offset + 1 || call.arguments.length > offset + 2) { + refuse(file, "an Event Binding takes a handler and optional options"); + } + const options: Record = {}; + const optionNode = call.arguments[offset + 1]; + if (optionNode) { + if (!ts.isObjectLiteralExpression(optionNode)) refuse(file, "Event Binding options must be a literal object"); + for (const member of optionNode.properties) { + if (!ts.isPropertyAssignment(member) || (!ts.isIdentifier(member.name) && !ts.isStringLiteral(member.name))) { + refuse(file, "Event Binding options must be named literal properties"); + } + const name = member.name.text; + if (Object.hasOwn(options, name)) refuse(file, `duplicate Event Binding option ${name}`); + options[name] = tsLiteral(file, member.initializer); + } + } + const localName = own && call.arguments[0] && ts.isStringLiteral(call.arguments[0]) ? call.arguments[0].text : undefined; + return binding(file, packageName, path, localName, options); +} + +function luaPath(expression: lua.Expression): string[] { + if (expression.type === "Identifier") return [expression.name]; + if (expression.type === "MemberExpression" && expression.indexer === ".") { + return [...luaPath(expression.base), expression.identifier.name]; + } + return []; +} + +function luaLiteral(file: string, expression: lua.Expression): unknown { + if (expression.type === "StringLiteral" || expression.type === "NumericLiteral" || expression.type === "BooleanLiteral") { + return expression.value; + } + if (expression.type === "UnaryExpression" && expression.operator === "-" && expression.argument.type === "NumericLiteral") { + return -expression.argument.value; + } + return refuse(file, "Event Binding options must be literal values"); +} + +function luaBinding(file: string, source: string, packageName: string): SourceBinding | undefined { + let tree: lua.Chunk; + try { + tree = lua.parse(source, { luaVersion: "5.3", encodingMode: "x-user-defined" }); + } catch (error) { + return refuse(file, `invalid Lua: ${error instanceof Error ? error.message : String(error)}`); + } + const calls = tree.body.filter((node): node is lua.CallStatement => node.type === "CallStatement") + .map((node) => node.expression) + .filter((node): node is lua.CallExpression => node.type === "CallExpression" && luaPath(node.base)[0] === "events"); + if (calls.length > 1) refuse(file, "each source file has exactly one Event Binding"); + const call = calls[0]; + let root = call?.base; + while (root?.type === "MemberExpression") root = root.base; + const walk = (node: unknown): void => { + if (!node || typeof node !== "object") return; + if (node === root) return; + if ("type" in node && node.type === "Identifier" && "name" in node && node.name === "events") { + refuse(file, "events may only appear in one unconditional top-level Event Binding"); + } + for (const value of Object.values(node)) { + if (Array.isArray(value)) value.forEach(walk); + else walk(value); + } + }; + walk(tree); + if (!call) return undefined; + if (tree.body.some((node) => node.type === "ReturnStatement")) refuse(file, "return a Script Answer from the handler, not the source file"); + const path = luaPath(call.base); + const own = path[1] === "package" && path[2] === "on"; + const offset = own ? 1 : 0; + const handler = call.arguments[offset]; + if (!handler || handler.type !== "Identifier") refuse(file, "bind a named function declared in this file"); + const declarations = tree.body.filter((node): node is lua.FunctionDeclaration => node.type === "FunctionDeclaration") + .filter((fn) => fn.identifier?.type === "Identifier" && fn.identifier.name === handler.name); + if (declarations.length !== 1 || declarations[0]!.parameters.length !== 1 || declarations[0]!.parameters[0]?.type !== "Identifier") { + refuse(file, "the bound function must declare one event parameter"); + } + if (call.arguments.length < offset + 1 || call.arguments.length > offset + 2) { + refuse(file, "an Event Binding takes a handler and optional options"); + } + const options: Record = {}; + const optionNode = call.arguments[offset + 1]; + if (optionNode) { + if (optionNode.type !== "TableConstructorExpression") refuse(file, "Event Binding options must be a literal table"); + for (const field of optionNode.fields) { + if (field.type !== "TableKeyString") refuse(file, "Event Binding options must be named literal fields"); + const name = field.key.name; + if (Object.hasOwn(options, name)) refuse(file, `duplicate Event Binding option ${name}`); + options[name] = luaLiteral(file, field.value); + } + } + const localName = own && call.arguments[0]?.type === "StringLiteral" ? call.arguments[0].value ?? undefined : undefined; + return binding(file, packageName, path, localName, options); +} + +export function readBinding(file: string, source: string, packageName: string): SourceBinding | undefined { + return file.endsWith(".ts") ? tsBinding(file, source, packageName) : luaBinding(file, source, packageName); +} + +/** Capture the one declared handler inside the Invocation, then preserve its Script Answer. */ +export function bindInvocation(source: string, binding: SourceBinding): string { + let local = "____lyracore_handler"; + while (source.includes(local)) local += "_"; + const parameters = binding.group === "package" ? "name, handler" : "handler"; + const lines = [ + `local ${local}`, + `local events = { ${binding.group} = { ${binding.method} = function(${parameters}) ${local} = handler end } }`, + source.trimEnd(), + `assert(event.name == ${JSON.stringify(binding.event)}, "Event Binding does not match the Invocation")`, + ]; + for (const field of binding.definition?.fields ?? []) { + if (field.type === "PlayerEntity" && field.required) { + lines.push(`assert(event.${field.name} and event.${field.name}.is_player, "${binding.event} requires a Character")`); + } + } + lines.push(`return ${local}(event)`, ""); + return lines.join("\n"); +} diff --git a/datascripts/runtime-scripts/build-scripts.ts b/datascripts/runtime-scripts/build-scripts.ts index ae8fa9a62..cc21bf606 100644 --- a/datascripts/runtime-scripts/build-scripts.ts +++ b/datascripts/runtime-scripts/build-scripts.ts @@ -1,51 +1,14 @@ -// Compile one Package's Runtime Scripts into its Script Artifact. -// -// bun run datascripts/runtime-scripts/build-scripts.ts -// -// `lyracore packages build` runs this once per enabled Package that carries a `scripts/` folder, -// the same way it runs a Datascript: one Bun subprocess, one artifact written, non-zero on any -// refusal and nothing written. Run it by hand the same way. -// -// # What a Package ships -// -// `packages//scripts/` holds the sources. A `.ts` file is compiled by the pinned -// `typescript-to-lua` under `tsconfig.json` next to this file; a `.lua` file is shipped unchanged, -// for the author who would rather write Lua. Both carry the same directive header: -// -// // @event on_login -- @event on_login -// // @id 100200 -- @id 100201 -// // @priority 10 -- @priority 10 -// // @enabled false -- @enabled false -// -// `@event` and `@id` are required; `@priority` defaults to 0 and `@enabled` to true. The directives -// stop at the first line that is neither blank nor a comment, so ordinary comments below them are -// just comments. -// -// # Why the identifier is written down -// -// A `script_id` is DURABLE: it is what `game_script` keys on and what a Package's own rows will -// point at. Deriving it from a sorted file index would renumber every later script the moment an -// author adds one alphabetically earlier, and two Packages would both start at the bottom of the -// band. So the author writes it, once, and a collision is a refusal that names both Packages -// instead of a silent renumber. The band is 100000..=999999. -// -// The script NAME is not written down: it is `.`, which is the one part of the -// identity that has an obviously right answer. -// -// # What checks what -// -// This builder refuses what it can name a FILE and a directive for. `lyracore-delta-check` is the -// authority on the artifact itself and runs afterwards over every Package at once, because a -// collision between Packages cannot be seen from inside one. The event catalogue arrives through -// `LYRACORE_HOOK_EVENTS` (the CLI fills it from `lyracore-delta-check --print-events`); without it -// this builder does not check event names and says so, and the validator still refuses an unknown -// one. +// Compile a Package's Runtime Scripts into one Script Artifact. Each file binds one named +// function to a typed event. Legacy Script Directives remain readable during migration. +// Stable numeric identities live in the Package's script-ids.json and must travel with its source. import { createHash } from "node:crypto"; import { existsSync, mkdtempSync, readdirSync, rmSync } from "node:fs"; import { mkdir, mkdtemp, open, readFile, rename, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { basename, dirname, join, resolve } from "node:path"; +import { bindInvocation, readBinding, type SourceBinding } from "./bindings.ts"; +import { allocateScriptId, renderScriptIds, scriptIds, SCRIPT_IDS_FILE } from "./script-ids.ts"; /// Where a Package keeps its Runtime Script sources, relative to the Package folder. const SCRIPTS_DIR = "scripts"; @@ -205,15 +168,15 @@ async function compile(scriptsDir: string, source: string, outDir: string): Prom // ---- the artifact ---- -/// The digest of the sources this artifact was generated from: each immediate regular `.ts` or -/// `.lua` file under `scripts/`, by sorted name, length-prefixed so no two inventories can collide. +/// The digest covers immediate `.ts` and `.lua` sources and the recorded script identities, +/// sorted by basename and length-prefixed to match the CLI Build Identity. /// /// The toolchain is deliberately NOT in it. `source_hash` answers "which revision of this Package's /// sources is on the Shard"; whether the compiler moved is the Build Identity sidecar's question. -async function sourceHash(scriptsDir: string, files: string[]): Promise { +async function sourceHash(scriptsDir: string, files: string[], ids: string): Promise { const digest = createHash("sha256"); - for (const file of files) { - const bytes = await readFile(join(scriptsDir, file)); + for (const file of [...files, SCRIPT_IDS_FILE].sort()) { + const bytes = file === SCRIPT_IDS_FILE ? Buffer.from(ids) : await readFile(join(scriptsDir, file)); digest.update(file); digest.update("\0"); digest.update(String(bytes.length)); @@ -301,36 +264,35 @@ export async function buildPackageScripts(packageName: string): Promise ); } - // Directives first, for every file, so a bad `@id` or a collision is a refusal before the first - // compile rather than after the last one. - const declared: Array & { file: string }> = []; + const ids = await scriptIds(packageDir, packageName); + const declared: Array & { file: string; binding?: SourceBinding }> = []; for (const file of files) { const path = join(scriptsDir, file); - const directives = readDirectives(path, await readFile(path, "utf8")); - const event = requiredDirective(path, directives, "event"); + const source = await readFile(path, "utf8"); + const directives = readDirectives(path, source); + const binding = readBinding(path, source, packageName); + if (binding && directives.size !== 0) refuse(path, "use an Event Binding or legacy Script Directives, not both"); + const event = binding?.event ?? requiredDirective(path, directives, "event"); checkEvent(path, event, packageName, catalogue); + const stem = file.slice(0, file.lastIndexOf(".")); + const name = `${packageName}.${stem}`; + if (!/^[a-z0-9_.-]{1,64}$/.test(name)) refuse(path, "a script name must fit 64 lowercase letters, digits, dots, underscores or hyphens"); + if (declared.some((entry) => entry.name === name)) refuse(path, `collides with another source on script name ${name}`); declared.push({ file, - script_id: scriptId(path, directives), - name: `${packageName}.${file.slice(0, file.lastIndexOf("."))}`, + binding, + script_id: allocateScriptId(path, ids, stem, binding ? undefined : scriptId(path, directives)), + name, event, - priority: priority(path, directives), - enabled: enabled(path, directives), + priority: binding?.priority ?? priority(path, directives), + enabled: binding?.enabled ?? enabled(path, directives), }); } - for (const [index, script] of declared.entries()) { - const clash = declared.findIndex( - (other, at) => at < index && (other.script_id === script.script_id || other.name === script.name), - ); - if (clash !== -1) { - refuse(join(scriptsDir, script.file), `collides with ${declared[clash]?.file} on \`@id\` or name`); - } - } const outDir = mkdtempSync(join(tmpdir(), "lyracore-scripts-")); try { const scripts: RuntimeScript[] = []; - for (const { file, ...script } of declared) { + for (const { file, binding, ...script } of declared) { const path = join(scriptsDir, file); const stem = file.slice(0, file.lastIndexOf(".")); let source: string; @@ -343,10 +305,12 @@ export async function buildPackageScripts(packageName: string): Promise }); } if (source.trim().length === 0) refuse(path, "compiles to nothing; a Runtime Script needs Lua in it"); - scripts.push({ ...script, source }); + scripts.push({ ...script, source: binding ? bindInvocation(source, binding) : source }); } - const artifact = renderArtifact(packageName, await sourceHash(scriptsDir, files), scripts); + const recordedIds = renderScriptIds(ids); + const artifact = renderArtifact(packageName, await sourceHash(scriptsDir, files, recordedIds), scripts); + await writeArtifact(join(packageDir, SCRIPT_IDS_FILE), recordedIds); const path = join(packageDir, GENERATED_DIR, `${packageName}.script.json`); await writeArtifact(path, artifact); return path; diff --git a/datascripts/runtime-scripts/event-names.rs b/datascripts/runtime-scripts/event-names.rs new file mode 100644 index 000000000..4ba5832dc --- /dev/null +++ b/datascripts/runtime-scripts/event-names.rs @@ -0,0 +1,24 @@ +// GENERATED by generate-events.ts from events.json. DO NOT EDIT. +/// Core hook names accepted by the Script Artifact parser. +pub const HOOK_EVENT_NAMES: &[&str] = &[ + "on_damage_taken", + "on_death_prevented", + "on_creature_spawn", + "on_levelup", + "on_group_invite", + "on_death", + "on_kill", + "on_aggro", + "on_cast_resolved", + "on_cast_finished", + "on_loot", + "on_quest_accept", + "on_quest_turnin", + "on_login", + "on_character_relocated", + "on_logout", + "on_gossip_select", + "on_creature_death", + "on_hp_threshold", + "on_go_used", +]; diff --git a/datascripts/runtime-scripts/events.json b/datascripts/runtime-scripts/events.json new file mode 100644 index 000000000..75173e60b --- /dev/null +++ b/datascripts/runtime-scripts/events.json @@ -0,0 +1,400 @@ +[ + { + "event": "on_damage_taken", + "payloadType": "crate::hooks::DamageTakenPayload", + "actor": "payload.attacker_guid", + "target": "payload.target_guid", + "registration": [ + "combat", + "onDamageTaken" + ], + "typeName": "DamageTakenEvent", + "fields": [ + { + "name": "periodic", + "type": "boolean", + "source": "payload.periodic" + } + ] + }, + { + "event": "on_death_prevented", + "payloadType": "crate::hooks::DeathPreventedPayload", + "actor": "payload.attacker_guid", + "target": "payload.creature_guid", + "registration": [ + "creature", + "onDeathPrevented" + ], + "typeName": "CreatureDeathPreventedEvent", + "fields": [] + }, + { + "event": "on_creature_spawn", + "payloadType": "crate::hooks::CreatureSpawnPayload", + "actor": "payload.guid", + "target": "0", + "registration": [ + "creature", + "onSpawn" + ], + "typeName": "CreatureSpawnEvent", + "fields": [ + { + "name": "entry", + "type": "number", + "source": "payload.entry" + }, + { + "name": "x", + "type": "number", + "source": "payload.x" + }, + { + "name": "y", + "type": "number", + "source": "payload.y" + }, + { + "name": "z", + "type": "number", + "source": "payload.z" + } + ] + }, + { + "event": "on_levelup", + "payloadType": "crate::hooks::LevelupPayload", + "actor": "payload.character_guid", + "target": "0", + "registration": [ + "player", + "onLevelUp" + ], + "typeName": "PlayerLevelUpEvent", + "fields": [ + { + "name": "player", + "type": "PlayerEntity", + "source": "actor", + "required": true + }, + { + "name": "newLevel", + "type": "number", + "source": "payload.new_level" + } + ] + }, + { + "event": "on_group_invite", + "payloadType": "crate::hooks::GroupInvitePayload", + "actor": "payload.inviter_guid", + "target": "payload.target_guid", + "registration": [ + "group", + "onInvite" + ], + "typeName": "GroupInviteEvent", + "fields": [] + }, + { + "event": "on_death", + "payloadType": "crate::hooks::DeathPayload", + "actor": "payload.killer_guid", + "target": "payload.victim_guid", + "registration": [ + "combat", + "onDeath" + ], + "typeName": "DeathEvent", + "fields": [ + { + "name": "victimIsPlayer", + "type": "boolean", + "source": "payload.victim_is_player" + } + ] + }, + { + "event": "on_kill", + "payloadType": "crate::hooks::KillPayload", + "actor": "payload.killer_guid", + "target": "payload.victim_guid", + "registration": [ + "combat", + "onKill" + ], + "typeName": "KillEvent", + "fields": [ + { + "name": "victimEntry", + "type": "number", + "source": "payload.victim_entry" + }, + { + "name": "victimLevel", + "type": "number", + "source": "payload.victim_level" + } + ] + }, + { + "event": "on_aggro", + "payloadType": "crate::hooks::AggroPayload", + "actor": "payload.creature_guid", + "target": "payload.target_guid", + "registration": [ + "creature", + "onAggro" + ], + "typeName": "CreatureAggroEvent", + "fields": [ + { + "name": "assist", + "type": "boolean", + "source": "payload.assist" + } + ] + }, + { + "event": "on_cast_resolved", + "payloadType": "crate::hooks::CastResolvedPayload", + "actor": "payload.caster_guid", + "target": "payload.target_guid", + "registration": [ + "spell", + "onCastResolved" + ], + "typeName": "SpellCastResolvedEvent", + "fields": [ + { + "name": "spellId", + "type": "number", + "source": "payload.spell_id" + } + ] + }, + { + "event": "on_cast_finished", + "payloadType": "crate::hooks::CastFinishedPayload", + "actor": "payload.caster_guid", + "target": "payload.target_guid", + "registration": [ + "spell", + "onCastFinished" + ], + "typeName": "SpellCastFinishedEvent", + "fields": [ + { + "name": "scheduledId", + "type": "string", + "source": "payload.scheduled_id" + }, + { + "name": "outcome", + "type": "string", + "source": "match &payload.outcome { crate::spell::CastFinish::Resolved => \"resolved\", crate::spell::CastFinish::Refused(_) => \"refused\", crate::spell::CastFinish::Cancelled => \"cancelled\", crate::spell::CastFinish::Expired => \"expired\" }" + } + ] + }, + { + "event": "on_loot", + "payloadType": "crate::hooks::LootPayload", + "actor": "payload.looter_guid", + "target": "payload.corpse_guid", + "registration": [ + "player", + "onLoot" + ], + "typeName": "PlayerLootEvent", + "fields": [ + { + "name": "itemEntry", + "type": "number", + "source": "payload.item_entry" + }, + { + "name": "count", + "type": "number", + "source": "payload.count" + } + ] + }, + { + "event": "on_quest_accept", + "payloadType": "crate::hooks::QuestAcceptPayload", + "actor": "payload.character_guid", + "target": "0", + "registration": [ + "player", + "onQuestAccept" + ], + "typeName": "PlayerQuestAcceptEvent", + "fields": [ + { + "name": "questEntry", + "type": "number", + "source": "payload.quest_entry" + } + ] + }, + { + "event": "on_quest_turnin", + "payloadType": "crate::hooks::QuestTurninPayload", + "actor": "payload.character_guid", + "target": "0", + "registration": [ + "player", + "onQuestTurnIn" + ], + "typeName": "PlayerQuestTurnInEvent", + "fields": [ + { + "name": "questEntry", + "type": "number", + "source": "payload.quest_entry" + } + ] + }, + { + "event": "on_login", + "payloadType": "crate::hooks::LoginPayload", + "actor": "payload.character_guid", + "target": "0", + "registration": [ + "player", + "onLogin" + ], + "typeName": "PlayerLoginEvent", + "fields": [ + { + "name": "player", + "type": "PlayerEntity", + "source": "actor", + "required": true + } + ] + }, + { + "event": "on_character_relocated", + "payloadType": "crate::hooks::CharacterRelocatedPayload", + "actor": "payload.character_guid", + "target": "0", + "registration": [ + "player", + "onRelocated" + ], + "typeName": "PlayerRelocatedEvent", + "fields": [] + }, + { + "event": "on_logout", + "payloadType": "crate::hooks::LogoutPayload", + "actor": "payload.character_guid", + "target": "0", + "registration": [ + "player", + "onLogout" + ], + "typeName": "PlayerLogoutEvent", + "fields": [] + }, + { + "event": "on_gossip_select", + "payloadType": "crate::hooks::GossipSelectPayload", + "actor": "payload.character_guid", + "target": "payload.npc_guid", + "registration": [ + "player", + "onGossipSelect" + ], + "typeName": "PlayerGossipSelectEvent", + "fields": [ + { + "name": "optionId", + "type": "number", + "source": "payload.option_id" + }, + { + "name": "optionRowId", + "type": "number", + "source": "payload.option_row_id" + } + ] + }, + { + "event": "on_creature_death", + "payloadType": "crate::hooks::CreatureDeathPayload", + "actor": "payload.killer_guid", + "target": "payload.creature_guid", + "registration": [ + "creature", + "onDeath" + ], + "typeName": "CreatureDeathEvent", + "fields": [ + { + "name": "entry", + "type": "number", + "source": "payload.entry" + }, + { + "name": "instanceId", + "type": "string", + "source": "payload.instance_id" + } + ] + }, + { + "event": "on_hp_threshold", + "payloadType": "crate::hooks::HpThresholdPayload", + "actor": "0", + "target": "payload.creature_guid", + "registration": [ + "creature", + "onHpThreshold" + ], + "typeName": "CreatureHpThresholdEvent", + "fields": [ + { + "name": "entry", + "type": "number", + "source": "payload.entry" + }, + { + "name": "instanceId", + "type": "string", + "source": "payload.instance_id" + }, + { + "name": "pct", + "type": "number", + "source": "payload.pct" + } + ] + }, + { + "event": "on_go_used", + "payloadType": "crate::hooks::GoUsedPayload", + "actor": "payload.user_guid", + "target": "payload.go_guid", + "registration": [ + "gameobject", + "onUsed" + ], + "typeName": "GameobjectUsedEvent", + "fields": [ + { + "name": "entry", + "type": "number", + "source": "payload.go_entry" + }, + { + "name": "instanceId", + "type": "string", + "source": "payload.instance_id" + } + ] + } +] diff --git a/datascripts/runtime-scripts/generate-events.test.ts b/datascripts/runtime-scripts/generate-events.test.ts new file mode 100644 index 000000000..69e309782 --- /dev/null +++ b/datascripts/runtime-scripts/generate-events.test.ts @@ -0,0 +1,25 @@ +import { expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { EVENTS, luaDeclarations, rustEventNames, typeScriptDeclarations } from "./generate-events"; + +const ROOT = dirname(fileURLToPath(import.meta.url)); + +test("checked-in editor declarations match the core event catalogue", () => { + expect(readFileSync(join(ROOT, "runtime-script.d.ts"), "utf8")).toBe(typeScriptDeclarations()); + expect(readFileSync(join(ROOT, "runtime-script.lua"), "utf8")).toBe(luaDeclarations()); + expect(readFileSync(join(ROOT, "event-names.rs"), "utf8")).toBe(rustEventNames()); +}); + +test("level-up payload reads the attained level from the core hook", () => { + const levelup = EVENTS.find(definition => definition.event === "on_levelup"); + expect(levelup?.fields).toContainEqual({ name: "newLevel", type: "number", source: "payload.new_level" }); +}); + +test("login and level-up declare a required Character handle", () => { + for (const name of ["on_login", "on_levelup"]) { + const definition = EVENTS.find(definition => definition.event === name); + expect(definition?.fields).toContainEqual({ name: "player", type: "PlayerEntity", source: "actor", required: true }); + } +}); diff --git a/datascripts/runtime-scripts/generate-events.ts b/datascripts/runtime-scripts/generate-events.ts new file mode 100644 index 000000000..32163b5f0 --- /dev/null +++ b/datascripts/runtime-scripts/generate-events.ts @@ -0,0 +1,192 @@ +import { readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +export interface EventField { + readonly name: string; + readonly type: "number" | "boolean" | "string" | "PlayerEntity"; + readonly source: string; + readonly required?: boolean; +} + +export interface EventDefinition { + readonly event: string; + readonly payloadType: string; + readonly actor: string; + readonly target: string; + readonly registration: readonly [string, string]; + readonly typeName: string; + readonly fields: readonly EventField[]; +} + +const ROOT = dirname(fileURLToPath(import.meta.url)); +export const EVENTS: readonly EventDefinition[] = JSON.parse(readFileSync(join(ROOT, "events.json"), "utf8")); + +function checkCatalogue(): void { + const names = new Set(); + const registrations = new Set(); + const types = new Set(); + for (const definition of EVENTS) { + const registration = definition.registration.join("."); + if (names.has(definition.event) || registrations.has(registration) || types.has(definition.typeName)) { + throw new Error(`duplicate event, registration or type in events.json: ${definition.event}`); + } + names.add(definition.event); + registrations.add(registration); + types.add(definition.typeName); + const fields = new Set(["name", "actor", "target"]); + for (const field of definition.fields) { + if (fields.has(field.name)) throw new Error(`${definition.event}: duplicate field ${field.name}`); + fields.add(field.name); + if (field.type === "PlayerEntity" && (field.name !== "player" || field.source !== "actor" || !field.required)) { + throw new Error(`${definition.event}: a required Character Entity must come from the actor`); + } + } + } +} + +function groups(): Map { + const grouped = new Map(); + for (const definition of EVENTS) { + const group = definition.registration[0]; + const events = grouped.get(group) ?? []; + events.push(definition); + grouped.set(group, events); + } + return grouped; +} + +export function typeScriptDeclarations(): string { + const lines = [ + "// GENERATED by generate-events.ts from events.json and runtime-script.base.d.ts. DO NOT EDIT.", + readFileSync(join(ROOT, "runtime-script.base.d.ts"), "utf8").trimEnd(), + "", + ]; + for (const definition of EVENTS) { + lines.push(`interface ${definition.typeName} extends ScriptEvent {`, ` readonly name: ${JSON.stringify(definition.event)};`); + for (const field of definition.fields) { + lines.push(` readonly ${field.name}: ${field.type};`); + } + lines.push("}", ""); + } + lines.push("declare const events: {"); + for (const [group, definitions] of groups()) { + lines.push(` readonly ${group}: {`); + for (const definition of definitions) { + lines.push(` ${definition.registration[1]}(this: void, handler: (this: void, event: ${definition.typeName}) => number | void, options?: EventRegistrationOptions): void;`); + } + lines.push(" };"); + } + lines.push( + " readonly package: {", + " on(this: void, localName: string, handler: (this: void, event: PackageEvent) => number | void, options?: EventRegistrationOptions): void;", + " };", + "};", + "", + ); + return lines.join("\n"); +} + +export function luaDeclarations(): string { + const lines = [ + "-- GENERATED by generate-events.ts from events.json. DO NOT EDIT.", + "---@meta", + "", + "---@class Entity", + "---@field name string", + "---@field is_player boolean", + "---@field level number", + "---@field health number", + "---@field max_health number", + "---@field map_id number", + "---@field x number", + "---@field y number", + "---@field z number", + "", + "---@class PlayerEntity: Entity", + "---@field is_player true", + "", + "---@class ScriptEvent", + "---@field name string", + "---@field actor? Entity", + "---@field target? Entity", + "", + "---@class PackageEvent: ScriptEvent", + "", + "---@class EventRegistrationOptions", + "---@field priority? integer", + "---@field enabled? boolean", + "", + "---@type ScriptEvent", + "event = nil", + "", + "---@param entity Entity", + "---@param amount number", + "function heal(entity, amount) end", + "", + "---@param player Entity", + "---@param text string", + "function send_chat(player, text) end", + "", + "---@param player Entity", + "---@param amount number", + "function grant_xp(player, amount) end", + "", + ]; + for (const definition of EVENTS) { + lines.push(`---@class ${definition.typeName}: ScriptEvent`, `---@field name ${JSON.stringify(definition.event)}`); + for (const field of definition.fields) lines.push(`---@field ${field.name} ${field.type}`); + lines.push(""); + } + lines.push("events = {}"); + for (const [group, definitions] of groups()) { + lines.push(`events.${group} = {}`); + for (const definition of definitions) { + lines.push( + "", + `---@param handler fun(event: ${definition.typeName}): number?`, + "---@param options? EventRegistrationOptions", + `function events.${group}.${definition.registration[1]}(handler, options) end`, + ); + } + } + lines.push( + "", + "events.package = {}", + "---@param localName string", + "---@param handler fun(event: PackageEvent): number?", + "---@param options? EventRegistrationOptions", + "function events.package.on(localName, handler, options) end", + "", + ); + return lines.join("\n"); +} + +export function rustEventNames(): string { + return [ + "// GENERATED by generate-events.ts from events.json. DO NOT EDIT.", + "/// Core hook names accepted by the Script Artifact parser.", + "pub const HOOK_EVENT_NAMES: &[&str] = &[", + ...EVENTS.map(definition => ` ${JSON.stringify(definition.event)},`), + "];", + "", + ].join("\n"); +} + +checkCatalogue(); + +if (import.meta.main) { + const outputs = new Map([ + ["runtime-script.d.ts", typeScriptDeclarations()], + ["runtime-script.lua", luaDeclarations()], + ["event-names.rs", rustEventNames()], + ]); + for (const [name, declarations] of outputs) { + const path = join(ROOT, name); + if (process.argv.includes("--check")) { + if (readFileSync(path, "utf8") !== declarations) throw new Error(`${name} differs from events.json; run generate-events.ts`); + } else { + writeFileSync(path, declarations); + } + } +} diff --git a/datascripts/runtime-scripts/lua-emit.cjs b/datascripts/runtime-scripts/lua-emit.cjs index 88377f0f9..d1c3d8115 100644 --- a/datascripts/runtime-scripts/lua-emit.cjs +++ b/datascripts/runtime-scripts/lua-emit.cjs @@ -18,12 +18,8 @@ // itself invents for classes, spreads and library helpers, which a visitor over TypeScript nodes // never meets. // -// # The entry point -// -// A Runtime Script answers its caller by RETURNING a number, and TypeScript has no top-level -// return. So every authored `.ts` script declares `function script()` and this printer closes the -// emitted file with `return script()`. `beforeTransform` refuses a file without one, where the -// diagnostic can name it, rather than leaving a chunk that calls nil at runtime. +// Named Event Bindings get their invocation wrapper from build-scripts.ts. Legacy sources still +// declare script(), whose return value becomes the Script Answer through the appended call. const ts = require("typescript"); const tstl = require("typescript-to-lua"); @@ -87,11 +83,20 @@ class PiccoloPrinter extends tstl.LuaPrinter { printFile(file) { return super.printFile({ ...file, - statements: [guardDeclaration(), ...file.statements, entryCall()], + statements: [guardDeclaration(), ...file.statements, ...(this.bound ? [] : [entryCall()])], }); } } +function hasEventBinding(file) { + return file.statements.some((statement) => { + if (!ts.isExpressionStatement(statement) || !ts.isCallExpression(statement.expression)) return false; + let target = statement.expression.expression; + while (ts.isPropertyAccessExpression(target)) target = target.expression; + return ts.isIdentifier(target) && target.text === "events"; + }); +} + function acceptsScriptAnswer(type) { if (type.isUnion()) return type.types.every(acceptsScriptAnswer); return (type.flags & (ts.TypeFlags.NumberLike | ts.TypeFlags.Void | ts.TypeFlags.Undefined | ts.TypeFlags.Never)) !== 0; @@ -103,7 +108,7 @@ function requireEntryPoint(program) { const diagnostics = []; const checker = program.getTypeChecker(); for (const file of program.getSourceFiles()) { - if (file.isDeclarationFile) continue; + if (file.isDeclarationFile || hasEventBinding(file)) continue; const declarations = file.statements.filter( (statement) => ts.isFunctionDeclaration(statement) && statement.name && statement.name.text === ENTRY, @@ -147,6 +152,9 @@ function requireEntryPoint(program) { module.exports = { beforeTransform: (program) => requireEntryPoint(program), - printer: (program, emitHost, fileName, file) => - new PiccoloPrinter(emitHost, program, fileName).print(file), + printer: (program, emitHost, fileName, file) => { + const printer = new PiccoloPrinter(emitHost, program, fileName); + printer.bound = program.getSourceFiles().some((source) => !source.isDeclarationFile && hasEventBinding(source)); + return printer.print(file); + }, }; diff --git a/datascripts/runtime-scripts/package.json b/datascripts/runtime-scripts/package.json index 1b2ac926c..3a81a1ab6 100644 --- a/datascripts/runtime-scripts/package.json +++ b/datascripts/runtime-scripts/package.json @@ -2,7 +2,11 @@ "name": "lyracore-script-compiler", "private": true, "dependencies": { + "luaparse": "0.3.1", "typescript": "6.0.2", "typescript-to-lua": "1.37.1" + }, + "devDependencies": { + "@types/luaparse": "0.2.13" } } diff --git a/datascripts/runtime-scripts/runtime-script.base.d.ts b/datascripts/runtime-scripts/runtime-script.base.d.ts new file mode 100644 index 000000000..8fd0c1dc4 --- /dev/null +++ b/datascripts/runtime-scripts/runtime-script.base.d.ts @@ -0,0 +1,39 @@ +// The Host supplies Entity Handles and gameplay operations for one Invocation. +// Scripts do not import modules. The builder lowers a static Event Binding to one Invocation. + +// Entity fields are snapshots. A level-up event carries the attained level in newLevel. +interface Entity { + readonly name: string; + readonly is_player: boolean; + readonly level: number; + readonly health: number; + readonly max_health: number; + readonly map_id: number; + readonly x: number; + readonly y: number; + readonly z: number; +} + +interface PlayerEntity extends Entity { + readonly is_player: true; +} + +interface ScriptEvent { + readonly name: string; + readonly actor?: Entity; + readonly target?: Entity; +} + +interface PackageEvent extends ScriptEvent {} + +interface EventRegistrationOptions { + readonly priority?: number; + readonly enabled?: boolean; +} + +// Kept for sources that declare the legacy script() entry point. +declare const event: ScriptEvent; + +declare function heal(entity: Entity, amount: number): void; +declare function send_chat(player: Entity, text: string): void; +declare function grant_xp(player: Entity, amount: number): void; diff --git a/datascripts/runtime-scripts/runtime-script.d.ts b/datascripts/runtime-scripts/runtime-script.d.ts index c77b5fd27..de7a8b870 100644 --- a/datascripts/runtime-scripts/runtime-script.d.ts +++ b/datascripts/runtime-scripts/runtime-script.d.ts @@ -1,22 +1,8 @@ -/// The Runtime Script Host API, as TypeScript sees it. -/// -/// Ambient on purpose: the Host hands an Invocation globals, not a module, so an authored script -/// imports nothing and exports nothing. A file with an `import` or an `export` becomes a TypeScript -/// module, and `typescript-to-lua` then emits a chunk that returns its export table instead of the -/// Script Answer. -/// -/// Hand-maintained against `module/src/runtime_script.rs`. The EVENT names are not here: they are a -/// closed catalogue the Module's own build owns, and `lyracore packages build` checks a script's -/// `@event` directive against `lyracore-delta-check --print-events` rather than against a copy that -/// can drift. -/// -/// Everything a script may reach is below. A name outside this file is nil inside an Invocation. - -/// One creature or player the Host resolved for this Invocation. -/// -/// Opaque: a script cannot read a guid out of it and cannot mint one, so the only entities a script -/// can act on are the ones its event carried. Every field is a snapshot taken before the script -/// ran; writing to one changes nothing. +// GENERATED by generate-events.ts from events.json and runtime-script.base.d.ts. DO NOT EDIT. +// The Host supplies Entity Handles and gameplay operations for one Invocation. +// Scripts do not import modules. The builder lowers a static Event Binding to one Invocation. + +// Entity fields are snapshots. A level-up event carries the attained level in newLevel. interface Entity { readonly name: string; readonly is_player: boolean; @@ -29,34 +15,172 @@ interface Entity { readonly z: number; } -/// What this Invocation is running for. +interface PlayerEntity extends Entity { + readonly is_player: true; +} + interface ScriptEvent { - /// The event label, as the Script Artifact spells it. readonly name: string; - /// Who caused the event, when the event carries one. readonly actor?: Entity; - /// What the event acted on, when the event carries one. readonly target?: Entity; } +interface PackageEvent extends ScriptEvent {} + +interface EventRegistrationOptions { + readonly priority?: number; + readonly enabled?: boolean; +} + +// Kept for sources that declare the legacy script() entry point. declare const event: ScriptEvent; -/// Stage a heal on `entity`, crediting heal-threat to `event.actor`. declare function heal(entity: Entity, amount: number): void; - -/// Stage a System Message to one online player. declare function send_chat(player: Entity, text: string): void; - -/// Stage an experience grant. declare function grant_xp(player: Entity, amount: number): void; -// Every authored `.ts` Runtime Script declares its own entry point: -// -// function script(): number | void { … } -// -// `lyracore packages build` appends `return script()` to the emitted Lua, so the number it returns -// is the Script Answer the asking Package reads back. Returning nothing answers nothing, and the -// caller keeps its own fallback. The build refuses a script file that declares no `script`. -// -// It is not declared here: an ambient declaration and the author's own implementation would be two -// declarations of one name. +interface DamageTakenEvent extends ScriptEvent { + readonly name: "on_damage_taken"; + readonly periodic: boolean; +} + +interface CreatureDeathPreventedEvent extends ScriptEvent { + readonly name: "on_death_prevented"; +} + +interface CreatureSpawnEvent extends ScriptEvent { + readonly name: "on_creature_spawn"; + readonly entry: number; + readonly x: number; + readonly y: number; + readonly z: number; +} + +interface PlayerLevelUpEvent extends ScriptEvent { + readonly name: "on_levelup"; + readonly player: PlayerEntity; + readonly newLevel: number; +} + +interface GroupInviteEvent extends ScriptEvent { + readonly name: "on_group_invite"; +} + +interface DeathEvent extends ScriptEvent { + readonly name: "on_death"; + readonly victimIsPlayer: boolean; +} + +interface KillEvent extends ScriptEvent { + readonly name: "on_kill"; + readonly victimEntry: number; + readonly victimLevel: number; +} + +interface CreatureAggroEvent extends ScriptEvent { + readonly name: "on_aggro"; + readonly assist: boolean; +} + +interface SpellCastResolvedEvent extends ScriptEvent { + readonly name: "on_cast_resolved"; + readonly spellId: number; +} + +interface SpellCastFinishedEvent extends ScriptEvent { + readonly name: "on_cast_finished"; + readonly scheduledId: string; + readonly outcome: string; +} + +interface PlayerLootEvent extends ScriptEvent { + readonly name: "on_loot"; + readonly itemEntry: number; + readonly count: number; +} + +interface PlayerQuestAcceptEvent extends ScriptEvent { + readonly name: "on_quest_accept"; + readonly questEntry: number; +} + +interface PlayerQuestTurnInEvent extends ScriptEvent { + readonly name: "on_quest_turnin"; + readonly questEntry: number; +} + +interface PlayerLoginEvent extends ScriptEvent { + readonly name: "on_login"; + readonly player: PlayerEntity; +} + +interface PlayerRelocatedEvent extends ScriptEvent { + readonly name: "on_character_relocated"; +} + +interface PlayerLogoutEvent extends ScriptEvent { + readonly name: "on_logout"; +} + +interface PlayerGossipSelectEvent extends ScriptEvent { + readonly name: "on_gossip_select"; + readonly optionId: number; + readonly optionRowId: number; +} + +interface CreatureDeathEvent extends ScriptEvent { + readonly name: "on_creature_death"; + readonly entry: number; + readonly instanceId: string; +} + +interface CreatureHpThresholdEvent extends ScriptEvent { + readonly name: "on_hp_threshold"; + readonly entry: number; + readonly instanceId: string; + readonly pct: number; +} + +interface GameobjectUsedEvent extends ScriptEvent { + readonly name: "on_go_used"; + readonly entry: number; + readonly instanceId: string; +} + +declare const events: { + readonly combat: { + onDamageTaken(this: void, handler: (this: void, event: DamageTakenEvent) => number | void, options?: EventRegistrationOptions): void; + onDeath(this: void, handler: (this: void, event: DeathEvent) => number | void, options?: EventRegistrationOptions): void; + onKill(this: void, handler: (this: void, event: KillEvent) => number | void, options?: EventRegistrationOptions): void; + }; + readonly creature: { + onDeathPrevented(this: void, handler: (this: void, event: CreatureDeathPreventedEvent) => number | void, options?: EventRegistrationOptions): void; + onSpawn(this: void, handler: (this: void, event: CreatureSpawnEvent) => number | void, options?: EventRegistrationOptions): void; + onAggro(this: void, handler: (this: void, event: CreatureAggroEvent) => number | void, options?: EventRegistrationOptions): void; + onDeath(this: void, handler: (this: void, event: CreatureDeathEvent) => number | void, options?: EventRegistrationOptions): void; + onHpThreshold(this: void, handler: (this: void, event: CreatureHpThresholdEvent) => number | void, options?: EventRegistrationOptions): void; + }; + readonly player: { + onLevelUp(this: void, handler: (this: void, event: PlayerLevelUpEvent) => number | void, options?: EventRegistrationOptions): void; + onLoot(this: void, handler: (this: void, event: PlayerLootEvent) => number | void, options?: EventRegistrationOptions): void; + onQuestAccept(this: void, handler: (this: void, event: PlayerQuestAcceptEvent) => number | void, options?: EventRegistrationOptions): void; + onQuestTurnIn(this: void, handler: (this: void, event: PlayerQuestTurnInEvent) => number | void, options?: EventRegistrationOptions): void; + onLogin(this: void, handler: (this: void, event: PlayerLoginEvent) => number | void, options?: EventRegistrationOptions): void; + onRelocated(this: void, handler: (this: void, event: PlayerRelocatedEvent) => number | void, options?: EventRegistrationOptions): void; + onLogout(this: void, handler: (this: void, event: PlayerLogoutEvent) => number | void, options?: EventRegistrationOptions): void; + onGossipSelect(this: void, handler: (this: void, event: PlayerGossipSelectEvent) => number | void, options?: EventRegistrationOptions): void; + }; + readonly group: { + onInvite(this: void, handler: (this: void, event: GroupInviteEvent) => number | void, options?: EventRegistrationOptions): void; + }; + readonly spell: { + onCastResolved(this: void, handler: (this: void, event: SpellCastResolvedEvent) => number | void, options?: EventRegistrationOptions): void; + onCastFinished(this: void, handler: (this: void, event: SpellCastFinishedEvent) => number | void, options?: EventRegistrationOptions): void; + }; + readonly gameobject: { + onUsed(this: void, handler: (this: void, event: GameobjectUsedEvent) => number | void, options?: EventRegistrationOptions): void; + }; + readonly package: { + on(this: void, localName: string, handler: (this: void, event: PackageEvent) => number | void, options?: EventRegistrationOptions): void; + }; +}; diff --git a/datascripts/runtime-scripts/runtime-script.lua b/datascripts/runtime-scripts/runtime-script.lua new file mode 100644 index 000000000..54c4b5a2f --- /dev/null +++ b/datascripts/runtime-scripts/runtime-script.lua @@ -0,0 +1,224 @@ +-- GENERATED by generate-events.ts from events.json. DO NOT EDIT. +---@meta + +---@class Entity +---@field name string +---@field is_player boolean +---@field level number +---@field health number +---@field max_health number +---@field map_id number +---@field x number +---@field y number +---@field z number + +---@class PlayerEntity: Entity +---@field is_player true + +---@class ScriptEvent +---@field name string +---@field actor? Entity +---@field target? Entity + +---@class PackageEvent: ScriptEvent + +---@class EventRegistrationOptions +---@field priority? integer +---@field enabled? boolean + +---@type ScriptEvent +event = nil + +---@param entity Entity +---@param amount number +function heal(entity, amount) end + +---@param player Entity +---@param text string +function send_chat(player, text) end + +---@param player Entity +---@param amount number +function grant_xp(player, amount) end + +---@class DamageTakenEvent: ScriptEvent +---@field name "on_damage_taken" +---@field periodic boolean + +---@class CreatureDeathPreventedEvent: ScriptEvent +---@field name "on_death_prevented" + +---@class CreatureSpawnEvent: ScriptEvent +---@field name "on_creature_spawn" +---@field entry number +---@field x number +---@field y number +---@field z number + +---@class PlayerLevelUpEvent: ScriptEvent +---@field name "on_levelup" +---@field player PlayerEntity +---@field newLevel number + +---@class GroupInviteEvent: ScriptEvent +---@field name "on_group_invite" + +---@class DeathEvent: ScriptEvent +---@field name "on_death" +---@field victimIsPlayer boolean + +---@class KillEvent: ScriptEvent +---@field name "on_kill" +---@field victimEntry number +---@field victimLevel number + +---@class CreatureAggroEvent: ScriptEvent +---@field name "on_aggro" +---@field assist boolean + +---@class SpellCastResolvedEvent: ScriptEvent +---@field name "on_cast_resolved" +---@field spellId number + +---@class SpellCastFinishedEvent: ScriptEvent +---@field name "on_cast_finished" +---@field scheduledId string +---@field outcome string + +---@class PlayerLootEvent: ScriptEvent +---@field name "on_loot" +---@field itemEntry number +---@field count number + +---@class PlayerQuestAcceptEvent: ScriptEvent +---@field name "on_quest_accept" +---@field questEntry number + +---@class PlayerQuestTurnInEvent: ScriptEvent +---@field name "on_quest_turnin" +---@field questEntry number + +---@class PlayerLoginEvent: ScriptEvent +---@field name "on_login" +---@field player PlayerEntity + +---@class PlayerRelocatedEvent: ScriptEvent +---@field name "on_character_relocated" + +---@class PlayerLogoutEvent: ScriptEvent +---@field name "on_logout" + +---@class PlayerGossipSelectEvent: ScriptEvent +---@field name "on_gossip_select" +---@field optionId number +---@field optionRowId number + +---@class CreatureDeathEvent: ScriptEvent +---@field name "on_creature_death" +---@field entry number +---@field instanceId string + +---@class CreatureHpThresholdEvent: ScriptEvent +---@field name "on_hp_threshold" +---@field entry number +---@field instanceId string +---@field pct number + +---@class GameobjectUsedEvent: ScriptEvent +---@field name "on_go_used" +---@field entry number +---@field instanceId string + +events = {} +events.combat = {} + +---@param handler fun(event: DamageTakenEvent): number? +---@param options? EventRegistrationOptions +function events.combat.onDamageTaken(handler, options) end + +---@param handler fun(event: DeathEvent): number? +---@param options? EventRegistrationOptions +function events.combat.onDeath(handler, options) end + +---@param handler fun(event: KillEvent): number? +---@param options? EventRegistrationOptions +function events.combat.onKill(handler, options) end +events.creature = {} + +---@param handler fun(event: CreatureDeathPreventedEvent): number? +---@param options? EventRegistrationOptions +function events.creature.onDeathPrevented(handler, options) end + +---@param handler fun(event: CreatureSpawnEvent): number? +---@param options? EventRegistrationOptions +function events.creature.onSpawn(handler, options) end + +---@param handler fun(event: CreatureAggroEvent): number? +---@param options? EventRegistrationOptions +function events.creature.onAggro(handler, options) end + +---@param handler fun(event: CreatureDeathEvent): number? +---@param options? EventRegistrationOptions +function events.creature.onDeath(handler, options) end + +---@param handler fun(event: CreatureHpThresholdEvent): number? +---@param options? EventRegistrationOptions +function events.creature.onHpThreshold(handler, options) end +events.player = {} + +---@param handler fun(event: PlayerLevelUpEvent): number? +---@param options? EventRegistrationOptions +function events.player.onLevelUp(handler, options) end + +---@param handler fun(event: PlayerLootEvent): number? +---@param options? EventRegistrationOptions +function events.player.onLoot(handler, options) end + +---@param handler fun(event: PlayerQuestAcceptEvent): number? +---@param options? EventRegistrationOptions +function events.player.onQuestAccept(handler, options) end + +---@param handler fun(event: PlayerQuestTurnInEvent): number? +---@param options? EventRegistrationOptions +function events.player.onQuestTurnIn(handler, options) end + +---@param handler fun(event: PlayerLoginEvent): number? +---@param options? EventRegistrationOptions +function events.player.onLogin(handler, options) end + +---@param handler fun(event: PlayerRelocatedEvent): number? +---@param options? EventRegistrationOptions +function events.player.onRelocated(handler, options) end + +---@param handler fun(event: PlayerLogoutEvent): number? +---@param options? EventRegistrationOptions +function events.player.onLogout(handler, options) end + +---@param handler fun(event: PlayerGossipSelectEvent): number? +---@param options? EventRegistrationOptions +function events.player.onGossipSelect(handler, options) end +events.group = {} + +---@param handler fun(event: GroupInviteEvent): number? +---@param options? EventRegistrationOptions +function events.group.onInvite(handler, options) end +events.spell = {} + +---@param handler fun(event: SpellCastResolvedEvent): number? +---@param options? EventRegistrationOptions +function events.spell.onCastResolved(handler, options) end + +---@param handler fun(event: SpellCastFinishedEvent): number? +---@param options? EventRegistrationOptions +function events.spell.onCastFinished(handler, options) end +events.gameobject = {} + +---@param handler fun(event: GameobjectUsedEvent): number? +---@param options? EventRegistrationOptions +function events.gameobject.onUsed(handler, options) end + +events.package = {} +---@param localName string +---@param handler fun(event: PackageEvent): number? +---@param options? EventRegistrationOptions +function events.package.on(localName, handler, options) end diff --git a/datascripts/runtime-scripts/script-ids.ts b/datascripts/runtime-scripts/script-ids.ts new file mode 100644 index 000000000..605d7aa69 --- /dev/null +++ b/datascripts/runtime-scripts/script-ids.ts @@ -0,0 +1,94 @@ +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; + +export const SCRIPT_IDS_FILE = "script-ids.json"; +const FLOOR = 100_000; +const CEILING = 999_999; + +interface ScriptIds { + version: 1; + package: string; + ids: Record; +} + +function object(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function refusal(path: string, message: string): never { + throw new Error(`${path}: ${message}`); +} + +async function readJson(path: string): Promise { + let contents: string; + try { + contents = await readFile(path, "utf8"); + } catch (error) { + if (object(error) && error.code === "ENOENT") return undefined; + throw error; + } + try { + return JSON.parse(contents); + } catch { + return refusal(path, "invalid JSON; restore the recorded script identities before building"); + } +} + +/** Recorded IDs outlive source removal, and an existing artifact supplies migration identities. */ +export async function scriptIds(packageDir: string, packageName: string): Promise { + const path = join(packageDir, SCRIPT_IDS_FILE); + const saved = await readJson(path); + const ledger: ScriptIds = { version: 1, package: packageName, ids: Object.create(null) }; + if (saved !== undefined) { + if (!object(saved) || saved.version !== 1 || saved.package !== packageName || !object(saved.ids) + || Object.keys(saved).some((key) => !["version", "package", "ids"].includes(key))) { + refusal(path, `expected version 1 identities owned by Package ${packageName}; use packages new for an independent copy`); + } + for (const [stem, id] of Object.entries(saved.ids)) recordId(path, ledger, stem, id); + } + + const artifactPath = join(packageDir, "data", ".generated", `${packageName}.script.json`); + const artifact = await readJson(artifactPath); + if (artifact !== undefined) { + if (!object(artifact) || artifact.kind !== "script" || artifact.version !== 1 + || artifact.package !== packageName || !Array.isArray(artifact.scripts)) { + refusal(artifactPath, "cannot recover script identities from this Script Artifact"); + } + for (const row of artifact.scripts) { + if (!object(row) || typeof row.name !== "string" || !row.name.startsWith(`${packageName}.`)) { + refusal(artifactPath, "a script name does not belong to this Package"); + } + recordId(artifactPath, ledger, row.name.slice(packageName.length + 1), row.script_id); + } + } + return ledger; +} + +function recordId(path: string, ledger: ScriptIds, stem: string, id: unknown): number { + if (!/^[a-z0-9_.-]+$/.test(stem) || typeof id !== "number" || !Number.isInteger(id) || id < FLOOR || id > CEILING) { + refusal(path, `invalid identity for ${stem}: expected a script ID in ${FLOOR}..=${CEILING}`); + } + const recorded = ledger.ids[stem]; + if (recorded !== undefined && recorded !== id) { + refusal(path, `${stem} already has script ID ${recorded}; refusing to replace it with ${id}`); + } + for (const [other, reserved] of Object.entries(ledger.ids)) { + if (other !== stem && reserved === id) { + refusal(path, `${stem} collides with ${other} on script ID ${id}; keep published IDs and allocate a different unused ID for the new script in ${SCRIPT_IDS_FILE}`); + } + } + ledger.ids[stem] = id; + return id; +} + +export function allocateScriptId(path: string, ledger: ScriptIds, stem: string, legacyId?: number): number { + const recorded = ledger.ids[stem]; + const digest = createHash("sha256").update(ledger.package).update("\0").update(stem).digest(); + const id = legacyId ?? recorded ?? FLOOR + digest.readUInt32BE(0) % (CEILING - FLOOR + 1); + return recordId(path, ledger, stem, id); +} + +export function renderScriptIds(ledger: ScriptIds): string { + return `${JSON.stringify({ ...ledger, ids: Object.fromEntries(Object.entries(ledger.ids).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) }, null, 2)}\n`; +} diff --git a/datascripts/tests/fixtures/typed/answer.generated.lua b/datascripts/tests/fixtures/typed/answer.generated.lua new file mode 100644 index 000000000..54495b7ce --- /dev/null +++ b/datascripts/tests/fixtures/typed/answer.generated.lua @@ -0,0 +1,11 @@ +local ____lyracore_handler +local events = { package = { on = function(name, handler) ____lyracore_handler = handler end } } +local function answer(event) + grant_xp(event.actor, 3) + assert(event.actor.level > 0, "invalid level") + return event.actor.level - 12 +end + +events.package.on("answer", answer) +assert(event.name == "example.scripts.answer", "Event Binding does not match the Invocation") +return ____lyracore_handler(event) diff --git a/datascripts/tests/fixtures/typed/answer.lua b/datascripts/tests/fixtures/typed/answer.lua new file mode 100644 index 000000000..5b54bb825 --- /dev/null +++ b/datascripts/tests/fixtures/typed/answer.lua @@ -0,0 +1,7 @@ +local function answer(event) + grant_xp(event.actor, 3) + assert(event.actor.level > 0, "invalid level") + return event.actor.level - 12 +end + +events.package.on("answer", answer) diff --git a/datascripts/tests/fixtures/typed/ding.generated.lua b/datascripts/tests/fixtures/typed/ding.generated.lua new file mode 100644 index 000000000..5a8de8796 --- /dev/null +++ b/datascripts/tests/fixtures/typed/ding.generated.lua @@ -0,0 +1,10 @@ +local ____lyracore_handler +local events = { player = { onLevelUp = function(handler) ____lyracore_handler = handler end } } +local function ding(event) + send_chat(event.player, "Ding " .. event.newLevel) +end + +events.player.onLevelUp(ding) +assert(event.name == "on_levelup", "Event Binding does not match the Invocation") +assert(event.player and event.player.is_player, "on_levelup requires a Character") +return ____lyracore_handler(event) diff --git a/datascripts/tests/fixtures/typed/ding.lua b/datascripts/tests/fixtures/typed/ding.lua new file mode 100644 index 000000000..834867424 --- /dev/null +++ b/datascripts/tests/fixtures/typed/ding.lua @@ -0,0 +1,5 @@ +local function ding(event) + send_chat(event.player, "Ding " .. event.newLevel) +end + +events.player.onLevelUp(ding) diff --git a/datascripts/tests/fixtures/typed/welcome.generated.lua b/datascripts/tests/fixtures/typed/welcome.generated.lua new file mode 100644 index 000000000..929f2b68b --- /dev/null +++ b/datascripts/tests/fixtures/typed/welcome.generated.lua @@ -0,0 +1,12 @@ +local ____lyracore_handler +local events = { player = { onLogin = function(handler) ____lyracore_handler = handler end } } +local function ____tbl(t) + return t +end +local function welcome(event) + send_chat(event.player, "Welcome, " .. event.player.name) +end +events.player.onLogin(welcome) +assert(event.name == "on_login", "Event Binding does not match the Invocation") +assert(event.player and event.player.is_player, "on_login requires a Character") +return ____lyracore_handler(event) diff --git a/datascripts/tests/fixtures/typed/welcome.ts b/datascripts/tests/fixtures/typed/welcome.ts new file mode 100644 index 000000000..cf486a1c4 --- /dev/null +++ b/datascripts/tests/fixtures/typed/welcome.ts @@ -0,0 +1,5 @@ +function welcome(event: PlayerLoginEvent): void { + send_chat(event.player, "Welcome, " + event.player.name); +} + +events.player.onLogin(welcome); diff --git a/datascripts/tests/runtime-scripts.test.ts b/datascripts/tests/runtime-scripts.test.ts index 6434f7eac..e4213ac99 100644 --- a/datascripts/tests/runtime-scripts.test.ts +++ b/datascripts/tests/runtime-scripts.test.ts @@ -88,7 +88,7 @@ test("two builds of one source tree write the same bytes", async () => { expect((await build(files)).artifact).toBe((await build(files)).artifact); }); -test("the source digest covers only immediate regular TypeScript and Lua files", async () => { +test("the source digest covers immediate sources and recorded script identities", async () => { const built = await build({ "alpha.ts": `${directives(EVENT, 100_201)}function script(): void {}\n`, "zeta.lua": "-- @event on_login\n-- @id 100202\nreturn 2\n", @@ -97,7 +97,7 @@ test("the source digest covers only immediate regular TypeScript and Lua files", }); expect(JSON.parse(built.artifact).source_hash).toBe( - "8395ead00aad341a7daa23658447385da94dabf6932b406cbfbdb5e2fd664002", + "1286e02d1b2421c63549d302a6a01c33a9dc0004a10188abc34357baa22c9a4d", ); expect(built.scripts.map((script) => script.name)).toEqual([ `${PACKAGE}.alpha`, diff --git a/datascripts/tests/typed-scripts.test.ts b/datascripts/tests/typed-scripts.test.ts new file mode 100644 index 000000000..0ecde7c9e --- /dev/null +++ b/datascripts/tests/typed-scripts.test.ts @@ -0,0 +1,175 @@ +import { expect, test } from "bun:test"; +import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createHash } from "node:crypto"; +import { buildPackageScripts } from "../runtime-scripts/build-scripts.ts"; + +const PACKAGE = "example.scripts"; +const WELCOME = `function welcome(event: PlayerLoginEvent): void { + send_chat(event.player, "Welcome!"); +} +events.player.onLogin(welcome); +`; + +interface Artifact { + source_hash: string; + scripts: Array<{ script_id: number; name: string; event: string; source: string; priority: number; enabled: boolean }>; +} + +async function scratch(run: (dir: string, build: () => Promise) => Promise): Promise { + const root = mkdtempSync(join(tmpdir(), "typed-scripts-")); + const before = process.env.LYRACORE_PACKAGES_ROOT; + const dir = join(root, PACKAGE); + mkdirSync(join(dir, "scripts"), { recursive: true }); + process.env.LYRACORE_PACKAGES_ROOT = root; + try { + await run(dir, async () => JSON.parse(readFileSync(await buildPackageScripts(PACKAGE), "utf8"))); + } finally { + if (before === undefined) delete process.env.LYRACORE_PACKAGES_ROOT; + else process.env.LYRACORE_PACKAGES_ROOT = before; + rmSync(root, { recursive: true, force: true }); + } +} + +test("named TS and Lua handlers bind the same event without directives", async () => { + await scratch(async (dir, build) => { + writeFileSync(join(dir, "scripts/welcome.ts"), WELCOME); + writeFileSync(join(dir, "scripts/ding.lua"), `local function ding(event) + send_chat(event.player, "Ding " .. event.newLevel) +end +events.player.onLevelUp(ding, {priority = -3, enabled = false}) +`); + const first = await build(); + const welcome = first.scripts.find((script) => script.event === "on_login")!; + const ding = first.scripts.find((script) => script.event === "on_levelup")!; + expect(welcome.source).toContain("welcome(event)"); + expect(welcome.source).not.toContain("return script()"); + expect(ding.priority).toBe(-3); + expect(ding.enabled).toBe(false); + expect(await build()).toEqual(first); + const ids = JSON.parse(readFileSync(join(dir, "script-ids.json"), "utf8")); + expect(ids.package).toBe(PACKAGE); + expect(ids.ids.welcome).toBe(welcome.script_id); + expect(ids.ids.ding).toBe(ding.script_id); + }); +}); + +test("a handler's type must match its event and its available payload", async () => { + await scratch(async (dir, build) => { + const path = join(dir, "scripts/welcome.ts"); + writeFileSync(path, WELCOME.replace("PlayerLoginEvent", "PlayerLevelUpEvent")); + await expect(build()).rejects.toThrow(/typescript-to-lua refused/); + writeFileSync(path, WELCOME.replace('"Welcome!"', "event.newLevel")); + await expect(build()).rejects.toThrow(/typescript-to-lua refused/); + writeFileSync(path, WELCOME.replace(": void", ": string").replace('send_chat(event.player, "Welcome!");', 'return "wrong";')); + await expect(build()).rejects.toThrow(/typescript-to-lua refused/); + }); +}); + +test("Package Events bind a local name and preserve numeric Script Answers", async () => { + await scratch(async (dir, build) => { + writeFileSync(join(dir, "scripts/answer.ts"), `function answer(event: PackageEvent): number { return 0; } +events.package.on("greeting", answer, {priority: 2});`); + const result = await build(); + expect(result.scripts[0]!.event).toBe(`${PACKAGE}.greeting`); + expect(result.scripts[0]!.priority).toBe(2); + expect(result.scripts[0]!.source).toContain("return 0"); + expect(result.scripts[0]!.source.trimEnd()).toMatch(/return ____lyracore_handler\(event\)$/); + }); +}); + +test("Lua comments and multiline strings do not create Event Bindings", async () => { + await scratch(async (dir, build) => { + writeFileSync(join(dir, "scripts/welcome.lua"), `-- events.player.onLevelUp(wrong) +local message = [=[events.player.onLogin(fake)]=] +local function welcome(event) send_chat(event.player, message) end +events.player.onLogin(welcome) +`); + expect((await build()).scripts[0]!.event).toBe("on_login"); + }); +}); + +test("conditional, duplicate, aliased and unknown bindings are refused", async () => { + await scratch(async (dir, build) => { + const path = join(dir, "scripts/welcome.lua"); + const handler = "local function welcome(event) end\n"; + for (const source of [ + handler + "if true then events.player.onLogin(welcome) end", + handler + "events.player.onLogin(welcome)\nevents.player.onLevelUp(welcome)", + handler + "local subscribe = events.player.onLogin\nsubscribe(welcome)", + handler + "events.player.onSneeze(welcome)", + handler + "events.player.onLogin(welcome, {priority = 2147483648})", + handler + "events.player.onLogin(welcome, {enabled = 1})", + handler + "events.player.onLogin(welcome, {priority = 1, priority = 2})", + handler + "events.player.onLogin(welcome)\nreturn 7", + ]) { + writeFileSync(path, source); + await expect(build()).rejects.toThrow(); + } + }); +}); + +test("migration adopts existing artifact IDs and refuses a changed durable ID", async () => { + await scratch(async (dir, build) => { + const path = join(dir, "scripts/welcome.ts"); + writeFileSync(path, "// @event on_login\n// @id 100300\nfunction script(): void {}\n"); + await build(); + rmSync(join(dir, "script-ids.json")); + writeFileSync(path, WELCOME); + expect((await build()).scripts[0]!.script_id).toBe(100300); + writeFileSync(join(dir, "script-ids.json"), JSON.stringify({ version: 1, package: PACKAGE, ids: { welcome: 100301 } })); + await expect(build()).rejects.toThrow(/already has script ID/); + }); +}); + +test("adding and removing sources or renaming a function keeps recorded IDs", async () => { + await scratch(async (dir, build) => { + writeFileSync(join(dir, "scripts/welcome.ts"), WELCOME); + const id = (await build()).scripts[0]!.script_id; + writeFileSync(join(dir, "scripts/aaa.ts"), WELCOME.replaceAll("welcome", "other")); + writeFileSync(join(dir, "scripts/welcome.ts"), WELCOME.replaceAll("welcome", "renamed")); + expect((await build()).scripts.find((script) => script.name.endsWith(".welcome"))!.script_id).toBe(id); + rmSync(join(dir, "scripts/welcome.ts")); + await build(); + expect(JSON.parse(readFileSync(join(dir, "script-ids.json"), "utf8")).ids.welcome).toBe(id); + writeFileSync(join(dir, "scripts/welcome.ts"), WELCOME); + expect((await build()).scripts.find((script) => script.name.endsWith(".welcome"))!.script_id).toBe(id); + }); +}); + +test("an identity file copied from another Package is refused", async () => { + await scratch(async (dir, build) => { + writeFileSync(join(dir, "scripts/welcome.ts"), WELCOME); + writeFileSync(join(dir, "script-ids.json"), JSON.stringify({ version: 1, package: "someone.else", ids: { welcome: 100300 } })); + await expect(build()).rejects.toThrow(/owned by Package/); + }); +}); + +test("the artifact source hash includes the exact recorded identity bytes", async () => { + await scratch(async (dir, build) => { + writeFileSync(join(dir, "scripts/welcome.ts"), WELCOME); + const artifact = await build(); + const digest = createHash("sha256"); + for (const [name, contents] of [ + ["script-ids.json", readFileSync(join(dir, "script-ids.json"))], + ["welcome.ts", Buffer.from(WELCOME)], + ] as const) { + digest.update(name).update("\0").update(String(contents.length)).update("\0").update(contents); + } + expect(artifact.source_hash).toBe(digest.digest("hex")); + }); +}); + +test("the Runtime Script Host runs the exact Lua this toolchain emits", async () => { + await scratch(async (dir, build) => { + const fixtures = join(import.meta.dir, "fixtures/typed"); + for (const file of ["welcome.ts", "ding.lua", "answer.lua"]) { + writeFileSync(join(dir, "scripts", file), readFileSync(join(fixtures, file))); + } + for (const script of (await build()).scripts) { + const stem = script.name.slice(PACKAGE.length + 1); + expect(script.source).toBe(readFileSync(join(fixtures, `${stem}.generated.lua`), "utf8")); + } + }); +}); diff --git a/datascripts/tsconfig.json b/datascripts/tsconfig.json index 124a1aad7..40be9e1b7 100644 --- a/datascripts/tsconfig.json +++ b/datascripts/tsconfig.json @@ -10,5 +10,6 @@ "skipLibCheck": true, "types": ["bun", "node"] }, - "include": ["lib", "src", "tests", "generated", "runtime-scripts/build-scripts.ts"] + "include": ["lib", "src", "tests", "generated", "runtime-scripts/build-scripts.ts"], + "exclude": ["tests/fixtures/typed"] } diff --git a/docs/architecture.md b/docs/architecture.md index 3da2d6a68..d7b1ae0f0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -763,7 +763,7 @@ warning banner states plainly, not a contradiction to resolve in this document. | [`testing.md`](./testing.md) | The test tiers and the commands CI runs. | | Recipes | Step lists for common changes: [add an opcode](./recipes/add-an-opcode.md), [add a spell effect](./recipes/add-a-spell-effect.md). | | [`schema.md`](./schema.md) | The table-level data model. | -| [`package-api.md`](./package-api.md) | The Package API, version 1: what a Package's Rust half may call, and what core promises about it. | +| [`package-api.md`](./package-api.md) | The Package API, version 2: what a Package's Rust half may call, and what core promises about it. | ### Operating and building diff --git a/docs/module-build.md b/docs/module-build.md index 4629a30ea..452da56f4 100644 --- a/docs/module-build.md +++ b/docs/module-build.md @@ -25,8 +25,11 @@ registers a transport arm, which exports an empty entry and absorbs arriving row handlers, and map-specific Encounter Authority. Registration wrappers stop Package work after Package Teardown. `GAME_PACKAGES` lists compiled Packages, their tables, and their Character reads. -`HOOK_EVENTS` generates `hook_dispatch.rs`, including the payload aliases and a `fire_*` function -for each event. `module/src/hooks.rs` includes it, so callers use the same dispatch paths. +`datascripts/runtime-scripts/events.json` supplies the event catalogue. The build uses it to +emit `hook_dispatch.rs`, including payload aliases, each `fire_*` function and the Runtime Script +payload fields. `module/src/hooks.rs` includes it, so callers keep the same dispatch paths. +`generate-events.ts` reads the same catalogue to emit TS/Lua editor declarations and the names +accepted by the Script Artifact parser. The Runtime Script tests check these generated files. The marker scanner ignores comments and string literals. It reads invocation heads, without interpreting function bodies. A live marker must match the supported grammar; malformed markers diff --git a/docs/package-api.md b/docs/package-api.md index 3cec7591f..f8265876d 100644 --- a/docs/package-api.md +++ b/docs/package-api.md @@ -1,4 +1,4 @@ -# Package API, version 1 +# Package API, version 2 The Package API is the part of the Module a Package may name. A Package compiles into the Module wasm and can reach any `crate::` path the compiler resolves; this document says which of those paths @@ -264,6 +264,7 @@ need. | operation | does | |---|---| | `ask_offline(event, actor, target, scripts)` | runs `scripts` in order on a fresh Runtime Script Host and returns the Script Answer as `script_binding::ask` reads it; discards Staged Effects and returns any Script Diagnostic as an error | +| `ask_artifact_offline(event, actor, target, artifact_json)` | parses a Script Artifact, selects its enabled Event Bindings in dispatch order, and runs them through `ask_offline` | | `EntityView`, `RuntimeScript` | the event entity and script values `ask_offline` takes | | `read_scanned(rel)` | reads a repository-relative source file; `None` when its optional directory is not installed | | `code_of(src, signature)` | the body after `signature`, comments removed | @@ -458,14 +459,26 @@ Script has no base import, so the Package owns the whole row, and two Packages m a collision rather than a merge. Script Artifacts and Package Deltas both live in `packages//data/.generated/`, told apart by a top-level kind. -**Script Directives.** `@event` and `@id` are required. `@priority` and `@enabled` have defaults. The -identifier is written down rather than derived because it is durable: deriving it from a file index -would renumber a Package's scripts the moment an author added one. - -**Runtime Script Toolchain.** Bun plus `typescript-to-lua`, its config, the hand-maintained Host API -typings, and the emitter that keeps generated Lua off the interpreter's known call-shape fault. It -lives in `datascripts/runtime-scripts/` and runs at author time only. An Operator installs the -prebuilt Lua. +**Event Bindings.** Each source file binds one named function to an event, for example +`events.player.onLogin(welcome)`. The function takes the event payload and may return a numeric +Script Answer. TypeScript and Lua use the same Event Binding names. TypeScript declarations and +Lua editor definitions come from the catalogue in `datascripts/runtime-scripts/events.json`. +Login and level-up guarantee `event.player`; level-up carries its attained level in `newLevel`. +Other fields follow each event's declaration. Entity Handles keep their existing constraints. + +**Script Identities.** The toolchain records numeric IDs in the Package-root `script-ids.json`, +keyed by source-file stem. Commit it with the sources and Script Artifact. Existing artifacts and +legacy `@event`/`@id` directives supply migration IDs; a conflict is refused. Renaming a function +keeps its identity. A source-file rename creates a new identity. Deleted entries remain reserved. + +**Runtime Script Toolchain.** Bun, `typescript-to-lua`, the Lua parser, event catalogue, generated +declarations and emitter live in `datascripts/runtime-scripts/`. They run only at author time. +The emitted Lua captures the declared function and calls it with the event in one Invocation. +An Operator can install the prebuilt Script Artifact. See +[Runtime Script authoring](../packages/README.md#building-source) for examples and migration. + +Version 2 includes this authoring form. The collection's `api-v1` tag remains available to older +checkouts whose toolchain requires Script Directives. ## Package identifier ranges diff --git a/docs/runtime-script-host.md b/docs/runtime-script-host.md index 65fe73378..8b4f6218d 100644 --- a/docs/runtime-script-host.md +++ b/docs/runtime-script-host.md @@ -24,12 +24,19 @@ never stops the next one, and never stops the core work that follows. # Host operations -An Invocation sees one global per host operation and one `event` table: +Authors bind a named function to an event. The toolchain emits a local wrapper that calls that +function with the event table and returns its Script Answer. Event Binding does not execute +source during the build or install persistent handlers in the interpreter. + +The Host still supplies one global per Host Operation and one `event` table, so existing compiled +Script Artifacts remain valid: ```lua event.name -- the event label, a string event.actor -- the Entity Handle that caused the event, or nil event.target -- the Entity Handle the event acted on, or nil +event.player -- a Character Entity Handle for login and level-up +event.newLevel -- the attained level, only for level-up -- An Entity Handle's readable fields, snapshotted when the Invocation started: e.name, e.is_player, e.level, e.health, e.max_health, e.map_id, e.x, e.y, e.z @@ -47,7 +54,11 @@ because a core hook event has no caller waiting on one. An Entity Handle is opaque: a script cannot read a guid out of it and cannot mint one, so the only entities a Runtime Script can act on are the ones the Host resolved for that Invocation. -Every readable field is a snapshot taken before the script ran; writing to one changes nothing. +Every Entity Handle field is a snapshot taken before the script ran; writing to one changes nothing. +Hook payload fields come from `events.json`. The level-up hook carries `newLevel` even before its +Character row stores that level. The required `player` field aliases the actor's opaque handle; +the generated wrapper refuses to invoke a typed handler if that Character is absent. Scalar +64-bit identifiers use decimal strings to preserve their value in TypeScript. A host operation called with a missing entity, the wrong type, an out-of-range amount, or past the staging cap raises a Lua error naming the call and the fault. The Invocation returns a diff --git a/module/Cargo.toml b/module/Cargo.toml index 0c7b456e4..0e9ea8397 100644 --- a/module/Cargo.toml +++ b/module/Cargo.toml @@ -45,7 +45,12 @@ piccolo = "=0.3.3" # `runtime_script::fixed_entropy`, selected by the `getrandom_backend` cfg in `.cargo/config.toml`. getrandom = "0.3" +[build-dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" + [dev-dependencies] +serde = { version = "1", features = ["derive"] } lyracore-test-support.workspace = true # Integration tests only. `spacetime call` parses every reducer argument as JSON, so a `String` # parameter has to arrive as a JSON string literal rather than as bare text — this encodes them. diff --git a/module/build.rs b/module/build.rs index 45158e244..a63f0c2ce 100644 --- a/module/build.rs +++ b/module/build.rs @@ -3,182 +3,42 @@ use std::fs; use std::path::{Path, PathBuf}; -/// One row of the notify-hook event catalog. -/// -/// `actor`/`target` are the Runtime Script Event Binding half: Rust expressions, evaluated -/// against `payload` inside the generated `fire_*`, naming the guid that CAUSED the event and the -/// guid it acted ON. `"0"` means the event has no such participant, which reaches a Runtime Script -/// as an absent `event.actor`/`event.target` rather than as an error. -/// -/// The mapping is a judgement per event, which is why it lives beside the event rather than being -/// derived: `on_loot`'s target is the corpse, `on_hp_threshold` has no actor at all, and `on_death` -/// is victim-centric while `on_kill` names the same two guids the other way round. +/// One core hook and the Runtime Script payload it supplies. +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] struct HookEvent { - event: &'static str, - payload_ty: &'static str, - actor: &'static str, - target: &'static str, + event: String, + payload_type: String, + actor: String, + target: String, + fields: Vec, } -const fn hook( - event: &'static str, - payload_ty: &'static str, - actor: &'static str, - target: &'static str, -) -> HookEvent { - HookEvent { - event, - payload_ty, - actor, - target, - } +#[derive(serde::Deserialize)] +struct HookField { + name: String, + #[serde(rename = "type")] + field_type: HookFieldType, + source: String, } -/// The notify-hook event catalog: event name -> the payload type the -/// handler receives (the struct lives in `src/hooks.rs`) -> the actor and target guids a Runtime -/// Script bound to it receives. This row is HALF of an event's -/// definition; the payload struct is the other half. From these rows build.rs generates the -/// per-event registry array (`package_registries.rs`) AND the `payload_for` alias + `fire_*` -/// dispatch fn (`hook_dispatch.rs`) — so adding an event is: payload struct in hooks.rs, one row -/// here, plus the dispatch call at the new core chokepoint. A `game_hook!` naming any other event -/// panics below with this list. -/// -/// The event NAMES are mirrored by `lyracore_package_delta::script::HOOK_EVENT_NAMES`, which a pure -/// crate needs to refuse a Package binding to an event that does not exist. This build emits -/// `GAME_HOOK_EVENT_NAMES` from these rows and `module/src/script_binding.rs` asserts the two lists -/// are identical, so the catalog still cannot drift. -const HOOK_EVENTS: &[HookEvent] = &[ - hook( - "on_damage_taken", - "crate::hooks::DamageTakenPayload", - "payload.attacker_guid", - "payload.target_guid", - ), - hook( - "on_death_prevented", - "crate::hooks::DeathPreventedPayload", - "payload.attacker_guid", - "payload.creature_guid", - ), - // The spawning creature is the subject of its own spawn, and there is nothing it acted on. - hook( - "on_creature_spawn", - "crate::hooks::CreatureSpawnPayload", - "payload.guid", - "0", - ), - // NOTE: `grant_xp` persists the mutated entity AFTER its ding loop, so a script reading - // `event.actor.level` here sees the level BEFORE the ding. Read the level from the payload's - // own consumer, not from the actor, until that site is reordered. - hook( - "on_levelup", - "crate::hooks::LevelupPayload", - "payload.character_guid", - "0", - ), - hook( - "on_group_invite", - "crate::hooks::GroupInvitePayload", - "payload.inviter_guid", - "payload.target_guid", - ), - hook( - "on_death", - "crate::hooks::DeathPayload", - "payload.killer_guid", - "payload.victim_guid", - ), - hook( - "on_kill", - "crate::hooks::KillPayload", - "payload.killer_guid", - "payload.victim_guid", - ), - hook( - "on_aggro", - "crate::hooks::AggroPayload", - "payload.creature_guid", - "payload.target_guid", - ), - hook( - "on_cast_resolved", - "crate::hooks::CastResolvedPayload", - "payload.caster_guid", - "payload.target_guid", - ), - hook( - "on_cast_finished", - "crate::hooks::CastFinishedPayload", - "payload.caster_guid", - "payload.target_guid", - ), - // The corpse is a loot container, not a live entity, so `event.target` is normally absent here. - hook( - "on_loot", - "crate::hooks::LootPayload", - "payload.looter_guid", - "payload.corpse_guid", - ), - hook( - "on_quest_accept", - "crate::hooks::QuestAcceptPayload", - "payload.character_guid", - "0", - ), - hook( - "on_quest_turnin", - "crate::hooks::QuestTurninPayload", - "payload.character_guid", - "0", - ), - hook( - "on_login", - "crate::hooks::LoginPayload", - "payload.character_guid", - "0", - ), - hook( - "on_character_relocated", - "crate::hooks::CharacterRelocatedPayload", - "payload.character_guid", - "0", - ), - // Fired BEFORE the live entity row is deleted, so the actor still reads. - hook( - "on_logout", - "crate::hooks::LogoutPayload", - "payload.character_guid", - "0", - ), - hook( - "on_gossip_select", - "crate::hooks::GossipSelectPayload", - "payload.character_guid", - "payload.npc_guid", - ), - // Encounter kernel: entry-keyed creature death, once-per-instance HP-threshold - // crossings (fired by encounter::encounter_hp_probe, not a new core chokepoint), and GO use. - hook( - "on_creature_death", - "crate::hooks::CreatureDeathPayload", - "payload.killer_guid", - "payload.creature_guid", - ), - // A threshold crossing has no actor: the probe fires it, not a unit. - hook( - "on_hp_threshold", - "crate::hooks::HpThresholdPayload", - "0", - "payload.creature_guid", - ), - // The gameobject is not a world entity, so `event.target` is normally absent here. - hook( - "on_go_used", - "crate::hooks::GoUsedPayload", - "payload.user_guid", - "payload.go_guid", - ), -]; +#[derive(serde::Deserialize)] +enum HookFieldType { + #[serde(rename = "PlayerEntity")] + PlayerEntity, + #[serde(rename = "number")] + Number, + #[serde(rename = "boolean")] + Boolean, + #[serde(rename = "string")] + Text, +} + +/// Author declarations and dispatch use one catalogue. Rust checks every payload expression. +static HOOK_EVENTS: std::sync::LazyLock> = std::sync::LazyLock::new(|| { + serde_json::from_str(include_str!("../datascripts/runtime-scripts/events.json")) + .expect("the checked-in Runtime Script event catalogue must have valid rows") +}); /// The Package API surface, as the module roots a Package may name. `docs/package-api.md` is the /// contract; this list is what enforces it. Root granularity: everything under a listed root is on @@ -236,6 +96,7 @@ fn main() { std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR is set by cargo"); let src_dir = Path::new(&manifest_dir).join("src"); println!("cargo:rerun-if-changed={}", src_dir.display()); + println!("cargo:rerun-if-changed=../datascripts/runtime-scripts/events.json"); let packages_dir = Path::new(&manifest_dir) .parent() @@ -466,8 +327,10 @@ fn main() { } out.push_str("];\n"); for HookEvent { - event, payload_ty, .. - } in HOOK_EVENTS + event, + payload_type: payload_ty, + .. + } in &*HOOK_EVENTS { let mut hooks: Vec = registries .hooks @@ -529,7 +392,7 @@ fn main() { // fn-pointer arrays above — the same reason `CHARACTER_OWNED_TRANSFER_NAMES` exists. This is // what `script_binding.rs` asserts the Package Delta crate's mirror of the catalog against. out.push_str("pub const GAME_HOOK_EVENT_NAMES: &[&str] = &[\n"); - for HookEvent { event, .. } in HOOK_EVENTS { + for HookEvent { event, .. } in &*HOOK_EVENTS { out.push_str(&format!(" \"{event}\",\n")); } out.push_str("];\n"); @@ -573,7 +436,7 @@ fn main() { // `fire_*` fns keep their `crate::hooks::` paths. Generated from the same HOOK_EVENTS rows as // the registry arrays above — the catalog cannot drift between alias, dispatch, and array. let mut out = String::new(); - out.push_str("// GENERATED by module/build.rs from HOOK_EVENTS. DO NOT EDIT.\n"); + out.push_str("// GENERATED by module/build.rs from events.json. DO NOT EDIT.\n"); out.push_str( "// Included inside src/hooks.rs — see the module doc there for firing semantics.\n", ); @@ -584,33 +447,63 @@ fn main() { ); out.push_str("#[allow(non_camel_case_types)]\npub mod payload_for {\n"); for HookEvent { - event, payload_ty, .. - } in HOOK_EVENTS + event, + payload_type: payload_ty, + .. + } in &*HOOK_EVENTS { out.push_str(&format!(" pub type {event} = {payload_ty};\n")); } out.push_str("}\n"); for HookEvent { event, - payload_ty, + payload_type: payload_ty, actor, target, - } in HOOK_EVENTS + fields, + } in &*HOOK_EVENTS { // Two dispatches per event, in this order. The Rust handlers registered by `game_hook!` // are compiled into the build and run first; the Runtime Scripts bound to the event are // data, reconciled onto the shard by a Package, and run after. A Package cannot displace // engine code by shipping a script. + let payload = script_payload(fields); out.push_str(&format!( "pub(crate) fn fire_{event}(ctx: &spacetimedb::ReducerContext, payload: &{payload_ty}) {{\n \ for f in crate::GAME_HOOKS_{} {{\n f(ctx, payload);\n }}\n \ - crate::script_binding::fire(ctx, \"{event}\", {actor}, {target});\n}}\n", + crate::script_binding::fire_hook(ctx, \"{event}\", {actor}, {target}, || {payload});\n}}\n", event.to_uppercase() )); } write_out(&out_dir, "hook_dispatch.rs", &out); } +fn script_payload(fields: &[HookField]) -> String { + let mut scalar_fields = Vec::new(); + let mut player = false; + for field in fields { + let source = &field.source; + let value = match field.field_type { + HookFieldType::PlayerEntity => { + assert!(field.name == "player" && source == "actor"); + player = true; + continue; + } + HookFieldType::Number => format!("Number(({source}) as f64)"), + HookFieldType::Boolean => format!("Boolean({source})"), + HookFieldType::Text => format!("Text(({source}).to_string())"), + }; + scalar_fields.push(format!( + "({:?}, crate::runtime_script::ScriptScalar::{value})", + field.name + )); + } + format!( + "crate::runtime_script::ScriptPayload {{ player: {player}, fields: vec![{}] }}", + scalar_fields.join(", ") + ) +} + #[derive(Default)] struct Registries { delete: Vec, @@ -1227,10 +1120,10 @@ fn scan_file(file: &Path, scan_root: &Path, in_package: bool, prefix: &str, reg: panic!( "build.rs: `game_hook!` in {}:{line} names unknown event {event:?} — known \ events: {:?}. Extending the catalog means adding the payload struct + \ - dispatch site in src/hooks.rs and the HOOK_EVENTS row in module/build.rs \ + dispatch site in src/hooks.rs and the row in datascripts/runtime-scripts/events.json \ (payload_for aliases and fire_* fns are generated from that row).", file.display(), - HOOK_EVENTS.iter().map(|h| h.event).collect::>() + HOOK_EVENTS.iter().map(|h| h.event.as_str()).collect::>() ); } check_facade_reexport(file, scan_root, in_package, &name); diff --git a/module/src/debug/runtime_script.rs b/module/src/debug/runtime_script.rs index f001667c9..22d72dc76 100644 --- a/module/src/debug/runtime_script.rs +++ b/module/src/debug/runtime_script.rs @@ -28,6 +28,7 @@ pub fn debug_run_runtime_script( name: event, actor: EntityView::read(ctx, actor_guid), target: EntityView::read(ctx, target_guid), + ..ScriptEvent::default() }; let Some((diagnostics, compilations)) = crate::runtime_script::with_host(|host| { let diagnostics = run_event( diff --git a/module/src/hooks.rs b/module/src/hooks.rs index 2cbf75057..12ed061e4 100644 --- a/module/src/hooks.rs +++ b/module/src/hooks.rs @@ -31,10 +31,9 @@ //! | `on_hp_threshold` | `encounter::encounter_hp_probe`, the kernel's own `on_damage_taken` handler; fires once per registered `(entry, pct)` crossing per instance | [`HpThresholdPayload`] | //! | `on_go_used` | `gameobject::apply_use_gameobject` success exit, player use AND the debug drivers | [`GoUsedPayload`] | //! -//! Extending the catalog = the payload struct here + the `HOOK_EVENTS` row in `module/build.rs` -//! (which generates the `payload_for` alias, the `fire_*` fn, and the registry array from that -//! row), and the one dispatch line at the new chokepoint. Two files, one row each — the catalog -//! cannot drift between alias, dispatch, and array. +//! Add a hook payload here, its row in `datascripts/runtime-scripts/events.json`, and its core +//! dispatch call. `build.rs` derives payload aliases, dispatch and registries. Author declarations +//! use the same catalogue. use spacetimedb::ReducerContext; @@ -241,10 +240,8 @@ pub struct GoUsedPayload { pub instance_id: u64, } -// The `payload_for` alias mod + the per-event `fire_*` dispatch fns are GENERATED by build.rs from -// its `HOOK_EVENTS` catalog. Included HERE so every existing path -// (`hooks::fire_*`, `hooks::payload_for::*`) is unchanged. Adding an event = the payload struct -// above + the `HOOK_EVENTS` row in module/build.rs + the dispatch line at the new chokepoint. +// Generated aliases and dispatch use the Runtime Script event catalogue. Including them here +// preserves the `hooks::fire_*` and `hooks::payload_for::*` paths. include!(concat!(env!("OUT_DIR"), "/hook_dispatch.rs")); /// Run every registered package tick pass, in the registry's deterministic (sorted-by-path) order. diff --git a/module/src/package_test.rs b/module/src/package_test.rs index 50006bb92..f71b228b6 100644 --- a/module/src/package_test.rs +++ b/module/src/package_test.rs @@ -22,6 +22,7 @@ pub(crate) fn ask_offline( name: event.to_string(), actor, target, + ..ScriptEvent::default() }; let (diagnostics, answer) = ask_event( &mut RuntimeScriptHost::new(), @@ -40,6 +41,32 @@ pub(crate) fn ask_offline( } } +/// Parse a Script Artifact and ask its enabled Event Bindings in priority and identifier order. +/// Staged Effects are discarded. Parse failures and Script Diagnostics return their text. +pub(crate) fn ask_artifact_offline( + event: &str, + actor: Option, + target: Option, + artifact_json: &str, +) -> Result, String> { + let artifact = lyracore_package_delta::ScriptArtifact::parse(artifact_json) + .map_err(|failure| failure.to_string())?; + let mut bound: Vec<_> = artifact + .scripts() + .iter() + .filter(|script| script.enabled() && script.event().as_str() == event) + .collect(); + bound.sort_by_key(|script| (script.priority(), script.script_id())); + let scripts: Vec<_> = bound + .iter() + .map(|script| RuntimeScript { + name: script.name().as_str(), + source: script.source(), + }) + .collect(); + ask_offline(event, actor, target, &scripts) +} + struct NoEffects; impl EffectSink for NoEffects { @@ -72,6 +99,45 @@ mod tests { ); } + #[test] + fn an_artifact_ask_selects_enabled_bindings_in_dispatch_order() { + let scripts = [ + (100_001, "pkg.slow", "pkg.flee_at", 5, true, "return 40"), + ( + 100_002, + "pkg.disabled", + "pkg.flee_at", + -10, + false, + "error('disabled')", + ), + (100_003, "pkg.first", "pkg.flee_at", -1, true, "return 10"), + (100_004, "pkg.later", "pkg.flee_at", -1, true, "return 15"), + ( + 100_005, + "pkg.other", + "pkg.other", + -20, + true, + "error('another event')", + ), + ]; + let artifact = serde_json::json!({ + "kind": "script", + "version": 1, + "package": "pkg", + "source_hash": "0".repeat(64), + "scripts": scripts.map(|(script_id, name, event, priority, enabled, source)| { + serde_json::json!({ "script_id": script_id, "name": name, "event": event, + "priority": priority, "enabled": enabled, "source": source }) + }), + }); + assert_eq!( + ask_artifact_offline("pkg.flee_at", None, None, &artifact.to_string()), + Ok(Some(10.0)) + ); + } + #[test] fn an_offline_ask_refuses_a_failing_script() { let failure = ask_offline("pkg.flee_at", None, None, &[script("error('broken')")]) diff --git a/module/src/runtime_script.rs b/module/src/runtime_script.rs index 06173710d..ab6de1467 100644 --- a/module/src/runtime_script.rs +++ b/module/src/runtime_script.rs @@ -315,6 +315,21 @@ pub(crate) struct ScriptEvent { pub name: String, pub actor: Option, pub target: Option, + pub payload: ScriptPayload, +} + +/// Payload fields captured at the core hook, before an Invocation reads the entity snapshot. +#[derive(Clone, PartialEq, Debug, Default)] +pub(crate) struct ScriptPayload { + pub player: bool, + pub fields: Vec<(&'static str, ScriptScalar)>, +} + +#[derive(Clone, PartialEq, Debug)] +pub(crate) enum ScriptScalar { + Number(f64), + Boolean(bool), + Text(String), } /// What an Entity Handle carries: the identity the Host acts on, and nothing a script can read. @@ -630,6 +645,20 @@ fn event_table<'gc>(ctx: Context<'gc>, event: &ScriptEvent) -> Table<'gc> { None => Value::Nil, }; set(ctx, table, field, value); + if field == "actor" + && event.payload.player + && view.as_ref().is_some_and(|view| view.is_player) + { + set(ctx, table, "player", value); + } + } + for (field, scalar) in &event.payload.fields { + let value = match scalar { + ScriptScalar::Number(number) => Value::Number(*number), + ScriptScalar::Boolean(boolean) => Value::Boolean(*boolean), + ScriptScalar::Text(value) => Value::String(text(ctx, value)), + }; + set(ctx, table, field, value); } table } @@ -1168,6 +1197,7 @@ if #roster > 0 then grant_xp(event.actor, 25) end name: "on_login".to_string(), actor: Some(player()), target: Some(creature()), + ..ScriptEvent::default() } } @@ -1458,6 +1488,194 @@ if #roster > 0 then grant_xp(event.actor, 25) end // ---- the event and its Entity Handles ---- + #[test] + fn compiled_typescript_login_handler_sends_chat_through_its_event_parameter() { + let mut host = RuntimeScriptHost::new(); + let event = ScriptEvent { + payload: ScriptPayload { + player: true, + fields: vec![], + }, + ..engagement() + }; + let source = + include_str!("../../datascripts/tests/fixtures/typed/welcome.generated.lua"); + assert_eq!( + committed(&mut host, &event, source).expect("compiled login handler runs"), + [Committed::Chat { + recipient: PLAYER_GUID, + message: "Welcome, Thrall".to_string(), + }] + ); + let missing = unattended("on_login"); + let failure = host + .invoke(script("welcome", source), &missing) + .expect_err("a typed handler cannot run without its required Character"); + assert_eq!(failure.kind, FailureKind::Runtime); + assert!(failure.message.contains("requires a Character")); + } + + #[test] + fn compiled_lua_levelup_handler_reads_the_attained_level() { + let mut host = RuntimeScriptHost::new(); + let event = ScriptEvent { + name: "on_levelup".to_string(), + actor: Some(player()), + payload: ScriptPayload { + player: true, + fields: vec![("newLevel", ScriptScalar::Number(13.0))], + }, + ..ScriptEvent::default() + }; + assert_eq!( + committed( + &mut host, + &event, + include_str!("../../datascripts/tests/fixtures/typed/ding.generated.lua"), + ) + .expect("compiled level-up handler runs"), + [Committed::Chat { + recipient: PLAYER_GUID, + message: "Ding 13".to_string(), + }] + ); + } + + #[test] + fn compiled_package_handler_keeps_zero_and_negative_answers_and_discards_failed_effects() { + let mut host = RuntimeScriptHost::new(); + let source = include_str!("../../datascripts/tests/fixtures/typed/answer.generated.lua"); + for level in [12, 11, 0] { + let mut character = player(); + character.level = level; + let event = ScriptEvent { + actor: Some(character), + ..unattended("example.scripts.answer") + }; + let mut sink = FakeEffects::default(); + let (diagnostics, answer) = + ask_event(&mut host, &mut sink, &event, &[script("answer", source)]); + if level == 0 { + assert_eq!(diagnostics.len(), 1); + assert_eq!(answer, None); + assert!(sink.committed.is_empty()); + } else { + assert!(diagnostics.is_empty()); + assert_eq!(answer, Some(f64::from(level) - 12.0)); + assert_eq!(sink.committed, [xp(PLAYER_GUID, 3)]); + } + } + } + + #[test] + fn a_levelup_handler_reads_the_hook_level_while_the_character_snapshot_is_stale() { + let mut host = RuntimeScriptHost::new(); + let event = ScriptEvent { + name: "on_levelup".to_string(), + actor: Some(player()), + payload: ScriptPayload { + player: true, + fields: vec![("newLevel", ScriptScalar::Number(13.0))], + }, + ..ScriptEvent::default() + }; + assert_eq!( + committed( + &mut host, + &event, + r#" +local function celebrate(event) + if event.player ~= event.actor then error("the Character must keep one handle") end + if event.player.level ~= 12 then error("the entity snapshot must keep its old level") end + grant_xp(event.player, event.newLevel) +end +celebrate(event) +"#, + ) + .expect("the hook's new level reaches the typed handler"), + [xp(PLAYER_GUID, 13)] + ); + } + + #[test] + fn a_login_handler_receives_an_opaque_character_handle() { + let mut host = RuntimeScriptHost::new(); + let event = ScriptEvent { + payload: ScriptPayload { + player: true, + fields: vec![], + }, + ..engagement() + }; + assert_eq!( + committed( + &mut host, + &event, + r#" +local function welcome(event) + if not event.player.is_player then error("login requires a Character") end + if event.player.guid ~= nil or type(event.player) ~= "userdata" then error("the handle must stay opaque") end + send_chat(event.player, "Welcome, " .. event.player.name) +end +welcome(event) +"#, + ) + .expect("login carries a Character handle"), + [Committed::Chat { recipient: PLAYER_GUID, message: "Welcome, Thrall".to_string() }] + ); + } + + #[test] + fn a_required_character_field_never_aliases_a_creature_or_an_absent_actor() { + let mut host = RuntimeScriptHost::new(); + for actor in [None, Some(creature())] { + let event = ScriptEvent { + actor, + payload: ScriptPayload { + player: true, + fields: vec![], + }, + ..unattended("on_login") + }; + committed( + &mut host, + &event, + "if event.player ~= nil then error('no Character is present') end", + ) + .expect("an absent Character field reads as nil"); + } + } + + #[test] + fn a_hook_payload_preserves_boolean_and_decimal_identifier_fields() { + let mut host = RuntimeScriptHost::new(); + let event = ScriptEvent { + payload: ScriptPayload { + player: false, + fields: vec![ + ("assist", ScriptScalar::Boolean(true)), + ( + "instanceId", + ScriptScalar::Text("18446744073709551615".to_string()), + ), + ], + }, + ..engagement() + }; + assert_eq!( + committed( + &mut host, + &event, + "if event.assist then send_chat(event.actor, event.instanceId) end", + ) + .expect("scalar payload fields retain their types"), + [Committed::Chat { + recipient: PLAYER_GUID, + message: "18446744073709551615".to_string() + }] + ); + } + #[test] fn a_script_reads_the_curated_fields_of_the_event_actor_and_target() { let mut host = RuntimeScriptHost::new(); @@ -1510,6 +1728,7 @@ if #roster > 0 then grant_xp(event.actor, 25) end name: "on_tick".to_string(), actor: Some(player()), target: None, + ..ScriptEvent::default() }; assert_eq!( committed( @@ -1550,6 +1769,7 @@ if #roster > 0 then grant_xp(event.actor, 25) end name: "on_tick".to_string(), actor: None, target: Some(creature()), + ..ScriptEvent::default() }; assert_eq!( committed(&mut host, &no_actor, "heal(event.target, 5)").unwrap(), @@ -1617,6 +1837,7 @@ if #roster > 0 then grant_xp(event.actor, 25) end name: "on_tick".to_string(), actor: Some(player()), target: None, + ..ScriptEvent::default() }; for (event, source, fault) in [ ( diff --git a/module/src/script_binding.rs b/module/src/script_binding.rs index e0615ea1d..54f11815d 100644 --- a/module/src/script_binding.rs +++ b/module/src/script_binding.rs @@ -6,7 +6,7 @@ //! //! # The dispatch //! -//! [`fire`] is called from every generated `fire_*` in `hooks.rs`, after that event's compiled +//! [`fire_hook`] is called from every generated `fire_*` in `hooks.rs`, after that event's compiled //! `game_hook!` handlers have run. It is a lookup, an ordering, and one call into the Runtime //! Script Host: //! @@ -47,7 +47,7 @@ use spacetimedb::{log, table, ReducerContext}; use crate::runtime_script::{ - ask_event, with_host, CoreEffects, EntityView, RuntimeScript, ScriptEvent, + ask_event, with_host, CoreEffects, EntityView, RuntimeScript, ScriptEvent, ScriptPayload, }; /// One Runtime Script a Package ships, reconciled onto this Shard. @@ -96,11 +96,21 @@ pub struct Script { /// name something that is not a live entity (a corpse, a gameobject, a character in transit), which /// reaches the script as an absent `event.actor`/`event.target` rather than as a failure. /// -/// Called only from the generated `fire_*` dispatchers, which is what keeps the event label here -/// identical to the one a Package binds to. A core hook event has no caller waiting on a Script -/// Answer, so any answer the scripts gave is dropped. +/// A Package Event has no caller waiting on a Script Answer, so any answer is dropped. +#[cfg_attr(not(has_packages), allow(dead_code))] pub(crate) fn fire(ctx: &ReducerContext, event: &str, actor_guid: u64, target_guid: u64) { - dispatch(ctx, event, actor_guid, target_guid); + dispatch(ctx, event, actor_guid, target_guid, ScriptPayload::default); +} + +/// Generated core dispatch supplies its typed payload after the Event Binding lookup. +pub(crate) fn fire_hook( + ctx: &ReducerContext, + event: &str, + actor_guid: u64, + target_guid: u64, + payload: impl FnOnce() -> ScriptPayload, +) { + dispatch(ctx, event, actor_guid, target_guid, payload); } /// Run every enabled Runtime Script bound to `event`, and read back the Script Answer. @@ -118,13 +128,19 @@ pub(crate) fn ask( actor_guid: u64, target_guid: u64, ) -> Option { - dispatch(ctx, event, actor_guid, target_guid) + dispatch(ctx, event, actor_guid, target_guid, ScriptPayload::default) } /// The lookup, the ordering and the one Host call both [`fire`] and [`ask`] are. One body rather /// than two, so the two verbs can never drift on the order they run scripts in or on what a failure /// does; they differ only in whether the Script Answer has a caller waiting for it. -fn dispatch(ctx: &ReducerContext, event: &str, actor_guid: u64, target_guid: u64) -> Option { +fn dispatch( + ctx: &ReducerContext, + event: &str, + actor_guid: u64, + target_guid: u64, + payload: impl FnOnce() -> ScriptPayload, +) -> Option { let bound = dispatch_order(ctx.db.game_script().by_event().filter(event).collect()); if bound.is_empty() { return None; @@ -136,6 +152,7 @@ fn dispatch(ctx: &ReducerContext, event: &str, actor_guid: u64, target_guid: u64 name: event.to_string(), actor: EntityView::read(ctx, actor_guid), target: EntityView::read(ctx, target_guid), + payload: payload(), }; let scripts: Vec> = bound .iter() @@ -200,7 +217,7 @@ mod tests { /// Event Bindings are what make Host re-entry REACHABLE for the first time: a script's Staged /// Effect commits through a core operation, that operation fires a hook, and the hook lands - /// back in [`fire`] — inside the invocation that staged it. + /// back in [`fire_hook`], inside the invocation that staged it. /// /// The Host must refuse the second borrow rather than recurse or panic, because recursion here /// is unbounded and a panic would take the whole reducer down. This drives the exact nesting @@ -224,17 +241,13 @@ mod tests { ); } - /// The catalog has two homes that cannot see each other: `HOOK_EVENTS` in `module/build.rs`, - /// which generates the dispatch, and `HOOK_EVENT_NAMES` in the Package Delta crate, which - /// refuses a Package binding to an event that does not exist. An event added to one and not the - /// other would make every Package binding to it unshippable, with nothing to say why. + /// The artifact parser must accept exactly the core hooks the Module dispatches. #[test] fn the_hook_catalogue_and_the_artifact_parsers_event_list_are_identical() { assert_eq!( crate::GAME_HOOK_EVENT_NAMES, lyracore_package_delta::HOOK_EVENT_NAMES, - "add the event to `HOOK_EVENT_NAMES` in crates/lyracore-package-delta/src/script.rs \ - as well as to `HOOK_EVENTS` in module/build.rs" + "run datascripts/runtime-scripts/generate-events.ts after changing events.json" ); } diff --git a/module/src/spell/cast/targeting.rs b/module/src/spell/cast/targeting.rs index 8053476a5..46948f4e8 100644 --- a/module/src/spell/cast/targeting.rs +++ b/module/src/spell/cast/targeting.rs @@ -1434,6 +1434,7 @@ fn apply_delegating_effect( name: format!("spell_effect:{}:{}", e.spell_id, e.effect_index), actor: crate::runtime_script::EntityView::read(ctx, caster_guid), target: crate::runtime_script::EntityView::read(ctx, target_guid), + ..crate::runtime_script::ScriptEvent::default() }; crate::runtime_script::invoke_by_identity( ctx, diff --git a/packages/README.md b/packages/README.md index e918e278c..9af0ea64e 100644 --- a/packages/README.md +++ b/packages/README.md @@ -65,19 +65,52 @@ Authoring Library and a Base Snapshot from your own client data. Their Package D Identities stay local under `data/.generated/`. Never commit a Package Delta to the collection. -Runtime Scripts use `scripts/*.ts` or `scripts/*.lua`. The build compiles them into a Script -Artifact and records its Build Identity. These two files may be committed to the collection. -Each source starts with an event and a durable Script ID: +Runtime Scripts use `scripts/*.ts` or `scripts/*.lua`. Each file declares one named function and +one Event Binding. TypeScript checks the event payload: ```ts -// @event on_login -// @id 100300 +function welcome(event: PlayerLoginEvent): void { + send_chat(event.player, "Welcome!"); +} + +events.player.onLogin(welcome); +``` + +Lua uses the same function and Event Binding shape: + +```lua +local function ding(event) + send_chat(event.player, "You reached level " .. event.newLevel) +end + +events.player.onLevelUp(ding) ``` -Optional `@priority` defaults to `0`, and `@enabled` defaults to `true`. TypeScript declares -`function script(): number | void`. A numeric Script Answer is what a Package reads through -`ask()`. Choose distinct IDs before installing multiple copies of a rung. After scaffolding, -run `packages apply` to build and activate the renamed source. +The event catalogue generates TypeScript declarations and Lua editor definitions. The checkout's +editor configuration loads them for installed Packages. Login and level-up carry a Character in +`event.player`; level-up also carries `event.newLevel`, even while the Character's level snapshot +still holds the old value. Other events expose their declared payload fields and optional +`event.actor` and `event.target` Entity Handles. + +A second argument sets options, for example `events.player.onLogin(welcome, { priority: 10 })`. +Priority defaults to `0` and enabled state to `true`. Lua uses `{ priority = 10 }`. An Event Binding +must be an unconditional top-level call to a named function in the file. Put shared helpers in that +file too. A function can return a numeric Script Answer for a Package that calls `ask()`. +Bind a Package Event with `events.package.on("welcome", welcome)`; the builder supplies the +shipping Package's prefix. + +`packages build` records stable Script Identities in the Package-root `script-ids.json` and emits +a Script Artifact with its Build Identity. Commit all three files with the sources. The Runtime +Script name remains `.`, so changing the function name keeps its identity. +Deleted entries stay reserved. `packages new` omits the copied identities and artifacts so the +new Package gets its own IDs. A filename change creates a new script identity. + +Existing `@event` and `@id` Script Directives still build. To migrate, build once to record the +identities, then replace the directives with a named function and Event Binding. An existing +Script Artifact also supplies its original IDs. The builder refuses a disagreement between +recorded identities, legacy directives and an existing artifact. + +After creating or editing a Package, run `packages apply` to build and activate it. ## Client content diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 000000000..8419f4e31 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "strict": true, + "target": "ES2020", + "module": "ESNext", + "moduleDetection": "force", + "noEmit": true, + "types": [] + }, + "include": [ + "packages/*/scripts/*.ts", + "datascripts/runtime-scripts/runtime-script.d.ts" + ] +} From bffc3ac6d7ea5a0f930a1283562110d7b74d32ca Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Sat, 10 Oct 2026 20:31:18 +0200 Subject: [PATCH 2/9] style(scripts): format compiled handler fixture --- module/src/runtime_script.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/module/src/runtime_script.rs b/module/src/runtime_script.rs index ab6de1467..49fea9100 100644 --- a/module/src/runtime_script.rs +++ b/module/src/runtime_script.rs @@ -1498,8 +1498,7 @@ if #roster > 0 then grant_xp(event.actor, 25) end }, ..engagement() }; - let source = - include_str!("../../datascripts/tests/fixtures/typed/welcome.generated.lua"); + let source = include_str!("../../datascripts/tests/fixtures/typed/welcome.generated.lua"); assert_eq!( committed(&mut host, &event, source).expect("compiled login handler runs"), [Committed::Chat { From b8be8163bada099152e179ad66a9ecf36d46ffed Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Sat, 10 Oct 2026 20:38:31 +0200 Subject: [PATCH 3/9] fix(scripts): preserve prior identities and probe unused IDs --- datascripts/runtime-scripts/script-ids.ts | 45 +++++-- datascripts/tests/script-identities.test.ts | 137 ++++++++++++++++++++ docs/package-api.md | 3 + 3 files changed, 177 insertions(+), 8 deletions(-) create mode 100644 datascripts/tests/script-identities.test.ts diff --git a/datascripts/runtime-scripts/script-ids.ts b/datascripts/runtime-scripts/script-ids.ts index 605d7aa69..4474e2659 100644 --- a/datascripts/runtime-scripts/script-ids.ts +++ b/datascripts/runtime-scripts/script-ids.ts @@ -1,5 +1,5 @@ import { createHash } from "node:crypto"; -import { readFile } from "node:fs/promises"; +import { readFile, readdir } from "node:fs/promises"; import { join } from "node:path"; export const SCRIPT_IDS_FILE = "script-ids.json"; @@ -35,7 +35,7 @@ async function readJson(path: string): Promise { } } -/** Recorded IDs outlive source removal, and an existing artifact supplies migration identities. */ +/** Recorded IDs outlive source removal. Prior Script Artifacts supply migration identities. */ export async function scriptIds(packageDir: string, packageName: string): Promise { const path = join(packageDir, SCRIPT_IDS_FILE); const saved = await readJson(path); @@ -48,9 +48,29 @@ export async function scriptIds(packageDir: string, packageName: string): Promis for (const [stem, id] of Object.entries(saved.ids)) recordId(path, ledger, stem, id); } - const artifactPath = join(packageDir, "data", ".generated", `${packageName}.script.json`); - const artifact = await readJson(artifactPath); - if (artifact !== undefined) { + const generated = join(packageDir, "data", ".generated"); + const canonicalName = `${packageName}.script.json`; + const entries = await readdir(generated, { withFileTypes: true }).catch((error: unknown) => { + if (object(error) && error.code === "ENOENT") return []; + throw error; + }); + for (const entry of entries.sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0)) { + const artifactPath = join(generated, entry.name); + const canonical = entry.name === canonicalName; + if (!entry.isFile()) { + if (canonical) refusal(artifactPath, "expected a regular Script Artifact file"); + continue; + } + if (!entry.name.endsWith(".json")) continue; + const contents = await readFile(artifactPath, "utf8"); + let artifact: unknown; + try { + artifact = JSON.parse(contents); + } catch { + if (canonical) refusal(artifactPath, "invalid JSON; restore the recorded script identities before building"); + continue; + } + if (!canonical && (!object(artifact) || artifact.kind !== "script")) continue; if (!object(artifact) || artifact.kind !== "script" || artifact.version !== 1 || artifact.package !== packageName || !Array.isArray(artifact.scripts)) { refusal(artifactPath, "cannot recover script identities from this Script Artifact"); @@ -75,7 +95,7 @@ function recordId(path: string, ledger: ScriptIds, stem: string, id: unknown): n } for (const [other, reserved] of Object.entries(ledger.ids)) { if (other !== stem && reserved === id) { - refusal(path, `${stem} collides with ${other} on script ID ${id}; keep published IDs and allocate a different unused ID for the new script in ${SCRIPT_IDS_FILE}`); + refusal(path, `${stem} collides with ${other} on recorded script ID ${id}; restore the published identities before building`); } } ledger.ids[stem] = id; @@ -84,9 +104,18 @@ function recordId(path: string, ledger: ScriptIds, stem: string, id: unknown): n export function allocateScriptId(path: string, ledger: ScriptIds, stem: string, legacyId?: number): number { const recorded = ledger.ids[stem]; + if (legacyId !== undefined || recorded !== undefined) { + return recordId(path, ledger, stem, legacyId ?? recorded); + } const digest = createHash("sha256").update(ledger.package).update("\0").update(stem).digest(); - const id = legacyId ?? recorded ?? FLOOR + digest.readUInt32BE(0) % (CEILING - FLOOR + 1); - return recordId(path, ledger, stem, id); + const first = FLOOR + digest.readUInt32BE(0) % (CEILING - FLOOR + 1); + const reserved = new Set(Object.values(ledger.ids)); + let id = first; + do { + if (!reserved.has(id)) return recordId(path, ledger, stem, id); + id = id === CEILING ? FLOOR : id + 1; + } while (id !== first); + return refusal(path, `the Package Script Range ${FLOOR}..=${CEILING} has no unused script ID`); } export function renderScriptIds(ledger: ScriptIds): string { diff --git a/datascripts/tests/script-identities.test.ts b/datascripts/tests/script-identities.test.ts new file mode 100644 index 000000000..8662670ab --- /dev/null +++ b/datascripts/tests/script-identities.test.ts @@ -0,0 +1,137 @@ +import { expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { buildPackageScripts } from "../runtime-scripts/build-scripts.ts"; + +const PACKAGE = "example"; +const SOURCE = "local function welcome(event) end\nevents.player.onLogin(welcome)\n"; + +interface Artifact { + scripts: Array<{ script_id: number; name: string }>; +} + +async function scratch(run: (dir: string, build: () => Promise) => Promise): Promise { + const root = mkdtempSync(join(tmpdir(), "script-identities-")); + const before = process.env.LYRACORE_PACKAGES_ROOT; + const dir = join(root, PACKAGE); + mkdirSync(join(dir, "scripts"), { recursive: true }); + mkdirSync(join(dir, "data/.generated"), { recursive: true }); + process.env.LYRACORE_PACKAGES_ROOT = root; + try { + await run(dir, async () => JSON.parse(readFileSync(await buildPackageScripts(PACKAGE), "utf8"))); + } finally { + if (before === undefined) delete process.env.LYRACORE_PACKAGES_ROOT; + else process.env.LYRACORE_PACKAGES_ROOT = before; + rmSync(root, { recursive: true, force: true }); + } +} + +function priorArtifact(dir: string, filename: string, ids: Record): void { + writeFileSync(join(dir, "data/.generated", filename), JSON.stringify({ + kind: "script", + version: 1, + package: PACKAGE, + source_hash: "0".repeat(64), + scripts: Object.entries(ids).map(([stem, script_id]) => ({ + script_id, + name: `${PACKAGE}.${stem}`, + event: "on_login", + priority: 0, + enabled: true, + source: "return", + })), + })); +} + +function ledger(dir: string): Record { + return JSON.parse(readFileSync(join(dir, "script-ids.json"), "utf8")).ids; +} + +test("migration preserves noncanonical artifact IDs and retired source reservations", async () => { + await scratch(async (dir, build) => { + priorArtifact(dir, "legacy.json", { welcome: 100300, retired: 941145 }); + writeFileSync(join(dir, "scripts/welcome.lua"), SOURCE); + writeFileSync(join(dir, "scripts/s101.lua"), SOURCE); + writeFileSync(join(dir, "data/.generated/spells.json"), JSON.stringify({ version: 1, claims: [] })); + + await build(); + + expect(ledger(dir)).toEqual({ welcome: 100300, retired: 941145, s101: 941146 }); + rmSync(join(dir, "data/.generated/legacy.json")); + expect((await build()).scripts.find((script) => script.name === "example.welcome")!.script_id).toBe(100300); + expect(ledger(dir).retired).toBe(941145); + }); +}); + +test("migration ignores internal transition backups and unrelated JSON", async () => { + await scratch(async (dir, build) => { + priorArtifact(dir, ".lyracore-script-build-backup-test", { welcome: 100300 }); + priorArtifact(dir, "prior.json.lyracore-script-build-backup", { retired: 100301 }); + writeFileSync(join(dir, "data/.generated/.lyracore-script-build-backup-sidecar"), '{"version":1}'); + writeFileSync(join(dir, "data/.generated/notes.json"), "invalid JSON"); + writeFileSync(join(dir, "scripts/welcome.lua"), SOURCE); + + await build(); + + expect(ledger(dir)).not.toHaveProperty("retired"); + expect(ledger(dir).welcome).not.toBe(100300); + }); +}); + +test("new identities with the same hash receive distinct durable IDs", async () => { + await scratch(async (dir, build) => { + writeFileSync(join(dir, "scripts/s101.lua"), SOURCE); + writeFileSync(join(dir, "scripts/s1014.lua"), SOURCE); + + const first = await build(); + + expect(ledger(dir)).toEqual({ s101: 941145, s1014: 941146 }); + expect(await build()).toEqual(first); + }); +}); + +test("legacy directives reserve IDs before an earlier new source allocates", async () => { + await scratch(async (dir, build) => { + writeFileSync(join(dir, "scripts/s101.lua"), SOURCE); + writeFileSync(join(dir, "scripts/zlegacy.lua"), "-- @event on_login\n-- @id 941145\nreturn\n"); + + await build(); + + expect(ledger(dir)).toEqual({ s101: 941146, zlegacy: 941145 }); + }); +}); + +test("conflicting stored identities refuse instead of reallocating published IDs", async () => { + await scratch(async (dir, build) => { + const recorded = JSON.stringify({ version: 1, package: PACKAGE, ids: { s101: 941145, s1014: 941145 } }); + writeFileSync(join(dir, "script-ids.json"), recorded); + writeFileSync(join(dir, "scripts/s101.lua"), SOURCE); + + await expect(build()).rejects.toThrow(/collides.*941145/); + + expect(readFileSync(join(dir, "script-ids.json"), "utf8")).toBe(recorded); + }); +}); + +test("disagreement between prior artifacts refuses migration", async () => { + await scratch(async (dir, build) => { + priorArtifact(dir, "first.json", { welcome: 100300 }); + priorArtifact(dir, "second.json", { welcome: 100301 }); + writeFileSync(join(dir, "scripts/welcome.lua"), SOURCE); + + await expect(build()).rejects.toThrow(/already has script ID 100300/); + }); +}); + +test.each([ + ["invalid JSON", "{", /invalid JSON/], + ["a different artifact kind", '{"version":1,"claims":[]}', /cannot recover script identities/], +])("the canonical artifact refuses %s", async (_description, contents, refusal) => { + await scratch(async (dir, build) => { + writeFileSync(join(dir, "data/.generated/example.script.json"), contents); + writeFileSync(join(dir, "scripts/welcome.lua"), SOURCE); + + await expect(build()).rejects.toThrow(refusal); + }); +}); diff --git a/docs/package-api.md b/docs/package-api.md index f8265876d..cf3e3acf8 100644 --- a/docs/package-api.md +++ b/docs/package-api.md @@ -470,6 +470,9 @@ Other fields follow each event's declaration. Entity Handles keep their existing keyed by source-file stem. Commit it with the sources and Script Artifact. Existing artifacts and legacy `@event`/`@id` directives supply migration IDs; a conflict is refused. Renaming a function keeps its identity. A source-file rename creates a new identity. Deleted entries remain reserved. +Migration reads every prior Script Artifact in `data/.generated/`, including noncanonical +filenames. For a new identity, the toolchain resolves a hash collision by choosing an unused ID. +It reserves recorded and legacy IDs before allocating new ones. **Runtime Script Toolchain.** Bun, `typescript-to-lua`, the Lua parser, event catalogue, generated declarations and emitter live in `datascripts/runtime-scripts/`. They run only at author time. From 21a39c09cf1d9dc2b3dc70f9cd7d52bf863c4212 Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Sat, 10 Oct 2026 20:38:34 +0200 Subject: [PATCH 4/9] fix(scripts): preserve legacy names and UTF-8 Lua during migration --- .lyracore-cli-rev | 2 +- datascripts/runtime-scripts/bindings.ts | 31 ++++++++++--- datascripts/runtime-scripts/build-scripts.ts | 25 +++++++---- datascripts/runtime-scripts/lua-emit.cjs | 19 +++----- datascripts/tests/typed-scripts.test.ts | 46 ++++++++++++++++++++ 5 files changed, 95 insertions(+), 28 deletions(-) diff --git a/.lyracore-cli-rev b/.lyracore-cli-rev index e3f5f4095..8f8239d2c 100644 --- a/.lyracore-cli-rev +++ b/.lyracore-cli-rev @@ -1 +1 @@ -65158741481236ab435de54693dd530a4075ad90 +ca4c9527b87fe4218f025595569c4236b4a62d47 diff --git a/datascripts/runtime-scripts/bindings.ts b/datascripts/runtime-scripts/bindings.ts index 2c832892d..b7706d4d3 100644 --- a/datascripts/runtime-scripts/bindings.ts +++ b/datascripts/runtime-scripts/bindings.ts @@ -63,21 +63,33 @@ function tsLiteral(file: string, expression: ts.Expression): unknown { function tsBinding(file: string, source: string, packageName: string): SourceBinding | undefined { const tree = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true); + // A legacy source can own a local variable with this name. + if (tree.statements.some((node) => + (ts.isVariableStatement(node) && node.declarationList.declarations.some((declaration) => + ts.isIdentifier(declaration.name) && declaration.name.text === "events")) + || (ts.isFunctionDeclaration(node) && node.name?.text === "events"))) return undefined; const calls = tree.statements.filter(ts.isExpressionStatement) .map((statement) => statement.expression).filter(ts.isCallExpression) .filter((call) => tsPath(call.expression)[0] === "events"); if (calls.length > 1) refuse(file, "each source file has exactly one Event Binding"); const call = calls[0]; + if (!call) return undefined; let root: ts.Node | undefined = call?.expression; while (root && ts.isPropertyAccessExpression(root)) root = root.expression; const walk = (node: ts.Node): void => { - if (ts.isIdentifier(node) && node.text === "events" && node !== root) { + const parent = node.parent; + const field = parent && ( + (ts.isPropertyAccessExpression(parent) && parent.name === node) + || (ts.isPropertyAssignment(parent) && parent.name === node) + || (ts.isPropertySignature(parent) && parent.name === node) + || (ts.isBindingElement(parent) && parent.propertyName === node) + ); + if (ts.isIdentifier(node) && node.text === "events" && node !== root && !field) { refuse(file, "events may only appear in one unconditional top-level Event Binding"); } ts.forEachChild(node, walk); }; walk(tree); - if (!call) return undefined; if (ts.isExternalModule(tree)) refuse(file, "a Runtime Script cannot import or export a module"); const path = tsPath(call.expression); const own = path[1] === "package" && path[2] === "on"; @@ -130,15 +142,23 @@ function luaLiteral(file: string, expression: lua.Expression): unknown { function luaBinding(file: string, source: string, packageName: string): SourceBinding | undefined { let tree: lua.Chunk; try { - tree = lua.parse(source, { luaVersion: "5.3", encodingMode: "x-user-defined" }); + // Lua strings contain bytes. Parse those bytes without changing the emitted UTF-8 source. + tree = lua.parse(Buffer.from(source, "utf8").toString("latin1"), { + luaVersion: "5.3", encodingMode: "pseudo-latin1", + }); } catch (error) { return refuse(file, `invalid Lua: ${error instanceof Error ? error.message : String(error)}`); } + if (tree.body.some((node) => + (node.type === "LocalStatement" && node.variables.some((variable) => variable.name === "events")) + || (node.type === "FunctionDeclaration" && node.identifier?.type === "Identifier" + && node.identifier.name === "events"))) return undefined; const calls = tree.body.filter((node): node is lua.CallStatement => node.type === "CallStatement") .map((node) => node.expression) .filter((node): node is lua.CallExpression => node.type === "CallExpression" && luaPath(node.base)[0] === "events"); if (calls.length > 1) refuse(file, "each source file has exactly one Event Binding"); const call = calls[0]; + if (!call) return undefined; let root = call?.base; while (root?.type === "MemberExpression") root = root.base; const walk = (node: unknown): void => { @@ -147,13 +167,14 @@ function luaBinding(file: string, source: string, packageName: string): SourceBi if ("type" in node && node.type === "Identifier" && "name" in node && node.name === "events") { refuse(file, "events may only appear in one unconditional top-level Event Binding"); } - for (const value of Object.values(node)) { + for (const [key, value] of Object.entries(node)) { + if ("type" in node && ((node.type === "MemberExpression" && key === "identifier") + || (node.type === "TableKeyString" && key === "key"))) continue; if (Array.isArray(value)) value.forEach(walk); else walk(value); } }; walk(tree); - if (!call) return undefined; if (tree.body.some((node) => node.type === "ReturnStatement")) refuse(file, "return a Script Answer from the handler, not the source file"); const path = luaPath(call.base); const own = path[1] === "package" && path[2] === "on"; diff --git a/datascripts/runtime-scripts/build-scripts.ts b/datascripts/runtime-scripts/build-scripts.ts index cc21bf606..0892448f7 100644 --- a/datascripts/runtime-scripts/build-scripts.ts +++ b/datascripts/runtime-scripts/build-scripts.ts @@ -134,7 +134,7 @@ function packagesRoot(): string { /// made absolute. It is NOT an `extends` of it: `typescript-to-lua` resolves a plugin path against /// the config it was invoked with, so a relative one in a base config would resolve from the wrong /// directory. Materialising the whole config keeps that resolution the toolchain's own business. -async function compile(scriptsDir: string, source: string, outDir: string): Promise { +async function compile(scriptsDir: string, source: string, outDir: string, bound: boolean): Promise { const toolchain = toolchainDir(); const config = JSON.parse(await readFile(join(toolchain, "tsconfig.json"), "utf8")) as { compilerOptions: Record; @@ -143,10 +143,11 @@ async function compile(scriptsDir: string, source: string, outDir: string): Prom }; config.compilerOptions.rootDir = scriptsDir; config.compilerOptions.outDir = outDir; - config.files = [join(toolchain, "runtime-script.d.ts"), source]; + config.files = [join(toolchain, bound ? "runtime-script.d.ts" : "runtime-script.base.d.ts"), source]; config.tstl.luaPlugins = config.tstl.luaPlugins.map((plugin) => ({ ...plugin, name: resolve(toolchain, plugin.name), + bound, })); const configPath = join(outDir, "tsconfig.json"); @@ -265,7 +266,9 @@ export async function buildPackageScripts(packageName: string): Promise } const ids = await scriptIds(packageDir, packageName); - const declared: Array & { file: string; binding?: SourceBinding }> = []; + const declared: Array & { + file: string; stem: string; legacyId?: number; binding?: SourceBinding; + }> = []; for (const file of files) { const path = join(scriptsDir, file); const source = await readFile(path, "utf8"); @@ -280,8 +283,9 @@ export async function buildPackageScripts(packageName: string): Promise if (declared.some((entry) => entry.name === name)) refuse(path, `collides with another source on script name ${name}`); declared.push({ file, + stem, binding, - script_id: allocateScriptId(path, ids, stem, binding ? undefined : scriptId(path, directives)), + legacyId: binding ? undefined : scriptId(path, directives), name, event, priority: binding?.priority ?? priority(path, directives), @@ -289,23 +293,28 @@ export async function buildPackageScripts(packageName: string): Promise }); } + // A new identity must never take an ID from a legacy source that sorts after it. + for (const { file, stem, legacyId } of declared) { + if (legacyId !== undefined) allocateScriptId(join(scriptsDir, file), ids, stem, legacyId); + } + const outDir = mkdtempSync(join(tmpdir(), "lyracore-scripts-")); try { const scripts: RuntimeScript[] = []; - for (const { file, binding, ...script } of declared) { + for (const { file, stem, legacyId, binding, ...script } of declared) { const path = join(scriptsDir, file); - const stem = file.slice(0, file.lastIndexOf(".")); + const script_id = allocateScriptId(path, ids, stem, legacyId); let source: string; if (file.endsWith(".lua")) { source = await readFile(path, "utf8"); } else { - await compile(scriptsDir, path, outDir); + await compile(scriptsDir, path, outDir, binding !== undefined); source = await readFile(join(outDir, `${stem}.lua`), "utf8").catch(() => { refuse(path, "typescript-to-lua emitted no Lua for it"); }); } if (source.trim().length === 0) refuse(path, "compiles to nothing; a Runtime Script needs Lua in it"); - scripts.push({ ...script, source: binding ? bindInvocation(source, binding) : source }); + scripts.push({ ...script, script_id, source: binding ? bindInvocation(source, binding) : source }); } const recordedIds = renderScriptIds(ids); diff --git a/datascripts/runtime-scripts/lua-emit.cjs b/datascripts/runtime-scripts/lua-emit.cjs index d1c3d8115..8ea799182 100644 --- a/datascripts/runtime-scripts/lua-emit.cjs +++ b/datascripts/runtime-scripts/lua-emit.cjs @@ -88,15 +88,6 @@ class PiccoloPrinter extends tstl.LuaPrinter { } } -function hasEventBinding(file) { - return file.statements.some((statement) => { - if (!ts.isExpressionStatement(statement) || !ts.isCallExpression(statement.expression)) return false; - let target = statement.expression.expression; - while (ts.isPropertyAccessExpression(target)) target = target.expression; - return ts.isIdentifier(target) && target.text === "events"; - }); -} - function acceptsScriptAnswer(type) { if (type.isUnion()) return type.types.every(acceptsScriptAnswer); return (type.flags & (ts.TypeFlags.NumberLike | ts.TypeFlags.Void | ts.TypeFlags.Undefined | ts.TypeFlags.Never)) !== 0; @@ -108,7 +99,7 @@ function requireEntryPoint(program) { const diagnostics = []; const checker = program.getTypeChecker(); for (const file of program.getSourceFiles()) { - if (file.isDeclarationFile || hasEventBinding(file)) continue; + if (file.isDeclarationFile) continue; const declarations = file.statements.filter( (statement) => ts.isFunctionDeclaration(statement) && statement.name && statement.name.text === ENTRY, @@ -150,11 +141,11 @@ function requireEntryPoint(program) { return diagnostics; } -module.exports = { - beforeTransform: (program) => requireEntryPoint(program), +module.exports = ({ bound = false }) => ({ + beforeTransform: (program) => bound ? [] : requireEntryPoint(program), printer: (program, emitHost, fileName, file) => { const printer = new PiccoloPrinter(emitHost, program, fileName); - printer.bound = program.getSourceFiles().some((source) => !source.isDeclarationFile && hasEventBinding(source)); + printer.bound = bound; return printer.print(file); }, -}; +}); diff --git a/datascripts/tests/typed-scripts.test.ts b/datascripts/tests/typed-scripts.test.ts index 0ecde7c9e..52c306260 100644 --- a/datascripts/tests/typed-scripts.test.ts +++ b/datascripts/tests/typed-scripts.test.ts @@ -90,6 +90,52 @@ events.player.onLogin(welcome) }); }); +test("Lua handlers preserve UTF-8 messages and ordinary events fields", async () => { + await scratch(async (dir, build) => { + const source = `local messages = { events = "你好,世界 🌍" } +local function welcome(event) send_chat(event.player, messages.events) end +events.player.onLogin(welcome) +`; + writeFileSync(join(dir, "scripts/welcome.lua"), source); + expect((await build()).scripts[0]!.source).toContain(source); + }); +}); + +test("typed TS handlers can use fields named events", async () => { + await scratch(async (dir, build) => { + writeFileSync(join(dir, "scripts/welcome.ts"), `function welcome(event: PlayerLoginEvent): void { + const counts: { events: number } = { events: 1 }; + const { events: count } = counts; + send_chat(event.player, String(counts.events + count)); +} +events.player.onLogin(welcome); +`); + expect((await build()).scripts[0]!.event).toBe("on_login"); + }); +}); + +test("legacy scripts retain local events names and UTF-8 source", async () => { + await scratch(async (dir, build) => { + const lua = `-- @event on_login +-- @id 100300 +local events = { greet = function() return "你好" end } +events.greet() +return 1 +`; + writeFileSync(join(dir, "scripts/welcome.lua"), lua); + writeFileSync(join(dir, "scripts/other.ts"), `// @event on_login +// @id 100301 +const events = { count: 1 }; +function script(): number { + return events.count; +} +`); + const scripts = (await build()).scripts; + expect(scripts.find((script) => script.script_id === 100300)!.source).toBe(lua); + expect(scripts.find((script) => script.script_id === 100301)!.source).toContain("return script()"); + }); +}); + test("conditional, duplicate, aliased and unknown bindings are refused", async () => { await scratch(async (dir, build) => { const path = join(dir, "scripts/welcome.lua"); From 78c1b3fdd022e734ef78afca556a1fb28ba3f012 Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Sat, 10 Oct 2026 20:38:50 +0200 Subject: [PATCH 5/9] test(script): verify typed level-up payloads through core hooks --- module/tests/package_runtime_scripts.rs | 81 ++++++++++++++++++++++++- 1 file changed, 78 insertions(+), 3 deletions(-) diff --git a/module/tests/package_runtime_scripts.rs b/module/tests/package_runtime_scripts.rs index 4915608ec..e9dba1618 100644 --- a/module/tests/package_runtime_scripts.rs +++ b/module/tests/package_runtime_scripts.rs @@ -28,9 +28,15 @@ fn script( enabled: bool, lua: &str, ) -> String { - format!( - r#"{{"script_id":{script_id},"name":"{name}","event":"{event}","priority":{priority},"enabled":{enabled},"source":"{lua}"}}"# - ) + serde_json::json!({ + "script_id": script_id, + "name": name, + "event": event, + "priority": priority, + "enabled": enabled, + "source": lua, + }) + .to_string() } fn artifact(package: &str, source_hash: &str, scripts: &[String]) -> String { @@ -458,3 +464,72 @@ fn a_package_script_fires_on_a_real_event_and_a_failing_one_does_not_block_the_n disabled one was never invoked" ); } + +#[test] +#[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] +fn a_typed_levelup_binding_receives_each_attained_level_from_the_core_hook() { + let mut standalone = Standalone::start("package-runtime-scripts-typed-levelup"); + standalone.publish_module(); + standalone.assert_call("claim_operator", &[]); + standalone.assert_call("install_guid_range", &["0"]); + standalone.assert_call("debug_seed_scenario_fixtures", &[]); + standalone.assert_call("debug_spawn_player_entity", &[&PLAYER.to_string()]); + standalone.assert_sql("UPDATE game_character SET online = true WHERE guid = 1"); + standalone.assert_sql("UPDATE game_quest_template SET reward_xp = 1300 WHERE entry = 50900"); + standalone.assert_sql("DELETE FROM game_quest_objective WHERE quest_entry = 50900"); + standalone.assert_call("debug_spawn_at_feet", &[&PLAYER.to_string(), "51003", "3"]); + let giver = standalone + .query_rows("SELECT guid FROM game_world_entity WHERE entry = 51003") + .into_iter() + .next() + .expect("the quest giver was spawned")["guid"] + .clone(); + + apply( + &standalone, + &[artifact( + "test_fixture.typed", + HASH_A, + &[script( + 100_100, + "typed.ding", + "on_levelup", + 0, + true, + include_str!("../../datascripts/tests/fixtures/typed/ding.generated.lua"), + )], + )], + ); + standalone.assert_call( + "debug_grant_quest", + &[&PLAYER.to_string(), &QUEST.to_string()], + ); + // L1 requires 400 XP and L2 requires 900 XP. The row is persisted after both hooks fire. + standalone.assert_call( + "debug_turn_in_quest", + &[&PLAYER.to_string(), &giver, &QUEST.to_string(), "0"], + ); + + let entity = standalone + .query_rows("SELECT level FROM game_world_entity WHERE guid = 1") + .into_iter() + .next() + .expect("the Character remains in the world"); + assert_eq!(entity["level"], "3"); + let mut messages = standalone.query_rows( + "SELECT id, recipient_guid, message FROM game_system_message_event WHERE recipient_guid = 1", + ); + messages.sort_by_key(|row| { + row["id"] + .parse::() + .expect("the message identifier is numeric") + }); + assert_eq!( + messages + .iter() + .map(|row| row["message"].as_str()) + .collect::>(), + ["Ding 2", "Ding 3"], + "the typed handler receives each attained level and its Character handle" + ); +} From 4b95d85f66fe6b95317e315da75ccd24e629a159 Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Sat, 10 Oct 2026 20:42:14 +0200 Subject: [PATCH 6/9] fix(scripts): guide new authors to bindings and speed identity checks --- datascripts/runtime-scripts/build-scripts.ts | 3 ++- datascripts/tests/typed-scripts.test.ts | 11 ++++++----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/datascripts/runtime-scripts/build-scripts.ts b/datascripts/runtime-scripts/build-scripts.ts index 0892448f7..a75673d9e 100644 --- a/datascripts/runtime-scripts/build-scripts.ts +++ b/datascripts/runtime-scripts/build-scripts.ts @@ -60,7 +60,7 @@ function readDirectives(file: string, source: string): Map { function requiredDirective(file: string, directives: Map, key: string): string { const value = directives.get(key); if (value === undefined) { - refuse(file, `no \`@${key}\` directive. Every Runtime Script declares \`@event\` and \`@id\`.`); + refuse(file, `no \`@${key}\` directive. A legacy Runtime Script declares both \`@event\` and \`@id\`.`); } return value; } @@ -274,6 +274,7 @@ export async function buildPackageScripts(packageName: string): Promise const source = await readFile(path, "utf8"); const directives = readDirectives(path, source); const binding = readBinding(path, source, packageName); + if (!binding && directives.size === 0) refuse(path, "declare one top-level Event Binding, such as events.player.onLogin(welcome)"); if (binding && directives.size !== 0) refuse(path, "use an Event Binding or legacy Script Directives, not both"); const event = binding?.event ?? requiredDirective(path, directives, "event"); checkEvent(path, event, packageName, catalogue); diff --git a/datascripts/tests/typed-scripts.test.ts b/datascripts/tests/typed-scripts.test.ts index 52c306260..8341d6a61 100644 --- a/datascripts/tests/typed-scripts.test.ts +++ b/datascripts/tests/typed-scripts.test.ts @@ -171,15 +171,16 @@ test("migration adopts existing artifact IDs and refuses a changed durable ID", test("adding and removing sources or renaming a function keeps recorded IDs", async () => { await scratch(async (dir, build) => { - writeFileSync(join(dir, "scripts/welcome.ts"), WELCOME); + const source = "local function welcome(event) end\nevents.player.onLogin(welcome)\n"; + writeFileSync(join(dir, "scripts/welcome.lua"), source); const id = (await build()).scripts[0]!.script_id; - writeFileSync(join(dir, "scripts/aaa.ts"), WELCOME.replaceAll("welcome", "other")); - writeFileSync(join(dir, "scripts/welcome.ts"), WELCOME.replaceAll("welcome", "renamed")); + writeFileSync(join(dir, "scripts/aaa.lua"), source.replaceAll("welcome", "other")); + writeFileSync(join(dir, "scripts/welcome.lua"), source.replaceAll("welcome", "renamed")); expect((await build()).scripts.find((script) => script.name.endsWith(".welcome"))!.script_id).toBe(id); - rmSync(join(dir, "scripts/welcome.ts")); + rmSync(join(dir, "scripts/welcome.lua")); await build(); expect(JSON.parse(readFileSync(join(dir, "script-ids.json"), "utf8")).ids.welcome).toBe(id); - writeFileSync(join(dir, "scripts/welcome.ts"), WELCOME); + writeFileSync(join(dir, "scripts/welcome.lua"), source); expect((await build()).scripts.find((script) => script.name.endsWith(".welcome"))!.script_id).toBe(id); }); }); From 8b19607c425b01f91a3220b6fe9fa5406d43821b Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Sat, 10 Oct 2026 20:50:21 +0200 Subject: [PATCH 7/9] refactor(script): use Character terms for internal payloads --- module/build.rs | 6 ++-- module/src/runtime_script.rs | 64 +++++++++++++++++++++++++----------- 2 files changed, 47 insertions(+), 23 deletions(-) diff --git a/module/build.rs b/module/build.rs index a63f0c2ce..b5f9ff65a 100644 --- a/module/build.rs +++ b/module/build.rs @@ -480,13 +480,13 @@ fn main() { fn script_payload(fields: &[HookField]) -> String { let mut scalar_fields = Vec::new(); - let mut player = false; + let mut character = false; for field in fields { let source = &field.source; let value = match field.field_type { HookFieldType::PlayerEntity => { assert!(field.name == "player" && source == "actor"); - player = true; + character = true; continue; } HookFieldType::Number => format!("Number(({source}) as f64)"), @@ -499,7 +499,7 @@ fn script_payload(fields: &[HookField]) -> String { )); } format!( - "crate::runtime_script::ScriptPayload {{ player: {player}, fields: vec![{}] }}", + "crate::runtime_script::ScriptPayload {{ character: {character}, fields: vec![{}] }}", scalar_fields.join(", ") ) } diff --git a/module/src/runtime_script.rs b/module/src/runtime_script.rs index 49fea9100..64b9e5ac3 100644 --- a/module/src/runtime_script.rs +++ b/module/src/runtime_script.rs @@ -321,7 +321,7 @@ pub(crate) struct ScriptEvent { /// Payload fields captured at the core hook, before an Invocation reads the entity snapshot. #[derive(Clone, PartialEq, Debug, Default)] pub(crate) struct ScriptPayload { - pub player: bool, + pub character: bool, pub fields: Vec<(&'static str, ScriptScalar)>, } @@ -646,7 +646,7 @@ fn event_table<'gc>(ctx: Context<'gc>, event: &ScriptEvent) -> Table<'gc> { }; set(ctx, table, field, value); if field == "actor" - && event.payload.player + && event.payload.character && view.as_ref().is_some_and(|view| view.is_player) { set(ctx, table, "player", value); @@ -1493,7 +1493,7 @@ if #roster > 0 then grant_xp(event.actor, 25) end let mut host = RuntimeScriptHost::new(); let event = ScriptEvent { payload: ScriptPayload { - player: true, + character: true, fields: vec![], }, ..engagement() @@ -1506,7 +1506,19 @@ if #roster > 0 then grant_xp(event.actor, 25) end message: "Welcome, Thrall".to_string(), }] ); - let missing = unattended("on_login"); + } + + #[test] + fn compiled_typescript_login_handler_requires_a_character() { + let mut host = RuntimeScriptHost::new(); + let source = include_str!("../../datascripts/tests/fixtures/typed/welcome.generated.lua"); + let missing = ScriptEvent { + payload: ScriptPayload { + character: true, + fields: vec![], + }, + ..unattended("on_login") + }; let failure = host .invoke(script("welcome", source), &missing) .expect_err("a typed handler cannot run without its required Character"); @@ -1521,7 +1533,7 @@ if #roster > 0 then grant_xp(event.actor, 25) end name: "on_levelup".to_string(), actor: Some(player()), payload: ScriptPayload { - player: true, + character: true, fields: vec![("newLevel", ScriptScalar::Number(13.0))], }, ..ScriptEvent::default() @@ -1541,10 +1553,10 @@ if #roster > 0 then grant_xp(event.actor, 25) end } #[test] - fn compiled_package_handler_keeps_zero_and_negative_answers_and_discards_failed_effects() { + fn compiled_package_handler_keeps_zero_and_negative_answers() { let mut host = RuntimeScriptHost::new(); let source = include_str!("../../datascripts/tests/fixtures/typed/answer.generated.lua"); - for level in [12, 11, 0] { + for (level, expected_answer) in [(12, 0.0), (11, -1.0)] { let mut character = player(); character.level = level; let event = ScriptEvent { @@ -1554,18 +1566,30 @@ if #roster > 0 then grant_xp(event.actor, 25) end let mut sink = FakeEffects::default(); let (diagnostics, answer) = ask_event(&mut host, &mut sink, &event, &[script("answer", source)]); - if level == 0 { - assert_eq!(diagnostics.len(), 1); - assert_eq!(answer, None); - assert!(sink.committed.is_empty()); - } else { - assert!(diagnostics.is_empty()); - assert_eq!(answer, Some(f64::from(level) - 12.0)); - assert_eq!(sink.committed, [xp(PLAYER_GUID, 3)]); - } + assert!(diagnostics.is_empty()); + assert_eq!(answer, Some(expected_answer)); + assert_eq!(sink.committed, [xp(PLAYER_GUID, 3)]); } } + #[test] + fn compiled_package_handler_discards_staged_effects_when_it_fails() { + let mut host = RuntimeScriptHost::new(); + let source = include_str!("../../datascripts/tests/fixtures/typed/answer.generated.lua"); + let mut character = player(); + character.level = 0; + let event = ScriptEvent { + actor: Some(character), + ..unattended("example.scripts.answer") + }; + let mut sink = FakeEffects::default(); + let (diagnostics, answer) = + ask_event(&mut host, &mut sink, &event, &[script("answer", source)]); + assert_eq!(diagnostics.len(), 1); + assert_eq!(answer, None); + assert!(sink.committed.is_empty()); + } + #[test] fn a_levelup_handler_reads_the_hook_level_while_the_character_snapshot_is_stale() { let mut host = RuntimeScriptHost::new(); @@ -1573,7 +1597,7 @@ if #roster > 0 then grant_xp(event.actor, 25) end name: "on_levelup".to_string(), actor: Some(player()), payload: ScriptPayload { - player: true, + character: true, fields: vec![("newLevel", ScriptScalar::Number(13.0))], }, ..ScriptEvent::default() @@ -1601,7 +1625,7 @@ celebrate(event) let mut host = RuntimeScriptHost::new(); let event = ScriptEvent { payload: ScriptPayload { - player: true, + character: true, fields: vec![], }, ..engagement() @@ -1631,7 +1655,7 @@ welcome(event) let event = ScriptEvent { actor, payload: ScriptPayload { - player: true, + character: true, fields: vec![], }, ..unattended("on_login") @@ -1650,7 +1674,7 @@ welcome(event) let mut host = RuntimeScriptHost::new(); let event = ScriptEvent { payload: ScriptPayload { - player: false, + character: false, fields: vec![ ("assist", ScriptScalar::Boolean(true)), ( From 22beb330f1283c4836e3a723d919714c4fd9a4ca Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Sat, 10 Oct 2026 20:50:32 +0200 Subject: [PATCH 8/9] fix(scripts): reserve script IDs across installed Packages --- datascripts/runtime-scripts/directives.ts | 16 +++ datascripts/runtime-scripts/script-ids.ts | 86 ++++++++++++---- datascripts/tests/script-identities.test.ts | 106 +++++++++++++++++++- 3 files changed, 183 insertions(+), 25 deletions(-) create mode 100644 datascripts/runtime-scripts/directives.ts diff --git a/datascripts/runtime-scripts/directives.ts b/datascripts/runtime-scripts/directives.ts new file mode 100644 index 000000000..a58c282d7 --- /dev/null +++ b/datascripts/runtime-scripts/directives.ts @@ -0,0 +1,16 @@ +/** Read legacy headers before the first line that is neither blank nor a comment. */ +export function readDirectives(file: string, source: string): Map { + const directives = new Map(); + for (const line of source.split("\n")) { + const text = line.trim(); + if (text.length === 0) continue; + const comment = text.startsWith("//") ? text.slice(2) : text.startsWith("--") ? text.slice(2) : undefined; + if (comment === undefined) break; + const match = /^\s*@([a-z]+)\s+(\S+)\s*$/.exec(comment); + if (!match) continue; + const [, key, value] = match as unknown as [string, string, string]; + if (directives.has(key)) throw new Error(`${file}: \`@${key}\` is declared twice`); + directives.set(key, value); + } + return directives; +} diff --git a/datascripts/runtime-scripts/script-ids.ts b/datascripts/runtime-scripts/script-ids.ts index 4474e2659..a8085b67d 100644 --- a/datascripts/runtime-scripts/script-ids.ts +++ b/datascripts/runtime-scripts/script-ids.ts @@ -1,15 +1,22 @@ import { createHash } from "node:crypto"; -import { readFile, readdir } from "node:fs/promises"; -import { join } from "node:path"; +import { readFile, readdir, stat } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { readDirectives } from "./directives.ts"; export const SCRIPT_IDS_FILE = "script-ids.json"; -const FLOOR = 100_000; -const CEILING = 999_999; +export const SCRIPT_ID_FLOOR = 100_000; +export const SCRIPT_ID_CEIL = 999_999; + +interface ScriptOwner { + readonly package: string; + readonly stem: string; +} interface ScriptIds { version: 1; package: string; ids: Record; + reservations: Map; } function object(value: unknown): value is Record { @@ -35,11 +42,10 @@ async function readJson(path: string): Promise { } } -/** Recorded IDs outlive source removal. Prior Script Artifacts supply migration identities. */ -export async function scriptIds(packageDir: string, packageName: string): Promise { +async function readPackageIds(packageDir: string, packageName: string): Promise { const path = join(packageDir, SCRIPT_IDS_FILE); const saved = await readJson(path); - const ledger: ScriptIds = { version: 1, package: packageName, ids: Object.create(null) }; + const ledger: ScriptIds = { version: 1, package: packageName, ids: Object.create(null), reservations: new Map() }; if (saved !== undefined) { if (!object(saved) || saved.version !== 1 || saved.package !== packageName || !object(saved.ids) || Object.keys(saved).some((key) => !["version", "package", "ids"].includes(key))) { @@ -82,22 +88,63 @@ export async function scriptIds(packageDir: string, packageName: string): Promis recordId(artifactPath, ledger, row.name.slice(packageName.length + 1), row.script_id); } } + + const scriptsDir = join(packageDir, "scripts"); + const sources = await readdir(scriptsDir, { withFileTypes: true }).catch((error: unknown) => { + if (object(error) && error.code === "ENOENT") return []; + throw error; + }); + for (const entry of sources.sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0)) { + if (!entry.isFile() || (!entry.name.endsWith(".ts") && !entry.name.endsWith(".lua"))) continue; + const sourcePath = join(scriptsDir, entry.name); + const id = readDirectives(sourcePath, await readFile(sourcePath, "utf8")).get("id"); + if (id !== undefined) recordId(sourcePath, ledger, entry.name.slice(0, entry.name.lastIndexOf(".")), Number(id)); + } + return ledger; +} + +/** New IDs avoid every installed Package's saved identities, artifacts and legacy headers. */ +export async function scriptIds(packageDir: string, packageName: string): Promise { + const ledger = await readPackageIds(packageDir, packageName); + const packagesDir = dirname(packageDir); + const entries = await readdir(packagesDir, { withFileTypes: true }); + for (const entry of entries.sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0)) { + if (entry.name === packageName || entry.name.startsWith(".")) continue; + const siblingDir = join(packagesDir, entry.name); + // Collection checks install Package directories as symlinks. Disabled Packages live elsewhere. + const directory = entry.isDirectory() || (entry.isSymbolicLink() && await stat(siblingDir).then( + (info) => info.isDirectory(), + (error: unknown) => { + if (object(error) && error.code === "ENOENT") return false; + throw error; + }, + )); + if (!directory) continue; + const sibling = await readPackageIds(siblingDir, entry.name); + for (const [stem, id] of Object.entries(sibling.ids)) { + reserveId(siblingDir, ledger, { package: sibling.package, stem }, id); + } + } return ledger; } +function reserveId(path: string, ledger: ScriptIds, owner: ScriptOwner, id: number): void { + const reserved = ledger.reservations.get(id); + if (reserved && (reserved.package !== owner.package || reserved.stem !== owner.stem)) { + refusal(path, `${owner.package}.${owner.stem} collides with ${reserved.package}.${reserved.stem} on recorded script ID ${id}; restore the published identities before building`); + } + ledger.reservations.set(id, owner); +} + function recordId(path: string, ledger: ScriptIds, stem: string, id: unknown): number { - if (!/^[a-z0-9_.-]+$/.test(stem) || typeof id !== "number" || !Number.isInteger(id) || id < FLOOR || id > CEILING) { - refusal(path, `invalid identity for ${stem}: expected a script ID in ${FLOOR}..=${CEILING}`); + if (!/^[a-z0-9_.-]+$/.test(stem) || typeof id !== "number" || !Number.isInteger(id) || id < SCRIPT_ID_FLOOR || id > SCRIPT_ID_CEIL) { + refusal(path, `invalid identity for ${stem}: expected a script ID in ${SCRIPT_ID_FLOOR}..=${SCRIPT_ID_CEIL}`); } const recorded = ledger.ids[stem]; if (recorded !== undefined && recorded !== id) { refusal(path, `${stem} already has script ID ${recorded}; refusing to replace it with ${id}`); } - for (const [other, reserved] of Object.entries(ledger.ids)) { - if (other !== stem && reserved === id) { - refusal(path, `${stem} collides with ${other} on recorded script ID ${id}; restore the published identities before building`); - } - } + reserveId(path, ledger, { package: ledger.package, stem }, id); ledger.ids[stem] = id; return id; } @@ -108,16 +155,15 @@ export function allocateScriptId(path: string, ledger: ScriptIds, stem: string, return recordId(path, ledger, stem, legacyId ?? recorded); } const digest = createHash("sha256").update(ledger.package).update("\0").update(stem).digest(); - const first = FLOOR + digest.readUInt32BE(0) % (CEILING - FLOOR + 1); - const reserved = new Set(Object.values(ledger.ids)); + const first = SCRIPT_ID_FLOOR + digest.readUInt32BE(0) % (SCRIPT_ID_CEIL - SCRIPT_ID_FLOOR + 1); let id = first; do { - if (!reserved.has(id)) return recordId(path, ledger, stem, id); - id = id === CEILING ? FLOOR : id + 1; + if (!ledger.reservations.has(id)) return recordId(path, ledger, stem, id); + id = id === SCRIPT_ID_CEIL ? SCRIPT_ID_FLOOR : id + 1; } while (id !== first); - return refusal(path, `the Package Script Range ${FLOOR}..=${CEILING} has no unused script ID`); + return refusal(path, `the Package Script Range ${SCRIPT_ID_FLOOR}..=${SCRIPT_ID_CEIL} has no unused script ID`); } export function renderScriptIds(ledger: ScriptIds): string { - return `${JSON.stringify({ ...ledger, ids: Object.fromEntries(Object.entries(ledger.ids).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) }, null, 2)}\n`; + return `${JSON.stringify({ version: ledger.version, package: ledger.package, ids: Object.fromEntries(Object.entries(ledger.ids).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) }, null, 2)}\n`; } diff --git a/datascripts/tests/script-identities.test.ts b/datascripts/tests/script-identities.test.ts index 8662670ab..340219775 100644 --- a/datascripts/tests/script-identities.test.ts +++ b/datascripts/tests/script-identities.test.ts @@ -1,7 +1,7 @@ import { expect, test } from "bun:test"; -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { buildPackageScripts } from "../runtime-scripts/build-scripts.ts"; const PACKAGE = "example"; @@ -27,15 +27,15 @@ async function scratch(run: (dir: string, build: () => Promise) => Pro } } -function priorArtifact(dir: string, filename: string, ids: Record): void { +function priorArtifact(dir: string, filename: string, ids: Record, packageName = PACKAGE): void { writeFileSync(join(dir, "data/.generated", filename), JSON.stringify({ kind: "script", version: 1, - package: PACKAGE, + package: packageName, source_hash: "0".repeat(64), scripts: Object.entries(ids).map(([stem, script_id]) => ({ script_id, - name: `${PACKAGE}.${stem}`, + name: `${packageName}.${stem}`, event: "on_login", priority: 0, enabled: true, @@ -102,6 +102,102 @@ test("legacy directives reserve IDs before an earlier new source allocates", asy }); }); +test("two Packages with the same initial ID keep distinct recorded IDs on rebuild", async () => { + await scratch(async (dir, build) => { + const sibling = join(dirname(dir), "another"); + mkdirSync(join(sibling, "scripts"), { recursive: true }); + writeFileSync(join(sibling, "scripts/s1204858.lua"), SOURCE); + writeFileSync(join(dir, "scripts/s101.lua"), SOURCE); + + await buildPackageScripts("another"); + const first = await build(); + + expect(ledger(sibling)).toEqual({ s1204858: 941145 }); + expect(ledger(dir)).toEqual({ s101: 941146 }); + await buildPackageScripts("another"); + expect(await build()).toEqual(first); + expect(ledger(sibling)).toEqual({ s1204858: 941145 }); + }); +}); + +test("a sibling's removed source keeps its ID reserved in the ledger", async () => { + await scratch(async (dir, build) => { + const sibling = join(dirname(dir), "another"); + mkdirSync(sibling); + const saved = JSON.stringify({ version: 1, package: "another", ids: { retired: 941145 } }); + writeFileSync(join(sibling, "script-ids.json"), saved); + writeFileSync(join(dir, "scripts/s101.lua"), SOURCE); + + await build(); + + expect(ledger(dir)).toEqual({ s101: 941146 }); + expect(readFileSync(join(sibling, "script-ids.json"), "utf8")).toBe(saved); + }); +}); + +test.each([ + ["legacy.lua", "-- @event on_login\n-- @id 941145\nreturn\n"], + ["legacy.ts", "// @event on_login\n// @id 941145\nfunction script(): void {}\n"], +])("a sibling's %s reserves its legacy ID before compilation", async (file, source) => { + await scratch(async (dir, build) => { + const sibling = join(dirname(dir), "another"); + mkdirSync(join(sibling, "scripts"), { recursive: true }); + writeFileSync(join(sibling, "scripts", file), source); + writeFileSync(join(dir, "scripts/s101.lua"), SOURCE); + + await build(); + + expect(ledger(dir)).toEqual({ s101: 941146 }); + expect(readFileSync(join(sibling, "scripts", file), "utf8")).toBe(source); + }); +}); + +test("a source-free sibling artifact reserves IDs without a ledger", async () => { + await scratch(async (dir, build) => { + const sibling = join(dirname(dir), "another"); + mkdirSync(join(sibling, "data/.generated"), { recursive: true }); + priorArtifact(sibling, "personality.json", { welcome: 941145 }, "another"); + writeFileSync(join(dir, "scripts/s101.lua"), SOURCE); + + await build(); + + expect(ledger(dir)).toEqual({ s101: 941146 }); + }); +}); + +test("Package directory symlinks reserve their recorded IDs", async () => { + await scratch(async (dir, build) => { + const collection = join(dirname(dir), ".collection", "another"); + mkdirSync(collection, { recursive: true }); + writeFileSync(join(collection, "script-ids.json"), JSON.stringify({ + version: 1, package: "another", ids: { retired: 941145 }, + })); + symlinkSync(collection, join(dirname(dir), "another"), "dir"); + writeFileSync(join(dir, "scripts/s101.lua"), SOURCE); + + await build(); + + expect(ledger(dir)).toEqual({ s101: 941146 }); + }); +}); + +test("contradictory recorded IDs name both Packages and leave their identities intact", async () => { + await scratch(async (dir, build) => { + const sibling = join(dirname(dir), "another"); + mkdirSync(sibling); + const currentIds = JSON.stringify({ version: 1, package: PACKAGE, ids: { s101: 941145 } }); + const siblingIds = JSON.stringify({ version: 1, package: "another", ids: { retired: 941145 } }); + writeFileSync(join(dir, "script-ids.json"), currentIds); + writeFileSync(join(sibling, "script-ids.json"), siblingIds); + writeFileSync(join(dir, "scripts/s101.lua"), SOURCE); + + await expect(build()).rejects.toThrow(/another\.retired collides with example\.s101.*941145/); + + expect(readFileSync(join(dir, "script-ids.json"), "utf8")).toBe(currentIds); + expect(readFileSync(join(sibling, "script-ids.json"), "utf8")).toBe(siblingIds); + }); +}); + test("conflicting stored identities refuse instead of reallocating published IDs", async () => { await scratch(async (dir, build) => { const recorded = JSON.stringify({ version: 1, package: PACKAGE, ids: { s101: 941145, s1014: 941145 } }); From f91066df4aef5c8d753dbe1d2203c6a669f485fa Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Sat, 10 Oct 2026 20:51:47 +0200 Subject: [PATCH 9/9] fix(scripts): preserve legacy source contracts and clarify binding terms --- CONTEXT.md | 8 +++- CORE_TERMS.md | 4 +- datascripts/runtime-scripts/bindings.ts | 4 +- datascripts/runtime-scripts/build-scripts.ts | 42 ++--------------- datascripts/runtime-scripts/events.json | 46 +++++++++---------- .../runtime-scripts/generate-events.test.ts | 2 +- .../runtime-scripts/generate-events.ts | 29 ++++++------ datascripts/runtime-scripts/lua-emit.cjs | 2 +- .../runtime-scripts/runtime-script.base.d.ts | 2 +- .../runtime-scripts/runtime-script.d.ts | 44 +++++++++--------- .../runtime-scripts/runtime-script.lua | 44 +++++++++--------- datascripts/tests/typed-scripts.test.ts | 2 +- docs/package-api.md | 8 +++- docs/runtime-script-host.md | 5 ++ packages/README.md | 7 ++- 15 files changed, 116 insertions(+), 133 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 03198b355..54ac64fa5 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -211,7 +211,10 @@ the final Gate. **Character**: A guid-owned player entity. -_Avoid_: player (as a noun in code) +The public Runtime Script authoring API calls a Character `player`, as in `events.player`, +`event.player`, `PlayerEntity` and `PlayerLoginEvent`. The public event payload uses the same +word, for example `victimIsPlayer`. Core code uses Character. +_Avoid_: player as a noun in Core code outside that authoring contract **Actor**: The Character a Gateway Verb acts as. In the Gateway it is the `Actor` type, a nonzero guid. @@ -1147,6 +1150,9 @@ source file creates a new one. An independent scaffold starts with new identitie A legacy `@key value` comment at the top of a Runtime Script source. Existing directives still build and preserve their IDs. New source declares its Event Binding in code; the toolchain records its Script Identity. +Legacy directives select the legacy source contract for the whole file. They remain supported +while Package API version 1 sources remain supported; their removal requires an explicit API +migration. _Avoid_: annotation, frontmatter, pragma, metadata header **Runtime Script Toolchain**: diff --git a/CORE_TERMS.md b/CORE_TERMS.md index 96ba933a6..11782121e 100644 --- a/CORE_TERMS.md +++ b/CORE_TERMS.md @@ -90,7 +90,9 @@ A login. Owns characters. **Character**: A guid-owned player entity. -_Avoid_: player (as a noun in code) +The public Runtime Script authoring API calls a Character `player`, as in `event.player` and +`PlayerLoginEvent`. Core code uses Character. +_Avoid_: player as a noun in Core code outside that authoring contract **Session**: The Module's record that an Account is logged in on a Character. diff --git a/datascripts/runtime-scripts/bindings.ts b/datascripts/runtime-scripts/bindings.ts index b7706d4d3..1eadc81af 100644 --- a/datascripts/runtime-scripts/bindings.ts +++ b/datascripts/runtime-scripts/bindings.ts @@ -22,7 +22,7 @@ function binding( if (path.length !== 3 || path[0] !== "events") refuse(file, "use events..(handler)"); const group = path[1]!; const method = path[2]!; - const definition = EVENTS.find((event) => event.registration[0] === group && event.registration[1] === method); + const definition = EVENTS.find((event) => event.binding[0] === group && event.binding[1] === method); let event = definition?.event; if (group === "package" && method === "on") { if (!localName || !/^[a-z][a-z0-9_]*$/.test(localName)) { @@ -220,7 +220,7 @@ export function bindInvocation(source: string, binding: SourceBinding): string { `assert(event.name == ${JSON.stringify(binding.event)}, "Event Binding does not match the Invocation")`, ]; for (const field of binding.definition?.fields ?? []) { - if (field.type === "PlayerEntity" && field.required) { + if (field.type === "PlayerEntity") { lines.push(`assert(event.${field.name} and event.${field.name}.is_player, "${binding.event} requires a Character")`); } } diff --git a/datascripts/runtime-scripts/build-scripts.ts b/datascripts/runtime-scripts/build-scripts.ts index a75673d9e..12e1c7239 100644 --- a/datascripts/runtime-scripts/build-scripts.ts +++ b/datascripts/runtime-scripts/build-scripts.ts @@ -8,6 +8,7 @@ import { mkdir, mkdtemp, open, readFile, rename, writeFile } from "node:fs/promi import { tmpdir } from "node:os"; import { basename, dirname, join, resolve } from "node:path"; import { bindInvocation, readBinding, type SourceBinding } from "./bindings.ts"; +import { readDirectives } from "./directives.ts"; import { allocateScriptId, renderScriptIds, scriptIds, SCRIPT_IDS_FILE } from "./script-ids.ts"; /// Where a Package keeps its Runtime Script sources, relative to the Package folder. @@ -16,10 +17,6 @@ const SCRIPTS_DIR = "scripts"; /// Where a Package's generated artifacts live, relative to the Package folder. const GENERATED_DIR = "data/.generated"; -/// The Package script band. Mirrors `is_package_script_id` in `lyracore-package-delta`. -const SCRIPT_ID_FLOOR = 100_000; -const SCRIPT_ID_CEIL = 999_999; - /// One script, as its directives and its Lua describe it. interface RuntimeScript { readonly script_id: number; @@ -39,24 +36,6 @@ function refuse(file: string, what: string): never { // ---- directives ---- -/// The `@key value` lines at the top of a script file, before the first line that is neither blank -/// nor a comment. Both comment markers are accepted so one reader serves `.ts` and `.lua`. -function readDirectives(file: string, source: string): Map { - const directives = new Map(); - for (const line of source.split("\n")) { - const text = line.trim(); - if (text.length === 0) continue; - const comment = text.startsWith("//") ? text.slice(2) : text.startsWith("--") ? text.slice(2) : undefined; - if (comment === undefined) break; - const match = /^\s*@([a-z]+)\s+(\S+)\s*$/.exec(comment); - if (!match) continue; - const [, key, value] = match as unknown as [string, string, string]; - if (directives.has(key)) refuse(file, `\`@${key}\` is declared twice`); - directives.set(key, value); - } - return directives; -} - function requiredDirective(file: string, directives: Map, key: string): string { const value = directives.get(key); if (value === undefined) { @@ -65,15 +44,6 @@ function requiredDirective(file: string, directives: Map, key: s return value; } -function scriptId(file: string, directives: Map): number { - const raw = requiredDirective(file, directives, "id"); - const id = Number(raw); - if (!Number.isInteger(id) || id < SCRIPT_ID_FLOOR || id > SCRIPT_ID_CEIL) { - refuse(file, `\`@id ${raw}\` is not a whole number in ${SCRIPT_ID_FLOOR}..=${SCRIPT_ID_CEIL}, the Package script band`); - } - return id; -} - function priority(file: string, directives: Map): number { const raw = directives.get("priority"); if (raw === undefined) return 0; @@ -273,9 +243,8 @@ export async function buildPackageScripts(packageName: string): Promise const path = join(scriptsDir, file); const source = await readFile(path, "utf8"); const directives = readDirectives(path, source); - const binding = readBinding(path, source, packageName); + const binding = directives.size === 0 ? readBinding(path, source, packageName) : undefined; if (!binding && directives.size === 0) refuse(path, "declare one top-level Event Binding, such as events.player.onLogin(welcome)"); - if (binding && directives.size !== 0) refuse(path, "use an Event Binding or legacy Script Directives, not both"); const event = binding?.event ?? requiredDirective(path, directives, "event"); checkEvent(path, event, packageName, catalogue); const stem = file.slice(0, file.lastIndexOf(".")); @@ -286,7 +255,7 @@ export async function buildPackageScripts(packageName: string): Promise file, stem, binding, - legacyId: binding ? undefined : scriptId(path, directives), + legacyId: binding ? undefined : Number(requiredDirective(path, directives, "id")), name, event, priority: binding?.priority ?? priority(path, directives), @@ -294,11 +263,6 @@ export async function buildPackageScripts(packageName: string): Promise }); } - // A new identity must never take an ID from a legacy source that sorts after it. - for (const { file, stem, legacyId } of declared) { - if (legacyId !== undefined) allocateScriptId(join(scriptsDir, file), ids, stem, legacyId); - } - const outDir = mkdtempSync(join(tmpdir(), "lyracore-scripts-")); try { const scripts: RuntimeScript[] = []; diff --git a/datascripts/runtime-scripts/events.json b/datascripts/runtime-scripts/events.json index 75173e60b..4480773c3 100644 --- a/datascripts/runtime-scripts/events.json +++ b/datascripts/runtime-scripts/events.json @@ -4,7 +4,7 @@ "payloadType": "crate::hooks::DamageTakenPayload", "actor": "payload.attacker_guid", "target": "payload.target_guid", - "registration": [ + "binding": [ "combat", "onDamageTaken" ], @@ -22,7 +22,7 @@ "payloadType": "crate::hooks::DeathPreventedPayload", "actor": "payload.attacker_guid", "target": "payload.creature_guid", - "registration": [ + "binding": [ "creature", "onDeathPrevented" ], @@ -34,7 +34,7 @@ "payloadType": "crate::hooks::CreatureSpawnPayload", "actor": "payload.guid", "target": "0", - "registration": [ + "binding": [ "creature", "onSpawn" ], @@ -67,7 +67,7 @@ "payloadType": "crate::hooks::LevelupPayload", "actor": "payload.character_guid", "target": "0", - "registration": [ + "binding": [ "player", "onLevelUp" ], @@ -76,8 +76,7 @@ { "name": "player", "type": "PlayerEntity", - "source": "actor", - "required": true + "source": "actor" }, { "name": "newLevel", @@ -91,7 +90,7 @@ "payloadType": "crate::hooks::GroupInvitePayload", "actor": "payload.inviter_guid", "target": "payload.target_guid", - "registration": [ + "binding": [ "group", "onInvite" ], @@ -103,7 +102,7 @@ "payloadType": "crate::hooks::DeathPayload", "actor": "payload.killer_guid", "target": "payload.victim_guid", - "registration": [ + "binding": [ "combat", "onDeath" ], @@ -121,7 +120,7 @@ "payloadType": "crate::hooks::KillPayload", "actor": "payload.killer_guid", "target": "payload.victim_guid", - "registration": [ + "binding": [ "combat", "onKill" ], @@ -144,7 +143,7 @@ "payloadType": "crate::hooks::AggroPayload", "actor": "payload.creature_guid", "target": "payload.target_guid", - "registration": [ + "binding": [ "creature", "onAggro" ], @@ -162,7 +161,7 @@ "payloadType": "crate::hooks::CastResolvedPayload", "actor": "payload.caster_guid", "target": "payload.target_guid", - "registration": [ + "binding": [ "spell", "onCastResolved" ], @@ -180,7 +179,7 @@ "payloadType": "crate::hooks::CastFinishedPayload", "actor": "payload.caster_guid", "target": "payload.target_guid", - "registration": [ + "binding": [ "spell", "onCastFinished" ], @@ -203,7 +202,7 @@ "payloadType": "crate::hooks::LootPayload", "actor": "payload.looter_guid", "target": "payload.corpse_guid", - "registration": [ + "binding": [ "player", "onLoot" ], @@ -226,7 +225,7 @@ "payloadType": "crate::hooks::QuestAcceptPayload", "actor": "payload.character_guid", "target": "0", - "registration": [ + "binding": [ "player", "onQuestAccept" ], @@ -244,7 +243,7 @@ "payloadType": "crate::hooks::QuestTurninPayload", "actor": "payload.character_guid", "target": "0", - "registration": [ + "binding": [ "player", "onQuestTurnIn" ], @@ -262,7 +261,7 @@ "payloadType": "crate::hooks::LoginPayload", "actor": "payload.character_guid", "target": "0", - "registration": [ + "binding": [ "player", "onLogin" ], @@ -271,8 +270,7 @@ { "name": "player", "type": "PlayerEntity", - "source": "actor", - "required": true + "source": "actor" } ] }, @@ -281,7 +279,7 @@ "payloadType": "crate::hooks::CharacterRelocatedPayload", "actor": "payload.character_guid", "target": "0", - "registration": [ + "binding": [ "player", "onRelocated" ], @@ -293,7 +291,7 @@ "payloadType": "crate::hooks::LogoutPayload", "actor": "payload.character_guid", "target": "0", - "registration": [ + "binding": [ "player", "onLogout" ], @@ -305,7 +303,7 @@ "payloadType": "crate::hooks::GossipSelectPayload", "actor": "payload.character_guid", "target": "payload.npc_guid", - "registration": [ + "binding": [ "player", "onGossipSelect" ], @@ -328,7 +326,7 @@ "payloadType": "crate::hooks::CreatureDeathPayload", "actor": "payload.killer_guid", "target": "payload.creature_guid", - "registration": [ + "binding": [ "creature", "onDeath" ], @@ -351,7 +349,7 @@ "payloadType": "crate::hooks::HpThresholdPayload", "actor": "0", "target": "payload.creature_guid", - "registration": [ + "binding": [ "creature", "onHpThreshold" ], @@ -379,7 +377,7 @@ "payloadType": "crate::hooks::GoUsedPayload", "actor": "payload.user_guid", "target": "payload.go_guid", - "registration": [ + "binding": [ "gameobject", "onUsed" ], diff --git a/datascripts/runtime-scripts/generate-events.test.ts b/datascripts/runtime-scripts/generate-events.test.ts index 69e309782..4bf940c21 100644 --- a/datascripts/runtime-scripts/generate-events.test.ts +++ b/datascripts/runtime-scripts/generate-events.test.ts @@ -20,6 +20,6 @@ test("level-up payload reads the attained level from the core hook", () => { test("login and level-up declare a required Character handle", () => { for (const name of ["on_login", "on_levelup"]) { const definition = EVENTS.find(definition => definition.event === name); - expect(definition?.fields).toContainEqual({ name: "player", type: "PlayerEntity", source: "actor", required: true }); + expect(definition?.fields).toContainEqual({ name: "player", type: "PlayerEntity", source: "actor" }); } }); diff --git a/datascripts/runtime-scripts/generate-events.ts b/datascripts/runtime-scripts/generate-events.ts index 32163b5f0..676f8e5a5 100644 --- a/datascripts/runtime-scripts/generate-events.ts +++ b/datascripts/runtime-scripts/generate-events.ts @@ -6,7 +6,6 @@ export interface EventField { readonly name: string; readonly type: "number" | "boolean" | "string" | "PlayerEntity"; readonly source: string; - readonly required?: boolean; } export interface EventDefinition { @@ -14,7 +13,7 @@ export interface EventDefinition { readonly payloadType: string; readonly actor: string; readonly target: string; - readonly registration: readonly [string, string]; + readonly binding: readonly [string, string]; readonly typeName: string; readonly fields: readonly EventField[]; } @@ -24,21 +23,21 @@ export const EVENTS: readonly EventDefinition[] = JSON.parse(readFileSync(join(R function checkCatalogue(): void { const names = new Set(); - const registrations = new Set(); + const bindings = new Set(); const types = new Set(); for (const definition of EVENTS) { - const registration = definition.registration.join("."); - if (names.has(definition.event) || registrations.has(registration) || types.has(definition.typeName)) { - throw new Error(`duplicate event, registration or type in events.json: ${definition.event}`); + const binding = definition.binding.join("."); + if (names.has(definition.event) || bindings.has(binding) || types.has(definition.typeName)) { + throw new Error(`duplicate event, binding or type in events.json: ${definition.event}`); } names.add(definition.event); - registrations.add(registration); + bindings.add(binding); types.add(definition.typeName); const fields = new Set(["name", "actor", "target"]); for (const field of definition.fields) { if (fields.has(field.name)) throw new Error(`${definition.event}: duplicate field ${field.name}`); fields.add(field.name); - if (field.type === "PlayerEntity" && (field.name !== "player" || field.source !== "actor" || !field.required)) { + if (field.type === "PlayerEntity" && (field.name !== "player" || field.source !== "actor")) { throw new Error(`${definition.event}: a required Character Entity must come from the actor`); } } @@ -48,7 +47,7 @@ function checkCatalogue(): void { function groups(): Map { const grouped = new Map(); for (const definition of EVENTS) { - const group = definition.registration[0]; + const group = definition.binding[0]; const events = grouped.get(group) ?? []; events.push(definition); grouped.set(group, events); @@ -73,13 +72,13 @@ export function typeScriptDeclarations(): string { for (const [group, definitions] of groups()) { lines.push(` readonly ${group}: {`); for (const definition of definitions) { - lines.push(` ${definition.registration[1]}(this: void, handler: (this: void, event: ${definition.typeName}) => number | void, options?: EventRegistrationOptions): void;`); + lines.push(` ${definition.binding[1]}(this: void, handler: (this: void, event: ${definition.typeName}) => number | void, options?: EventBindingOptions): void;`); } lines.push(" };"); } lines.push( " readonly package: {", - " on(this: void, localName: string, handler: (this: void, event: PackageEvent) => number | void, options?: EventRegistrationOptions): void;", + " on(this: void, localName: string, handler: (this: void, event: PackageEvent) => number | void, options?: EventBindingOptions): void;", " };", "};", "", @@ -113,7 +112,7 @@ export function luaDeclarations(): string { "", "---@class PackageEvent: ScriptEvent", "", - "---@class EventRegistrationOptions", + "---@class EventBindingOptions", "---@field priority? integer", "---@field enabled? boolean", "", @@ -145,8 +144,8 @@ export function luaDeclarations(): string { lines.push( "", `---@param handler fun(event: ${definition.typeName}): number?`, - "---@param options? EventRegistrationOptions", - `function events.${group}.${definition.registration[1]}(handler, options) end`, + "---@param options? EventBindingOptions", + `function events.${group}.${definition.binding[1]}(handler, options) end`, ); } } @@ -155,7 +154,7 @@ export function luaDeclarations(): string { "events.package = {}", "---@param localName string", "---@param handler fun(event: PackageEvent): number?", - "---@param options? EventRegistrationOptions", + "---@param options? EventBindingOptions", "function events.package.on(localName, handler, options) end", "", ); diff --git a/datascripts/runtime-scripts/lua-emit.cjs b/datascripts/runtime-scripts/lua-emit.cjs index 8ea799182..984deb979 100644 --- a/datascripts/runtime-scripts/lua-emit.cjs +++ b/datascripts/runtime-scripts/lua-emit.cjs @@ -19,7 +19,7 @@ // never meets. // // Named Event Bindings get their invocation wrapper from build-scripts.ts. Legacy sources still -// declare script(), whose return value becomes the Script Answer through the appended call. +// declare script(); the appended call preserves its Script Answer. const ts = require("typescript"); const tstl = require("typescript-to-lua"); diff --git a/datascripts/runtime-scripts/runtime-script.base.d.ts b/datascripts/runtime-scripts/runtime-script.base.d.ts index 8fd0c1dc4..c7251cf00 100644 --- a/datascripts/runtime-scripts/runtime-script.base.d.ts +++ b/datascripts/runtime-scripts/runtime-script.base.d.ts @@ -26,7 +26,7 @@ interface ScriptEvent { interface PackageEvent extends ScriptEvent {} -interface EventRegistrationOptions { +interface EventBindingOptions { readonly priority?: number; readonly enabled?: boolean; } diff --git a/datascripts/runtime-scripts/runtime-script.d.ts b/datascripts/runtime-scripts/runtime-script.d.ts index de7a8b870..7eb250473 100644 --- a/datascripts/runtime-scripts/runtime-script.d.ts +++ b/datascripts/runtime-scripts/runtime-script.d.ts @@ -27,7 +27,7 @@ interface ScriptEvent { interface PackageEvent extends ScriptEvent {} -interface EventRegistrationOptions { +interface EventBindingOptions { readonly priority?: number; readonly enabled?: boolean; } @@ -149,38 +149,38 @@ interface GameobjectUsedEvent extends ScriptEvent { declare const events: { readonly combat: { - onDamageTaken(this: void, handler: (this: void, event: DamageTakenEvent) => number | void, options?: EventRegistrationOptions): void; - onDeath(this: void, handler: (this: void, event: DeathEvent) => number | void, options?: EventRegistrationOptions): void; - onKill(this: void, handler: (this: void, event: KillEvent) => number | void, options?: EventRegistrationOptions): void; + onDamageTaken(this: void, handler: (this: void, event: DamageTakenEvent) => number | void, options?: EventBindingOptions): void; + onDeath(this: void, handler: (this: void, event: DeathEvent) => number | void, options?: EventBindingOptions): void; + onKill(this: void, handler: (this: void, event: KillEvent) => number | void, options?: EventBindingOptions): void; }; readonly creature: { - onDeathPrevented(this: void, handler: (this: void, event: CreatureDeathPreventedEvent) => number | void, options?: EventRegistrationOptions): void; - onSpawn(this: void, handler: (this: void, event: CreatureSpawnEvent) => number | void, options?: EventRegistrationOptions): void; - onAggro(this: void, handler: (this: void, event: CreatureAggroEvent) => number | void, options?: EventRegistrationOptions): void; - onDeath(this: void, handler: (this: void, event: CreatureDeathEvent) => number | void, options?: EventRegistrationOptions): void; - onHpThreshold(this: void, handler: (this: void, event: CreatureHpThresholdEvent) => number | void, options?: EventRegistrationOptions): void; + onDeathPrevented(this: void, handler: (this: void, event: CreatureDeathPreventedEvent) => number | void, options?: EventBindingOptions): void; + onSpawn(this: void, handler: (this: void, event: CreatureSpawnEvent) => number | void, options?: EventBindingOptions): void; + onAggro(this: void, handler: (this: void, event: CreatureAggroEvent) => number | void, options?: EventBindingOptions): void; + onDeath(this: void, handler: (this: void, event: CreatureDeathEvent) => number | void, options?: EventBindingOptions): void; + onHpThreshold(this: void, handler: (this: void, event: CreatureHpThresholdEvent) => number | void, options?: EventBindingOptions): void; }; readonly player: { - onLevelUp(this: void, handler: (this: void, event: PlayerLevelUpEvent) => number | void, options?: EventRegistrationOptions): void; - onLoot(this: void, handler: (this: void, event: PlayerLootEvent) => number | void, options?: EventRegistrationOptions): void; - onQuestAccept(this: void, handler: (this: void, event: PlayerQuestAcceptEvent) => number | void, options?: EventRegistrationOptions): void; - onQuestTurnIn(this: void, handler: (this: void, event: PlayerQuestTurnInEvent) => number | void, options?: EventRegistrationOptions): void; - onLogin(this: void, handler: (this: void, event: PlayerLoginEvent) => number | void, options?: EventRegistrationOptions): void; - onRelocated(this: void, handler: (this: void, event: PlayerRelocatedEvent) => number | void, options?: EventRegistrationOptions): void; - onLogout(this: void, handler: (this: void, event: PlayerLogoutEvent) => number | void, options?: EventRegistrationOptions): void; - onGossipSelect(this: void, handler: (this: void, event: PlayerGossipSelectEvent) => number | void, options?: EventRegistrationOptions): void; + onLevelUp(this: void, handler: (this: void, event: PlayerLevelUpEvent) => number | void, options?: EventBindingOptions): void; + onLoot(this: void, handler: (this: void, event: PlayerLootEvent) => number | void, options?: EventBindingOptions): void; + onQuestAccept(this: void, handler: (this: void, event: PlayerQuestAcceptEvent) => number | void, options?: EventBindingOptions): void; + onQuestTurnIn(this: void, handler: (this: void, event: PlayerQuestTurnInEvent) => number | void, options?: EventBindingOptions): void; + onLogin(this: void, handler: (this: void, event: PlayerLoginEvent) => number | void, options?: EventBindingOptions): void; + onRelocated(this: void, handler: (this: void, event: PlayerRelocatedEvent) => number | void, options?: EventBindingOptions): void; + onLogout(this: void, handler: (this: void, event: PlayerLogoutEvent) => number | void, options?: EventBindingOptions): void; + onGossipSelect(this: void, handler: (this: void, event: PlayerGossipSelectEvent) => number | void, options?: EventBindingOptions): void; }; readonly group: { - onInvite(this: void, handler: (this: void, event: GroupInviteEvent) => number | void, options?: EventRegistrationOptions): void; + onInvite(this: void, handler: (this: void, event: GroupInviteEvent) => number | void, options?: EventBindingOptions): void; }; readonly spell: { - onCastResolved(this: void, handler: (this: void, event: SpellCastResolvedEvent) => number | void, options?: EventRegistrationOptions): void; - onCastFinished(this: void, handler: (this: void, event: SpellCastFinishedEvent) => number | void, options?: EventRegistrationOptions): void; + onCastResolved(this: void, handler: (this: void, event: SpellCastResolvedEvent) => number | void, options?: EventBindingOptions): void; + onCastFinished(this: void, handler: (this: void, event: SpellCastFinishedEvent) => number | void, options?: EventBindingOptions): void; }; readonly gameobject: { - onUsed(this: void, handler: (this: void, event: GameobjectUsedEvent) => number | void, options?: EventRegistrationOptions): void; + onUsed(this: void, handler: (this: void, event: GameobjectUsedEvent) => number | void, options?: EventBindingOptions): void; }; readonly package: { - on(this: void, localName: string, handler: (this: void, event: PackageEvent) => number | void, options?: EventRegistrationOptions): void; + on(this: void, localName: string, handler: (this: void, event: PackageEvent) => number | void, options?: EventBindingOptions): void; }; }; diff --git a/datascripts/runtime-scripts/runtime-script.lua b/datascripts/runtime-scripts/runtime-script.lua index 54c4b5a2f..d3368cc95 100644 --- a/datascripts/runtime-scripts/runtime-script.lua +++ b/datascripts/runtime-scripts/runtime-script.lua @@ -22,7 +22,7 @@ ---@class PackageEvent: ScriptEvent ----@class EventRegistrationOptions +---@class EventBindingOptions ---@field priority? integer ---@field enabled? boolean @@ -133,92 +133,92 @@ events = {} events.combat = {} ---@param handler fun(event: DamageTakenEvent): number? ----@param options? EventRegistrationOptions +---@param options? EventBindingOptions function events.combat.onDamageTaken(handler, options) end ---@param handler fun(event: DeathEvent): number? ----@param options? EventRegistrationOptions +---@param options? EventBindingOptions function events.combat.onDeath(handler, options) end ---@param handler fun(event: KillEvent): number? ----@param options? EventRegistrationOptions +---@param options? EventBindingOptions function events.combat.onKill(handler, options) end events.creature = {} ---@param handler fun(event: CreatureDeathPreventedEvent): number? ----@param options? EventRegistrationOptions +---@param options? EventBindingOptions function events.creature.onDeathPrevented(handler, options) end ---@param handler fun(event: CreatureSpawnEvent): number? ----@param options? EventRegistrationOptions +---@param options? EventBindingOptions function events.creature.onSpawn(handler, options) end ---@param handler fun(event: CreatureAggroEvent): number? ----@param options? EventRegistrationOptions +---@param options? EventBindingOptions function events.creature.onAggro(handler, options) end ---@param handler fun(event: CreatureDeathEvent): number? ----@param options? EventRegistrationOptions +---@param options? EventBindingOptions function events.creature.onDeath(handler, options) end ---@param handler fun(event: CreatureHpThresholdEvent): number? ----@param options? EventRegistrationOptions +---@param options? EventBindingOptions function events.creature.onHpThreshold(handler, options) end events.player = {} ---@param handler fun(event: PlayerLevelUpEvent): number? ----@param options? EventRegistrationOptions +---@param options? EventBindingOptions function events.player.onLevelUp(handler, options) end ---@param handler fun(event: PlayerLootEvent): number? ----@param options? EventRegistrationOptions +---@param options? EventBindingOptions function events.player.onLoot(handler, options) end ---@param handler fun(event: PlayerQuestAcceptEvent): number? ----@param options? EventRegistrationOptions +---@param options? EventBindingOptions function events.player.onQuestAccept(handler, options) end ---@param handler fun(event: PlayerQuestTurnInEvent): number? ----@param options? EventRegistrationOptions +---@param options? EventBindingOptions function events.player.onQuestTurnIn(handler, options) end ---@param handler fun(event: PlayerLoginEvent): number? ----@param options? EventRegistrationOptions +---@param options? EventBindingOptions function events.player.onLogin(handler, options) end ---@param handler fun(event: PlayerRelocatedEvent): number? ----@param options? EventRegistrationOptions +---@param options? EventBindingOptions function events.player.onRelocated(handler, options) end ---@param handler fun(event: PlayerLogoutEvent): number? ----@param options? EventRegistrationOptions +---@param options? EventBindingOptions function events.player.onLogout(handler, options) end ---@param handler fun(event: PlayerGossipSelectEvent): number? ----@param options? EventRegistrationOptions +---@param options? EventBindingOptions function events.player.onGossipSelect(handler, options) end events.group = {} ---@param handler fun(event: GroupInviteEvent): number? ----@param options? EventRegistrationOptions +---@param options? EventBindingOptions function events.group.onInvite(handler, options) end events.spell = {} ---@param handler fun(event: SpellCastResolvedEvent): number? ----@param options? EventRegistrationOptions +---@param options? EventBindingOptions function events.spell.onCastResolved(handler, options) end ---@param handler fun(event: SpellCastFinishedEvent): number? ----@param options? EventRegistrationOptions +---@param options? EventBindingOptions function events.spell.onCastFinished(handler, options) end events.gameobject = {} ---@param handler fun(event: GameobjectUsedEvent): number? ----@param options? EventRegistrationOptions +---@param options? EventBindingOptions function events.gameobject.onUsed(handler, options) end events.package = {} ---@param localName string ---@param handler fun(event: PackageEvent): number? ----@param options? EventRegistrationOptions +---@param options? EventBindingOptions function events.package.on(localName, handler, options) end diff --git a/datascripts/tests/typed-scripts.test.ts b/datascripts/tests/typed-scripts.test.ts index 8341d6a61..b2fb7394d 100644 --- a/datascripts/tests/typed-scripts.test.ts +++ b/datascripts/tests/typed-scripts.test.ts @@ -118,7 +118,7 @@ test("legacy scripts retain local events names and UTF-8 source", async () => { await scratch(async (dir, build) => { const lua = `-- @event on_login -- @id 100300 -local events = { greet = function() return "你好" end } +events = { greet = function() return "你好" end } events.greet() return 1 `; diff --git a/docs/package-api.md b/docs/package-api.md index cf3e3acf8..38299a06a 100644 --- a/docs/package-api.md +++ b/docs/package-api.md @@ -468,15 +468,19 @@ Other fields follow each event's declaration. Entity Handles keep their existing **Script Identities.** The toolchain records numeric IDs in the Package-root `script-ids.json`, keyed by source-file stem. Commit it with the sources and Script Artifact. Existing artifacts and -legacy `@event`/`@id` directives supply migration IDs; a conflict is refused. Renaming a function +legacy `@event`/`@id` directives supply migration IDs; the compiler refuses a conflict. Renaming a function keeps its identity. A source-file rename creates a new identity. Deleted entries remain reserved. Migration reads every prior Script Artifact in `data/.generated/`, including noncanonical filenames. For a new identity, the toolchain resolves a hash collision by choosing an unused ID. -It reserves recorded and legacy IDs before allocating new ones. +It reserves recorded and legacy IDs across the enabled Package Inventory before allocating new +ones. A conflict between previously recorded IDs names both owners and requires reconciling their +published identities; the compiler never silently renumbers them. **Runtime Script Toolchain.** Bun, `typescript-to-lua`, the Lua parser, event catalogue, generated declarations and emitter live in `datascripts/runtime-scripts/`. They run only at author time. The emitted Lua captures the declared function and calls it with the event in one Invocation. +Top-level source runs on each Invocation before the handler. Any Host Operations there share the +handler's Staged Effects and failure boundary. An Operator can install the prebuilt Script Artifact. See [Runtime Script authoring](../packages/README.md#building-source) for examples and migration. diff --git a/docs/runtime-script-host.md b/docs/runtime-script-host.md index 8b4f6218d..671979f38 100644 --- a/docs/runtime-script-host.md +++ b/docs/runtime-script-host.md @@ -27,6 +27,8 @@ never stops the next one, and never stops the core work that follows. Authors bind a named function to an event. The toolchain emits a local wrapper that calls that function with the event table and returns its Script Answer. Event Binding does not execute source during the build or install persistent handlers in the interpreter. +Top-level source runs before the handler on each Invocation. Host Operations there share the same +Staged Effects and failure boundary as the handler. The Host still supplies one global per Host Operation and one `event` table, so existing compiled Script Artifacts remain valid: @@ -59,6 +61,9 @@ Hook payload fields come from `events.json`. The level-up hook carries `newLevel Character row stores that level. The required `player` field aliases the actor's opaque handle; the generated wrapper refuses to invoke a typed handler if that Character is absent. Scalar 64-bit identifiers use decimal strings to preserve their value in TypeScript. +New event payload fields use camelCase. Existing Entity Handle fields keep their snake_case names +for compatibility. The loot hook's target is a corpse GUID, so it normally has no live Entity +Handle. The HP threshold hook has no actor; its target is the creature that crossed the threshold. A host operation called with a missing entity, the wrong type, an out-of-range amount, or past the staging cap raises a Lua error naming the call and the fault. The Invocation returns a diff --git a/packages/README.md b/packages/README.md index 9af0ea64e..0f180af27 100644 --- a/packages/README.md +++ b/packages/README.md @@ -103,12 +103,17 @@ shipping Package's prefix. a Script Artifact with its Build Identity. Commit all three files with the sources. The Runtime Script name remains `.`, so changing the function name keeps its identity. Deleted entries stay reserved. `packages new` omits the copied identities and artifacts so the -new Package gets its own IDs. A filename change creates a new script identity. +new Package gets its own IDs. A filename change creates a new Script Identity. The full script +name must fit 64 lowercase letters, digits, dots, underscores or hyphens, as the Script Artifact +format requires. Existing `@event` and `@id` Script Directives still build. To migrate, build once to record the identities, then replace the directives with a named function and Event Binding. An existing Script Artifact also supplies its original IDs. The builder refuses a disagreement between recorded identities, legacy directives and an existing artifact. +An allocation also reserves identities across the enabled Package Inventory. If two previously +published Packages already claim the same ID, the compiler names both owners and refuses the +build. Resolve that published identity conflict before installing them together. After creating or editing a Package, run `packages apply` to build and activate it.