diff --git a/.github/check-datascripts.sh b/.github/check-datascripts.sh index 571c47f..b120c77 100755 --- a/.github/check-datascripts.sh +++ b/.github/check-datascripts.sh @@ -19,14 +19,12 @@ # 3. `lyracore packages check` verifies every committed artifact's Build Identity against this # checkout: source, typings, authoring library and toolchain pins. # -# What this job does NOT do: re-emit a Datascript. That needs a Base Snapshot, which is the -# Operator's own client-derived data and does not exist on a CI runner — see -# `docs/agents/cross-repo-cli.md` in LyraCore and `packages check`'s own handling of a missing -# snapshot. It never writes a regenerated artifact back to this checkout either; a diff here is a -# failure to fix by hand and re-commit, never something CI commits on the collection's behalf. +# example-data is also built in a temporary directory against Core's synthetic Base Snapshot. +# No client-derived Base Snapshot is needed, and no Delta is written into the collection. set -euo pipefail +collection_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) core_root=${1:-} if [[ -z "$core_root" || ! -f "$core_root/module/Cargo.toml" ]]; then echo "usage: $0 /path/to/LyraCore" >&2 @@ -59,5 +57,8 @@ echo "reproducible." echo "== installing the pinned Datascript dependencies and typechecking" (cd datascripts && bun install --frozen-lockfile && bun ./node_modules/typescript/bin/tsc --noEmit) +echo "== checking example-data with a synthetic Base Snapshot" +python3 "$collection_root/.github/check-example-data.py" "$core_root" + echo "== lyracore packages check" ./lyracore packages check diff --git a/.github/check-example-data.py b/.github/check-example-data.py new file mode 100644 index 0000000..0c3a2b4 --- /dev/null +++ b/.github/check-example-data.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +"""Build example-data against Core's synthetic Base Snapshot, outside the collection.""" + +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile + + +def check(core: Path) -> None: + collection = Path(__file__).resolve().parent.parent + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + (root / "datascripts").symlink_to(core / "datascripts", target_is_directory=True) + package = root / "packages/example-data" + shutil.copytree(collection / "example-data/datascripts", package / "datascripts") + source = package / "datascripts/welcome.ts" + with tempfile.NamedTemporaryFile( + mode="w", suffix=".json", prefix=".example-data-", dir=core / "datascripts" + ) as config: + json.dump({"extends": "./tsconfig.json", "files": [str(source)]}, config) + config.flush() + subprocess.run( + ["bun", "./node_modules/typescript/bin/tsc", "--noEmit", "--project", config.name], + cwd=core / "datascripts", check=True, + ) + subprocess.run( + ["bun", "run", str(source)], check=True, + env={**os.environ, + "LYRACORE_BASE_SNAPSHOT": str(core / "datascripts/tests/fixtures/base-snapshot.json"), + "LYRACORE_PACKAGES_ROOT": str(root / "packages")}, + ) + artifact = package / "data/.generated/spell.json" + delta = json.loads(artifact.read_text()) + assert delta["package"] == "example-data" + spells = [claim for claim in delta["claims"] if claim["table"] == "game_spell"] + assert len(spells) == 1 + assert spells[0]["operation"] == "insert" + assert spells[0]["key"] == {"spell_id": 6000300} + assert spells[0]["fields"]["name"]["value"] == "A Warm Welcome" + subprocess.run( + ["cargo", "run", "--locked", "--quiet", "-p", "lyracore-package-delta", + "--bin", "lyracore-delta-check", "--", str(artifact)], + cwd=core, check=True, + ) + + +if __name__ == "__main__": + check(Path(sys.argv[1]).resolve()) diff --git a/README.md b/README.md index d58a7f6..0b397c4 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,16 @@ Provenance Stamp records the exact collection commit it came from. ## Packages +Start with these Reference Packages. Each adds a small welcome at one level of the Package API. + +| Package | What to read | +| --- | --- | +| [`example-script`](example-script/) | TypeScript on login and Lua on level-up, with one chat line each | +| [`example-client`](example-client/) | An addon and a UI Transform | +| [`example-data`](example-data/) | A Datascript that clones one spell, with a Delta built locally | +| [`example-rust`](example-rust/) | A Rust hook, Package Config and a Package test | +| [`example-all`](example-all/) | Rust asks a Runtime Script, with Package Config as the fallback | + - [`dungeons`](dungeons/) holds scripted dungeon choreography, one submodule per dungeon. Deadmines is its first dungeon. - [`playerbots`](playerbots/) fields a population of session-less Characters a player can group diff --git a/example-all/README.md b/example-all/README.md new file mode 100644 index 0000000..6ba3f1e --- /dev/null +++ b/example-all/README.md @@ -0,0 +1,15 @@ +# Rust asks a Runtime Script + +On login, Rust calls `ask()` for Package Event `example-all.welcome`. `scripts/welcome.lua` +answers `1`, so Rust sends a welcome through `actor::system_message`. Change the Script Answer +to `0` to suppress it. + +When no script answers, Rust reads Package Config key `welcome`. `false` suppresses the greeting; +`true`, a missing value, or an invalid value sends it. The default is `true`. Package tests run +the authored Lua in the Runtime Script Host and check the fallback. + +Install with `./lyracore packages add example-all`, then publish through the normal realm +update. Apply its Script Artifact with `./lyracore packages replay` on your development topology. +After editing the Runtime Script, run `./lyracore packages build` and commit its Script +Artifact and Build Identity together. Its Script ID is 100302. Give copies distinct IDs before +installing them together. diff --git a/example-all/data/.generated/example-all.script.json b/example-all/data/.generated/example-all.script.json new file mode 100644 index 0000000..dbc2c2f --- /dev/null +++ b/example-all/data/.generated/example-all.script.json @@ -0,0 +1,16 @@ +{ + "kind": "script", + "version": 1, + "package": "example-all", + "source_hash": "bd88e41c6f9976c287287db30ed0ce0b07d0b64fca7398d1093f5987dacc9dfb", + "scripts": [ + { + "script_id": 100302, + "name": "example-all.welcome", + "event": "example-all.welcome", + "priority": 0, + "enabled": true, + "source": "-- @event example-all.welcome\n-- @id 100302\n\n-- A positive Script Answer sends the welcome. Zero suppresses it.\nreturn 1\n" + } + ] +} diff --git a/example-all/data/.generated/script.identity b/example-all/data/.generated/script.identity new file mode 100644 index 0000000..f4ca595 --- /dev/null +++ b/example-all/data/.generated/script.identity @@ -0,0 +1 @@ +{"artifact_hash":"sha256-v1:7e6c882d9086c81c9bb49d1a676b8d96cd7f2c08d9dc5e4662d72eeb95f9a816","bun_lock_hash":"sha256-v1:a3dc4c9ef1ff2b0b3502de7fc828c237f16df8a817e14a3412a08f8b052c0252","bun_version":"1.3.7","source_hash":"sha256-script-sources-v1:bd88e41c6f9976c287287db30ed0ce0b07d0b64fca7398d1093f5987dacc9dfb","toolchain_hash":"sha256-dir-v1:f53d4056ac988e2966186bcc407b34a9ed525b385e53cc7135d2fa866dab69f4","version":1} \ No newline at end of file diff --git a/example-all/scripts/welcome.lua b/example-all/scripts/welcome.lua new file mode 100644 index 0000000..d962a53 --- /dev/null +++ b/example-all/scripts/welcome.lua @@ -0,0 +1,5 @@ +-- @event example-all.welcome +-- @id 100302 + +-- A positive Script Answer sends the welcome. Zero suppresses it. +return 1 diff --git a/example-all/src/mod.rs b/example-all/src/mod.rs new file mode 100644 index 0000000..f96be84 --- /dev/null +++ b/example-all/src/mod.rs @@ -0,0 +1,30 @@ +use crate::package_config::{ensure_package_config_default, game_package_config}; + +const PACKAGE: &str = "example-all"; +const WELCOME_EVENT: &str = "example-all.welcome"; + +crate::game_hook!(on_login, fn example_all_on_login(ctx, payload) { + ensure_package_config_default(ctx, PACKAGE, "welcome", "true"); + let fallback = ctx.db.game_package_config().by_package_key() + .filter((PACKAGE, "welcome")).next().map(|row| row.value); + let answer = crate::script_binding::ask(ctx, WELCOME_EVENT, payload.character_guid, 0); + if should_welcome(answer, fallback.as_deref()) { + if let Err(reason) = crate::actor::system_message( + ctx, payload.character_guid, "Welcome from example-all!".to_string(), + ) { + spacetimedb::log::warn!("{PACKAGE}: welcome failed: {reason}"); + } + } +}); + +fn should_welcome(answer: Option, fallback: Option<&str>) -> bool { + match answer { + Some(number) => number > 0.0, + None => fallback + .and_then(|text| text.trim().parse().ok()) + .unwrap_or(true), + } +} + +#[cfg(test)] +mod tests; diff --git a/example-all/src/tests.rs b/example-all/src/tests.rs new file mode 100644 index 0000000..5dd6659 --- /dev/null +++ b/example-all/src/tests.rs @@ -0,0 +1,28 @@ +#![cfg(test)] + +use crate::package_test::{ask_offline, RuntimeScript}; + +#[test] +fn the_script_answer_overrides_config() { + let answer = ask_offline( + super::WELCOME_EVENT, + None, + None, + &[RuntimeScript { + name: "example-all.welcome", + source: include_str!("../scripts/welcome.lua"), + }], + ) + .unwrap(); + assert_eq!(answer, Some(1.0)); + assert!(super::should_welcome(answer, Some("false"))); + assert!(!super::should_welcome(Some(0.0), Some("true"))); +} + +#[test] +fn no_script_uses_config() { + let answer = ask_offline(super::WELCOME_EVENT, None, None, &[]).unwrap(); + assert!(!super::should_welcome(answer, Some("false"))); + assert!(super::should_welcome(answer, Some("true"))); + assert!(super::should_welcome(answer, Some("invalid"))); +} diff --git a/example-client/README.md b/example-client/README.md new file mode 100644 index 0000000..62f9467 --- /dev/null +++ b/example-client/README.md @@ -0,0 +1,10 @@ +# Welcome from the client + +The addon prints one line when it loads. The UI Transform adds a second welcome to the default +chat frame on world entry. It inserts code after `ChatFrame_OnEvent` in the client's own +`Interface/FrameXML/ChatFrame.lua`. + +Install with `./lyracore packages add example-client`, run `./lyracore client sync`, then restart +the client. This Package needs no Module change. The UI Transform requires a 1.12.1 Baseline with +one matching anchor. It ships only the insertion, never the Baseline. `client pack` refuses a +distributable Client Pack that contains a UI Transform's derived output. diff --git a/example-client/client/addons/example-client/example-client.lua b/example-client/client/addons/example-client/example-client.lua new file mode 100644 index 0000000..e2df031 --- /dev/null +++ b/example-client/client/addons/example-client/example-client.lua @@ -0,0 +1 @@ +DEFAULT_CHAT_FRAME:AddMessage("Welcome from the example-client addon!") diff --git a/example-client/client/addons/example-client/example-client.toc b/example-client/client/addons/example-client/example-client.toc new file mode 100644 index 0000000..0bf8be1 --- /dev/null +++ b/example-client/client/addons/example-client/example-client.toc @@ -0,0 +1,4 @@ +## Interface: 11200 +## Title: example-client +## Notes: A welcome from a client Package. +example-client.lua diff --git a/example-client/client/ui-transforms.json b/example-client/client/ui-transforms.json new file mode 100644 index 0000000..2aae018 --- /dev/null +++ b/example-client/client/ui-transforms.json @@ -0,0 +1,7 @@ +[ + { + "path": "Interface/FrameXML/ChatFrame.lua", + "after": "function ChatFrame_OnEvent(event)", + "insert": "\n\tif event == \"PLAYER_ENTERING_WORLD\" and this == DEFAULT_CHAT_FRAME then\n\t\tthis:AddMessage(\"Welcome from the example-client UI Transform!\")\n\tend\n" + } +] diff --git a/example-data/README.md b/example-data/README.md new file mode 100644 index 0000000..83ef8ee --- /dev/null +++ b/example-data/README.md @@ -0,0 +1,12 @@ +# A warm welcome spell + +`datascripts/welcome.ts` clones Fireball, spell 133, into Package Spell 6000300 and names it +`A Warm Welcome`. Its other columns and effects stay the same. + +Install with `./lyracore packages add example-data`. With a Base Snapshot from your own client +data, run `./lyracore packages build`, then use the normal base import to apply the Package Delta. +The spell has Fireball's effect when cast. An unmodified client has no tooltip for a Package Spell. + +Keep the Datascript as source. The generated Delta and its Build Identity stay local under +`data/.generated/` and must not be committed. A copied Package must choose a different Package +Spell ID before it can be installed beside this one. diff --git a/example-data/data/.gitignore b/example-data/data/.gitignore new file mode 100644 index 0000000..ecb842d --- /dev/null +++ b/example-data/data/.gitignore @@ -0,0 +1 @@ +.generated/ diff --git a/example-data/datascripts/welcome.ts b/example-data/datascripts/welcome.ts new file mode 100644 index 0000000..240ac71 --- /dev/null +++ b/example-data/datascripts/welcome.ts @@ -0,0 +1,5 @@ +import { run } from "../../../datascripts/lib/index.ts"; + +await run("example-data", (data) => { + data.spell(133).clone(6_000_300).set("name", "A Warm Welcome"); +}); diff --git a/example-rust/README.md b/example-rust/README.md new file mode 100644 index 0000000..d52a4bf --- /dev/null +++ b/example-rust/README.md @@ -0,0 +1,9 @@ +# Welcome from Rust + +One `on_login` hook sends a System Message through `actor::system_message`. It seeds Package +Config key `greeting` on first login and reads it on each login. A blank value uses the default +welcome. The Package test covers that fallback. + +Install with `./lyracore packages add example-rust`, then publish through the normal realm +update. On your development topology, `./lyracore packages config example-rust greeting "Hello!"` +changes the next welcome without another publish. diff --git a/example-rust/src/mod.rs b/example-rust/src/mod.rs new file mode 100644 index 0000000..3710f1d --- /dev/null +++ b/example-rust/src/mod.rs @@ -0,0 +1,29 @@ +use crate::package_config::{ensure_package_config_default, game_package_config}; + +const PACKAGE: &str = "example-rust"; +const DEFAULT_GREETING: &str = "Welcome from example-rust!"; + +crate::game_hook!(on_login, fn example_rust_on_login(ctx, payload) { + ensure_package_config_default(ctx, PACKAGE, "greeting", DEFAULT_GREETING); + let configured = ctx.db.game_package_config().by_package_key() + .filter((PACKAGE, "greeting")).next().map(|row| row.value); + let message = greeting(configured.as_deref()).to_string(); + if let Err(reason) = crate::actor::system_message(ctx, payload.character_guid, message) { + spacetimedb::log::warn!("{PACKAGE}: welcome failed: {reason}"); + } +}); + +fn greeting(configured: Option<&str>) -> &str { + configured + .map(str::trim) + .filter(|text| !text.is_empty()) + .unwrap_or(DEFAULT_GREETING) +} + +#[cfg(test)] +mod tests { + #[test] + fn blank_config_keeps_the_default_greeting() { + assert_eq!(super::greeting(Some(" ")), "Welcome from example-rust!"); + } +} diff --git a/example-script/README.md b/example-script/README.md new file mode 100644 index 0000000..0306209 --- /dev/null +++ b/example-script/README.md @@ -0,0 +1,10 @@ +# Welcome with Runtime Scripts + +`welcome.ts` sends one chat line on login. `ding.lua` sends one on level-up. Both use +`send_chat` on the Character carried by the event. This Package needs no Rust. + +Install with `./lyracore packages add example-script`, then apply the committed Script Artifact +with `./lyracore packages replay` on your development topology. After editing either source, run +`./lyracore packages build` and commit the Script Artifact and its Build Identity together. + +The Script IDs are 100300 and 100301. Give copies distinct IDs before installing them together. diff --git a/example-script/data/.generated/example-script.script.json b/example-script/data/.generated/example-script.script.json new file mode 100644 index 0000000..d4c82d6 --- /dev/null +++ b/example-script/data/.generated/example-script.script.json @@ -0,0 +1,24 @@ +{ + "kind": "script", + "version": 1, + "package": "example-script", + "source_hash": "24b601f2db72799e6c3879c59b1bfd03d4ee92e10dde5ebc94920fb91cea9f72", + "scripts": [ + { + "script_id": 100300, + "name": "example-script.welcome", + "event": "on_login", + "priority": 0, + "enabled": true, + "source": "local function ____tbl(t)\n return t\nend\nlocal function script()\n if event.actor and event.actor.is_player then\n send_chat(event.actor, \"Welcome from example-script!\")\n end\nend\nreturn script()\n" + }, + { + "script_id": 100301, + "name": "example-script.ding", + "event": "on_levelup", + "priority": 0, + "enabled": true, + "source": "-- @event on_levelup\n-- @id 100301\n\nif event.actor and event.actor.is_player then\n send_chat(event.actor, \"Welcome to your new level from example-script!\")\nend\n" + } + ] +} diff --git a/example-script/data/.generated/script.identity b/example-script/data/.generated/script.identity new file mode 100644 index 0000000..cecf6cc --- /dev/null +++ b/example-script/data/.generated/script.identity @@ -0,0 +1 @@ +{"artifact_hash":"sha256-v1:8f8a8b9d8d9b029091c7478912c35d1b7a0a74bda9b9fbbb8c8ed9672fd35a07","bun_lock_hash":"sha256-v1:a3dc4c9ef1ff2b0b3502de7fc828c237f16df8a817e14a3412a08f8b052c0252","bun_version":"1.3.7","source_hash":"sha256-script-sources-v1:24b601f2db72799e6c3879c59b1bfd03d4ee92e10dde5ebc94920fb91cea9f72","toolchain_hash":"sha256-dir-v1:f53d4056ac988e2966186bcc407b34a9ed525b385e53cc7135d2fa866dab69f4","version":1} \ No newline at end of file diff --git a/example-script/scripts/ding.lua b/example-script/scripts/ding.lua new file mode 100644 index 0000000..b18f08c --- /dev/null +++ b/example-script/scripts/ding.lua @@ -0,0 +1,6 @@ +-- @event on_levelup +-- @id 100301 + +if event.actor and event.actor.is_player then + send_chat(event.actor, "Welcome to your new level from example-script!") +end diff --git a/example-script/scripts/welcome.ts b/example-script/scripts/welcome.ts new file mode 100644 index 0000000..a6fccb5 --- /dev/null +++ b/example-script/scripts/welcome.ts @@ -0,0 +1,8 @@ +// @event on_login +// @id 100300 + +function script(): void { + if (event.actor && event.actor.is_player) { + send_chat(event.actor, "Welcome from example-script!"); + } +}