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/.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/CONTEXT.md b/CONTEXT.md index 268123824..54ac64fa5 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) @@ -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. @@ -1095,7 +1098,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 +1136,23 @@ _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. +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/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..1eadc81af --- /dev/null +++ b/datascripts/runtime-scripts/bindings.ts @@ -0,0 +1,229 @@ +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.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)) { + 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); + // 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 => { + 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 (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 { + // 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 => { + 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 [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 (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") { + 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..12e1c7239 100644 --- a/datascripts/runtime-scripts/build-scripts.ts +++ b/datascripts/runtime-scripts/build-scripts.ts @@ -1,51 +1,15 @@ -// 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 { 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. const SCRIPTS_DIR = "scripts"; @@ -53,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; @@ -76,41 +36,14 @@ 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) { - 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; } -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; @@ -171,7 +104,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; @@ -180,10 +113,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"); @@ -205,15 +139,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,52 +235,56 @@ 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; stem: string; legacyId?: number; 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 = 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)"); + 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("."))}`, + stem, + binding, + legacyId: binding ? undefined : Number(requiredDirective(path, directives, "id")), + 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, 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 }); + scripts.push({ ...script, script_id, 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/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/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..4480773c3 --- /dev/null +++ b/datascripts/runtime-scripts/events.json @@ -0,0 +1,398 @@ +[ + { + "event": "on_damage_taken", + "payloadType": "crate::hooks::DamageTakenPayload", + "actor": "payload.attacker_guid", + "target": "payload.target_guid", + "binding": [ + "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", + "binding": [ + "creature", + "onDeathPrevented" + ], + "typeName": "CreatureDeathPreventedEvent", + "fields": [] + }, + { + "event": "on_creature_spawn", + "payloadType": "crate::hooks::CreatureSpawnPayload", + "actor": "payload.guid", + "target": "0", + "binding": [ + "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", + "binding": [ + "player", + "onLevelUp" + ], + "typeName": "PlayerLevelUpEvent", + "fields": [ + { + "name": "player", + "type": "PlayerEntity", + "source": "actor" + }, + { + "name": "newLevel", + "type": "number", + "source": "payload.new_level" + } + ] + }, + { + "event": "on_group_invite", + "payloadType": "crate::hooks::GroupInvitePayload", + "actor": "payload.inviter_guid", + "target": "payload.target_guid", + "binding": [ + "group", + "onInvite" + ], + "typeName": "GroupInviteEvent", + "fields": [] + }, + { + "event": "on_death", + "payloadType": "crate::hooks::DeathPayload", + "actor": "payload.killer_guid", + "target": "payload.victim_guid", + "binding": [ + "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", + "binding": [ + "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", + "binding": [ + "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", + "binding": [ + "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", + "binding": [ + "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", + "binding": [ + "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", + "binding": [ + "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", + "binding": [ + "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", + "binding": [ + "player", + "onLogin" + ], + "typeName": "PlayerLoginEvent", + "fields": [ + { + "name": "player", + "type": "PlayerEntity", + "source": "actor" + } + ] + }, + { + "event": "on_character_relocated", + "payloadType": "crate::hooks::CharacterRelocatedPayload", + "actor": "payload.character_guid", + "target": "0", + "binding": [ + "player", + "onRelocated" + ], + "typeName": "PlayerRelocatedEvent", + "fields": [] + }, + { + "event": "on_logout", + "payloadType": "crate::hooks::LogoutPayload", + "actor": "payload.character_guid", + "target": "0", + "binding": [ + "player", + "onLogout" + ], + "typeName": "PlayerLogoutEvent", + "fields": [] + }, + { + "event": "on_gossip_select", + "payloadType": "crate::hooks::GossipSelectPayload", + "actor": "payload.character_guid", + "target": "payload.npc_guid", + "binding": [ + "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", + "binding": [ + "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", + "binding": [ + "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", + "binding": [ + "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..4bf940c21 --- /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" }); + } +}); diff --git a/datascripts/runtime-scripts/generate-events.ts b/datascripts/runtime-scripts/generate-events.ts new file mode 100644 index 000000000..676f8e5a5 --- /dev/null +++ b/datascripts/runtime-scripts/generate-events.ts @@ -0,0 +1,191 @@ +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; +} + +export interface EventDefinition { + readonly event: string; + readonly payloadType: string; + readonly actor: string; + readonly target: string; + readonly binding: 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 bindings = new Set(); + const types = new Set(); + for (const definition of EVENTS) { + 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); + 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")) { + 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.binding[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.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?: EventBindingOptions): 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 EventBindingOptions", + "---@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? EventBindingOptions", + `function events.${group}.${definition.binding[1]}(handler, options) end`, + ); + } + } + lines.push( + "", + "events.package = {}", + "---@param localName string", + "---@param handler fun(event: PackageEvent): number?", + "---@param options? EventBindingOptions", + "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..984deb979 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(); the appended call preserves its Script Answer. const ts = require("typescript"); const tstl = require("typescript-to-lua"); @@ -87,7 +83,7 @@ class PiccoloPrinter extends tstl.LuaPrinter { printFile(file) { return super.printFile({ ...file, - statements: [guardDeclaration(), ...file.statements, entryCall()], + statements: [guardDeclaration(), ...file.statements, ...(this.bound ? [] : [entryCall()])], }); } } @@ -145,8 +141,11 @@ function requireEntryPoint(program) { return diagnostics; } -module.exports = { - beforeTransform: (program) => requireEntryPoint(program), - printer: (program, emitHost, fileName, file) => - new PiccoloPrinter(emitHost, program, fileName).print(file), -}; +module.exports = ({ bound = false }) => ({ + beforeTransform: (program) => bound ? [] : requireEntryPoint(program), + printer: (program, emitHost, fileName, file) => { + const printer = new PiccoloPrinter(emitHost, program, fileName); + printer.bound = bound; + 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..c7251cf00 --- /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 EventBindingOptions { + 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..7eb250473 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 EventBindingOptions { + 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?: 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?: 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?: 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?: EventBindingOptions): void; + }; + readonly spell: { + 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?: EventBindingOptions): void; + }; + readonly package: { + 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 new file mode 100644 index 000000000..d3368cc95 --- /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 EventBindingOptions +---@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? EventBindingOptions +function events.combat.onDamageTaken(handler, options) end + +---@param handler fun(event: DeathEvent): number? +---@param options? EventBindingOptions +function events.combat.onDeath(handler, options) end + +---@param handler fun(event: KillEvent): number? +---@param options? EventBindingOptions +function events.combat.onKill(handler, options) end +events.creature = {} + +---@param handler fun(event: CreatureDeathPreventedEvent): number? +---@param options? EventBindingOptions +function events.creature.onDeathPrevented(handler, options) end + +---@param handler fun(event: CreatureSpawnEvent): number? +---@param options? EventBindingOptions +function events.creature.onSpawn(handler, options) end + +---@param handler fun(event: CreatureAggroEvent): number? +---@param options? EventBindingOptions +function events.creature.onAggro(handler, options) end + +---@param handler fun(event: CreatureDeathEvent): number? +---@param options? EventBindingOptions +function events.creature.onDeath(handler, options) end + +---@param handler fun(event: CreatureHpThresholdEvent): number? +---@param options? EventBindingOptions +function events.creature.onHpThreshold(handler, options) end +events.player = {} + +---@param handler fun(event: PlayerLevelUpEvent): number? +---@param options? EventBindingOptions +function events.player.onLevelUp(handler, options) end + +---@param handler fun(event: PlayerLootEvent): number? +---@param options? EventBindingOptions +function events.player.onLoot(handler, options) end + +---@param handler fun(event: PlayerQuestAcceptEvent): number? +---@param options? EventBindingOptions +function events.player.onQuestAccept(handler, options) end + +---@param handler fun(event: PlayerQuestTurnInEvent): number? +---@param options? EventBindingOptions +function events.player.onQuestTurnIn(handler, options) end + +---@param handler fun(event: PlayerLoginEvent): number? +---@param options? EventBindingOptions +function events.player.onLogin(handler, options) end + +---@param handler fun(event: PlayerRelocatedEvent): number? +---@param options? EventBindingOptions +function events.player.onRelocated(handler, options) end + +---@param handler fun(event: PlayerLogoutEvent): number? +---@param options? EventBindingOptions +function events.player.onLogout(handler, options) end + +---@param handler fun(event: PlayerGossipSelectEvent): number? +---@param options? EventBindingOptions +function events.player.onGossipSelect(handler, options) end +events.group = {} + +---@param handler fun(event: GroupInviteEvent): number? +---@param options? EventBindingOptions +function events.group.onInvite(handler, options) end +events.spell = {} + +---@param handler fun(event: SpellCastResolvedEvent): number? +---@param options? EventBindingOptions +function events.spell.onCastResolved(handler, options) end + +---@param handler fun(event: SpellCastFinishedEvent): number? +---@param options? EventBindingOptions +function events.spell.onCastFinished(handler, options) end +events.gameobject = {} + +---@param handler fun(event: GameobjectUsedEvent): number? +---@param options? EventBindingOptions +function events.gameobject.onUsed(handler, options) end + +events.package = {} +---@param localName string +---@param handler fun(event: PackageEvent): number? +---@param options? EventBindingOptions +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..a8085b67d --- /dev/null +++ b/datascripts/runtime-scripts/script-ids.ts @@ -0,0 +1,169 @@ +import { createHash } from "node:crypto"; +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"; +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 { + 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"); + } +} + +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), 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))) { + 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 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"); + } + 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); + } + } + + 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 < 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}`); + } + reserveId(path, ledger, { package: ledger.package, stem }, id); + ledger.ids[stem] = id; + return id; +} + +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 first = SCRIPT_ID_FLOOR + digest.readUInt32BE(0) % (SCRIPT_ID_CEIL - SCRIPT_ID_FLOOR + 1); + let id = first; + do { + 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 ${SCRIPT_ID_FLOOR}..=${SCRIPT_ID_CEIL} has no unused script ID`); +} + +export function renderScriptIds(ledger: ScriptIds): string { + 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/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/script-identities.test.ts b/datascripts/tests/script-identities.test.ts new file mode 100644 index 000000000..340219775 --- /dev/null +++ b/datascripts/tests/script-identities.test.ts @@ -0,0 +1,233 @@ +import { expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, 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, packageName = PACKAGE): void { + writeFileSync(join(dir, "data/.generated", filename), JSON.stringify({ + kind: "script", + version: 1, + package: packageName, + source_hash: "0".repeat(64), + scripts: Object.entries(ids).map(([stem, script_id]) => ({ + script_id, + name: `${packageName}.${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("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 } }); + 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/datascripts/tests/typed-scripts.test.ts b/datascripts/tests/typed-scripts.test.ts new file mode 100644 index 000000000..b2fb7394d --- /dev/null +++ b/datascripts/tests/typed-scripts.test.ts @@ -0,0 +1,222 @@ +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("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 +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"); + 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) => { + 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.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.lua")); + await build(); + expect(JSON.parse(readFileSync(join(dir, "script-ids.json"), "utf8")).ids.welcome).toBe(id); + writeFileSync(join(dir, "scripts/welcome.lua"), source); + 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..38299a06a 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,33 @@ 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; 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 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. + +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..671979f38 100644 --- a/docs/runtime-script-host.md +++ b/docs/runtime-script-host.md @@ -24,12 +24,21 @@ 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. +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: ```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 +56,14 @@ 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. +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/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..b5f9ff65a 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 character = false; + for field in fields { + let source = &field.source; + let value = match field.field_type { + HookFieldType::PlayerEntity => { + assert!(field.name == "player" && source == "actor"); + character = 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 {{ character: {character}, 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..64b9e5ac3 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 character: 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.character + && 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,217 @@ 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 { + character: 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(), + }] + ); + } + + #[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"); + 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 { + character: 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() { + let mut host = RuntimeScriptHost::new(); + let source = include_str!("../../datascripts/tests/fixtures/typed/answer.generated.lua"); + for (level, expected_answer) in [(12, 0.0), (11, -1.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)]); + 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(); + let event = ScriptEvent { + name: "on_levelup".to_string(), + actor: Some(player()), + payload: ScriptPayload { + character: 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 { + character: 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 { + character: 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 { + character: 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 +1751,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 +1792,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 +1860,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/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" + ); +} diff --git a/packages/README.md b/packages/README.md index e918e278c..0f180af27 100644 --- a/packages/README.md +++ b/packages/README.md @@ -65,19 +65,57 @@ 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. 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. ## 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" + ] +}