diff --git a/.changeset/README.md b/.changeset/README.md index 5b0c2ea..d2e2b94 100644 --- a/.changeset/README.md +++ b/.changeset/README.md @@ -1,10 +1,10 @@ # Changesets This folder is managed by [Changesets](https://github.com/changesets/changesets). -It tracks pending version bumps + changelog entries for the publishable packages -(`@bedrock-core/server`, `@bedrock-core/server-runtime`, `@bedrock-core/sync`). -The monorepo root and the reference addons (`packages/test-addon*`) are `private` -and are ignored automatically. +It tracks pending version bumps and changelog entries for the publishable packages: +`@bedrock-core/server`, `@bedrock-core/server-runtime`, `@bedrock-core/sync`, +`@bedrock-core/db`, `@bedrock-core/observable` and `@bedrock-core/i18n`. The monorepo +root and the GameTest fixtures are `private` and are ignored automatically. ## Authoring a changeset @@ -20,36 +20,41 @@ your change. ## How releasing works -Releasing is automatic. The **Release** workflow (`.github/workflows/publish.yml`) -runs on every push to `main` and, through `changesets/action`, does one of two -things: - -- **Changesets are pending** → it opens (or refreshes) a **"Version Packages"** PR - built by `yarn version-packages`. That runs `changeset version` — consuming the - pending changesets, bumping the changed packages **and their dependents** - (`updateInternalDependencies: patch`, e.g. a `sync` bump patches - `server-runtime`) and writing CHANGELOGs — then - `scripts/sync-runtime-version.mjs`, which rewrites `RUNTIME_VERSION` so the - constant can never disagree with the tag that ships it. The PR is refreshed on - every further push while it stays open. -- **No changesets left** → merging that PR lands the bumps on `main`, and the run - that follows executes `yarn release` (`lint:libs`, `build:libs`, then - `changeset publish`): each changed package goes to npm, gets tagged - `@bedrock-core/@`, and gets a GitHub release. +The **Release** workflow (`.github/workflows/publish.yml`) has two jobs: + +- **A push to `main` with changesets pending** opens (or refreshes) a **"Version + Packages"** PR built by `yarn version-packages`. That runs `changeset version`, + consuming the pending changesets, bumping the changed packages **and their + dependents** (`updateInternalDependencies: patch`) and writing CHANGELOGs, then + `scripts/sync-meta-version.mjs` and `scripts/sync-runtime-version.mjs`, which set + the root meta's version and rewrite `RUNTIME_VERSION`. Nothing is published from + a push. +- **A manual run** (Actions, Release, Run workflow) after that PR merges runs + `yarn release`: `lint:libs`, `build:libs`, `scripts/publish-tarballs.mjs`, + and `scripts/tag-packages.mjs`. Each changed package goes to npm + through trusted publishing (OIDC), with no npm token, and is tagged + `@bedrock-core/@`. + +**`@bedrock-core/server`'s version IS `@bedrock-core/server-runtime`'s**, +character for character, prerelease tag included. The runtime is what the meta +is; `db`, `observable`, `sync` and `i18n` are support around it, so a consumer +reading either number is reading the same one. A release the runtime does not move +leaves the meta where it is. The root is not a valid changeset target: do not +select it. + +`0.0.0` is what an unreleased package sits at, and `publish-tarballs.mjs` skips it. Only the publishable libraries are installed for the release (`yarn workspaces -focus`), so the `portal:../ui` resolutions the test addons use are never resolved -and the job needs no sibling `ui` checkout. +focus`), and nothing is checked out beside this repository: every dependency +resolves from the registry. The root `resolutions` must carry versions rather than +`portal:` entries before a release can install; that swap is the release step. -The `workspace:` ranges the libraries use to depend on each other must come out of -the tarball as concrete versions. **Release rehearsal** -(`.github/workflows/rehearsal.yml`, `workflow_dispatch`) is what proves they do: -it publishes every package to a throwaway local registry and installs each one -from a clean consumer. Run it before any release you care about. +The `workspace:` ranges the libraries use to depend on each other come out of each +tarball as concrete versions, because `publish-tarballs.mjs` packs with Yarn. -Two repo settings the workflow depends on: +Two things the workflow depends on: -- *Allow GitHub Actions to create and approve pull requests* (Settings → Actions → - General) — without it the Version PR can't be opened. -- An `NPM_TOKEN` repository secret with publish rights on the `@bedrock-core` - scope. +- *Allow GitHub Actions to create and approve pull requests* (Settings, Actions, + General). Without it the Version PR cannot be opened. +- A trusted publisher on npmjs.com for each package, naming this repository and + `publish.yml`. diff --git a/.changeset/db.md b/.changeset/db.md new file mode 100644 index 0000000..d2f31c7 --- /dev/null +++ b/.changeset/db.md @@ -0,0 +1,31 @@ +--- +"@bedrock-core/db": minor +--- + +Add `@bedrock-core/db`: typed documents persisted on whatever dynamic properties a target can +hold. + +**The host resolver** probes a target for where its documents can live — its own dynamic +properties through the engine's six-method or component ABI, or a world property keyed by its +identity when it holds nothing — behind one adapter over both ABIs, with a capability record per +host, refusals by name for an `ItemStack` and a stackable slot, and a per-type cache that never +caches a throw. + +**Collections** key documents by target on the host the resolver finds. One JSON envelope per +document carries its version; migrations run lazily per document; `defaults` fill deep on read and +a `normalize` runs on every write; `patch` merges plain objects recursively and replaces arrays and +scalars, with `undefined` deleting a key. A document that cannot be read is quarantined under +`#bad` rather than deleted. A budget check runs before the engine, with chunking on the direct +ABI, `accept` and `require` are checked at compile time against what the target type can do, and a +validity gate re-resolves the target on every operation. + +**The chunked index** has a resumable `all()` that heals a replaced block, a `blockCleanup` custom +component for `onBreak`, and `coalesce` write-behind that parks on `entityLoad` and flushes on +`playerLeave` — wired to the engine only when a coalescing collection exists. + +A collection is **local**. Nothing here is reachable from another realm: what crosses is what the +owning addon puts on one of the runtime's channels. + +Tuned for the engine throughout: hosts, resolutions, document stores and handles are classes with +prototype methods, a handle resolves its target once per tick, the resolver trusts a cached type +decision, index chunks are written behind, and a single value skips the batch write. diff --git a/.changeset/i18n-overlay.md b/.changeset/i18n-overlay.md new file mode 100644 index 0000000..8ab80fe --- /dev/null +++ b/.changeset/i18n-overlay.md @@ -0,0 +1,18 @@ +--- +"@bedrock-core/i18n": minor +--- + +A library's strings can be overridden by the world it runs in. + +A library that draws UI ships its own bundle, keyed under a namespace it shares with the rest of +its family. A running realm has more than that: every addon present has announced a bundle, and +one of them may carry the very same key — deliberately, to rename what the library calls something +("Addons" becomes "Mods"), or simply because it ships a locale the library does not. + +`overlay(bound, published, bundle)` is that precedence, as verbs. `t()` prefers the published value +wherever it carries the key, so an override and an unshipped locale reach the strings a script +renders rather than only the keys a client paints. `resolve()` and `display()` become the world's, +so a key from any addon's bundle resolves — which is what a screen showing another addon's display +fields needs. + +A realm with no published bundles gets the bound instance back untouched and allocates nothing. diff --git a/.changeset/observable.md b/.changeset/observable.md new file mode 100644 index 0000000..06c99a8 --- /dev/null +++ b/.changeset/observable.md @@ -0,0 +1,11 @@ +--- +"@bedrock-core/observable": minor +--- + +Add `@bedrock-core/observable`, the reactive primitive the stack notifies through: `observable` +with `get` / `set` / `subscribe`, `computed`, `effect` and `batch`, delivering synchronously with +listeners isolated from each other. `last(signal)` turns anything with `subscribe` — a Minecraft event +signal included — into an observable of its most recent payload, `undefined` until the first one +arrives, released with `dispose()`. The `/minecraft` entry bridges one of ours to a data-driven UI +observable with `toNative`, keeping the native in step for a form's lifetime and writing back only +when the control is client-writable. diff --git a/.changeset/presence-as-a-value.md b/.changeset/presence-as-a-value.md new file mode 100644 index 0000000..affdad6 --- /dev/null +++ b/.changeset/presence-as-a-value.md @@ -0,0 +1,37 @@ +--- +"@bedrock-core/sync": minor +"@bedrock-core/server-runtime": minor +--- + +Who is present is a value, not a stream: discovery, the registry and the host election are +observables. + +**Breaking.** `discovery.peers` and `discovery.incompatiblePeers` are `ReadonlyObservable` lists +rather than array getters, so they read `.get()`, watch with `.subscribe()`, and compose with +`computed()` like a config leaf or a shared value: + +```ts +sync.discovery.peers.subscribe(peers => redraw(peers)); +core.registry.addons.subscribe(addons => redraw(addons)); + +const hostBanner = computed(() => `hosted by ${core.host.id.get()}`, [core.host.id]); +``` + +The lists republish only when the world actually changes. A heartbeat that repeats what a peer +already said refreshes its liveness and notifies nobody, which is what lets a listener sit on the +list without waking every five seconds per peer. `lastSeen` therefore left `PeerInfo` and +`IncompatiblePeer` — a tick that moves on every heartbeat cannot live inside an observable value — +and is asked for by id instead: + +```ts +sync.discovery.lastSeen('drav0011_economy'); // tick, or undefined +``` + +`onPeerUp` / `onPeerDown` / `onRegister` / `onUnregister` stay: an arrival is a delta, and a +caller that wants the one peer that changed still wants an event. A TTL sweep publishes the list +once for the whole sweep, before any listener runs, so a handler never sees a half-swept world. + +`Registry` keeps no directory of its own — `all()`, `get()` and `has()` read `addons`, so a cached +answer can no longer disagree with the live one — and `HostElection` is `computed` over that list, +which retires its `start()`. `core.host.hostId` is now `core.host.id`, an observable; `HostListener` +takes a `previousHostId: string`, since a derived value always has one. diff --git a/.changeset/screens-feed.md b/.changeset/screens-feed.md new file mode 100644 index 0000000..14ebd2c --- /dev/null +++ b/.changeset/screens-feed.md @@ -0,0 +1,5 @@ +--- +"@bedrock-core/server-runtime": minor +--- + +**Breaking.** Guides left the runtime: the `guide` and `guideReference` options of `register()`, `core.guides`, `core.guides.manifest`, `GuidesRegistry`, `GuideReference` and `GuideManifest` are removed. A guide is a set of compiled screens from `@bedrock-core/guides`, reached by navigating to its key, and the screens an addon publishes are read through `screens(core)` from `@bedrock-core/navigation`. diff --git a/.changeset/server-runtime.md b/.changeset/server-runtime.md new file mode 100644 index 0000000..dee694d --- /dev/null +++ b/.changeset/server-runtime.md @@ -0,0 +1,50 @@ +--- +"@bedrock-core/server-runtime": minor +--- + +Everything an addon owns is declared in `register()`, and everything that crosses a realm goes over +one of three channels. + +**Breaking.** `register()` installs declarations, not plain objects: `shared: registerShared(keys)`, +`events: registerEvents(tree)`, and one field per app, such as `config: registerConfig(definition)` +from `@bedrock-core/config`. Config and guides are no longer part of the runtime. They are the +`@bedrock-core/config` and `@bedrock-core/guides` apps, which keep their state in a `RuntimeSlots` +slot the runtime fills and hands back (`core.fill` / `core.slot`); `core.config`, `core.guides` and +`core.pages` are gone. + +**`core.shared`** — a flat `shared` shape comes back as a typed tree, one observable per key with +`get` / `set` / `subscribe` and the usual `(next, prev)` listener. Peers read +`core.shared.of(ns)`, materialized from the key names the owner announces under +`core-shared/shape`, and never write: a peer's tree has no `set`, in the type and at runtime. The +backend subscription is attached with the first listener and released with the last. The mirror +stores nothing — a value that must survive a restart is a db document the owner maps onto a key. + +**`core.events`** — an `events` shape comes back as a typed tree with `emit` and `subscribe`, the +payload type declared by `event()`. A peer reads `core.events.of(ns)`, which has +`subscribe` alone and answers before the owning addon exists, so a listener attached early hears +the first event announced. Nothing is replayed. + +**`core.rpc`** — a question the owner answers. `authorize(target, actorId, operation)` is the one +rule such a handler applies: an operator reaches anything, anyone else only their own entity, and a +request with no acting player is an addon acting for itself. + +**`core.db`** is this addon's `@bedrock-core/db`, keyed under its namespace, with the declaration +API (`schema`, the acceptors and combinators, the errors) re-exported so a collection is declared +from the runtime import alone. It is local: a peer reaches a document only through a method the +owner wrote. + +**Every cross-addon feed is an `Announcement`** — one value under a `core-` key in the owner's +namespace, with `provide` / `own` / `of(ns)` / `namespaces` / `subscribe` and a guard on read. +`core.translations` is one over the bundle, its verbs at `i18n(ns)`; `core.features.flags` +announces every flag as one record under `core-feature/flags`; the shared shape sits at +`core.shared.shape`. `provideManifest`, `provideReference`, `referenceOf`, `bundleOf`, +`addonsWithGuides` and `has` are gone with it. + +**The package exports what an addon writes against.** Registries are exported as types — the +runtime constructs them — and the schema, document and wire helpers (`flattenSchema`, +`defaultsOf`, `normalizeAgainst`, `coerce`, `CONFIG_COLLECTIONS`, `configMethod`, +`SHARED_SHAPE_KEY`, `validateManifest`, `addonNamespace`, `compareVersions`, `PROTOCOL_MIN` / +`PROTOCOL_MAX`) are no longer exported. + +**`core.state` and `ScopedState` are removed.** A shared key covers every call they had; the raw +namespace, framework keys included, stays reachable at `core.node.state`. diff --git a/.changeset/sync-one-writer-per-namespace.md b/.changeset/sync-one-writer-per-namespace.md new file mode 100644 index 0000000..89aa0ee --- /dev/null +++ b/.changeset/sync-one-writer-per-namespace.md @@ -0,0 +1,6 @@ +--- +"@bedrock-core/sync": minor +"@bedrock-core/server-runtime": minor +--- + +**Breaking.** A node writes one namespace, the one named by its id. `ownedNamespaces` and `strictOwnership` are removed from `createSync` / `SyncNode`, and `StateOptions` from the exports: every mirror only ever applied a namespace's writes from the node whose id it is, so the options could not widen that. A write to another node's namespace is dropped by every mirror and counted in `droppedForeign`, and a node answers snapshot requests for its own namespace. diff --git a/.changeset/sync.md b/.changeset/sync.md new file mode 100644 index 0000000..beaca06 --- /dev/null +++ b/.changeset/sync.md @@ -0,0 +1,16 @@ +--- +"@bedrock-core/sync": minor +--- + +**Owner-only state.** A mirror applies an entry for a namespace only from the namespace's owner — +the sending node for a delta, the recorded writer for a snapshot entry, so a snapshot relayed by a +third party still names the original writer. Anything else is dropped and counted in +`droppedForeign`. The rule holds for a node's own writes too, so a foreign write is visible locally +exactly when it is visible everywhere, which is never. + +**`Events`, the subsystem for a happening.** `node.events.emit(name, payload)` broadcasts one +message; `on(namespace, name, handler)` subscribes to one sender's name. The namespace a handler +matches is the envelope's `src`, read from the transport rather than the payload, so a message +cannot claim to come from a node that did not send it. The sender dispatches to its own handlers +first, synchronously, before the message leaves; a handler that throws is caught and the others +still run. Nothing is stored and nothing is replayed. diff --git a/.changeset/tagged-wire-and-batching.md b/.changeset/tagged-wire-and-batching.md new file mode 100644 index 0000000..9608577 --- /dev/null +++ b/.changeset/tagged-wire-and-batching.md @@ -0,0 +1,49 @@ +--- +"@bedrock-core/sync": minor +"@bedrock-core/server-runtime": minor +--- + +Rework the script-event wire format, and negotiate the protocol per peer instead of demanding a +match. + +A message now opens with a tag saying which shape follows: one envelope, a batch of envelopes, or +one frame of a chunked envelope. An envelope that fits in a message is sent whole instead of nested +inside a frame's `p` field, so it is no longer JSON-escaped to sit inside a JSON string — a small +message loses about a third of its length, and a round trip costs roughly half the CPU. + +The outbound queue packs consecutive envelopes into one message up to the size cap. The engine +bounds script events per tick by count rather than by size, so a node that sends a burst in a single +tick — a run of `State.set` calls, a snapshot broadcast, an RPC fan-out — now spends a few of its +per-tick slots instead of one per envelope. Each addon has its own queue, so this packs one node's +own traffic and never several nodes' together. + +Framing charges each character what JSON actually spends escaping it, rather than reserving two +characters for every one. Real payloads fill a frame instead of half of it: a 16KB envelope splits +into 10 frames where it previously took 18. + +**`PROTOCOL_VERSION` is replaced by `PROTOCOL_MIN` and `PROTOCOL_MAX`.** A node advertises the +range it speaks in every announce and talks to each peer at the newest version both know, so a +world may hold addons built against different releases without partitioning. Gating on one exact +version would have made this bump — and every later one — a silent split: two meshes on a single +channel, each listing only its own half, each electing its own UI host, each timing out every RPC +to the other. + +Consequently: + +- A protocol-1 message is a bare frame with no tag, and is read as one. Announces and `whois` are + pinned to `PROTOCOL_MIN` so the message that establishes a version never assumes one. +- Broadcasts go out at the lowest version any live peer can read, and packing stops while a peer + that predates the batch tag is present. Both recover on their own once that peer expires. +- `PeerInfo` gains `protocol` and `caps`. Capabilities are advertised per node and narrowed by the + negotiated version, so a later addition can appear or degrade without a version bump. +- A node whose range does not overlap this build's is reported through + `Discovery.onIncompatible` / `Registry.onIncompatible` and listed by `Registry.incompatible()`, + with a warning naming both ranges. It is named rather than silently absent. +- `negotiateProtocol` and `capsFor` are exported for anyone writing an interoperating + implementation. + +The support window is two versions wide. Raising `PROTOCOL_MIN` drops everything below it and is a +breaking change. + +`MAX_MESSAGE`, the default per-message character budget, is now exported alongside the existing +`BusOptions.maxMessage` override. diff --git a/.copilot/config.json b/.copilot/config.json deleted file mode 100644 index f96984c..0000000 --- a/.copilot/config.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "commitMessage": { - "instruction": "Follow Conventional Commits. Use the package name as the scope — for example, feat(testing): add runner API or fix(db): relation sync. Keep the summary imperative." - } -} diff --git a/.github/workflows/mc-tests.yml b/.github/workflows/mc-tests.yml new file mode 100644 index 0000000..7899e69 --- /dev/null +++ b/.github/workflows/mc-tests.yml @@ -0,0 +1,88 @@ +# The slow gate: builds the test addons with Regolith and runs their GameTests on a real Bedrock +# Dedicated Server. Kept off every-push because it downloads a ~200 MB server and takes minutes; +# opt in with the `run-mc-tests` label on a PR, or let it run on main. +# +# Nothing is checked out beside this repo: `@bedrock-core/bds-runner` installs from npm and the +# Regolith filters are pinned to published tags. +name: GameTests (BDS) + +on: + push: + branches: [main] + pull_request: + types: [labeled, synchronize] + workflow_dispatch: + +concurrency: + group: mc-tests-${{ github.ref }} + cancel-in-progress: true + +env: + REGOLITH_VERSION: '1.8.0' + +jobs: + gametest: + if: >- + github.event_name != 'pull_request' || + contains(github.event.pull_request.labels.*.name, 'run-mc-tests') + runs-on: ubuntu-latest + timeout-minutes: 45 + + defaults: + run: + working-directory: server + + steps: + - uses: actions/checkout@v7 + with: + path: server + + - uses: actions/setup-node@v7 + with: + node-version: 24 + cache: yarn + cache-dependency-path: server/yarn.lock + + - run: corepack enable + + - run: yarn install --immutable + + - name: Set up Regolith + uses: bedrock-core/setup-regolith@1.0.1 + with: + regolith-version: ${{ env.REGOLITH_VERSION }} + resolvers: | + github.com/bedrock-core/regolith-filters + + - name: Read the pinned server version + id: bds + run: echo "version=$(jq -r .version bds-runner.json)" >> "$GITHUB_OUTPUT" + + # Keyed on the resolved version alone, not on a file hash, so an unrelated edit to + # bds-runner.json cannot invalidate a 200 MB download. + - name: Cache Bedrock Dedicated Server + uses: actions/cache@v6 + with: + path: server/.bds/cache + key: bds-${{ runner.os }}-${{ steps.bds.outputs.version }} + + - run: yarn bds:fetch + + # Installs the url-pinned bundler and, for the filters run from the sibling checkout, their + # npm dependencies — Regolith does that for local-script filters too. + - run: yarn regolith-install + + # Both addons are built and deployed into one world: the last test asserts that the Shop pack + # is registered with the live runtime, so it can only pass with both installed. + - name: GameTests + run: yarn test:mc + + # The server console is the only debugging surface for a CI failure, so keep it either way. + - name: Upload server logs + if: always() + uses: actions/upload-artifact@v7 + with: + name: bds-logs + path: server/.bds/logs/** + if-no-files-found: warn + retention-days: 14 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 0834a72..416b25b 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,22 +1,20 @@ name: Release -# Standard Changesets flow, powered by changesets/action. +# Changesets flow with a manual publish — the same shape every bedrock-core repo uses. # -# Author changesets during development (`yarn changeset`). Every push to `main` -# runs this workflow: -# • changesets pending → the action opens/updates a "Version Packages" PR that -# bumps versions + writes changelogs (running the `version` command below). -# The PR is refreshed on every push while it stays open. -# • no changesets left → merging that PR lands the bumps on `main`; this run -# then executes the `publish` command, pushing each changed package to npm, -# tagging it and cutting a GitHub release. +# Author changesets during development (`yarn changeset`). # -# The @bedrock-core/{server,sync,server-runtime} libraries have no cross-repo -# dependencies, so we install ONLY those (plus the monorepo root, for the -# changesets CLI) with `yarn workspaces focus`. The `portal:../ui` resolutions used -# by the private test addons are therefore never resolved — this release is -# self-contained and does NOT need the sibling `ui` checkout. (If that ever stops -# being true, check out `bedrock-core/ui` as a sibling and run a full install.) +# • push to `main` with changesets pending → the action opens/updates a +# "Version Packages" PR that bumps the changed packages and writes their +# changelogs. Nothing is published from a push, ever. +# • Run this workflow by hand (Actions → Release → Run workflow) after that PR +# has merged → the changed packages are published to npm and tagged. +# +# Only the publishable libraries are installed, with `yarn workspaces focus`, so +# the GameTest fixtures and their Regolith toolchain stay out of a release. +# +# Nothing is checked out beside this repo: every dependency resolves from the +# registry, including the published BDS runner used by the test fixtures. # # NOTE: opening the Version PR needs "Allow GitHub Actions to create and approve # pull requests" enabled under Settings → Actions → General. If that is off, @@ -24,6 +22,7 @@ name: Release on: push: branches: [main] + workflow_dispatch: concurrency: ${{ github.workflow }}-${{ github.ref }} @@ -33,41 +32,86 @@ permissions: id-token: write # OIDC for npm trusted publishing — no npm token anywhere jobs: - release: - name: Version or publish changed packages + version: + name: Open or refresh the Version PR + if: github.event_name == 'push' + runs-on: ubuntu-latest + + defaults: + run: + working-directory: server + + steps: + - uses: actions/checkout@v7 + with: + path: server + fetch-depth: 0 + + - run: corepack enable + + - uses: actions/setup-node@v7 + with: + node-version: 24 + cache: 'yarn' + cache-dependency-path: server/yarn.lock + + - run: yarn install --immutable + + # No `publish` input: with changesets pending this opens the PR, and with + # none pending it does nothing. + - uses: changesets/action@v2 + with: + cwd: server + version-script: yarn version-packages + commit-message: 'chore(release): version packages' + pr-title: 'chore(release): version packages' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + publish: + name: Publish to npm + if: github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' runs-on: ubuntu-latest + + defaults: + run: + working-directory: server + steps: - - name: Checkout - uses: actions/checkout@v4 + - uses: actions/checkout@v7 with: + path: server fetch-depth: 0 - - name: Enable Corepack - run: corepack enable + - run: corepack enable - - name: Use Node.js - uses: actions/setup-node@v4 + # Node 24, not 22: trusted publishing needs the OIDC exchange that shipped in + # npm 11.5.1, and the 22 line tops out at npm 10.9.x. 24.5.0+ bundles 11.5.1+. + - uses: actions/setup-node@v7 with: - node-version: 22 + node-version: 24 cache: 'yarn' + cache-dependency-path: server/yarn.lock registry-url: 'https://registry.npmjs.org' scope: '@bedrock-core' - # Trusted publishing needs the OIDC exchange in the npm CLI, which shipped in - # npm 11.5.1 — newer than what the runner's Node bundles. - - name: Update npm for trusted publishing - run: npm install -g npm@latest + - name: Install the publishable libraries only + run: yarn workspaces focus @bedrock-core/server @bedrock-core/sync @bedrock-core/server-runtime - - name: Install publishable libraries only - run: yarn workspaces focus @bedrock-core/server-monorepo @bedrock-core/server @bedrock-core/sync @bedrock-core/server-runtime + # Refuse to publish while changesets are still pending: merge the Version PR first. + - name: Check that nothing is pending + run: | + pending=$(ls .changeset/*.md 2>/dev/null | grep -v '/README\.md$' || true) + if [ -n "$pending" ]; then + echo "Unreleased changesets are pending; merge the Version PR before publishing:" + echo "$pending" + exit 1 + fi - - name: Create Version PR or publish - uses: changesets/action@v1 + - uses: changesets/action@v2 with: - version: yarn version-packages - publish: yarn release - commit: 'chore(release): version packages' - title: 'chore(release): version packages' + cwd: server + publish-script: yarn release env: # No npm token: publishes authenticate via OIDC trusted publishing (the # id-token permission above + a trusted publisher configured per package diff --git a/.github/workflows/rehearsal.yml b/.github/workflows/rehearsal.yml deleted file mode 100644 index ac36e33..0000000 --- a/.github/workflows/rehearsal.yml +++ /dev/null @@ -1,45 +0,0 @@ -# Release rehearsal: publish every package to a throwaway local registry and -# install each one from a clean consumer, proving version ranges and tarball -# dependencies resolve. Never touches registry.npmjs.org (reads are proxied, -# writes stay on localhost). -# -# The rehearsal script lives in the (public) ui repo and is run against both -# checkouts side by side, because this repo's portal: resolutions require a -# sibling ui checkout. Once the portals are dropped (post first release), this -# can gain a pull_request trigger. -name: Release rehearsal - -on: - workflow_dispatch: - inputs: - publisher: - description: Publish flow to rehearse - type: choice - options: [changeset, yarn] - default: changeset - -jobs: - rehearsal: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - path: server-public - - - uses: actions/checkout@v4 - with: - repository: bedrock-core/ui - path: ui - - - uses: actions/setup-node@v4 - with: - node-version: 22 - - - run: corepack enable - - - name: Run rehearsal - run: > - node ui/scripts/release-rehearsal.mjs - --ui-root "$GITHUB_WORKSPACE/ui" - --server-root "$GITHUB_WORKSPACE/server-public" - --publisher "${{ inputs.publisher }}" diff --git a/.gitignore b/.gitignore index ed1cf28..e59d142 100644 --- a/.gitignore +++ b/.gitignore @@ -54,4 +54,14 @@ lerna-debug.log* *.temp .cache -TODO \ No newline at end of file +TODO +# BDS runner (binary cache, server tree, run logs) +.bds/ + +# Addon build artifacts (each addon also ignores its own /build) +packages/*/build/ + +# …but the parser's fixtures are real BDS transcripts and must be committed: the +# report parser is developed against real engine output, not hand-written samples. +!packages/bds-runner/src/**/fixtures/ +!packages/bds-runner/src/**/fixtures/*.log diff --git a/packages/server/CHANGELOG.md b/CHANGELOG.md similarity index 100% rename from packages/server/CHANGELOG.md rename to CHANGELOG.md diff --git a/README.md b/README.md index d56fbff..334f0ce 100644 --- a/README.md +++ b/README.md @@ -4,70 +4,62 @@ > ⚠️ Beta Status: Active development. Breaking changes may occur until 1.0.0. Pin exact versions for stability. +A framework for Minecraft Bedrock addon development, built for cross-addon compatibility: every +addon runs in its own isolated script realm, and bedrock-core lets addons from different creators find each other, call +each other, and share state, settings and guides. -A framework for Minecraft Bedrock addon development, built for cross-addon compatibility. Every addon runs in its own isolated script realm — bedrock-core lets addons from different creators find each other, call each other, and share state, settings and guides. - -Full documentation & guides: https://bedrock-core.drav.dev/ - ---- - -## ✨ Features - -- **Addon discovery** — addons announce their identity, version and dependencies, and enumerate their peers at runtime. -- **Replicated state** — shared last-write-wins key/value, scoped to your namespace. -- **Typed RPC** — typed request/response calls between addons, with timeouts. -- **Features** — enable or disable behaviour based on which peers are present. -- **Configuration** — server / dimension / player scopes, with typed accessors and live change subscriptions. -- **Guides** — compiled in-game guides, declared when the addon registers. - -## 🚀 Quick start +## Install ```sh yarn add @bedrock-core/server ``` -`@minecraft/server` is a peer dependency (`>=2.8.0`) — pin the version your pack's -`manifest.json` declares. +`@minecraft/server` is a peer dependency, pinned to what your pack's `manifest.json` declares. + +## Usage Register once, near the top of your script entry. `register()` is what brings the addon online — there is no separate `start()` — and everything the addon declares rides in that one call: ```ts import { core } from '@bedrock-core/server'; -const config = core.register({ - creator: 'ms', // creator id — [a-z0-9_]+ - pack: 'shop', // pack id — [a-z0-9_]+ → namespace `ms_shop` - packName: 'My Cool Shop', // display label only, not part of identity - version: '1.0.0', - dependencies: ['os_economy'], // soft — logs while absent, never blocks +const { config, shared } = core.register({ + manifest: { + creator: 'ms', // creator id — [a-z0-9_]+ + pack: 'shop', // pack id — [a-z0-9_]+ → namespace `ms_shop` + packName: 'My Cool Shop', // display label only, not part of identity + version: '1.0.0', + dependencies: ['os_economy'], // soft — logs while absent, never blocks + }, config: { server: { taxRate: { type: 'number', default: 0.05, min: 0, max: 1, label: 'Tax Rate' }, }, }, + shared: { open: false }, // what every other realm may read }); config.server.taxRate.get(); // 0.05 — a dotted accessor tree mirroring the schema config.server.taxRate.subscribe((next, prev) => { /* … */ }); core.registry.all(); // every bedrock-core addon present in the world -core.state.set('open', true); // replicated under `ms_shop` +shared.open.set(true); // every realm sees it this tick await core.rpc.request('os_economy', 'getBalance', { player: 'Steve' }); ``` -Full API — features, config scopes, translations, guides, host election — in the -[`@bedrock-core/server-runtime` README](./packages/server-runtime/README.md). +Each package the runtime is built on has its own subpath, for when you reach past `core` to the +thing itself: `@bedrock-core/server/sync` for the transport, `/db` for the rest of the document +surface, `/observable` for `computed` / `effect` / `last` and the `toNative` bridge to a +data-driven form, and `/i18n` for `createI18n` and the translation verbs. -## 📦 Packages +## Documentation -- **`@bedrock-core/server`** — the meta package: one install for the whole stack. Re-exports the runtime at the root and the transport at `/sync`. -- **`@bedrock-core/server-runtime`** — the framework runtime: registration, the cross-addon registry, features, config and guides. Built on `sync`. -- **`@bedrock-core/sync`** — the low-level transport: message bus, discovery, RPC and replicated state over script events. +https://bedrock-core.drav.dev -## 🤝 Contributing +## Contributing -Let's talk in Discord: +Discord: https://bedrock-core.drav.dev/discord -## 📄 License +## License MIT diff --git a/assets/logo/texture.png b/assets/logo/texture.png deleted file mode 100644 index f7fe6ef..0000000 Binary files a/assets/logo/texture.png and /dev/null differ diff --git a/bds-runner.json b/bds-runner.json new file mode 100644 index 0000000..d493d90 --- /dev/null +++ b/bds-runner.json @@ -0,0 +1,8 @@ +{ + "$schema": "./.bds/schema/bds-runner.json", + "version": "1.26.51.1", + "channel": "stable", + "properties": { + "view-distance": 6 + } +} diff --git a/docs/04-query.md b/docs/04-query.md new file mode 100644 index 0000000..1333e8c --- /dev/null +++ b/docs/04-query.md @@ -0,0 +1,157 @@ +# 04 — Query + +`core.query` — another addon's data, held in your realm as a cache with a lifecycle. TanStack +Query's model — keys, a client, hooks — cut to what a game tick can use. + +**Reading side only, over the three channels that already exist.** An rpc method is the fetch and +the mutation; a `shared` key is the warm first read and, when the owner mirrors a value, the +change signal; an event is the other change signal. The owner writes nothing for query's sake +beyond what it would publish anyway, and an addon written before query ships is already queryable. +Everything below is the peer's half; the key shape and the client are the parts still to build. +Not started, and not until a screen needs it. + +## The concern it names + +Realm isolation makes every addon a *client* of every other addon. From your realm a peer's config +or documents are **server state**: you do not own them, they live elsewhere, they can be stale, you +read them asynchronously and change them by asking the owner. Today that surface is +`core.config.of(ns)` — a fresh RPC on every call, no cache, no dedup, no staleness, no way to be +told it changed. Query is that surface with the missing half added. + +## Old → new + +```ts +// before — a round trip per read, a Promise per call, nothing to subscribe to +const shop = core.config.of('os_shop'); +const prices = await shop.server.get(); +await core.config.of('os_shop', { actorId: player.id }).server.patch({ taxRate: 0.1 }); + +// after — typed keys, readable now, refetched by rules, observable +const prices = peerConfig('os_shop').server; // QueryKey +core.query.observe(prices).get().data; // value or undefined — this tick, always +await core.query.mutation(prices).mutate({ taxRate: 0.1 }, { actorId: player.id }); + +const balance = peerDoc('os_shop', 'balances', playerId); // a db document +const { data, status } = useQuery(balance); // in a screen +``` + +## Keys — *Decided* + +A key is an array, `[namespace, collection, ...path]`, so invalidation is hierarchical exactly as in +TanStack. Nobody writes the array by hand: typed factories build it and carry the result type. + +```ts +peerConfig(ns) // .server → [ns,'config','server'] .player(id) → [ns,'config','player',id] .dimension(id) +peerDoc(ns, collection, key) // [ns, collection, key] +peerAll(ns, collection) // [ns, collection] — the owner's index walk, paged + +type QueryKey = readonly string[] & { readonly [resultBrand]?: T }; +``` + +A typo in a collection name is not a compile error on its own — the string is the peer's — but the +factory is one place to put a published `Collections` type when a peer exports one, the same way a +`ConfigDefinition` is published today. + +## The client — server-side surface + +```ts +core.query.observe(key) // ReadonlyObservable> — creates or reuses the cache entry +core.query.getData(key) // T | undefined, no side effects +core.query.fetch(key) // Promise — force, dedups with anything in flight +core.query.invalidate(prefix) // mark every key under the prefix stale; refetch on next observe/read +core.query.setData(key, next) // write the cache without asking anyone (optimistic seeds, tests) +core.query.mutation(key, hooks?) // Mutation +``` + +```ts +interface QueryResult { + data: T | undefined; + status: 'pending' | 'success' | 'error' | 'unavailable'; + fetchStatus: 'idle' | 'fetching'; + isStale: boolean; + error?: string; // the owner's denyReason text, verbatim +} + +interface Mutation extends ReadonlyObservable { + mutate(patch: DeepPartial, options?: { actorId?: string }): Promise; +} +// hooks: onMutate(patch) → context, onError(reason, context), onSettled(result | undefined) +``` + +`observe(key)` returns an **observable** (`@bedrock-core/observable`), so `computed([q])` +and `useObservable(q)` work unchanged, and an observable-driven host binds to it like anything else. + +## The hooks + +```ts +const { data, status, isStale } = useQuery(balance); // = useObservable(core.query.observe(balance)) +const { mutate, isPending, error } = useMutation(balance, { onError }); +``` + +Sugar over the client — nothing a hook can do that `core.query.*` cannot from plain server code. + +Options, per key or per namespace via `core.query.defaults(ns, options)`: + +| Option | Default | Meaning | +| --- | --- | --- | +| `staleTime` | 100 ticks (5 s) | how long `data` counts as fresh; no refetch while fresh | +| `gcTime` | 2 400 ticks (2 min) | unobserved + stale for this long → dropped from the cache | +| `maxEntries` | 256 per namespace | oldest unobserved evicted first — QuickJS realm memory is finite | + +## Refetch triggers — the Minecraft set + +| Trigger | Fires when | +| --- | --- | +| stale on observe/read | `staleTime` elapsed since last success — stale-while-revalidate, `data` stays available | +| **owner invalidation** | the owner mirrors the value on a `shared` key or emits an event; a query names whichever it wants and every cache for that key goes stale the same tick — or, from a mirrored key, simply has the new value | +| peer joins | registry `onRegister(ns)` — everything cached for `ns` goes stale | +| peer leaves | registry `onUnregister(ns)` — `status` becomes `unavailable`, `data` kept | +| after `mutate` | the reply *is* the new value (config RPC already answers read-after-write); no extra fetch | + +Absent by design: window focus, network reconnect, polling intervals. There is no window and no +network; there is a registry that knows exactly when a peer comes and goes. + +## Warm caches — how `shared` and query meet + +A query definition may name a shared key as `warm`. The mirror already holds that value in every +realm, so `observe(key)` resolves `success` on first read with **no RPC**, and owner writes arrive +as mirror deltas. The consumer's code is identical either way — the owner chose to mirror the +value, the consumer never sees which path it took. + +That is the whole relationship between the two: `shared` is one strategy for keeping a query's +cache warm, and it is the owner's call to make per value, never per collection. + +## Mutations + +`mutate` runs `onMutate`, applies the patch to the cached value immediately (optimistic), calls the +served `write` endpoint with the `actorId`, and on the reply either +replaces the cache with the authoritative value or **rolls back** to the `onMutate` snapshot and +sets `status: 'error'` with the owner's reason. In-flight requests for one key are deduplicated: +two screens asking for the same document in one tick produce one RPC. + +Own data never goes through this: `core.config` and `core.db` on your own namespace are +synchronous and authoritative. Same API for own and peer would make the fast path async by +accident, so they stay different on purpose. + +## What is not copied from TanStack Query + +- **Suspense.** A screen is compiled and tick-driven; nothing can suspend. `data` is readable every + tick, `undefined` plus `status` says why. +- **`unavailable` is not `error`.** A peer that is not in the world is absent, not failing. The + registry knows; no retry loop. +- **Infinite queries, prefetching, dehydration, devtools.** Nothing here paginates, navigates or + reloads a page; `peerAll` pages through the owner's index and that is the whole story. +- **Hand-written key arrays.** Factories only — the array is the wire format, not the API. + +## Replaces + +- `RemoteConfigAccessor` / `TypedRemoteConfig` / `core.config.of(ns)` — kept one minor as a + deprecated alias over `core.query.observe(peerConfig(ns).*)`. +- The live-value-push proposal (`core:config.watch` / `core:config.changed`) — an owner that wants + peers told emits an event or mirrors a value, and a query subscribes to whichever it named. + +## Measure + +[S6](./spikes/S6-shared-bus-cost.md) sets what may be shared. Query itself adds one +number: cache memory at `maxEntries` × a 1 KB document, to confirm the default is sane in a +QuickJS realm. diff --git a/docs/06-patching.md b/docs/06-patching.md new file mode 100644 index 0000000..691f1e5 --- /dev/null +++ b/docs/06-patching.md @@ -0,0 +1,176 @@ +# 06 — Patching — *Proposed: `script_eval` first* + +One addon changes the behavior of another, in the spirit of Java Mixins, without the target being +designed around every addon that might want to. Reopened 2026-09-02 after the +[functions-from-strings spike](./spikes/S-function-from-string.md): with the `script_eval` +manifest capability a target realm can compile a patcher's source text at native speed, which +makes real, synchronous patches possible. That is the tier built first. The **expression tree** +tier — the same thing with a bounded evaluator instead of `Function` — is parked as the fallback +if in-game tests or the security verification below say so. + +## Principle for the docs — *Decided* + +Stated as a rule, not a recommendation: + +> **Typed RPC first.** A capability you *intend* others to use is a typed RPC method. A patch point +> is an admission that a normal API is insufficient for this one function — it is opt-in, versioned, +> and not a stable public API. + +## What the boundary forbids — and the one thing it allows + +None of the facts below can be engineered around; the design fits them. + +1. **No code object crosses realms.** Only data does — and with `script_eval`, *source text is + data*. The target compiles it; nothing of the patcher's realm comes with it. +2. **No reply arrives on the tick it was asked for.** So the patch body must already live in the + target when the call happens: sent once at registration, compiled once, called forever. +3. **Only serializable values cross.** Irrelevant here — the body runs where the real `Player`, + `Block`, `ItemStack` already are. Nothing about the call crosses at all. +4. **`Function` needs `script_eval` in the manifest of the pack that calls it** — the target. + Measured: native speed, ~9 µs per compile, body scope is exactly `console` and `print`. No + imports, no closures — everything the body uses is passed in. The capability (per the user) + excludes the pack from Marketplace; *verify against current guidelines* before this is + documented as a rule. + +Consequences carried over from the first review: + +- The target **must declare the point**. No build step can make an undeclared function reachable; + reaching it means the target's own code dispatches to the patch. +- `@PatchPoint()` cannot exist — decorators do not apply to standalone functions. The wrapper is a + plain call. + +## The shape — *Proposed* + +### Target + +```ts +// os_shop — manifest declares "capabilities": ["script_eval"]; the build refuses this call otherwise +export const calculateDamage = core.patchPoint( + 'calculateDamage', + (player: Player, damage: number): number => damage, +); +``` + +`patchPoint` returns a function of the same signature. With no patch registered it is the original +plus one `chain.length === 0` check (S3 measures that). Arg names are taken from the function by +the build and become the keys of `ctx.args`. + +### Patcher + +```ts +// drav0011_hardcore — ordinary TypeScript, type-checked against os_shop's published PatchPoints type +core.patch('os_shop:calculateDamage', { + priority: 100, + requires: { version: '^1.2' }, + before(ctx, mc) { + if (ctx.args.player.hasTag('boss')) { ctx.args.damage *= 2; } + }, + after(ctx, mc) { + if (ctx.result > 100) { mc.world.sendMessage(`${ctx.args.player.name} hit for ${ctx.result}`); } + }, +}); +``` + +Also `around(ctx, mc, next)` (call `next()` for the rest of the chain, or not) and `replace` — +`around` with no `next`, at most one patcher per point. + +**Every handler runs synchronously in the target's realm.** `ctx.args` are the live objects the +target was called with; `mc` is the `@minecraft/server` namespace the target passes in, so +`world` / `system` are available without an async import. The patcher's own state is reachable +through `ctx.shared.get(ns, key)` — the shared mirror every realm holds — and that is the +patcher's way to hand its config or flags to its patches (publish what a patch needs via +`core.shared`; config *values* are not mirrored and are not visible here). + +**What a handler cannot use**: anything from the patcher's module scope — imports, closures, +its own functions and maps. The build rejects each free variable with the line and the name; +allowed identifiers are the parameters, JS globals (`Math`, `JSON`, `Object`, …) and `console`. + +### What crosses, and when + +1. Build: the patcher's handlers are extracted as source strings with their free-variable check + passed, into `patches.generated.json`. +2. Boot: the patcher registers with `core:patch.register { point, handlers: { before?: string, … }, + priority, requires }` over RPC to the owner. +3. Owner: validates the point exists, the version satisfies `requires`, no second `replace`; then + `new Function('ctx', 'mc', 'next', body)` per handler, once. Rejections go back with a reason + the patcher logs. +4. Calls: the chain runs in place. Nothing is sent. + +## Failure isolation — and the one thing it cannot catch + +Each handler runs inside a `try`. A throw is caught, counted, logged with the patcher's namespace +and the point, and after N consecutive failures the patch is disabled with a diagnostic — the +robustness rule of the [trust model](https://bedrock-core.drav.dev/docs/server/guides/trust-model). +The target keeps running unpatched. + +**An infinite loop is not a throw.** A handler that never returns trips the script watchdog, and +the watchdog terminates the *target's* realm — the patcher's bug takes down the addon it patched. +No runtime mechanism catches this; it is the accepted cost of running real code, and the first +item on the verification list below. If it turns out unacceptable, the expression tree tier +(bounded by construction) is the answer. + +## Security verification — before this ships + +The trust model does not change: a pack can already do anything to the world from its own realm. +What `script_eval` adds is a patcher's code running with access to whatever `ctx` exposes of the +target. Keep `ctx` minimal — args, result, `cancel`, `state` — and never pass the target's module +scope. Then verify, in game: + +1. **Watchdog behavior.** An infinite loop in a handler: which realm dies, does the world survive, + is it recoverable without a restart, what does the content log say. +2. **Scope containment.** Measured already: `globalThis` inside a compiled body is `console,print`. + Re-check with `mc` passed in that nothing else leaks (prototype walks from `ctx.args.player` + reach the engine, which is fine — the engine is world-global anyway). +3. **Diagnostics.** Does an exception thrown inside a `Function` body carry a stack the log can + attribute to the patcher? If not, the wrapper stamps the namespace on every log line. +4. **Marketplace.** The `script_eval` restriction is user-stated. Find the rule in writing. +5. **Cost with real handlers.** S3 below. + +## Build-time responsibility + +Target side (the bundler filter): + +1. Discover `core.patchPoint('', fn)` calls; emit `patch-points.generated.json` — name, arg + names, and the function's TS signature. +2. Refuse the build if any point exists and the manifest lacks `"capabilities": ["script_eval"]`. +3. Export a `PatchPoints` type per addon, published the way a config type is, so a patcher gets + `ctx.args` and `ctx.result` typed. + +Patcher side: + +4. Extract each handler's source; run the free-variable check; emit `patches.generated.json`. +5. Stable ids are `:` from the string the author wrote, never a file path. + +**Against auto-marking every export.** The bundler could wrap every exported function. It would +cost the dispatch check on every call and turn every internal refactor into a breaking change for +someone. Not in 1.0; revisit only if asked. + +## Runtime responsibility + +- Owners publish their point manifest to replicated state under `core-patch/points` (small, static — like the config schema). +- Registration, validation and rejection as above; `requires` checked against the registry's known version of the target. +- Chain order: `priority` desc, then namespace asc — deterministic, never load order. +- A patcher leaving (registry `onUnregister`) drops its patches; rejoining re-registers. +- `core.patches.disable(point, ns)` for the failure-isolation rule and for operators. + +## Parked: the expression tree tier + +Same registration, same chain, same `ctx` — but the handler is compiled at build into an +expression tree and evaluated by a small evaluator the runtime ships. Pure expressions over args, +result, state; `cancel` / `returnWith`; no statements, loops or engine calls (those go async via a +notify-after event). Bounded by construction, no manifest capability, Marketplace-safe, ~1 week. +Built only if the verification above fails or a Marketplace-bound addon needs to *host* points. + +## Measure — S3 + +1. Per-call overhead of `patchPoint()` with zero patches, one string handler, five — in a tight + loop in game. Bar: zero-patch cost indistinguishable from a plain call. +2. Registration round trip: ticks from patcher boot to a compiled chain in the owner, one and + three patchers. +3. Verification items 1–3 above, recorded in a findings page whichever way they go. + +## Non-goals + +- Patching functions the owner did not mark. Impossible across realms; not attempted. +- Passing the target's module scope, or anything beyond `ctx` and `mc`, into a handler. +- Patching the framework itself. Runtime functions are not patch points. diff --git a/docs/07-capabilities.md b/docs/07-capabilities.md new file mode 100644 index 0000000..3013bff --- /dev/null +++ b/docs/07-capabilities.md @@ -0,0 +1,165 @@ +# 07 — Capabilities — *Proposed* + +Two addons that both ship an energy library must run **one** grid, not two. Bedrock has no shared +module system, so every addon bundles its own copy of every library: the code is duplicated by +construction and only the world state can be shared. A capability is that shared thing named — a +contract several addons implement, with exactly one of them running it and the rest talking to it +through the same local API. + +Not started. What is below is the shape and the questions that have to be measured before any of +it is written. How a capability plugs into `register()` — the declaration contract, the election +helper, the owner-namespace state — is the workspace root `PLAN.md` §2.4. + +## The concern it names + +`core.slot()` answers *what can this runtime do* — the config registry, translations, guides. That +is per runtime, and most extensions stop there. A capability answers a different question: *who +runs the world's energy grid*. It survives the addon that started it being removed, it must not +run twice, and every addon that ships the library has to see the same state through its own copy +of the code. + +The pieces already exist and are wired to one hard-coded job. `HostElection` picks a realm by a +pure function of the registry — highest runtime version, ties broken by lowest namespace, no +negotiation messages, re-run whenever a peer appears or disappears. It elects once, globally, for +"who draws the UI". Generalising that election per capability is most of this page. + +## Local first, shared only where it must be + +Most extensions are **local** and stay local. An addon's config is its own — the registry, the +values and the screens belong to it, nothing is elected, and no other realm ever draws its +settings. Pressing another addon in the list hands the player to THAT addon's list page, a +sibling screen in its own pack, and everything from there — its config, its guide — is served by +the addon that owns it. What crosses a realm is navigation, not settings: the screen references +an addon publishes so a key resolves, and values a peer asks for by rpc. + +A capability is the exception, for state no addon can own alone: an energy grid, a fluid network, +a physics world, heat. Election is reserved for those. The test is not "is it shared code" but +*would two of these running at once be wrong*. + +## The shape + +A capability is a field of the one `register()` call, like config or guides. The library exports +a declaration factory built over the floor's `capability(spec)`, itself a `Declaration`: + +```ts +// the addon +const { energy } = core.register({ manifest, energy: registerEnergy({ implementation: '1.4.0' }) }); + +energy.grid.at(pos).charge; // read — from the mirror, local, no round trip +await energy.connect(a, b); // topology — may cross a realm boundary +energy.flow(pos, 40); // per-tick — never crosses one + +// @bedrock-core/energy +export const registerEnergy = (options: { implementation: string }) => capability({ + id: 'core:energy', // namespaced like feeds, slots and rpc methods + contract: 2, // MAJOR — a different major is a different capability + implementation: options.implementation, // MINOR — decides which compatible copy runs + state: energyState, // the shared tree the owner writes, everyone mirrors + tick: grid => grid.step(), // runs in the owner only + failover: 'adopt', // see below — each capability declares its own +}); +``` + +The call site is identical in every addon whether or not this realm won the election. + +### Rules + +1. **Major is identity.** `core:energy@1` and `core:energy@2` are different capabilities, elected + separately. Incompatible majors coexist instead of corrupting one another. +2. **Minor decides the winner.** Among compatible copies the newest implementation runs, by the + rule `HostElection` already applies. +3. **Reads are local, writes are owned.** The owner writes the capability's shared tree; every + realm mirrors it. A read is a mirror lookup and costs nothing. +4. **Fast path never crosses a realm.** Energy *flow* runs every tick and is computed by the owner + inside its own tick; *connecting* a machine to the grid is a topology change and may take a + round trip. A capability that cannot express that split is not ready to be one. +5. **One capability, one owner — not one owner for everything.** Election is per capability, so + energy and physics can be owned by different addons, which is also how the load spreads. + +## Failover is the capability's own problem + +An owner can disappear at any time — its addon is removed, its realm unloads. What happens next is +not something the floor can decide for a grid it knows nothing about, so each capability declares +it: + +- **`adopt`** — the state is durable and the new owner continues from it. Needs a fence (below). +- **`rebuild`** — the state is derivable from the world (blocks, entities) and the new owner + recomputes it. Slower, needs no durability. +- **`drop`** — the capability simply stops until an owner returns. Correct for anything cosmetic. + +Whatever the policy, a **fence** is required: a generation counter bumped at every election, with +writes carrying the generation they were made under. A write from a deposed owner arriving late is +rejected rather than applied. Without it, failover silently corrupts the state it was meant to +save. + +Proposed 2026-09-14 (`PLAN.md` §2.4): the state lives under `core-capability//…` in the +OWNER's own namespace, and the election names which namespace every realm reads. Sync applies a +namespace's writes only from the node whose id it is, so a deposed owner's late write lands where +nobody reads — the fence falls out of ownership, with no counter and no change to +`ownedNamespaces`. `adopt` then means: copy the previous owner's last mirrored keys into your own +namespace, and continue. + +## What changes in what is built + +| Today | After | +| --- | --- | +| `HostElection`, one global election, used only for the UI | `elect(capability)`, one election per capability id | +| `core.host.isHost` / `hostId` in the config app | nothing elects — a screen is drawn by the addon whose pack holds it, and navigation moves the player there | +| `ownedNamespaces: [namespace]`, fixed at register | unchanged under the owner-namespace proposal; otherwise a capability namespace whose owner changes on failover | +| config reachable as a runtime property | a local plugin found through its slot, serving its own addon only | + +Phase D of the workspace plan removes election from the UI, and it is right to: a compiled screen's +layout lives in its owner's resource pack, so drawing must happen where the pack is. That is a +statement about screens, not about election. The mechanism stays, with no user — **dormant until +the first capability needs it** — rather than being deleted and rebuilt from memory later. + +## Startup churn + +Addons load at different times, and the election is a pure function of the registry: every peer +that appears re-runs it. A world with ten addons can therefore hand one capability's ownership +over several times in the first few seconds, each time a newer implementation shows up. + +That is correct behaviour and it is also when handover is cheapest — early owners hold little or +no state. It still constrains the design in two ways: + +1. **A handover must be safe to repeat.** Whatever `failover` does, it happens N times at startup, + not once. `rebuild` that walks the world is wrong if it runs ten times; `adopt` must be + idempotent. +2. **Expensive work waits for quiet.** A capability should not start ticking or building state on + the frame it wins — it should settle first, by a window of no registry change. How long that + window is, and whether it is a count of ticks or a signal from the registry, is unmeasured. + +The alternative — sticky ownership, where the first owner keeps it — is rejected: it makes which +implementation runs depend on load order, so the same world behaves differently between sessions. + +## What has to be measured first + +Four spikes, each settling one thing this page assumes. Under the owner-namespace proposal, 1 is +not needed and 3 reduces to proving that `State.droppedForeign` counts a non-owner's delta; 2 +becomes per-key delta volume at 20 t/s; 4 is unchanged. + +1. **Can a namespace's owner change at runtime?** `ownedNamespaces` is fixed when the node starts, + and sync's late-join snapshot exchange answers for owned namespaces only. Hand a namespace from + one realm to another and see whether every peer agrees afterwards. +2. **What does a crossing write cost at tick rate?** Measure an rpc round trip per mutation against + a batched one, at 20 t/s, with the grid sizes a real addon has. This is what decides whether + rule 4 is a guideline or a hard boundary. +3. **Is a fenced write actually rejected?** Force a failover with a slow write in flight and prove + the old generation is refused. +4. **How long is startup churn?** Count the ownership handovers a capability sees in a world of + ten addons, and how long after the last one the registry goes quiet. That number is the settle + window. + +Until 1 and 3 have numbers, `failover: 'adopt'` is a hypothesis. + +## Open questions + +- **Data skew.** Election picks the newest writer, so an older reader must tolerate a tree written + by a newer minor. That is an encoding rule — decided once, enforced by the library, not the floor. +- **Load distribution.** Per-capability election spreads owners across addons, but nothing balances + them deliberately. Whether that needs to be more than incidental is unknown until something owns + two expensive capabilities at once. +- **Discovery for a capability nobody implements.** An addon that needs energy and finds no + implementation should degrade, not throw. `registry.onDependenciesSatisfied` and + `features.add({ condition })` already express exactly this and should be the answer rather than a + new one. diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..964be0a --- /dev/null +++ b/docs/README.md @@ -0,0 +1,14 @@ +# Design notes + +What is built is documented on the [docs site](https://bedrock-core.drav.dev/docs/server). This +folder holds what is not built yet, and the measurements the code's comments cite. + +| Page | What it is | +| --- | --- | +| [04-query](./04-query.md) | A peer's data as a cache with a lifecycle: typed keys, `core.query.*`, `useQuery` / `useMutation`, invalidation over the three channels. Deferred until a screen needs it. | +| [06-patching](./06-patching.md) | Cross-addon patching via `script_eval` string handlers. Deferred; needs S3 and a security verification first. | +| [07-capabilities](./07-capabilities.md) | One grid, many addons: a contract several addons implement with one elected owner per capability, local plugins for everything that is not shared. Proposed; four spikes first. | +| [spikes/](./spikes/) | Findings pages — every measured number a comment in `packages/*/src` relies on. | + +Each spike is one probe in `packages/test-addon`, driven by a custom command, with its numbers +recorded whichever way they go. The probe is deleted once the page is written. diff --git a/docs/spikes/S-function-from-string.md b/docs/spikes/S-function-from-string.md new file mode 100644 index 0000000..b4dcd5e --- /dev/null +++ b/docs/spikes/S-function-from-string.md @@ -0,0 +1,48 @@ +# Spike — functions from strings + +**Question.** Can a patch body travel as source text and be compiled in the target realm? + +**Answer (2026-09-02, 1.26.43, in game): yes, but only with the `script_eval` manifest +capability — which the target pack must declare, and which (per the user) Marketplace content +may not use.** + +## Without the capability + +| Call | Result | +| --- | --- | +| `new Function('a', 'return a * 2')` | `TypeError: Function from string is not supported` | +| `Function('return 1 + 1')` | same | +| `eval('1 + 1')` | `ReferenceError: 'eval' is not defined` | + +## With `"capabilities": ["script_eval"]` in `manifest.json` + +| Probe | Early execution | Tick 1 | +| --- | --- | --- | +| `new Function('a','return a*2')(21)` | `42` | `42` | +| `Function('return 1+1')()` | `2` | `2` | +| `eval('1+1')` | `2` | `2` | +| body sees module import `world` | `undefined` | `undefined` | +| body sees `console` | `object` | `object` | +| `Object.keys(globalThis)` inside the body | `console,print` | `console,print` | +| handle passed as a parameter, `w.getAllPlayers().length` | early-execution refusal (engine rule, not eval's) | works | +| `ctx` bag passed in and mutated (`ctx.args.damage *= 2`) | `10` | `10` | +| `import("@minecraft/server")` inside the body | returns a Promise | returns a Promise | +| 100 000 calls, native vs compiled | 13 ms vs 11 ms | 11 ms vs 12 ms | +| 1 000 compiles | 9 ms | 9 ms | + +## What it means + +- **Speed is a non-issue.** A `Function`-compiled body runs at native speed — the engine + compiles both to the same bytecode — and a compile costs ~9 µs. Compile once at registration, + call forever. +- **A compiled body sees nothing of either realm.** Its scope is `console` and `print`. No + module imports, no patcher closures. Everything it uses must be passed as parameters — the + `ctx` bag, and the engine module namespace itself if the body needs `world`/`system` + synchronously (dynamic `import()` works but is async). +- **The target opts in, not the patcher.** The capability is a manifest field on the pack that + calls `Function`, i.e. the one hosting the patch point. Declaring it is what would cost that + pack its Marketplace eligibility — so a Marketplace-bound addon can never host string patches, + whatever the patcher does. + +Probe was `packs/BP/scripts/probe-function.ts` in `test-addon` with the capability added to its +manifest; both removed once this page was written. diff --git a/docs/spikes/S2-dynamic-property-costs.md b/docs/spikes/S2-dynamic-property-costs.md new file mode 100644 index 0000000..8750b9a --- /dev/null +++ b/docs/spikes/S2-dynamic-property-costs.md @@ -0,0 +1,57 @@ +# Spike S2 — world dynamic property costs + +**Measured 2026-09-03 on the 1.26.50 preview**, same probe as [S4](./S4-block-documents.md) +(`s2` step), in a live world with the player nearby. + +## Write and read cost + +| Operation | Total | Per call | +| --- | --- | --- | +| 1 000 × `setDynamicProperty`, 1 KB string | 17 ms | **17 µs** | +| 100 × `setDynamicProperty`, 30 KB string | 19 ms | **190 µs** | +| 100 × `getDynamicProperty`, 30 KB string | 3 ms | **30 µs** | +| 2 000 × short-string `set` (from S4) | 21 ms | 11 µs | +| 2 000 × short-string `get` (from S4) | 2 ms | 1 µs | + +Cost scales with payload, not with the number of properties: a 30 KB write is ~11× a 1 KB write. + +## Write-behind vs write-through + +1 000 logical writes to 50 distinct keys in one tick: + +| Mode | Cost | +| --- | --- | +| write-through (1 000 DP writes) | 16 ms | +| write-behind (dirty map, 50 DP writes) | 4 ms | + +Coalescing pays **4×** — but the write-through case is *1 000 writes in one tick*, a third of a +tick budget at an extreme rate. At 100 writes per tick it is 1.6 ms; at 10 it is noise. + +## The cap + +| Length | Result | +| --- | --- | +| 32 704 chars | ok, read back intact | +| 32 768 chars | `ArgumentOutOfBoundsError: … String length … Argument max: 32767` | +| 40 960 chars | same | + +**A DP string holds at most 32 767 characters and the engine throws, never truncates.** + +## Index at scale (from S4, chunked) + +| Keys | Chunks | Chars | Write | Parse | `getBlock` walk | +| --- | --- | --- | --- | --- | --- | +| 1 000 | 1 | 23 999 | 0–1 ms | 0 ms | 8–9 ms (all loaded) | +| 5 000 | 4 | 119 996 | 0 ms | 2 ms | 50 ms (2 756 loaded; unloaded chunks return `undefined` fast) | + +## Not measured + +World-save impact at several MB of DPs (item 4). Nothing in the probe writes enough to see +it; left for a real addon to report. + +## Decision — see [03-db](../03-db.md) + +DP writes are cheap enough that **write-through is the default everywhere**; coalescing is a +per-collection opt-in for hot counters, not the store's reason to exist. The store is still a +subsystem — for block documents, validity, one `Location` seam and lazy per-document migrations — +not for batching. diff --git a/docs/spikes/S4-block-documents.md b/docs/spikes/S4-block-documents.md new file mode 100644 index 0000000..c35fabe --- /dev/null +++ b/docs/spikes/S4-block-documents.md @@ -0,0 +1,75 @@ +# Spike S4 — block documents on native block dynamic properties + +**Measured 2026-09-03 on the 1.26.50 preview**, custom block `drav0011_economy:s4_probe` with +`format_version` 1.26.50 and `minecraft:block_entity { dynamic_properties: true }`, no experiment +toggle. Probe: `packs/BP/scripts/probe-s4.ts` + `packs/BP/blocks/s4_probe.json` in `test-addon` +(deleted once this page was complete). + +## Graduation + +| Question | Answer | +| --- | --- | +| Works without the Upcoming Creator Features experiment? | **Yes.** `hasComponent('minecraft:dynamic_properties')` true, `get`/`set`/`totalByteCount` work | +| `format_version` | `1.26.50` accepted | +| Size cap | `totalByteCount` 937 at a 900-char value → **~1 024 bytes per pack per block including key and overhead** (37 bytes for key `doc` + empty value). 1 000 chars **throws** `Error: World metadata storage limit exceeded` — no truncation. Usable document ≈ 950 bytes | +| Survives world reload | **Yes** — doc read back identical after quit + reopen | + +## Removal coverage — does the document die, does `onBreak` fire + +| Means | Block after | Document | `onBreak` fired | +| --- | --- | --- | --- | +| `/setblock … air replace` (default mode) | air | gone | **yes** — docs say no; measured yes | +| `/setblock … air destroy` | air | gone | yes | +| `/fill … air` | air | gone | yes (once per block — 1 000 for the cube cleanup) | +| script `setPermutation(air)` | air | gone | **yes** — docs say no; measured yes | +| `createExplosion(r=2, breaksBlocks)` | air | gone | yes | +| player break (creative) | air | gone | yes | +| water source beside / above | unchanged | intact | — | +| piston push | **block does not move; piston does not extend** | intact | — (the later `setblock air` reset fired it) | +| re-place the same type after removal | new block | **fresh** (empty), not the old value | — | + +Inside `onBreak`, `ev.block.typeId` is already `minecraft:air`, `Object.keys(ev)` is empty, and +the document reads `undefined` — **the document is not readable during `onBreak`**. An index +must key by location, never by document contents. + +## Cost + +| | Native block DP | World DP | +| --- | --- | --- | +| 2 000 × `set` (short string) | 22 ms → **11 µs** | 21 ms | +| 2 000 × `get` | 3 ms → **1.5 µs** | 2 ms | + +Parity. 1 000 block entities placed with a document each: **49 ms** total; tick interval +49.98 → 49.99 ms (no measurable load; RAM not measurable from script — nothing visible in +the game at that count). + +## Index + +1 000 keys (`overworld:x,y,z`, 24 chars each): 23 999 chars in one DP — write 1 ms, parse 0 ms, +`getBlock` walk of all 1 000 (loaded) **8 ms**. + +5 000 keys = 119 999 chars → `ArgumentOutOfBoundsError: String length … Argument max: 32767`. +**A dynamic property string caps at 32 767 characters and throws** — the S2 "failure mode" +question, answered by accident. The index chunks across DPs (≤ ~1 300 keys of this shape each); +re-measured in the `s2` step. + +## Validity (S2.6) + +| Question | Answer | +| --- | --- | +| `world.setDynamicProperty` inside `beforeEvents.playerLeave` | **Allowed**, and the value persisted across the session | +| `entityRemove` on chunk unload | **Fires** (armor stand, player walked away) — the event does not distinguish unload from death | +| `entityLoad` on return | fires, `isValid` true | +| `world.getEntity(id)` after reload for a loaded entity | returns the entity, `isValid` true | + +## Consequences for [03-db](../03-db.md) + +- Native backend is the default: graduation confirmed, orphan problem gone by construction. +- Document budget documented as **~950 bytes**; the store refuses larger with the collection named. +- `onBreak` covers every removal measured, including the two the docs exclude — the `worldKeyed` + fallback's residue is smaller than written; keep validate-on-read anyway (undocumented ≠ guaranteed). +- Index: chunked DPs, keyed by location; `onBreak` cannot read the doc, so no per-document + metadata in the index. +- Entity write-behind: parking on `entityRemove` is right — it fires for unload too, and the + entity comes back through `entityLoad`. +- Player writes can flush in `beforeEvents.playerLeave` — the park step is unnecessary for players. diff --git a/docs/spikes/S5-abi-survey.md b/docs/spikes/S5-abi-survey.md new file mode 100644 index 0000000..c911a1c --- /dev/null +++ b/docs/spikes/S5-abi-survey.md @@ -0,0 +1,86 @@ +# Spike S5 — dynamic-property ABI survey + +**Measured 2026-09-05/06 on 1.26.50** with `probe-abi.ts` in `test-addon` (`/drav0011_economy:abi all`). One +`describe()` per target: methods present, every `/ynamic/` member on the prototype chain, both +component ids, write-then-read on the same handle and on a **freshly fetched** handle, +enumeration, `Vector3`, `setDynamicProperties`, byte budget by ladder, 1 000× cost. + +## The table the resolver is built from + +| Target | ABI | Fresh-handle read | Enumerable | Batch | Cap | `set` / `get` | +| --- | --- | --- | --- | --- | --- | --- | +| `World` | direct | n/a (singleton) | yes | yes | 32 767 chars, throws | 10 µs / 1 µs | +| `Player` | direct | **sticks** | yes | yes | 32 767, throws | 10 µs / 2 µs | +| `Entity` (armor stand) | direct | **sticks** | yes | yes | 32 767, throws | 10 µs / 2 µs | +| `Dimension` | **none** — no method, no `getComponent` | — | — | — | — | proxy required | +| `ItemStack`, constructed `minecraft:stone` | direct methods present | — | — | — | — | **write throws** `UnsupportedFunctionalityError: Cannot set dynamic properties on stackable items` | +| `ItemStack` from `slot.getItem()` (non-stackable) | direct | **`undefined`** — the write landed on a copy | yes (on the copy) | yes | 32 767 | 12 µs / 2 µs | +| `ContainerSlot` holding a **non-stackable** item | direct | **sticks** | yes | yes | 32 767, throws | **327 µs** / 2 µs | +| `ContainerSlot` holding a **stackable** item (the mined probe block) | direct methods present | — | — | — | — | **write throws** `Cannot set dynamic properties on stackable items` — the live slot too, not only the copy | +| Block with `minecraft:block_entity` | component `get` / `set` / `totalByteCount` — reproduced here and in [S4](./S4-block-documents.md) | **sticks** | **no** | no | 900 chars ok, **throws at 1 000** | 11 µs / 1 µs | +| vanilla block (`minecraft:stone`) | **none** — no `minecraft:dynamic_properties` component | — | — | — | — | proxy required | + +Every direct-ABI host exposes the same six methods — `getDynamicProperty`, `setDynamicProperty`, +`setDynamicProperties`, `getDynamicPropertyIds`, `getDynamicPropertyTotalByteCount`, +`clearDynamicProperties` — and nothing else matches `/ynamic/` on the prototype chain. No hidden +surface. + +## What changed in the design + +1. **`ItemStack` is refused twice over.** A *stackable* item throws on write; a *non-stackable* + item accepts the write on a detached copy and the world never sees it — measured: same-handle + read `probe`, fresh handle `undefined`. Structural typing alone would accept both. The resolver + refuses `ItemStack` by name and points at `ContainerSlot`. +2. **A `ContainerSlot` write is 30× a world write** (327 µs vs 10 µs) — it goes through the item's + NBT. Slot documents stay write-through (the slot can empty next tick) but the cost is documented + and a slot collection is not the place for a per-tick counter. +3. **`minecraft:block_actor_dynamic_properties` exists on every block item**, stackable stone + included — the *component* is there even when the item cannot hold data. Presence of the + component is not sufficient; the resolver must confirm with a write, or prefer the direct ABI + when both are present (it does: direct is checked first). +4. **`Dimension` is permanently proxied** — confirmed by absence: no method, no `getComponent`. +5. **Every direct host shares the 32 767-character cap and ~10 µs write**, so the capability + record's `budget` is one constant for the direct family and ~950 bytes for the component family. + +## Native DDUI observables (`@minecraft/server-ui` 2.1.0, `ObservableNumber`) + +| Question | Answer | +| --- | --- | +| prototype members | `setData`, `getData`, `subscribe`, `unsubscribe` | +| `subscribe(cb)` returns | **the callback itself** — not an unsubscriber; calling it does nothing | +| how to stop listening | **`obs.unsubscribe(cb)`** → `boolean`; re-entrant (re-subscribe fires again, unsubscribe stops it) | +| `setData` with an equal value | **does not notify** — native guards equality itself | +| notification timing | **synchronous**, inside `setData`; nothing deferred to the next tick | +| cost | 1 000 × `setData` = 1 ms (**1 µs**); `getData` ≈ 0; 1 000 constructions = 3 ms | +| `toJSON` | absent — `JSON.stringify` gives `{}` | + +Consequences for the [`toNative` bridge](../01-observable.md#native-observables--the-ddui-bridge): +`dispose()` must call `native.unsubscribe(cb)` with the exact callback it registered — nothing +else releases it; the `Object.is` guard on native → ours is still needed (native does not notify +on equal values, but *our* write into native must not echo back); a native observable is cheap +enough that one per visible control is free. + +## Why the block half failed on the first pass + +The block JSON declared a custom component (`drav0011_economy:s4_probe`) that no script registered +any more — the engine validates block components against the schema *including* script-registered +custom components at startup, and rejects the block outright: `this component was found in the +input, but is not present in the Schema`. Removing the reference fixed it. Rule for db's build step: +a `core:store_block`-style component the build injects must always be registered by the runtime, or +every accepted block type disappears from the world. + +## The mined block-entity item (`place` → break → `item`) + +| Question | Answer | +| --- | --- | +| `block_actor_dynamic_properties` on the drop | **present, empty** — `carried=undefined`, 0 bytes. The document does **not** travel into the drop by default; that is what the `carry_over_block_entity_data` loot function (1.26.40) exists for, and the probe block declared no loot table | +| write to the drop's `ItemStack` | throws — stackable | +| write to the **live `ContainerSlot`** holding it | **throws** — stackable. The slot ABI is not a host for stackable items at all | + +Consequence: a `ContainerSlot` is a host only when its item is non-stackable (`maxAmount === 1`). +The static capability record for `ContainerSlot` is `own: boolean` — the instance decides, exactly +as a block's type does — and the resolver checks `slot.getItem()?.maxAmount === 1` before +offering the slot as a host. Carrying a block document into its drop is opt-in via the loot +function and stays outside the store until someone needs it. + +**S5 is complete.** The probe (`probe-abi.ts`, `blocks/s4_probe.json`) is deleted. diff --git a/docs/spikes/S6-shared-bus-cost.md b/docs/spikes/S6-shared-bus-cost.md new file mode 100644 index 0000000..edb9ad9 --- /dev/null +++ b/docs/spikes/S6-shared-bus-cost.md @@ -0,0 +1,73 @@ +# Spike S6 — what a shared delta costs on the bus + +**Measured 2026-09-08 on BDS 1.26.43.1**, headless through `bc-bds run --tag bench`. Probe: +`packs/BP/scripts/tests/bench-shared.ts` in `test-addon`, kept rather than deleted — it is a +benchmark under the `bench` tag, not a throwaway, and `scripts/bench-report.mjs` reads its +`BENCH {...}` lines out of the transcript. + +Peers are separate `State` instances over their own `Bus`, all in one script realm, talking through +the engine's real `scriptEvent` transport. Broadcasts go over the wire: the loopback shortcut in +`Bus.send` applies only to a message addressed to the sender's own id. + +## Numbers + +Publishing, over 50 runs so the figure is not `Date.now()` noise: + +| Value | µs per `state.set()` | Share of one 50 ms tick | +| --- | --- | --- | +| 1 KB | 380 | 0.8 % | +| 10 KB | 3 120 | 6.2 % | + +Convergence, worst peer of each fan-out: + +| Peers | Value | Ticks to converge | Arrived | +| --- | --- | --- | --- | +| 2 | 1 KB | 0 | 2 of 2 | +| 4 | 1 KB | 0 | 4 of 4 | +| 2 | 10 KB | 0 | 2 of 2 | +| 4 | 10 KB | 0 | 4 of 4 | + +Boot burst — 100 keys of 200 B published in one tick, as a `persisted` shared tree does a tick +after registration: + +| Keys | Applied | Ticks | ms | Snapshot entries a late joiner replays | +| --- | --- | --- | --- | --- | +| 100 | 100 | 1 | 119 | 100 | + +## What the numbers say + +**Publishing is the cost, not delivery.** A 10 KB value costs the owner 3.1 ms on its own tick, 8× +a 1 KB one and 6 % of a tick for a single key. The work is serialization and it lands on the caller +synchronously, so an addon that republishes a large tree every tick will show up as a tick-time +regression in its own realm before anything is visible on the wire. + +**Fan-out is free to the owner.** One broadcast reaches every peer, so 2 and 4 peers cost the +publisher the same. Apply cost is per-peer and does scale, but it is paid in each peer's own realm. + +**A delta converges in the same tick it is sent**, at both sizes and both fan-outs. The established +`send_latency` benchmark reports the same 0 ticks for raw envelopes, so this is the transport's +behaviour rather than an artifact of this probe. + +**The boot burst survives.** All 100 keys applied, one tick later, nothing dropped. + +## What this gates + +**The `shared` cap on db collections is viable, with a size caveat.** Delivery is same-tick and +lossless at 4 peers, so a shared collection's readers are not waiting on the transport. The limit +is document size on the publishing side: at 10 KB a single publish is already 6 % of a tick, so a +`shared` collection wants either small documents or a coalesced publish, not a write-through one +per mutation. + +**Phase 6's warm read from the mirror holds.** A peer's copy is present on the tick after the +owner writes, so a query reading the mirror is reading something current rather than something it +has to justify. + +## Caveats + +`Date.now()` in the engine has millisecond resolution, so the per-delta `ms` figures are noise — +one 1 KB delta reported 66 ms and one 10 KB delta 12 ms in the same run, ordering artifacts rather +than a real difference. Only the 50-run publish figures and the tick counts are load-bearing here. + +Peers share this realm's QuickJS heap. Four separate packs would each parse their own copy of a +value, and that parse is counted once here. Apply and convergence hold; per-realm memory is +unmeasured. diff --git a/docs/spikes/S7-observable-bench.md b/docs/spikes/S7-observable-bench.md new file mode 100644 index 0000000..f28fe4a --- /dev/null +++ b/docs/spikes/S7-observable-bench.md @@ -0,0 +1,35 @@ +# Spike S7 — `@bedrock-core/observable` against the engine's `ObservableNumber` + +**Measured 2026-09-06 in a realm on 1.26.50**, `probe-obs.ts` in `test-addon`, µs per operation, +same operation on both sides. Two runs: before and after switching listener storage to +copy-on-write (allocate on subscribe/unsubscribe, iterate a stable array on delivery). + +| Operation | ours, before | ours, after | native | after ÷ native | +| --- | --- | --- | --- | --- | +| construct ×10 000 | 1.90 | **1.80** | 2.90 | 0.62× | +| `get` ×100 000 | 0.16 | **0.14** | 0.29 | 0.48× | +| `set`, 0 listeners ×100 000 | 1.59 | **0.43** | 0.41 | 1.05× | +| `set`, equal value ×100 000 | 0.24 | **0.24** | 0.41 | 0.59× | +| `set`, 1 listener ×100 000 | 1.79 | **0.54** | 0.87 | 0.62× | +| `set`, 10 listeners ×100 000 | 3.86 | **1.57** | 3.53 | 0.44× | +| subscribe + unsubscribe ×10 000 | 1.00 | **3.80** | 2.60 | 1.46× | +| derived chain (`computed` vs hand-wired `subscribe → setData`), set ×100 000 | 3.72 | **1.32** | 1.66 | 0.80× | +| `batch` of 100 000 sets | 0.30 / set, 1 notification | same | — | — | +| bridged set, ours → native ×100 000 | 2.61 | **1.33** | 0.41 (bare native set) | additive: our set + `getData` + `setData` | +| bridged pull, native → ours ×100 000 | 3.53 | **2.02** | — | additive | + +## What it says + +- **Before the change, every `set` paid ~1.2 µs to spread the listener set into an array**, even + with no listeners. Copy-on-write removed it: parity with the engine on a bare set, ahead on + everything that has listeners, and per-listener delivery at 0.11 µs against the engine's 0.31. +- **The trade is subscribe/unsubscribe** — an array copy each — 3.8 µs a pair. Those happen when a + screen mounts or a form opens, not per tick. +- **`computed` beats a hand-wired native chain** (1.32 vs 1.66) because the intermediate native + `setData` is the expensive step and ours is not. +- **The bridge adds nothing of its own**: a bridged set is exactly one of our sets plus one native + `getData` + `setData`. +- **Absolute scale**: a pathological 10 000 sets in one tick costs 4 ms. Nothing here needs + optimizing further; the point of the bar was "a plain object, not slower than the engine". + +Probe deleted once this page was written. diff --git a/docs/spikes/S8-db-in-game.md b/docs/spikes/S8-db-in-game.md new file mode 100644 index 0000000..7212d58 --- /dev/null +++ b/docs/spikes/S8-db-in-game.md @@ -0,0 +1,74 @@ +# Spike S8 — `@bedrock-core/db` in the engine + +**Measured 2026-09-07 on the 1.26.50 preview**, `@minecraft/server` 2.10.0, custom block +`drav0011_economy:db_probe` with `minecraft:block_entity { dynamic_properties: true }` and the +`blockCleanup` component. Probe: `packs/BP/scripts/probe-db.ts` + `packs/BP/blocks/db_probe.json` +in `test-addon`, driven by `/drav0011_economy:db all` (deleted once this page was complete). +Timings are `Date.now()` deltas over 1 000 operations, so ±1 ms. + +## What works + +| Check | Result | +| --- | --- | +| 1 000 block-entity documents written through `probes.for(block).set()` | all on the block's own properties (`where` → `own: true, budget 950`), nothing but the index on the world | +| `probes.all()` over 1 000 index entries | 1 000 handles, all available, all with a document | +| `blockCleanup` on `setBlockType(stone)`, `setBlockType(air)`, `/setblock … replace`, `/fill … air` | `onBreak` fires for each, **one tick later** — the index shows the drop on the next tick, never in the same call | +| a stone block at a former document's position | `where` → `minecraft:stone is not accepted`, `get` → `undefined`; the old document died with the block entity | +| 1 000 coalesced `patch` on the player, then one flush | the property holds the last value; nothing written before the flush | +| the index after `/fill` over the cube | 0 entries, chunk properties emptied | + +## Cost, and what it took + +Bedrock's script engine is QuickJS: closures and native property lookups dominate, not the +dynamic-property write. Four rounds: + +| 1 000× | round 1 | index write-behind, per-tick memo, single-value writes | classes, cached type decisions | raw engine | +| --- | --- | --- | --- | --- | +| new block document incl. index | 370 ms | 166 ms | **91 ms** | getBlock 3 + getComponent 5 + component.set 14 | +| walk 1 000 index entries (`all()` + `available` + `get`) | 178 ms | 88 ms | **56 ms** | getBlock 3 + getComponent 5 + component.get 3 | +| `resolver.resolve(player)` | — | 15 ms | **5 ms** | id + typeId reads 0 | +| `resolver.resolve(block)` | — | 34 ms | **21 ms** | getComponent 5 | +| `for(player)` with no operation | — | 37 ms | **10 ms** | — | +| `for(player).get()` | 52 ms | 34 ms | **14 ms** | getDynamicProperty 2 + JSON.parse 2 | +| write-through `patch` on one handle | 83 ms | 26 ms | **28 ms** | setDynamicProperty 12 | +| write-through `patch` via `for(player)` each time | 122 ms | 95 ms | **46 ms** | setDynamicProperty 12 | +| cached `get()` on one handle | 18 ms | 1 ms | **1 ms** | — | +| `available` | 18 ms | 1 ms | **1 ms** | — | +| coalesced `patch` | 24 ms | 6 ms | **6 ms** | — | + +What each round removed: + +1. **Index write-behind.** Every add rewrote its whole chunk string (up to 32 767 characters); a + thousand placements meant a thousand growing rewrites. Now a change marks the chunk dirty and + `system.run` writes each dirty chunk once. The index lives on the world, which cannot vanish, + so nothing is at risk. +2. **Per-tick memo.** A handle re-resolved its target on every operation — `getEntity`/`getBlock`, + `getComponent`, a new resolution and store. Within one tick the script is the only actor, so a + handle now resolves once per `system.currentTick`; an engine throw on a target the script itself + removed becomes `DbTargetError`. +3. **Single-value writes** go through `setDynamicProperty`; the batch call is for chunk sets only. +4. **Classes, not closures.** Host, prefixed host, resolution, document store and handle were + closure bundles made per operation (~30 closures per `for()`). They are classes with prototype + methods now, and the resolver trusts a cached type decision instead of probing four methods on + the target again — a block entity skips the direct-ABI probe entirely. + +## What the numbers mean for the design + +- **Write-through `patch` costs ~2.3× a raw property write** (28 vs 12 µs): JSON, a merge, a + chunk-count read, the cache and the change event. Acceptable for documents; not for a per-tick + counter — that is what `coalesce` is for, at **6 µs a patch**, 4.7× cheaper. +- **`for()` is 10 µs on an entity and 30 on a block.** Hold the handle when hammering one target; + the per-tick memo makes a held handle nearly free. +- **A block document is ~90 µs to create, ~55 µs to visit through `all()`.** 1 000 elevators walked + in 56 ms means 20 per tick is 1.1 ms — the example's pacing. +- **The index costs ~65 bytes per block** (`block:minecraft:overworld:1625,-60,1075:drav0011_economy:db_probe`); + 1 000 blocks = 69 KB in three chunks. Fine for thousands, not for hundreds of thousands. A denser + encoding is a later step if a collection ever needs it. +- **`onBreak` is deferred by a tick.** Code that removes a block and reads the index in the same + call sees the old entry; `all()` heals it anyway. + +## Left unmeasured + +- `coalesce` parking across a chunk unload and `entityLoad` — needs a world where the entity's + chunk actually unloads; covered by unit tests against the measured event semantics (S4). +- The `beforeEvents.playerLeave` flush — S4 measured the write is allowed there; not re-measured. diff --git a/eslint.config.mjs b/eslint.config.mjs index d778d59..67d877d 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -12,8 +12,14 @@ export default defineConfig([ { ignores: [ ".yarn/**", + // The Bedrock Dedicated Server tree the GameTest runner downloads and runs from. + ".bds/**", "dist/**", "node_modules/**", + // Regolith's temporary workspace during filter execution. + "**/.regolith/**", + // Pack build output in test fixtures. + "packages/test-fixtures/**/build/**", ], }, @@ -130,5 +136,6 @@ export default defineConfig([ '@typescript-eslint/naming-convention': 'off', }, }, + ]); diff --git a/nodemon.json b/nodemon.json index ea30435..593876b 100644 --- a/nodemon.json +++ b/nodemon.json @@ -1,5 +1,6 @@ { "watch": [ + "packages/i18n/src", "packages/sync/src", "packages/server-runtime/src", "types", @@ -7,9 +8,8 @@ "../ui/packages/flexbox/src", "../ui/packages/navigation/src", "../ui/packages/ore-styled/src", - "../ui/packages/guides/src", - "../ui/packages/config/src", - "../ui/packages/i18n/src" + "../apps/packages/guides/src", + "../apps/packages/config/src" ], "ext": "ts,tsx,js,json", "exec": "node nodemon-execution.js" diff --git a/package.json b/package.json index 05d524d..94e9884 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { - "name": "@bedrock-core/server-monorepo", + "name": "@bedrock-core/server", "version": "0.1.0", - "description": "Monorepo root for the @bedrock-core/server packages — a framework for cross-addon compatible Minecraft Bedrock development", + "description": "A framework for cross-addon compatible Minecraft Bedrock development", "keywords": [ "minecraft", "bedrock", @@ -20,51 +20,119 @@ } ], "repository": "github:bedrock-core/server", - "private": true, "type": "module", + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + }, + "./sync": { + "types": "./src/sync.ts", + "import": "./src/sync.ts" + }, + "./db": { + "types": "./src/db.ts", + "import": "./src/db.ts" + }, + "./i18n": { + "types": "./src/i18n.ts", + "import": "./src/i18n.ts" + }, + "./observable": { + "types": "./src/observable.ts", + "import": "./src/observable.ts" + } + }, + "publishConfig": { + "access": "public" + }, + "files": [ + "src", + "README.md", + "LICENSE", + "!.changeset/**", + "!docs/**" + ], "packageManager": "yarn@4.9.3", "workspaces": [ - "packages/server", + "packages/db", + "packages/i18n", + "packages/observable", "packages/sync", "packages/server-runtime", - "packages/test-addon", - "packages/test-addon-2" + "packages/test-fixtures" ], "resolutions": { - "@bedrock-core/ui": "portal:../ui", - "@bedrock-core/ui-runtime": "portal:../ui/packages/ui-runtime", - "@bedrock-core/flexbox": "portal:../ui/packages/flexbox", - "@bedrock-core/navigation": "portal:../ui/packages/navigation", - "@bedrock-core/ore-styled": "portal:../ui/packages/ore-styled", - "@bedrock-core/guides": "portal:../ui/packages/guides", - "@bedrock-core/config": "portal:../ui/packages/config", - "@bedrock-core/i18n": "^0.1.0" + "@bedrock-core/bds-runner": "^0.1.0", + "@minecraft/server": "2.10.0", + "@minecraft/server-gametest": "1.0.0-beta.1.26.50-stable", + "@minecraft/server-ui": "2.2.0", + "@minecraft/vanilla-data": "1.26.50", + "@eslint/js": "^10.0.1", + "@eslint/json": "^2.0.0", + "@stylistic/eslint-plugin": "^5.10.0", + "eslint": "^10.5.0", + "eslint-plugin-minecraft-linting": "^2.0.12", + "globals": "^17.7.0", + "typescript": "^6.0.3", + "typescript-eslint": "^8.62.0", + "vitest": "^4.1.10" }, "scripts": { "regolith-install": "yarn workspaces foreach -i -A --topological --jobs=1 run regolith-install", "build": "yarn workspaces foreach -ptA run build", - "build:libs": "yarn workspaces foreach -A -pt --no-private run build", - "lint:libs": "yarn workspaces foreach -A -pt --no-private run lint", - "prepack": "yarn build", + "build:libs": "yarn workspaces foreach -A -pt --no-private --exclude '@bedrock-core/server' run build", + "lint:libs": "yarn workspaces foreach -A -pt --no-private --exclude '@bedrock-core/server' run lint", + "prepack": "yarn build:libs", "watch": "yarn install && nodemon", "lint": "eslint .", + "test": "yarn workspaces foreach -ptA run test", + "bench": "yarn workspace @bedrock-core/sync run bench", + "test:mc": "yarn workspaces foreach -A --jobs=1 run build:test && bc-bds run --packs packages/test-fixtures/main/build/test --packs packages/test-fixtures/peer/build/test --tag core --expect-registered 18", + "test:mc:bench": "yarn workspaces foreach -A --jobs=1 run build:test && bc-bds run --packs packages/test-fixtures/main/build/test --packs packages/test-fixtures/peer/build/test --tag bench --expect-registered 9 --idle 120 && node scripts/bench-report.mjs", + "bench:report": "node scripts/bench-report.mjs", + "bds:fetch": "bc-bds fetch", + "bds:where": "bc-bds where", "changeset": "changeset", - "version-packages": "changeset version && node scripts/sync-runtime-version.mjs", - "release": "yarn lint:libs && yarn build:libs && node scripts/publish-tarballs.mjs && changeset tag" + "version-packages": "changeset version && node scripts/sync-meta-version.mjs && node scripts/sync-runtime-version.mjs", + "release": "yarn lint:libs && yarn build:libs && node scripts/publish-tarballs.mjs && node scripts/tag-packages.mjs", + "clean": "node scripts/clean.mjs" + }, + "dependencies": { + "@bedrock-core/db": "workspace:*", + "@bedrock-core/i18n": "workspace:*", + "@bedrock-core/observable": "workspace:*", + "@bedrock-core/server-runtime": "workspace:*", + "@bedrock-core/sync": "workspace:*" + }, + "peerDependencies": { + "@minecraft/server": ">=2.9.0 || >=2.10.0-0", + "@minecraft/server-ui": ">=2.1.0" + }, + "peerDependenciesMeta": { + "@minecraft/server-ui": { + "optional": true + } }, "devDependencies": { + "@bedrock-core/bds-runner": "^0.1.0", "@changesets/changelog-github": "^0.7.0", - "@changesets/cli": "^2.31.0", + "@changesets/cli": "3.0.3", "@eslint/js": "^10.0.1", "@eslint/json": "^2.0.0", + "@minecraft/server": "*", + "@minecraft/server-ui": "*", + "@minecraft/vanilla-data": "*", "@stylistic/eslint-plugin": "^5.10.0", "@types/node": "^26.0.1", + "@vitest/coverage-v8": "^4.1.10", "concurrently": "^10.0.3", "eslint": "^10.5.0", "globals": "^17.7.0", "jiti": "^2.7.0", "nodemon": "^3.1.14", "typescript": "^6.0.3", - "typescript-eslint": "^8.62.0" + "typescript-eslint": "^8.62.0", + "vitest": "^4.1.10" } } diff --git a/packages/db/README.md b/packages/db/README.md new file mode 100644 index 0000000..0ef5a4e --- /dev/null +++ b/packages/db/README.md @@ -0,0 +1,42 @@ +# @bedrock-core/db + +![Logo](https://raw.githubusercontent.com/bedrock-core/server/main/assets/logo/title.png) + +Persisted documents on dynamic properties for `@bedrock-core/server`. The engine offers two ABIs +for them — six methods on the world, entities and container slots; three on a block entity's +component — and nothing at all on a dimension or a vanilla block; this package puts one adapter +over all of it, a **resolver** that decides, per target, where a document can live, and typed +**collections** on top, local by design so nothing in one reaches another realm unless the addon +answers an rpc method over it. + +## Install + +```bash +yarn add @bedrock-core/db +``` + +`@minecraft/server` is a peer dependency, pinned to what your manifest declares; block entities +need a manifest new enough to support the block-entity component. + +## Usage + +```ts +import { createEngineDb } from '@bedrock-core/db/minecraft'; +import { schema } from '@bedrock-core/db'; + +const db = createEngineDb('drav0011_economy'); + +const balances = db.collection('balances', { schema: schema<{ gold: number; lastSeen: number }>() }); + +balances.for(player).get(); // undefined until written; cached after the first read +balances.for(player).patch({ gold: 10 }); // written through, one dynamic property; merges deep +balances.for(player).subscribe(doc => hud.refresh(doc)); +``` + +## Documentation + +https://bedrock-core.drav.dev/docs/db + +## License + +MIT diff --git a/packages/db/examples/elevators.ts b/packages/db/examples/elevators.ts new file mode 100644 index 0000000..0eec7ff --- /dev/null +++ b/packages/db/examples/elevators.ts @@ -0,0 +1,108 @@ +/** + * A small addon using `@bedrock-core/db` end to end: a per-player document on any target, and a + * per-block document that only accepts one block type and insists on living on the block itself. + * + * Compiled with the package's type assertions (`yarn test:types`); it is not deployed. + */ +import { accepting, anyOf, blockTypes, entityTypes, players, schema } from '@bedrock-core/db'; +import { blockCleanup, createEngineDb } from '@bedrock-core/db/minecraft'; +import { Entity, system, world } from '@minecraft/server'; + +const db = createEngineDb('papi'); + +// ─── Players and the addon's own mobs: acceptors compose ─────────────────────── + +interface Balance { + gold: number; + lastSeen: number; +} + +const balances = db.collection('balances', { + // players, zombies, and every entity under the addon's namespace — for() takes a Player | Entity + accept: anyOf(players(), entityTypes('minecraft:zombie'), accepting(id => id.startsWith('papi:'), ['entity'])), + schema: schema({ defaults: { gold: 0, lastSeen: 0 } }), +}); + +world.afterEvents.playerSpawn.subscribe(({ player, initialSpawn }) => { + if (initialSpawn) { + balances.for(player).patch({ lastSeen: Date.now() }); // one dynamic property on the player, written through + } +}); + +// ─── Blocks: the acceptor types `for()` and gates `require` ─────────────────── + +interface Elevator { + facing: 'north' | 'south' | 'east' | 'west'; + particles: boolean; +} + +const elevators = db.collection('elevators', { + schema: schema({ + version: 2, + defaults: { facing: 'north', particles: true }, + migrate: { + 2: doc => ({ ...doc, facing: doc.facingDirection ?? 'north' }), // v1 called it facingDirection + }, + }), + accept: blockTypes('papi:elevator'), + require: { own: true }, // papi:elevator turns on block entity dynamic_properties, so the document dies with the block +}); + +system.beforeEvents.startup.subscribe(({ blockComponentRegistry }) => { + // papi:elevator lists both components in its JSON. This one keeps the index honest on every removal. + blockComponentRegistry.registerCustomComponent('papi:db_block', blockCleanup(db)); + + blockComponentRegistry.registerCustomComponent('papi:elevator', { + onPlayerInteract({ block, player }) { + const doc = elevators.for(block); + + if (!doc.available) { + console.warn(doc.reason); // e.g. "require.own: the document would live on the world, not on the target" + + return; + } + + const current = doc.get(); // undefined until the first write; defaults are not a document + const particles = !(current?.particles ?? true); + + doc.patch({ particles }); + player?.sendMessage(`Particles ${particles ? 'on' : 'off'}`); + }, + }); +}); + +// ─── Walking the index a few per tick ────────────────────────────────────────── + +let walk = elevators.all(); + +system.runInterval(() => { + for (let i = 0; i < 20; i++) { + const next = walk.next(); + + if (next.done) { + walk = elevators.all(); // start over next tick + + return; + } + + const doc = next.value.get(); // undefined while the chunk is unloaded; a replaced block heals itself out + + if (doc?.particles) { + // spawn particles at next.value.identity's position + } + } +}, 1); + +// ─── A HUD reacting to a document ────────────────────────────────────────────── + +world.afterEvents.playerSpawn.subscribe(({ player }) => { + const stop = balances.for(player).subscribe((doc) => { + player.onScreenDisplay.setActionBar(`Gold: ${doc?.gold ?? 0}`); + }); + + world.afterEvents.playerLeave.subscribe(({ playerId }) => { + if (playerId === player.id) { + stop(); + } + }); +}); diff --git a/packages/db/package.json b/packages/db/package.json new file mode 100644 index 0000000..e2b082a --- /dev/null +++ b/packages/db/package.json @@ -0,0 +1,60 @@ +{ + "name": "@bedrock-core/db", + "version": "0.0.0", + "description": "Persisted documents for @bedrock-core: a resolver that finds where a target can hold dynamic properties, and typed collections over it", + "keywords": [ + "minecraft", + "bedrock", + "dynamic-properties", + "persistence", + "database", + "collections" + ], + "license": "MIT", + "author": "DrAv0011", + "contributors": [ + { + "name": "DrAv0011", + "email": "contact@drav.dev", + "url": "https://drav.dev" + } + ], + "repository": "github:bedrock-core/server", + "type": "module", + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + }, + "./minecraft": { + "types": "./src/minecraft.ts", + "import": "./src/minecraft.ts" + } + }, + "publishConfig": { + "access": "public" + }, + "files": [ + "src/**/*" + ], + "scripts": { + "build": "tsc -p tsconfig.json", + "test": "vitest run", + "test:types": "tsc -p tsconfig.typecheck.json", + "lint": "eslint ." + }, + "dependencies": { + "@bedrock-core/observable": "workspace:^" + }, + "devDependencies": { + "@minecraft/server": "*", + "@stylistic/eslint-plugin": "*", + "eslint": "*", + "typescript": "*", + "typescript-eslint": "*", + "vitest": "*" + }, + "peerDependencies": { + "@minecraft/server": ">=2.9.0 || >=2.10.0-0" + } +} diff --git a/packages/db/src/accept.ts b/packages/db/src/accept.ts new file mode 100644 index 0000000..f08c77f --- /dev/null +++ b/packages/db/src/accept.ts @@ -0,0 +1,172 @@ +/** + * Acceptors: what a collection takes in `for()`, checked twice. + * + * At compile time an acceptor is branded with the target type, so a block collection refuses a + * `Player` in the IDE, and `require` is compared against what that type can *possibly* do — a + * `Dimension` never holds its own properties, so `require: { own: true }` on a dimension collection + * is a type error whose message is the property name the checker asks for. At runtime the acceptor + * is a cheap predicate asked once per target type and cached beside the resolver's decision. + * + * Only `import type` touches the engine here: the types are erased, so this file runs in vitest. + */ +import type { Block, ContainerSlot, Dimension, Entity, ItemStack, Player, World } from '@minecraft/server'; +import type { TargetKind } from './resolve'; + +/** Anything the resolver can host. The default target when a collection declares no `accept`. */ +export type StorableTarget = World | Dimension | Entity | Block | ContainerSlot; + +declare const targetBrand: unique symbol; + +/** + * A rule over target types. `kinds` narrows first so a block acceptor never sees an entity; `test` + * is asked with the type id (block or entity type, item type of a slot, `minecraft:overworld` for + * a dimension, `world` for the world). Types, not instances: the answer is cached per type beside + * the resolver's decision, which is what makes the check free on the hot path. + */ +export interface Acceptor extends Rule { + /** The brand, invariant so `Acceptor` never passes where any storable target is meant. */ + readonly [targetBrand]?: (target: T) => T; +} + +/** An acceptor without its brand — what the runtime reads, and what compositions take. */ +export interface Rule { + readonly kinds: readonly TargetKind[]; + test(typeId: string, kind: TargetKind): boolean; +} + +function acceptor(kinds: readonly TargetKind[], test: (typeId: string, kind: TargetKind) => boolean): Acceptor { + return { kinds, test }; +} + +const ALL_KINDS: readonly TargetKind[] = ['world', 'dimension', 'entity', 'block', 'slot']; + +/** Does this acceptor take a target of `kind` with `typeId` — kinds first, then the rule. */ +export function accepts(acceptor: Rule, typeId: string, kind: TargetKind): boolean { + return acceptor.kinds.includes(kind) && acceptor.test(typeId, kind); +} + +const any = (): boolean => true; + +const listed = (ids: readonly string[]): ((typeId: string) => boolean) => { + if (ids.length === 0) { + return any; + } + + const set = new Set(ids); + + return (typeId): boolean => set.has(typeId); +}; + +/** Blocks of the listed types; every block when none is listed. */ +export function blockTypes(...ids: string[]): Acceptor { + return acceptor(['block'], listed(ids)); +} + +/** Entities of the listed types; every entity, players included, when none is listed. */ +export function entityTypes(...ids: string[]): Acceptor { + return acceptor(['entity'], listed(ids)); +} + +/** Players only. */ +export function players(): Acceptor { + return acceptor(['entity'], typeId => typeId === 'minecraft:player'); +} + +/** Container slots holding a non-stackable item of the listed types; any non-stackable item when none is listed. */ +export function slots(...itemIds: string[]): Acceptor { + return acceptor(['slot'], listed(itemIds)); +} + +/** The world. */ +export function worldTarget(): Acceptor { + return acceptor(['world'], any); +} + +/** The listed dimensions; every dimension when none is listed. */ +export function dimensions(...ids: string[]): Acceptor { + return acceptor(['dimension'], listed(ids)); +} + +/** The escape hatch: any rule over type ids, for the target type the caller names. */ +export function accepting(test: (typeId: string, kind: TargetKind) => boolean, kinds?: readonly TargetKind[]): Acceptor { + return acceptor(kinds ?? ALL_KINDS, test); +} + +// ─── Composition ─────────────────────────────────────────────────────────────── + +type Of = R extends Acceptor ? T : never; + +type Union = Of; + +type Intersection + = A extends readonly [infer H extends Rule, ...infer R extends readonly Rule[]] ? Of & Intersection : unknown; + +/** Any of the rules: `anyOf(players(), entityTypes('papi:merchant'))` takes a `Player | Entity`. */ +export function anyOf(...acceptors: A): Acceptor> { + const kinds = [...new Set(acceptors.flatMap(a => a.kinds))]; + + return acceptor(kinds, (typeId, kind) => acceptors.some(a => accepts(a, typeId, kind))); +} + +/** All of the rules: the target type is the intersection. */ +export function allOf(...acceptors: A): Acceptor> { + const kinds = acceptors.length === 0 + ? ALL_KINDS + : acceptors.map(a => a.kinds).reduce((shared, next) => shared.filter(kind => next.includes(kind))); + + return acceptor(kinds, (typeId, kind) => acceptors.every(a => accepts(a, typeId, kind))); +} + +/** + * The base rule minus another: `except(entityTypes(), players())` takes every entity but a player. + * The type stays the base type — TypeScript cannot subtract a subclass — so what is excluded is + * refused when `for()` runs, with a reason. + */ +export function except(base: Acceptor, excluded: Rule): Acceptor { + return acceptor(base.kinds, (typeId, kind) => accepts(base, typeId, kind) && !accepts(excluded, typeId, kind)); +} + +// ─── The static check ────────────────────────────────────────────────────────── + +/** What a collection may demand of its host. Only `true` is a demand; omitted means indifferent. */ +export interface Requirements { + /** The bytes live on the target and die with it — no orphans, no cleanup. */ + readonly own?: true; + /** Keys can be listed. */ + readonly enumerable?: true; + /** Documents stay reachable while the target is unloaded. */ + readonly readableWhenUnloaded?: true; +} + +/** + * What each target type can possibly do. `true` always, `false` never, `boolean` only the instance + * knows — a block is `own` when its type declares a block entity, a slot when its item does not + * stack — and only the resolver can answer that, at runtime. + */ +export type Caps + = T extends ItemStack ? never + : T extends Dimension ? { own: false; enumerable: true; readableWhenUnloaded: true } + : T extends Block ? { own: boolean; enumerable: boolean; readableWhenUnloaded: boolean } + : T extends World ? { own: true; enumerable: true; readableWhenUnloaded: true } + : T extends ContainerSlot ? { own: boolean; enumerable: true; readableWhenUnloaded: false } + : T extends Entity ? { own: true; enumerable: true; readableWhenUnloaded: false } + : { own: boolean; enumerable: boolean; readableWhenUnloaded: boolean }; + +type Impossible = { + [K in keyof R & keyof Caps]: R[K] extends true + ? (Caps[K] extends false ? `require.${K & string} is impossible: this target type never has it` : never) + : never; +}[keyof R & keyof Caps]; + +type Exclusive = R extends { own: true; readableWhenUnloaded: true } + ? 'require.own and require.readableWhenUnloaded are mutually exclusive' + : never; + +type Problems = ([Caps] extends [never] ? 'accept: an ItemStack is a detached copy, use slots()' : never) | Impossible | Exclusive; + +/** + * `unknown` when `require` is satisfiable for the target type — intersecting with it changes + * nothing — and otherwise an object demanding a property named after the conflict, so the reason + * is the missing-property message in the IDE. + */ +export type Conflicts = [Problems] extends [never] ? unknown : Record, never>; diff --git a/packages/db/src/collection.ts b/packages/db/src/collection.ts new file mode 100644 index 0000000..0ddf3ab --- /dev/null +++ b/packages/db/src/collection.ts @@ -0,0 +1,930 @@ +/** + * A collection: typed documents keyed by target, stored wherever the target can hold bytes. + * + * `for(target)` keeps an identity and a way to find the target again, never the handle it was + * given — a `Block` goes stale when its chunk unloads, an `Entity` after removal — and every + * operation re-resolves, at most once per tick. Reads answer `undefined` when the target cannot be + * reached; writes throw `DbTargetError` naming the collection, because writing to nothing is a bug + * at the call site. A proxied document (world property keyed by identity) stays readable while its + * target is unloaded, since the world needs no chunk. + * + * Write-through: a `set` or `patch` updates the in-memory document and the dynamic property in the + * same call — measured at 17 µs there is nothing to flush and nothing to lose. `patch` merges deep: + * a nested object merges key by key, an array replaces, `undefined` deletes. What is stored is + * what was written; the schema's `defaults` are filled in on the way out, so a key nobody wrote + * keeps following its default. Documents are cached by identity once read; a container slot has + * no identity, so its documents are read each time and never indexed. + * + * `all()` walks the collection's index — world properties listing the identities that hold a + * document — and self-heals: a block found replaced by another type drops out of the index, and its + * world-kept document with it. + * + * `coalesce: true` turns a collection write-behind: writes land in memory and one flush per tick + * writes each dirty document once. It is offered only where a dirty document cannot die with its + * target inside that window — on the world, and on entities, whose unloaded documents are parked + * and written when the entity loads again (players are flushed as they leave instead). + * + * Handles and collections are classes with prototype methods: the engine runs QuickJS, where a + * `for()` that allocated a dozen closures cost more than the property write it wrapped. + */ +import { observable, type Observable, type Unsubscribe } from '@bedrock-core/observable'; +import { accepts, type Acceptor, type Conflicts, type Requirements, type StorableTarget } from './accept'; +import { createDocumentStore, type DocumentSchema, type DocumentStore, type Schema } from './document'; +import { fill, merge, type DeepPartial } from './merge'; +import { DbBudgetError, DbTargetError } from './errors'; +import { directHost, prefixed, type Capabilities, type DirectDp, type DpHost } from './host'; +import { createIndexSet, type IndexSet } from './indexed'; +import { createResolver, type Accepted, type Classifier, type Resolution, type Resolver, type TargetKind } from './resolve'; + +/** What `db.collection()` takes beside the name. */ +export interface CollectionOptions { + /** The document type, with its version, defaults and migrations: `schema({ version: 2 })`. */ + schema: Schema; + /** Which targets may carry a document. Anything the resolver can host when omitted. */ + accept?: Acceptor; + /** What the host must offer; a target whose host lacks it is refused with a reason instead of silently stored elsewhere. */ + require?: R; + /** + * Write-behind: one dynamic-property write per dirty document per flush, for the counter bumped a + * hundred times a tick. Refused with a reason on a block or slot, where the target can vanish + * before the flush. + */ + coalesce?: boolean; +} + +/** One target's document: an observable over its bytes, with a validity gate. */ +export interface Document { + /** Whether the target can be reached and the collection accepts it right now. */ + readonly available: boolean; + /** Why `available` is false, or `undefined`. */ + readonly reason: string | undefined; + /** The document with its defaults filled in, `undefined` when there is none or the target cannot be reached. Treat it as immutable; change it with `patch`. */ + get(): T | undefined; + /** Stores `doc` as it is, after the schema's `normalize`. */ + set(doc: T): void; + /** + * Merges `changes` into the stored document, deep: a nested object merges key by key, an array + * replaces the one there, `undefined` deletes a key — which puts it back to its default. + */ + patch(changes: DeepPartial): void; + /** Removes the document. Never throws: a document whose target is gone is removed where it can be. */ + delete(): void; + /** + * Local change events for this document; fires with the new document or `undefined` on delete, + * and the one before it. A listener attached before the document was first read hears it load. + * + * `get` and `subscribe` together are `@bedrock-core/observable`'s `ReadonlyObservable`, which is + * what lets a document be `computed` over or handed to a UI hook directly. A listener that only + * wants the new value still takes one argument. + */ + subscribe(listener: (doc: T | undefined, prev: T | undefined) => void): Unsubscribe; +} + +/** A document reached through the index rather than a target the caller holds. */ +export interface IndexedDocument extends Document { + readonly kind: TargetKind; + /** The identity the index keeps: an entity id, `:,,:` for a block, a dimension id. */ + readonly identity: string; +} + +/** What `where()` answers: whether the collection would store on a target, and with which capabilities. */ +export type Where + = | { ok: true; kind: TargetKind; caps: Capabilities } + | { ok: false; kind: TargetKind; reason: string }; + +/** Typed documents keyed by target, on the host the resolver finds for each. */ +export interface Collection { + readonly name: string; + for(target: Target): Document; + /** The probe: whether this collection would store on `target`, and with which capabilities. */ + where(target: Target): Where; + /** Drop what the collection remembers about a target — its cached document and subscribers. */ + forget(target: Target): void; + /** + * Every target known to hold a document, lazily, from the index. Take a few per tick: the + * iterator is resumable. A target that cannot be reached right now is still yielded, with + * `available` false — or readable when its document lives on the world. Slots are never indexed. + */ + all(): IterableIterator>; + /** + * The document for one indexed identity, without holding the target — what a remote caller has, + * since a `Block` or an `Entity` cannot travel over the wire. `undefined` when the collection + * has no such entry. + */ + at(kind: TargetKind, identity: string): IndexedDocument | undefined; + /** How many documents the index knows of. */ + readonly size: number; +} + +/** + * How a kept target is found again for the next operation, and how an index entry becomes a target. + * The default trusts the handle while it says it is valid and cannot locate by identity; + * `@bedrock-core/db/minecraft` supplies one that asks the world by id and the dimension by location. + */ +export interface Locator { + bind(target: unknown, resolution: Accepted): () => unknown; + /** The target for an identity the index kept, or `undefined` when it is not loaded or gone. */ + fromIdentity(kind: TargetKind, identity: string): unknown; +} + +function isValid(target: unknown): boolean { + if (typeof target !== 'object' || target === null || !('isValid' in target)) { + return true; + } + + const valid: unknown = target.isValid; + + return typeof valid === 'function' ? Boolean(valid.call(target)) : valid !== false; +} + +/** The default locator: trusts a handle while it is valid, and cannot find a target by identity. */ +export const structuralLocator: Locator = { + bind: (target): (() => unknown) => (): unknown => (isValid(target) ? target : undefined), + fromIdentity: (): undefined => undefined, +}; + +/** The pieces of a block identity, `:,,:`. */ +export function parseBlockIdentity(identity: string): { dimensionId: string; x: number; y: number; z: number; typeId: string } | undefined { + const match = /^(.*):(-?\d+),(-?\d+),(-?\d+):(.*)$/.exec(identity); + + if (match === null) { + return undefined; + } + + return { dimensionId: match[1] ?? '', x: Number(match[2]), y: Number(match[3]), z: Number(match[4]), typeId: match[5] ?? '' }; +} + +/** + * What `coalesce` and the index need from the engine, wired only when the first coalescing + * collection is made so a db without one costs nothing per tick. + */ +export interface Lifecycle { + /** Run `flush` once, later in this tick or next — `system.run` in the engine. */ + schedule(flush: () => void): void; + /** + * The current tick. A handle re-resolves its target at most once per tick: within one tick the + * script is the only thing running, so a target it has not itself removed is still there. + * Without a clock every operation re-resolves. + */ + tick?(): number; + /** Called once; `loaded` must be called with every entity that loads, `leaving` with every player about to leave. */ + attach(hooks: { loaded(target: unknown): void; leaving(target: unknown): void }): void; +} + +/** What `createDb()` takes. */ +export interface DbOptions { + /** The world: proxied documents and the indexes live on it. */ + world: DirectDp; + /** The addon's namespace — the first segment of every key on the world. */ + namespace: string; + classify?: Classifier; + locate?: Locator; + lifecycle?: Lifecycle; + /** How long a parked document waits for its entity to load again before it is dropped, in milliseconds. Five minutes when omitted. */ + parkFor?: number; + /** Where quarantine lines go. `console.warn` when omitted. */ + log?: (message: string) => void; +} + +/** A set of collections over one namespace, sharing a resolver and the world. */ +export interface Db { + /** + * A collection of documents. The document type rides the schema, the target type rides the + * acceptor — any storable target when there is none — and `require` is checked against what that + * target type can do: + * `db.collection('elevators', { schema: schema(), accept: blockTypes('papi:elevator'), require: { own: true } })`. + */ + collection( + name: string, + options: CollectionOptions & Conflicts, + ): Collection; + /** + * A block at `location` in `dimensionId` of type `typeId` is gone. Drops it from every block + * collection's index and removes a world-kept document. The block itself is air by the time the + * engine's `onBreak` runs, which is why this takes the identity, not the block. + */ + blockRemoved(dimensionId: string, location: { x: number; y: number; z: number }, typeId: string): void; + /** Write every coalesced document and every dirty index chunk now — all of them, or one target's documents. */ + flush(target?: unknown): void; + /** + * A collection already declared under `name`, or `undefined`. + * + * Loosely typed on purpose: the caller is the RPC layer, which has a name off the wire and no + * document type to go with it. Anything that knows the type holds the collection itself. + */ + find(name: string): Collection | undefined; + readonly resolver: Resolver; +} + +// ─── Internals ───────────────────────────────────────────────────────────────── + +const REQUIREMENTS: readonly (keyof Requirements)[] = ['own', 'enumerable', 'readableWhenUnloaded']; + +const REQUIREMENT_TEXT: Record = { + own: 'the document would live on the world, not on the target', + enumerable: 'the host cannot list keys', + readableWhenUnloaded: 'the document is only reachable while the target is loaded', +}; + +/** The one key a collection's document sits under, inside the collection's prefix. */ +const DOC = 'doc'; + +const KINDS: readonly string[] = ['world', 'dimension', 'entity', 'block', 'slot']; + +function isKind(value: string): value is TargetKind { + return KINDS.includes(value); +} + +const PARK_FOR = 5 * 60_000; + +/** `coalesce` is safe where a dirty document cannot die with its target before the flush. */ +const coalescable = (resolution: Accepted): boolean => resolution.host.caps.readableWhenUnloaded || resolution.kind === 'entity'; + +const NOTHING = (): undefined => undefined; + +const NO_LISTENER = (): void => {}; + +type Admitted = { ok: true; resolution: Accepted } | { ok: false; kind: TargetKind; reason: string }; + +type State = { ok: true; resolution: Accepted } | { ok: false; reason: string }; + +interface Stream { + source: Observable; + listeners: number; +} + +/** One cached document: as stored, and as read with defaults filled. `undefined` when there is none. */ +interface Cached { + stored: T | undefined; + doc: T | undefined; +} + +interface Dirty { + doc: T; + last: Accepted; + find(): unknown; +} + +interface Parked { + doc: T; + until: number; +} + +/** What every collection shares from its db. */ +interface Shared { + readonly namespace: string; + readonly resolver: Resolver; + readonly locate: Locator; + readonly worldHost: DpHost; + readonly tick: (() => number) | undefined; + readonly scheduleIndex: ((flush: () => void) => void) | undefined; + readonly parkFor: number; + log(message: string): void; + attach(): void; + scheduleFlush(): void; +} + +/** Documents are cached, subscribed and indexed by identity; a slot has none, so each handle stands alone. */ +function identityKey(kind: TargetKind, identity: string): string | undefined { + return kind === 'slot' ? undefined : `${kind}:${identity}`; +} + +function typeIdOfEntry(kind: TargetKind, identity: string): string { + switch (kind) { + case 'block': + return parseBlockIdentity(identity)?.typeId ?? '?'; + case 'dimension': + return identity; + default: + return '?'; + } +} + +// ─── The handle ──────────────────────────────────────────────────────────────── + +class Handle implements Document { + /** Cache, stream and index key: `kind:identity`. Undefined for a slot, which has no identity. */ + readonly key: string | undefined; + /** The world is one target with one document; it needs no index. */ + private readonly _indexKey: string | undefined; + private _last: Accepted | undefined; + private _memo: State | undefined; + private _memoTick: number; + private _store: DocumentStore | undefined; + private _storeFor: Accepted | undefined; + private _local: Stream | undefined; + + constructor( + private readonly _c: CollectionImpl, + private readonly _first: Admitted, + private _find: () => unknown, + ) { + this._last = _first.ok ? _first.resolution : undefined; + this.key = this._last === undefined ? undefined : identityKey(this._last.kind, this._last.identity); + this._indexKey = this.key !== undefined && this._last?.kind !== 'world' ? this.key : undefined; + // `first` was resolved this very tick: the first operation need not resolve again. + this._memoTick = _c.shared.tick === undefined ? -1 : _c.shared.tick(); + this._memo = _first.ok ? _first : undefined; + } + + get available(): boolean { + return this._current().ok; + } + + get reason(): string | undefined { + const state = this._current(); + + return state.ok ? undefined : state.reason; + } + + get(): T | undefined { + const state = this._current(); + + return state.ok ? this._read(state.resolution).doc : undefined; + } + + set(doc: T): void { + this._write(this._reachable(), this._c.normalize(doc)); + } + + patch(changes: DeepPartial): void { + const resolution = this._reachable(); + const stored: unknown = this._read(resolution).stored; + // Both are JSON objects; `merge` is untyped because the document type is the caller's contract. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const merged = merge(stored as Record | undefined, changes) as T; + + this._write(resolution, this._c.normalize(merged)); + } + + delete(): void { + const state = this._current(); + const resolution = state.ok ? state.resolution : this._last; + + if (resolution === undefined) { + return; + } + + const c = this._c; + + if (this.key !== undefined) { + c.dirty.delete(this.key); + c.parked.delete(this.key); + } + + if (state.ok || resolution.host.caps.readableWhenUnloaded) { + try { + this._storeOf(resolution).remove(DOC); + } catch { + this._memo = undefined; + } + } + + if (this.key !== undefined) { + c.cache.delete(this.key); + } + + if (this._indexKey !== undefined) { + c.index.remove(this._indexKey); + } + + this._notify(undefined); + } + + subscribe(listener: (doc: T | undefined, prev: T | undefined) => void): Unsubscribe { + if (!this._first.ok) { + return NO_LISTENER; + } + + const c = this._c; + const key = this.key; + const entry = key === undefined + ? (this._local ??= { source: observable(undefined, { label: `${c.name}/slot` }), listeners: 0 }) + : c.stream(key, c.cache.get(key)?.doc); + const release = entry.source.subscribe(listener); + let released = false; + + entry.listeners++; + + return (): void => { + if (released) { + return; + } + + released = true; + release(); + entry.listeners--; + + if (entry.listeners === 0 && key !== undefined && c.streams.get(key) === entry) { + c.streams.delete(key); + } + }; + } + + private _current(): State { + const tick = this._c.shared.tick; + + if (tick === undefined) { + return this._resolveNow(); + } + + const now = tick(); + + if (this._memo !== undefined && this._memoTick === now) { + return this._memo; + } + + this._memo = this._resolveNow(); + this._memoTick = now; + + return this._memo; + } + + private _resolveNow(): State { + const found = this._find(); + const last = this._last; + + if (found === undefined) { + // A world property keyed by identity needs no loaded target. + if (last !== undefined && last.host.caps.readableWhenUnloaded) { + return { ok: true, resolution: last }; + } + + return { ok: false, reason: this._first.ok ? 'the target is not loaded or no longer exists' : this._first.reason }; + } + + const admitted = this._c.admit(this._c.shared.resolver.resolve(found)); + + if (!admitted.ok) { + return { ok: false, reason: admitted.reason }; + } + + if (last === undefined || admitted.resolution.identity !== last.identity) { + this._find = this._c.shared.locate.bind(found, admitted.resolution); + } + + this._last = admitted.resolution; + + return admitted; + } + + private _reachable(): Accepted { + const state = this._current(); + + if (!state.ok) { + throw new DbTargetError(this._c.name, state.reason); + } + + return state.resolution; + } + + /** The store for a resolution, kept while the resolution object is the same one. */ + private _storeOf(resolution: Accepted): DocumentStore { + if (this._store === undefined || this._storeFor !== resolution) { + this._store = this._c.storeFor(resolution); + this._storeFor = resolution; + } + + return this._store; + } + + private _notify(doc: T | undefined): void { + (this.key === undefined ? this._local : this._c.streams.get(this.key))?.source.set(doc); + } + + private _read(resolution: Accepted): Cached { + const c = this._c; + const key = this.key; + const cached = key === undefined ? undefined : c.cache.get(key); + + if (cached !== undefined) { + return cached; + } + + const stored = this._storeOf(resolution).read(DOC); + const entry: Cached = { stored, doc: c.fill(stored) }; + + if (key !== undefined) { + c.cache.set(key, entry); + // A subscriber attached before the first read has been holding `undefined`; the load is + // the change it was waiting for. + c.streams.get(key)?.source.set(entry.doc); + } + + return entry; + } + + /** Stores `stored` as it is; what subscribers and peers see is it with defaults filled. */ + private _write(resolution: Accepted, stored: T): void { + const c = this._c; + const key = this.key; + + if (c.coalesce && key !== undefined) { + c.dirty.set(key, { doc: stored, last: resolution, find: (): unknown => this._find() }); + c.shared.scheduleFlush(); + } else { + // An engine throw on a target the memo still trusted — removed by this very script this + // tick — becomes ours. + try { + this._storeOf(resolution).write(DOC, stored); + } catch (error) { + this._memo = undefined; + + if (error instanceof DbBudgetError) { + throw error; + } + + throw new DbTargetError(c.name, `the target is gone: ${String(error)}`); + } + } + + const doc = c.fill(stored); + + if (key !== undefined) { + c.cache.set(key, { stored, doc }); + } + + if (this._indexKey !== undefined) { + c.index.add(this._indexKey); + } + + this._notify(doc); + } +} + +class IndexedHandle extends Handle implements IndexedDocument { + constructor(c: CollectionImpl, first: Admitted, find: () => unknown, readonly kind: TargetKind, readonly identity: string) { + super(c, first, find); + } +} + +// ─── The collection ──────────────────────────────────────────────────────────── + +class CollectionImpl implements Collection { + readonly coalesce: boolean; + readonly cache = new Map>(); + readonly streams = new Map>(); + readonly dirty = new Map>(); + readonly parked = new Map>(); + readonly index: IndexSet; + readonly kinds: readonly TargetKind[] | undefined; + private readonly _acceptor: Acceptor | undefined; + private readonly _require: Requirements | undefined; + private readonly _schema: DocumentSchema; + private readonly _accepted = new Map(); + + constructor(readonly shared: Shared, readonly name: string, options: CollectionOptions) { + this._acceptor = options.accept; + this._require = options.require; + this._schema = options.schema; + this.coalesce = options.coalesce === true; + this.kinds = options.accept?.kinds; + this.index = createIndexSet(prefixed(shared.worldHost, `core-db:${shared.namespace}:index:${name}:`), { schedule: shared.scheduleIndex }); + + if (this.coalesce) { + shared.attach(); + } + } + + get size(): number { + return this.index.size; + } + + for(target: unknown): Document { + const first = this.admit(this.shared.resolver.resolve(target)); + + return new Handle(this, first, first.ok ? this.shared.locate.bind(target, first.resolution) : NOTHING); + } + + where(target: unknown): Where { + const admitted = this.admit(this.shared.resolver.resolve(target)); + + return admitted.ok + ? { ok: true, kind: admitted.resolution.kind, caps: admitted.resolution.host.caps } + : admitted; + } + + forget(target: unknown): void { + const resolution = this.shared.resolver.resolve(target); + const key = resolution.ok ? identityKey(resolution.kind, resolution.identity) : undefined; + + if (key === undefined) { + return; + } + + this.cache.delete(key); + this.streams.delete(key); + } + + * all(): IterableIterator> { + for (const entry of [...this.index.entries()]) { + const at = entry.indexOf(':'); + const kind = entry.slice(0, at); + const identity = entry.slice(at + 1); + + if (!isKind(kind)) { + this.index.remove(entry); + continue; + } + + const doc = this._handleForEntry(kind, identity); + + if (doc !== undefined) { + yield doc; + } + } + } + + at(kind: TargetKind, identity: string): IndexedDocument | undefined { + return this._handleForEntry(kind, identity); + } + + admit(resolution: Resolution): Admitted { + if (!resolution.ok) { + return { ok: false, kind: resolution.kind, reason: resolution.reason }; + } + + if (this._acceptor !== undefined) { + let pass = this._accepted.get(resolution.typeKey); + + if (pass === undefined) { + pass = accepts(this._acceptor, resolution.typeId, resolution.kind); + this._accepted.set(resolution.typeKey, pass); + } + + if (!pass) { + return { ok: false, kind: resolution.kind, reason: `${resolution.typeId} is not accepted by '${this.name}'` }; + } + } + + if (this._require !== undefined) { + for (const requirement of REQUIREMENTS) { + if (this._require[requirement] === true && !resolution.host.caps[requirement]) { + return { ok: false, kind: resolution.kind, reason: `require.${requirement}: ${REQUIREMENT_TEXT[requirement]}` }; + } + } + } + + if (this.coalesce && !coalescable(resolution)) { + return { ok: false, kind: resolution.kind, reason: `coalesce: a ${resolution.kind} document could die with its target before the flush` }; + } + + return { ok: true, resolution }; + } + + storeFor(resolution: Accepted): DocumentStore { + const { version, migrate } = this._schema; + + return createDocumentStore(prefixed(resolution.host, resolution.prefixFor(this.name)), { version, migrate, collection: this.name, log: this.shared.log }); + } + + /** `stored` with the schema's defaults filled in, at every depth. */ + fill(stored: T | undefined): T | undefined { + const defaults: unknown = this._schema.defaults; + + if (stored === undefined || defaults === undefined) { + return stored; + } + + // Both are JSON objects; `fill` is untyped because the document type is the caller's contract. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + return fill(defaults as Record, stored as Record) as T; + } + + /** What the schema's `normalize` makes of a document about to be stored. */ + normalize(doc: T): T { + const normalize = this._schema.normalize; + + return normalize === undefined ? doc : normalize(doc); + } + + stream(key: string, initial: T | undefined): Stream { + let entry = this.streams.get(key); + + if (entry === undefined) { + entry = { source: observable(initial, { label: `${this.name}/${key}` }), listeners: 0 }; + this.streams.set(key, entry); + } + + return entry; + } + + /** A block or entity is gone for good, by identity. */ + removed(kind: TargetKind, typeId: string, identity: string): void { + const key = identityKey(kind, identity); + + if (key !== undefined && this.index.has(key)) { + this._dropIdentity(key, this.shared.resolver.absent(kind, typeId, identity)); + } + } + + /** Write the dirty documents — all, or one key — parking those whose entity is away; then the index. */ + flush(only?: string): void { + for (const [key, entry] of [...this.dirty]) { + if (only !== undefined && key !== only) { + continue; + } + + this.dirty.delete(key); + + const resolution = this._flushTarget(entry); + + if (resolution === undefined) { + this.parked.set(key, { doc: entry.doc, until: Date.now() + this.shared.parkFor }); + continue; + } + + this.storeFor(resolution).write(DOC, entry.doc); + } + + this.index.flush(); + } + + /** An entity came back: write its parked document, and drop parked documents past their time. */ + loaded(target: unknown): void { + if (this.parked.size === 0) { + return; + } + + const now = Date.now(); + + for (const [key, entry] of [...this.parked]) { + if (entry.until <= now) { + this.parked.delete(key); + this.shared.log(`[db] ${this.name}: dropped a document parked for '${key}' — its target did not load again in time`); + } + } + + const resolution = this.shared.resolver.resolve(target); + + if (!resolution.ok) { + return; + } + + const key = identityKey(resolution.kind, resolution.identity); + const waiting = key === undefined ? undefined : this.parked.get(key); + + if (key === undefined || waiting === undefined) { + return; + } + + this.parked.delete(key); + this.storeFor(resolution).write(DOC, waiting.doc); + } + + /** Where a dirty document goes now: its target found again, or the world when the document lives there. */ + private _flushTarget(entry: Dirty): Accepted | undefined { + const found = entry.find(); + + if (found === undefined) { + return entry.last.host.caps.readableWhenUnloaded ? entry.last : undefined; + } + + const resolved = this.shared.resolver.resolve(found); + + return resolved.ok && resolved.identity === entry.last.identity ? resolved : undefined; + } + + /** The identity is gone for good: forget it, tell subscribers, and remove a world-kept document. */ + private _dropIdentity(key: string, resolution: Accepted | undefined): void { + if (resolution !== undefined && resolution.host.caps.readableWhenUnloaded) { + this.storeFor(resolution).remove(DOC); + } + + this.cache.delete(key); + this.streams.get(key)?.source.set(undefined); + this.streams.delete(key); + this.index.remove(key); + } + + /** A handle for an index entry: the located target when it is at hand, else the world-kept document, else nothing reachable. */ + private _handleForEntry(kind: TargetKind, identity: string): IndexedDocument | undefined { + const key = `${kind}:${identity}`; + const located = this.shared.locate.fromIdentity(kind, identity); + + if (located !== undefined) { + const resolved = this.shared.resolver.resolve(located); + + // The index said one thing, the world holds another: a block of another type now stands + // there (the identity carries the type). Whatever was kept for the old one is gone or orphaned. + if (resolved.ok && resolved.identity !== identity) { + this._dropIdentity(key, this.shared.resolver.absent(kind, typeIdOfEntry(kind, identity), identity)); + + return undefined; + } + + const first = this.admit(resolved); + + return new IndexedHandle(this, first, first.ok ? this.shared.locate.bind(located, first.resolution) : NOTHING, kind, identity); + } + + const absent = this.shared.resolver.absent(kind, typeIdOfEntry(kind, identity), identity); + const first: Admitted = absent === undefined + ? { ok: false, kind, reason: 'the target is not loaded or no longer exists' } + : this.admit(absent); + + return new IndexedHandle(this, first, NOTHING, kind, identity); + } +} + +// ─── The db ──────────────────────────────────────────────────────────────────── + +/** A db over any world with the six-method ABI; `@bedrock-core/db/minecraft` wires the engine's. */ +export function createDb(options: DbOptions): Db { + const { world, namespace } = options; + const resolver = createResolver({ world, namespace, classify: options.classify }); + const collections: CollectionImpl[] = []; + const lifecycle = options.lifecycle; + let attached = false; + let flushScheduled = false; + + const keyOf = (target: unknown): string | undefined => { + const resolution = resolver.resolve(target); + + return resolution.ok ? identityKey(resolution.kind, resolution.identity) : undefined; + }; + + const flushAll = (key?: string): void => { + flushScheduled = false; + + for (const collection of collections) { + collection.flush(key); + } + }; + + const shared: Shared = { + namespace, + resolver, + locate: options.locate ?? structuralLocator, + worldHost: directHost(world, { readableWhenUnloaded: true }), + tick: lifecycle?.tick?.bind(lifecycle), + scheduleIndex: lifecycle === undefined ? undefined : lifecycle.schedule.bind(lifecycle), + parkFor: options.parkFor ?? PARK_FOR, + + log: options.log ?? ((message: string): void => { + console.warn(message); + }), + + /** The first coalescing collection wires the engine hooks; a db without one never does. */ + attach: (): void => { + if (attached) { + return; + } + + attached = true; + lifecycle?.attach({ + loaded: (target): void => { + for (const collection of collections) { + collection.loaded(target); + } + }, + leaving: (target): void => { + flushAll(keyOf(target)); + }, + }); + }, + + scheduleFlush: (): void => { + if (flushScheduled) { + return; + } + + flushScheduled = true; + + if (lifecycle === undefined) { + flushAll(); + } else { + lifecycle.schedule(() => flushAll()); + } + }, + }; + + return { + resolver, + + collection(name: string, collectionOptions: CollectionOptions): Collection { + // The acceptor's brand is compile-time only; at runtime every collection handles `unknown`. + const impl = new CollectionImpl(shared, name, collectionOptions as CollectionOptions); // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion + + collections.push(impl as unknown as CollectionImpl); // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion + + return impl; + }, + + flush: (target): void => { + flushAll(target === undefined ? undefined : keyOf(target)); + }, + + find: (name): Collection | undefined => collections.find(collection => collection.name === name), + + blockRemoved: (dimensionId, location, typeId): void => { + const identity = `${dimensionId}:${location.x},${location.y},${location.z}:${typeId}`; + + for (const collection of collections) { + if (collection.kinds === undefined || collection.kinds.includes('block')) { + collection.removed('block', typeId, identity); + } + } + }, + }; +} diff --git a/packages/db/src/document.ts b/packages/db/src/document.ts new file mode 100644 index 0000000..f54639b --- /dev/null +++ b/packages/db/src/document.ts @@ -0,0 +1,317 @@ +/** + * How a document becomes bytes on a host and comes back — one codec for every host. + * + * On disk a document is one JSON string, `{"v":,"d":}`, under the collection's + * key. The envelope carries the version so a document written by version 1 of an addon is + * migrated to version 4 the first time version 4 reads it — lazily, per document, because block + * and entity documents are never all loaded at once. What is stored is exactly what was written: + * a collection fills `defaults` on top of what this reads, so they are never persisted. + * + * A string past the host's per-value cap throws in the engine (measured: 32 767 characters on the + * direct ABI), so a document that does not fit in one value is split into `key#0..n` with the + * chunk count under `key`. That is a safety net on the direct and proxied hosts only: a block + * entity's ~950 bytes are the whole budget, and a document that does not fit there is refused. + * + * A document that cannot be parsed or migrated is quarantined under `key#bad`, not deleted: + * persistence that discards a player's data on a bad deploy is worse than an error, and the copy + * is what makes a fix possible. + */ +import { DbBudgetError } from './errors'; +import type { DpHost } from './host'; +import type { DeepPartial } from './merge'; + +/** One migration step: the document as the previous version wrote it, to the next shape. */ +/** One migration step: the stored document as the previous version wrote it, to the next shape. */ +export type MigrateStep = (doc: Record) => Record; + +/** What `schema()` takes: the version, the defaults, the steps between versions, and a normalizer. */ +export interface DocumentSchema { + /** The version documents are written at. `1` when omitted; documents at that version never migrate. */ + version?: number; + /** + * Filled into missing keys on read, at every depth — a nested object with one key stored still + * reads with its siblings' defaults. Never persisted, so changing a default reaches every + * document that never wrote that key. + */ + defaults?: DeepPartial; + /** Keyed by the version the step produces: `migrate[3]` takes a version-2 document to version 3. */ + migrate?: Record; + /** + * Runs on every write — `set`, `patch`, and a peer's RPC alike — over the document about to be + * stored, before defaults; what it returns is what is written. The place to coerce a value + * into range, or to drop a key that equals its default so the default keeps applying. + */ + normalize?: (doc: T) => T; +} + +declare const documentType: unique symbol; + +/** + * A `DocumentSchema` that also names its document type, so a collection whose `accept` fixes the + * target type can take the document type from the schema instead of an explicit type argument — + * TypeScript infers all of a call's type arguments or none. + */ +export type Schema = DocumentSchema & { readonly [documentType]?: (doc: T) => T }; + +/** Declare a document type, with or without a version and migrations: `schema({ version: 2 })`. */ +export function schema(definition: DocumentSchema = {}): Schema { + return definition; +} + +/** What a document store takes beside the host. */ +export interface DocumentStoreOptions extends Pick, 'version' | 'migrate'> { + /** For the error and log lines. */ + collection: string; + /** Where the log line for a quarantine goes. `console.warn` when omitted. */ + log?: (message: string) => void; +} + +/** Reads and writes documents of one collection on one resolved host. */ +export interface DocumentStore { + /** + * The document as stored, migrated to the current version. `undefined` when there is none, or + * when it was unreadable and has been quarantined. Defaults are not applied here. + */ + read(key: string): T | undefined; + /** Throws `DbBudgetError` when the document does not fit; nothing is written then. */ + write(key: string, doc: T): void; + remove(key: string): void; + /** Document keys under this collection, or `undefined` on a host that cannot list. */ + keys(): readonly string[] | undefined; +} + +const CHUNKS = '#'; +const BAD = '#bad'; + +interface Envelope { + v: number; + d: Record; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function parseEnvelope(raw: string): Envelope | undefined { + const parsed: unknown = JSON.parse(raw); + + if (!isRecord(parsed) || typeof parsed.v !== 'number' || !isRecord(parsed.d)) { + return undefined; + } + + return { v: parsed.v, d: parsed.d }; +} + +/** Is this key a chunk or quarantine of another document rather than a document of its own. */ +function isDerivedKey(key: string): boolean { + const hash = key.lastIndexOf(CHUNKS); + + if (hash < 0) { + return false; + } + + const suffix = key.slice(hash + 1); + + return suffix === 'bad' || /^\d+$/.test(suffix); +} + +class DocumentStoreImpl implements DocumentStore { + private readonly _version: number; + private readonly _chunked: boolean; + + constructor(private readonly _host: DpHost, private readonly _options: DocumentStoreOptions) { + this._version = _options.version ?? 1; + this._chunked = _host.abi !== 'component'; + } + + read(key: string): T | undefined { + const raw = this._readRaw(key); + + if (raw === undefined) { + return undefined; + } + + let envelope: Envelope | undefined; + + try { + envelope = parseEnvelope(raw); + } catch (error) { + return this._quarantine(key, raw, `not JSON: ${String(error)}`); + } + + if (envelope === undefined) { + return this._quarantine(key, raw, 'not a document envelope'); + } + + if (envelope.v > this._version) { + return this._quarantine(key, raw, `written at version ${envelope.v}, this addon reads version ${this._version}`); + } + + if (envelope.v === this._version) { + return this._typed(envelope.d); + } + + let migrated: Record; + + try { + migrated = this._migrate(envelope); + } catch (error) { + return this._quarantine(key, raw, `migration from version ${envelope.v} failed: ${String(error)}`); + } + + // Migrated once, written once: the next read is a plain parse. + this._writeRaw(key, JSON.stringify({ v: this._version, d: migrated })); + + return this._typed(migrated); + } + + write(key: string, doc: T): void { + this._writeRaw(key, JSON.stringify({ v: this._version, d: doc })); + } + + remove(key: string): void { + const count = this._chunkCount(key); + + if (count === 0) { + this._host.write(key, undefined); + + return; + } + + const values: Record = { [key]: undefined }; + + for (let i = 0; i < count; i++) { + values[`${key}${CHUNKS}${i}`] = undefined; + } + + this._host.writeMany(values); + } + + keys(): readonly string[] | undefined { + return this._host.keys()?.filter(key => !isDerivedKey(key)); + } + + private _log(message: string): void { + if (this._options.log !== undefined) { + this._options.log(message); + } else { + console.warn(message); + } + } + + private _chunkCount(key: string): number { + const head = this._host.read(key); + + return typeof head === 'string' && head.startsWith(CHUNKS) ? Number(head.slice(CHUNKS.length)) : 0; + } + + private _readRaw(key: string): string | undefined { + const head = this._host.read(key); + + if (typeof head !== 'string') { + return undefined; + } + + if (!head.startsWith(CHUNKS)) { + return head; + } + + const count = Number(head.slice(CHUNKS.length)); + let joined = ''; + + for (let i = 0; i < count; i++) { + const part = this._host.read(`${key}${CHUNKS}${i}`); + + if (typeof part !== 'string') { + return undefined; + } + + joined += part; + } + + return joined; + } + + private _writeRaw(key: string, raw: string): void { + const budget = this._host.caps.budget; + + if (!this._chunked) { + // The component budget counts key and value together, per block per pack. + if (key.length + raw.length > budget) { + throw new DbBudgetError(this._options.collection, key, key.length + raw.length, budget); + } + + this._host.write(key, raw); + + return; + } + + const previous = this._chunkCount(key); + + if (raw.length <= budget && previous === 0) { + this._host.write(key, raw); + + return; + } + + const values: Record = {}; + let count = 0; + + if (raw.length <= budget) { + values[key] = raw; + } else { + count = Math.ceil(raw.length / budget); + values[key] = `${CHUNKS}${count}`; + + for (let i = 0; i < count; i++) { + values[`${key}${CHUNKS}${i}`] = raw.slice(i * budget, (i + 1) * budget); + } + } + + for (let i = count; i < previous; i++) { + values[`${key}${CHUNKS}${i}`] = undefined; + } + + this._host.writeMany(values); + } + + private _quarantine(key: string, raw: string, why: string): undefined { + this._log(`[db] ${this._options.collection}: document '${key}' quarantined under '${key}${BAD}' — ${why}`); + + try { + this._writeRaw(`${key}${BAD}`, raw); + } catch (error) { + this._log(`[db] ${this._options.collection}: could not keep the quarantined copy of '${key}': ${String(error)}`); + } + + this.remove(key); + + return undefined; + } + + private _migrate(envelope: Envelope): Record { + let doc = envelope.d; + + for (let v = envelope.v + 1; v <= this._version; v++) { + const step = this._options.migrate?.[v]; + + if (step === undefined) { + throw new Error(`no migration step to version ${v}`); + } + + doc = step(doc); + } + + return doc; + } + + // The caller's type is the contract for what is on disk; the store cannot check it. + private _typed(doc: Record): T { + return doc as T; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion + } +} + +/** One collection's documents on one host: the envelope, migration, chunking and quarantine. */ +export function createDocumentStore(host: DpHost, options: DocumentStoreOptions): DocumentStore { + return new DocumentStoreImpl(host, options); +} diff --git a/packages/db/src/errors.ts b/packages/db/src/errors.ts new file mode 100644 index 0000000..646d5dd --- /dev/null +++ b/packages/db/src/errors.ts @@ -0,0 +1,25 @@ +/** + * The two ways a document operation fails loudly. Reads never throw — a missing or unreadable + * document is `undefined` — because the caller asked a question. Writes throw, because writing to + * nothing, or more than the host can hold, is a bug at the call site and the engine's own error + * (`InvalidEntityError`, `World metadata storage limit exceeded`) names neither the collection nor + * the target. + */ + +/** A write or delete on a target the collection cannot reach right now, or refused outright. */ +export class DbTargetError extends Error { + override readonly name = 'DbTargetError'; + + constructor(readonly collection: string, readonly reason: string) { + super(`${collection}: ${reason}`); + } +} + +/** A document that would not fit in the host's budget. Nothing was written. */ +export class DbBudgetError extends Error { + override readonly name = 'DbBudgetError'; + + constructor(readonly collection: string, readonly key: string, readonly size: number, readonly budget: number) { + super(`${collection}: document '${key}' is ${size} characters, the host holds ${budget}`); + } +} diff --git a/packages/db/src/host.ts b/packages/db/src/host.ts new file mode 100644 index 0000000..a752146 --- /dev/null +++ b/packages/db/src/host.ts @@ -0,0 +1,226 @@ +/** + * A host is where a document's bytes sit. The engine offers two ABIs for dynamic properties — + * six methods on world, entities and container slots; three on a block entity's component — and + * nothing at all on a dimension or a vanilla block. This file is the one adapter over all of it: + * three verbs, one capability record, and the target's identity where it has to be part of the key. + * + * Every number here is measured, not assumed — see `docs/spikes/S2`, `S4` and `S5`. Hosts are + * classes with prototype methods rather than closures: the engine's JavaScript is QuickJS, where a + * host is made per operation and each closure costs. + */ + +/** What a dynamic property can hold. Documents travel as JSON strings under the per-value cap. */ +export type DpValue = boolean | number | string | { x: number; y: number; z: number }; + +/** The six-method ABI: `World`, `Entity`, `Player`, `ContainerSlot`. */ +export interface DirectDp { + getDynamicProperty(identifier: string): DpValue | undefined; + setDynamicProperty(identifier: string, value?: DpValue): void; + getDynamicPropertyIds(): string[]; + getDynamicPropertyTotalByteCount(): number; + setDynamicProperties?(values: Record): void; +} + +/** The three-method ABI: `BlockDynamicPropertiesComponent`. */ +export interface ComponentDp { + get(key: string): DpValue | undefined; + set(key: string, value?: DpValue): void; + totalByteCount(): number; +} + +/** How a host reaches its bytes: the six-method ABI, a block entity's component, or a world property by identity. */ +export type HostAbi = 'direct' | 'component' | 'proxied'; + +/** What a host can do, and what a collection may `require` of it. */ +export interface Capabilities { + /** The bytes live on the target and die with it. `false` means a world property keyed by identity. */ + readonly own: boolean; + /** Keys can be listed, so a collection needs no index of its own. */ + readonly enumerable: boolean; + /** Characters per value on the direct ABI; bytes per block on the component ABI. */ + readonly budget: number; + /** Reachable while the target is not loaded. */ + readonly readableWhenUnloaded: boolean; + /** `setDynamicProperties` is available for a one-call flush. */ + readonly batch: boolean; +} + +/** Where one target's documents live: an adapter over the ABI with its capabilities. */ +export interface DpHost { + readonly abi: HostAbi; + readonly caps: Capabilities; + read(key: string): DpValue | undefined; + write(key: string, value: DpValue | undefined): void; + /** `undefined` when the ABI cannot list keys. */ + keys(): readonly string[] | undefined; + bytes(): number; + writeMany(values: Record): void; +} + +/** A dynamic-property string may hold this many characters; one more throws. Measured. */ +export const DIRECT_BUDGET = 32_767; + +/** A block entity holds this many bytes per pack, key and overhead included; more throws. Measured. */ +export const COMPONENT_BUDGET = 950; + +const direct = (batch: boolean, readableWhenUnloaded: boolean): Capabilities => + Object.freeze({ own: true, enumerable: true, budget: DIRECT_BUDGET, readableWhenUnloaded, batch }); + +/** The four direct capability records, so a host never allocates one. */ +const DIRECT_CAPS = { + loaded: { batch: direct(true, false), single: direct(false, false) }, + unloaded: { batch: direct(true, true), single: direct(false, true) }, +}; + +const COMPONENT_CAPS: Capabilities = Object.freeze({ + own: true, + enumerable: false, + budget: COMPONENT_BUDGET, + readableWhenUnloaded: false, + batch: false, +}); + +const PROXIED_CAPS: Capabilities = Object.freeze({ + own: false, + enumerable: true, + budget: DIRECT_BUDGET, + readableWhenUnloaded: true, + batch: true, +}); + +class DirectHost implements DpHost { + readonly abi = 'direct'; + + constructor(private readonly _target: DirectDp, readonly caps: Capabilities) {} + + read(key: string): DpValue | undefined { + return this._target.getDynamicProperty(key); + } + + write(key: string, value: DpValue | undefined): void { + this._target.setDynamicProperty(key, value); + } + + keys(): readonly string[] { + return this._target.getDynamicPropertyIds(); + } + + bytes(): number { + return this._target.getDynamicPropertyTotalByteCount(); + } + + writeMany(values: Record): void { + if (this.caps.batch && this._target.setDynamicProperties) { + this._target.setDynamicProperties(values); + + return; + } + + for (const key of Object.keys(values)) { + this._target.setDynamicProperty(key, values[key]); + } + } +} + +class ComponentHost implements DpHost { + readonly abi = 'component'; + readonly caps = COMPONENT_CAPS; + + constructor(private readonly _component: ComponentDp) {} + + read(key: string): DpValue | undefined { + return this._component.get(key); + } + + write(key: string, value: DpValue | undefined): void { + this._component.set(key, value); + } + + keys(): undefined { + return undefined; + } + + bytes(): number { + return this._component.totalByteCount(); + } + + writeMany(values: Record): void { + for (const key of Object.keys(values)) { + this._component.set(key, values[key]); + } + } +} + +class PrefixedHost implements DpHost { + constructor( + private readonly _host: DpHost, + private readonly _prefix: string, + readonly caps: Capabilities, + readonly abi: HostAbi, + ) {} + + read(key: string): DpValue | undefined { + return this._host.read(this._prefix + key); + } + + write(key: string, value: DpValue | undefined): void { + this._host.write(this._prefix + key, value); + } + + keys(): readonly string[] | undefined { + const prefix = this._prefix; + + return this._host.keys() + ?.filter(id => id.startsWith(prefix)) + .map(id => id.slice(prefix.length)); + } + + bytes(): number { + return this._host.bytes(); + } + + writeMany(values: Record): void { + const mapped: Record = {}; + + for (const key of Object.keys(values)) { + mapped[this._prefix + key] = values[key]; + } + + this._host.writeMany(mapped); + } +} + +/** + * The target holds its own properties through the six-method ABI. `batch` says whether the target + * has `setDynamicProperties`; when omitted it is probed, one native lookup — pass it where the type + * is already known. + */ +export function directHost(target: DirectDp, options?: { readableWhenUnloaded?: boolean; batch?: boolean }): DpHost { + const batch = options?.batch ?? typeof target.setDynamicProperties === 'function'; + const family = options?.readableWhenUnloaded === true ? DIRECT_CAPS.unloaded : DIRECT_CAPS.loaded; + + return new DirectHost(target, batch ? family.batch : family.single); +} + +/** The target holds its own properties through a block entity's component. */ +export function componentHost(component: ComponentDp): DpHost { + return new ComponentHost(component); +} + +/** + * Scope a host to a prefix: every key goes through it, `keys()` lists only what is under it, + * unprefixed. This is how collections never see each other's keys on one target, and how a + * proxied document folds its target's identity into the world's key space. `bytes()` is still + * the whole target's count — the engine has no per-prefix figure. + */ +export function prefixed(host: DpHost, prefix: string, caps: Capabilities = host.caps, abi: HostAbi = host.abi): DpHost { + return new PrefixedHost(host, prefix, caps, abi); +} + +/** + * The target holds nothing of its own; its properties live on the world under a prefix that + * carries the target's identity. + */ +export function proxiedHost(world: DirectDp, prefix: string): DpHost { + return new PrefixedHost(directHost(world, { readableWhenUnloaded: true }), prefix, PROXIED_CAPS, 'proxied'); +} diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts new file mode 100644 index 0000000..1fc248a --- /dev/null +++ b/packages/db/src/index.ts @@ -0,0 +1,39 @@ +/** + * `@bedrock-core/db` — persisted documents on dynamic properties. + * + * The resolver finds where a target can hold bytes — its own properties through one of the + * engine's two ABIs, or a world property keyed by its identity when it holds nothing — and caches + * the answer per type. Collections put typed, versioned JSON documents on whatever the resolver + * finds, refuse targets that cannot satisfy their requirements, and never trust a handle past the + * call it arrived in. Everything in this entry is structural and runs without the engine; the + * `./minecraft` entry adds the `instanceof` classifier, the locator, and the world. + */ +export { + COMPONENT_BUDGET, + DIRECT_BUDGET, + componentHost, + directHost, + prefixed, + proxiedHost, +} from './host'; +export type { Capabilities, ComponentDp, DirectDp, DpHost, DpValue, HostAbi } from './host'; + +export { createResolver, structuralClassifier } from './resolve'; +export type { Accepted, Classifier, Refused, Resolution, Resolver, ResolverOptions, TargetKind } from './resolve'; + +export { createDb, parseBlockIdentity, structuralLocator } from './collection'; +export type { Collection, CollectionOptions, Db, DbOptions, Document, IndexedDocument, Lifecycle, Locator, Where } from './collection'; + +export { createIndexSet } from './indexed'; +export type { IndexSet } from './indexed'; + +export { createDocumentStore, schema } from './document'; +export type { DocumentSchema, DocumentStore, DocumentStoreOptions, MigrateStep, Schema } from './document'; + +export { fill, isPlainObject, merge } from './merge'; +export type { DeepPartial } from './merge'; + +export { accepting, accepts, allOf, anyOf, blockTypes, dimensions, entityTypes, except, players, slots, worldTarget } from './accept'; +export type { Acceptor, Caps, Conflicts, Requirements, Rule, StorableTarget } from './accept'; + +export { DbBudgetError, DbTargetError } from './errors'; diff --git a/packages/db/src/indexed.ts b/packages/db/src/indexed.ts new file mode 100644 index 0000000..b5e1d16 --- /dev/null +++ b/packages/db/src/indexed.ts @@ -0,0 +1,161 @@ +/** + * The index: which targets of a collection hold a document. + * + * A block entity cannot list its keys, an unloaded entity cannot be asked, and the plan forbids + * scanning the world's property ids. So a collection keeps the identities of its documents in + * world properties of its own: one string per chunk, entries joined by `\n`, a chunk capped under + * the per-value limit (measured 32 767 characters, ~1 300 block identities). Loaded once into a + * `Set`, then membership is a hash lookup and a change rewrites the one chunk it touched. + * + * An entry is added by the first write of a document and dropped by `delete`, by `onBreak`, and by + * `all()` when it finds the target replaced. An orphan costs its identity's length until then. + * + * Chunks are written behind: a change marks its chunk dirty and the chunk is written once when + * `schedule` fires — the index lives on the world, which cannot vanish, so nothing is at risk. A + * thousand blocks placed in one tick cost a thousand set-inserts and three string writes. + */ +import type { DpHost } from './host'; + +const SEPARATOR = '\n'; + +/** The identities a collection knows of, kept in chunked properties on one host. */ +export interface IndexSet { + /** Loads the chunks on first use. */ + has(entry: string): boolean; + add(entry: string): void; + remove(entry: string): void; + entries(): Iterable; + readonly size: number; + /** Write the dirty chunks now. */ + flush(): void; +} + +/** What `createIndexSet()` takes beside the host. */ +export interface IndexSetOptions { + /** Characters per chunk. The host's budget when omitted. */ + budget?: number; + /** Run `flush` once, later — `system.run` in the engine. Chunks are written at once when omitted. */ + schedule?: (flush: () => void) => void; +} + +/** An index set over a host, loaded on first use. */ +export function createIndexSet(host: DpHost, options: IndexSetOptions = {}): IndexSet { + const budget = options.budget ?? host.caps.budget; + let chunks: string[][] | undefined; + let where: Map | undefined; + /** Joined length per chunk, so appending never re-joins 30 KB to measure it. */ + const lengths: number[] = []; + const dirtyChunks = new Set(); + let scheduled = false; + + const load = (): { chunks: string[][]; where: Map } => { + if (chunks !== undefined && where !== undefined) { + return { chunks, where }; + } + + chunks = []; + where = new Map(); + + for (let i = 0; ; i++) { + const raw = host.read(String(i)); + + if (typeof raw !== 'string') { + break; + } + + const entries = raw.length === 0 ? [] : raw.split(SEPARATOR); + + chunks.push(entries); + lengths.push(raw.length); + + for (const entry of entries) { + where.set(entry, i); + } + } + + return { chunks, where }; + }; + + const flush = (): void => { + scheduled = false; + + for (const index of dirtyChunks) { + host.write(String(index), chunks?.[index]?.join(SEPARATOR) ?? ''); + } + + dirtyChunks.clear(); + }; + + const save = (index: number, added: string | undefined): void => { + if (added !== undefined) { + lengths[index] = (lengths[index] ?? 0) + (lengths[index] === undefined || lengths[index] === 0 ? 0 : SEPARATOR.length) + added.length; + } + + dirtyChunks.add(index); + + if (options.schedule === undefined) { + flush(); + } else if (!scheduled) { + scheduled = true; + options.schedule(flush); + } + }; + + return { + has: (entry): boolean => load().where.has(entry), + + add: (entry): void => { + const state = load(); + + if (state.where.has(entry)) { + return; + } + + // Append to the last chunk while it fits, else open a new one. + let index = state.chunks.length - 1; + let target = index >= 0 ? state.chunks[index] : undefined; + + if (target === undefined || (lengths[index] ?? 0) + SEPARATOR.length + entry.length > budget) { + target = []; + index = state.chunks.push(target) - 1; + lengths.push(0); + } + + target.push(entry); + state.where.set(entry, index); + save(index, entry); + }, + + remove: (entry): void => { + const state = load(); + const index = state.where.get(entry); + + if (index === undefined) { + return; + } + + const target = state.chunks[index]; + + if (target !== undefined) { + const at = target.indexOf(entry); + + if (at >= 0) { + target.splice(at, 1); + } + + // Removals leave the length estimate high, which only makes a chunk split a little early. + save(index, undefined); + } + + state.where.delete(entry); + }, + + entries: (): Iterable => load().where.keys(), + + get size(): number { + return load().where.size; + }, + + flush, + }; +} diff --git a/packages/db/src/merge.ts b/packages/db/src/merge.ts new file mode 100644 index 0000000..2481560 --- /dev/null +++ b/packages/db/src/merge.ts @@ -0,0 +1,67 @@ +/** + * Deep merging for JSON documents: what `patch` does to a stored document, and what `defaults` + * do to one being read. + * + * A plain object merges key by key, recursively. Anything else — a scalar, an array, `null` — + * replaces what was there: an array is a value, not a container, so a patch that names one + * replaces the whole array. `undefined` in a patch deletes the key, which is how a caller puts a + * key back to its default without knowing what the default is. + */ + +/** A patch for `T`: every key optional at every depth; an array is replaced whole. */ +export type DeepPartial = { + [K in keyof T]?: T[K] extends readonly unknown[] ? T[K] + : T[K] extends object ? DeepPartial + : T[K] +}; + +/** A non-null, non-array object: what merges as structure rather than replacing as a value. */ +export function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** `base` with `changes` merged in. Neither input is mutated. */ +export function merge(base: Record | undefined, changes: Record): Record { + const result: Record = { ...base }; + + for (const key of Object.keys(changes)) { + const change = changes[key]; + + if (change === undefined) { + delete result[key]; + } else if (isPlainObject(change) && isPlainObject(result[key])) { + result[key] = merge(result[key], change); + } else { + result[key] = change; + } + } + + return result; +} + +/** + * `doc` with every key it lacks taken from `defaults`, at every depth. + * + * A default is handed over by reference, not cloned: a document is treated as immutable and + * changed with `patch`, so nothing should be writing into one. + */ +export function fill(defaults: Record | undefined, doc: Record): Record { + if (defaults === undefined) { + return doc; + } + + const result: Record = { ...doc }; + + for (const key of Object.keys(defaults)) { + const fallback = defaults[key]; + const present = result[key]; + + if (present === undefined) { + result[key] = fallback; + } else if (isPlainObject(fallback) && isPlainObject(present)) { + result[key] = fill(fallback, present); + } + } + + return result; +} diff --git a/packages/db/src/minecraft.ts b/packages/db/src/minecraft.ts new file mode 100644 index 0000000..9af2986 --- /dev/null +++ b/packages/db/src/minecraft.ts @@ -0,0 +1,163 @@ +/** + * The engine half — the only file in this package that imports `@minecraft/server`. + * + * `engineClassifier` recognizes targets by class, which is exact where the structural test reads + * members; `engineLocator` finds a kept target again through the world — an entity by id, a block + * by dimension and location — so a stale handle is never dereferenced; `createEngineResolver` and + * `createEngineDb` bind both to the real world. + */ +import { Block, ContainerSlot, Dimension, Entity, ItemStack, World, system, world, type BlockCustomComponent } from '@minecraft/server'; +import { createDb, parseBlockIdentity, type Db, type Lifecycle, type Locator } from './collection'; +import { createResolver, structuralClassifier, type Classifier, type Resolver, type TargetKind } from './resolve'; + +/** The engine's classifier: a target's kind by `instanceof`. */ +export const engineClassifier: Classifier = { + kindOf(target: unknown): TargetKind { + if (target instanceof World) { + return 'world'; + } + + if (target instanceof ItemStack) { + return 'itemStack'; + } + + if (target instanceof ContainerSlot) { + return 'slot'; + } + + if (target instanceof Block) { + return 'block'; + } + + if (target instanceof Entity) { + return 'entity'; + } + + if (target instanceof Dimension) { + return 'dimension'; + } + + return structuralClassifier.kindOf(target); + }, +}; + +/** The engine's locator: an entity by id, a block by dimension and location, the world as itself. */ +export const engineLocator: Locator = { + bind(target: unknown): () => unknown { + if (target instanceof Entity) { + const id = target.id; + + return (): unknown => world.getEntity(id); + } + + if (target instanceof Block) { + const { dimension, location } = target; + + return (): unknown => { + try { + return dimension.getBlock(location); + } catch { + return undefined; + } + }; + } + + if (target instanceof ContainerSlot) { + return (): unknown => (target.isValid ? target : undefined); + } + + return (): unknown => target; + }, + + fromIdentity(kind: TargetKind, identity: string): unknown { + switch (kind) { + case 'world': + return world; + + case 'entity': + return world.getEntity(identity); + + case 'dimension': + try { + return world.getDimension(identity); + } catch { + return undefined; + } + + case 'block': { + const parsed = parseBlockIdentity(identity); + + if (parsed === undefined) { + return undefined; + } + + try { + return world.getDimension(parsed.dimensionId).getBlock(parsed); + } catch { + return undefined; + } + } + + default: + return undefined; + } + }, +}; + +/** A resolver over the engine's world. */ +export function createEngineResolver(namespace: string): Resolver { + return createResolver({ world, namespace, classify: engineClassifier }); +} + +/** + * What coalescing collections need from the engine: one flush per tick through `system.run`, an + * entity's return through `entityLoad`, and a player's pending documents written in + * `beforeEvents.playerLeave` — where a dynamic-property write is still allowed (measured). Nothing + * here subscribes until the first coalescing collection asks. + */ +export const engineLifecycle: Lifecycle = { + schedule(flush): void { + system.run(flush); + }, + + tick: (): number => system.currentTick, + + attach({ loaded, leaving }): void { + // `entityLoad` is an afterEvent, so the entity can already be gone by the time this runs; a + // handle whose `isValid` is false would throw on the first property read downstream. + world.afterEvents.entityLoad.subscribe(({ entity }) => { + if (entity.isValid) { loaded(entity); } + }); + world.beforeEvents.playerLeave.subscribe(({ player }) => { + leaving(player); + }); + }, +}; + +/** A db over the engine's world, with its classifier, locator and lifecycle hooks; what `core.db` is. */ +export function createEngineDb(namespace: string, log?: (message: string) => void): Db { + return createDb({ world, namespace, classify: engineClassifier, locate: engineLocator, lifecycle: engineLifecycle, log }); +} + +/** + * The custom component that keeps block indexes honest. Register it once in `startup` and add it + * to every block type a block collection accepts — a block type that references a component nobody + * registered is removed from the world, so the registration is not optional: + * + * ```ts + * system.beforeEvents.startup.subscribe(({ blockComponentRegistry }) => { + * blockComponentRegistry.registerCustomComponent('papi:db_block', blockCleanup(db)); + * }); + * ``` + * + * `onBreak` fires for every removal — player, explosion, `/setblock` and `/fill` in either mode, + * script `setPermutation` (measured) — with the block already air, so the identity comes from the + * event's location and broken permutation, never from the block. + */ +export function blockCleanup(db: Db): BlockCustomComponent { + return { + onBreak({ block, brokenBlockPermutation, dimension }): void { + db.blockRemoved(dimension.id, block.location, brokenBlockPermutation.type.id); + }, + }; +} diff --git a/packages/db/src/resolve.ts b/packages/db/src/resolve.ts new file mode 100644 index 0000000..83a5af0 --- /dev/null +++ b/packages/db/src/resolve.ts @@ -0,0 +1,383 @@ +/** + * The resolver: given anything, decide where its documents can live. + * + * Nothing is declared. The target is probed — does it expose the six-method ABI, does it carry a + * `minecraft:dynamic_properties` component, or does it hold nothing and need a world property + * keyed by its identity — and the answer is cached **per type**, because whether a type holds its + * own properties is a property of the type: `minecraft:block_entity` cannot vary by permutation, + * and an item's stackability is fixed by its type. A throw is never cached; a block in an + * unloaded chunk throws on `getComponent`, and caching that would demote a whole block type for + * the rest of the session. + * + * Refusals come first, by name, because the structural test alone would accept the one target + * where every write is a silent lie: an `ItemStack` is a copy (measured — a stackable one throws, + * a non-stackable one writes to the copy and a fresh read gives `undefined`). + */ +import { + componentHost, + directHost, + proxiedHost, + type ComponentDp, + type DirectDp, + type DpHost, +} from './host'; + +/** What a target is, as the classifier sees it. */ +export type TargetKind = 'world' | 'dimension' | 'entity' | 'block' | 'slot' | 'itemStack' | 'unknown'; + +/** + * How the resolver recognizes targets. The default is structural — it reads members, so it runs + * without the engine — and `@bedrock-core/db/minecraft` supplies one built on `instanceof`. + */ +export interface Classifier { + kindOf(target: unknown): TargetKind; +} + +/** A resolution that found a host: the target's kind, type, identity and where its bytes live. */ +export interface Accepted { + readonly ok: true; + readonly kind: TargetKind; + /** The block or entity type, a slot's item type, a dimension's id, `world`. */ + readonly typeId: string; + readonly typeKey: string; + readonly identity: string; + readonly host: DpHost; + /** + * The prefix a collection applies on top of `host` so its keys never meet another + * collection's, config's, or a proxied document's — the key scheme in one place: + * `core-db:::::`, the identity empty on an own host + * because the target *is* the identity, and only `:` on a block entity, whose + * ~950 bytes are per pack per block already. + */ + prefixFor(collection: string): string; +} + +/** A resolution that found no host, with the reason. */ +export interface Refused { + readonly ok: false; + readonly kind: TargetKind; + readonly reason: string; +} + +/** What `resolve()` answers. */ +export type Resolution = Accepted | Refused; + +/** What `createResolver()` takes. */ +export interface ResolverOptions { + /** The world, for proxied hosts. Anything with the six-method ABI. */ + world: DirectDp; + /** The addon's namespace — the first segment of every proxied key. */ + namespace: string; + classify?: Classifier; +} + +/** Decides, per target, where a document can live, probing each type once. */ +export interface Resolver { + resolve(target: unknown): Resolution; + /** The cached decision for a type key, for tests and diagnostics. */ + decision(typeKey: string): 'direct' | 'component' | 'proxied' | undefined; + /** + * A resolution for a target that is not at hand — an index entry whose block sits in an unloaded + * chunk — when its documents live on the world anyway: a dimension always, a block or entity type + * this session already saw resolve to the proxy. `undefined` when the bytes would be on the target. + */ + absent(kind: TargetKind, typeId: string, identity: string): Accepted | undefined; +} + +// ─── Structural reads, without assertions ───────────────────────────────────── + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function str(record: Record, key: string): string | undefined { + const value = record[key]; + + return typeof value === 'string' ? value : undefined; +} + +function num(record: Record, key: string): number | undefined { + const value = record[key]; + + return typeof value === 'number' ? value : undefined; +} + +function isDirectDp(value: unknown): value is DirectDp { + return isRecord(value) + && typeof value.getDynamicProperty === 'function' + && typeof value.setDynamicProperty === 'function' + && typeof value.getDynamicPropertyIds === 'function' + && typeof value.getDynamicPropertyTotalByteCount === 'function'; +} + +function isComponentDp(value: unknown): value is ComponentDp { + return isRecord(value) + && typeof value.get === 'function' + && typeof value.set === 'function' + && typeof value.totalByteCount === 'function'; +} + +function hasGetComponent(value: unknown): value is { getComponent(id: string): unknown } { + return isRecord(value) && typeof value.getComponent === 'function'; +} + +/** + * Recognize a target by the members the engine gives each class and nothing else has: + * `World.getAllPlayers`, `ItemStack.clone` (a slot mirrors most of a stack's members but not that + * one), `ContainerSlot.getItem`, `Block.permutation`, `Entity.id` beside the DP methods, + * `Dimension.getBlock`. + */ +export const structuralClassifier: Classifier = { + kindOf(target: unknown): TargetKind { + if (!isRecord(target)) { + return 'unknown'; + } + + if (typeof target.getAllPlayers === 'function' && typeof target.getDynamicProperty === 'function') { + return 'world'; + } + + if (typeof target.clone === 'function' && typeof target.getItem !== 'function' && 'maxAmount' in target) { + return 'itemStack'; + } + + if (typeof target.getItem === 'function' && 'maxAmount' in target) { + return 'slot'; + } + + if ('permutation' in target && 'location' in target && 'typeId' in target) { + return 'block'; + } + + if ('typeId' in target && 'id' in target && typeof target.getDynamicProperty === 'function') { + return 'entity'; + } + + if ('id' in target && typeof target.getBlock === 'function') { + return 'dimension'; + } + + return 'unknown'; + }, +}; + +// ─── Identity ────────────────────────────────────────────────────────────────── + +function identityOf(target: Record, kind: TargetKind): string { + switch (kind) { + case 'world': + return ''; + case 'dimension': + case 'entity': + return str(target, 'id') ?? ''; + + // Dimension, position AND type: a block of another type at the same position gets a different + // key, so a replacement can never inherit a document — the type check is in the key, not on read. + case 'block': { + const dimension = target.dimension; + const location = target.location; + const dimensionId = isRecord(dimension) ? str(dimension, 'id') ?? '' : ''; + const typeId = str(target, 'typeId') ?? '?'; + + if (!isRecord(location)) { + return `${dimensionId}::${typeId}`; + } + + return `${dimensionId}:${num(location, 'x') ?? 0},${num(location, 'y') ?? 0},${num(location, 'z') ?? 0}:${typeId}`; + } + + default: + return ''; + } +} + +function typeIdOf(target: Record, kind: TargetKind): string { + switch (kind) { + case 'block': + case 'entity': + return str(target, 'typeId') ?? '?'; + + case 'slot': { + const item = typeof target.getItem === 'function' ? target.getItem() : undefined; + + return isRecord(item) ? str(item, 'typeId') ?? '?' : 'empty'; + } + + case 'dimension': + return str(target, 'id') ?? '?'; + + default: + return kind; + } +} + +/** The cache key for a resolver decision: per type where the type decides, one per kind otherwise. */ +function typeKeyOf(kind: TargetKind, typeId: string): string { + switch (kind) { + case 'block': + case 'entity': + case 'slot': + return `${kind}:${typeId}`; + + default: + return kind; + } +} + +// ─── The resolver ────────────────────────────────────────────────────────────── + +type Decision + = | { abi: 'direct'; batch: boolean } + | { abi: 'component' } + | { abi: 'proxied' }; + +const PROXIED: Decision = { abi: 'proxied' }; +const COMPONENT: Decision = { abi: 'component' }; +const DIRECT_BATCH: Decision = { abi: 'direct', batch: true }; +const DIRECT_SINGLE: Decision = { abi: 'direct', batch: false }; + +class AcceptedResolution implements Accepted { + readonly ok = true; + readonly typeKey: string; + + constructor( + readonly kind: TargetKind, + readonly typeId: string, + readonly identity: string, + readonly host: DpHost, + private readonly _namespace: string, + ) { + this.typeKey = typeKeyOf(kind, typeId); + } + + prefixFor(collection: string): string { + return this.host.abi === 'direct' + ? `core-db:${this._namespace}:${this.kind}::${collection}:` + : `${collection}:`; + } +} + +/** A resolver over any world with the six-method ABI and any classifier. */ +export function createResolver(options: ResolverOptions): Resolver { + const classify = options.classify ?? structuralClassifier; + const { namespace, world } = options; + const decisions = new Map(); + + const proxied = (kind: TargetKind, typeId: string, identity: string): Accepted => + new AcceptedResolution(kind, typeId, identity, proxiedHost(world, `core-db:${namespace}:${kind}:${identity}:`), namespace); + + const direct = (kind: TargetKind, typeId: string, identity: string, target: DirectDp, batch: boolean): Accepted => + new AcceptedResolution(kind, typeId, identity, directHost(target, { readableWhenUnloaded: kind === 'world', batch }), namespace); + + const refuse = (kind: TargetKind, reason: string): Refused => ({ ok: false, kind, reason }); + + const resolve = (target: unknown): Resolution => { + const kind = classify.kindOf(target); + + if (!isRecord(target)) { + return refuse(kind, 'not an object'); + } + + if (kind === 'itemStack') { + return refuse(kind, 'an ItemStack is a detached copy — a write never reaches the world; bind the ContainerSlot it lives in'); + } + + if (kind === 'unknown') { + return refuse(kind, 'not a target the resolver recognizes'); + } + + const identity = identityOf(target, kind); + const typeId = typeIdOf(target, kind); + const typeKey = typeKeyOf(kind, typeId); + + if (kind === 'slot') { + const item = typeof target.getItem === 'function' ? target.getItem() : undefined; + + if (!isRecord(item)) { + return refuse(kind, 'the slot is empty'); + } + + if ((num(item, 'maxAmount') ?? 64) !== 1) { + return refuse(kind, `a stackable item (${str(item, 'typeId') ?? '?'}) cannot hold dynamic properties`); + } + } + + // A type decided once is trusted: whether it holds its own properties, and how, cannot change. + const cached = decisions.get(typeKey); + + if (cached !== undefined) { + switch (cached.abi) { + case 'direct': + return direct(kind, typeId, identity, target as unknown as DirectDp, cached.batch); // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion + + case 'proxied': + return proxied(kind, typeId, identity); + + case 'component': { + const component = probeComponent(target); + + if (component === undefined) { + return refuse(kind, 'cannot probe right now: the target is not loaded'); + } + + return new AcceptedResolution(kind, typeId, identity, componentHost(component), namespace); + } + } + } + + // Direct ABI first: it wins where both are present (a block item has the component too). + if (isDirectDp(target)) { + const batch = typeof target.setDynamicProperties === 'function'; + + decisions.set(typeKey, batch ? DIRECT_BATCH : DIRECT_SINGLE); + + return direct(kind, typeId, identity, target, batch); + } + + // Component ABI: probe, and never cache a throw — an unloaded chunk throws here. + if (hasGetComponent(target)) { + let component: unknown; + + try { + component = target.getComponent('minecraft:dynamic_properties'); + } catch (error) { + return refuse(kind, `cannot probe right now: ${String(error)}`); + } + + if (isComponentDp(component)) { + decisions.set(typeKey, COMPONENT); + + return new AcceptedResolution(kind, typeId, identity, componentHost(component), namespace); + } + } + + decisions.set(typeKey, PROXIED); + + return proxied(kind, typeId, identity); + }; + + return { + resolve, + decision: typeKey => decisions.get(typeKey)?.abi, + absent: (kind, typeId, identity): Accepted | undefined => { + const decided = kind === 'dimension' ? 'proxied' : decisions.get(typeKeyOf(kind, typeId))?.abi; + + return decided === 'proxied' ? proxied(kind, typeId, identity) : undefined; + }, + }; +} + +/** The component of a block whose type is known to have one; `undefined` while its chunk is unloaded. */ +function probeComponent(target: Record): ComponentDp | undefined { + if (!hasGetComponent(target)) { + return undefined; + } + + try { + const component = target.getComponent('minecraft:dynamic_properties'); + + return isComponentDp(component) ? component : undefined; + } catch { + return undefined; + } +} diff --git a/packages/db/test/coalesce.spec.ts b/packages/db/test/coalesce.spec.ts new file mode 100644 index 0000000..4425c6e --- /dev/null +++ b/packages/db/test/coalesce.spec.ts @@ -0,0 +1,221 @@ +/** + * Write-behind: many writes, one property write per flush; a flush that finds the entity away parks + * the document and writes it on load; a leaving player is flushed at once; a block or slot is + * refused; a db with no coalescing collection never touches the lifecycle. + */ +import { describe, expect, it, vi } from 'vitest'; +import { createDb, schema } from '../src/index'; +import type { Collection, Lifecycle, Schema } from '../src/index'; +import { BlockStub, ComponentStub, EntityStub, WorldStub } from './stubs'; + +interface Counter { + hits: number; +} + +interface LooseDb { + collection(name: string, options: { schema: Schema; coalesce?: boolean }): Collection; + flush(target?: unknown): void; +} + +function harness(parkFor?: number): { + world: WorldStub; + entities: Map; + db: LooseDb; + tick(): void; + hooks(): { loaded(target: unknown): void; leaving(target: unknown): void } | undefined; + lifecycle: { schedule: ReturnType; attach: ReturnType }; + log: ReturnType void>>; +} { + const world = new WorldStub(); + const entities = new Map(); + const scheduled: (() => void)[] = []; + let attachedHooks: { loaded(target: unknown): void; leaving(target: unknown): void } | undefined; + let now = 0; + const lifecycle = { + schedule: vi.fn((flush: () => void) => { + scheduled.push(flush); + }), + tick: (): number => now, + attach: vi.fn((hooks: { loaded(target: unknown): void; leaving(target: unknown): void }) => { + attachedHooks = hooks; + }), + }; + const log = vi.fn<(message: string) => void>(); + const db: LooseDb = createDb({ + world, + namespace: 'ns', + lifecycle: lifecycle satisfies Lifecycle, + parkFor, + log, + locate: { + bind: target => (): unknown => (target instanceof EntityStub ? entities.get(target.id) : target), + fromIdentity: (kind, identity): unknown => (kind === 'entity' ? entities.get(identity) : undefined), + }, + }); + + return { + world, + entities, + db, + lifecycle, + log, + tick: (): void => { + now++; + + for (const flush of scheduled.splice(0)) { + flush(); + } + }, + hooks: () => attachedHooks, + }; +} + +describe('coalesce', () => { + it('writes once per flush however many times the document changed', () => { + const { entities, db, tick, lifecycle } = harness(); + const counters = db.collection('counters', { schema: schema(), coalesce: true }); + const mob = new EntityStub('m', 'ns:mob'); + + entities.set('m', mob); + + const spy = vi.spyOn(mob, 'setDynamicProperty'); + + for (let i = 1; i <= 100; i++) { + counters.for(mob).patch({ hits: i }); + } + + expect(counters.for(mob).get()).toEqual({ hits: 100 }); + expect(spy).not.toHaveBeenCalled(); + // One flush for the documents, one for the index chunk. + expect(lifecycle.schedule).toHaveBeenCalledTimes(2); + + tick(); + + expect(spy).toHaveBeenCalledTimes(1); + expect(mob.getDynamicProperty('core-db:ns:entity::counters:doc')).toBe('{"v":1,"d":{"hits":100}}'); + }); + + it('parks a document whose entity left before the flush and writes it when the entity loads', () => { + const { entities, db, tick, hooks } = harness(); + const counters = db.collection('counters', { schema: schema(), coalesce: true }); + const mob = new EntityStub('m', 'ns:mob'); + + entities.set('m', mob); + counters.for(mob).set({ hits: 7 }); + entities.delete('m'); + tick(); + + expect(mob.getDynamicProperty('core-db:ns:entity::counters:doc')).toBeUndefined(); + + const back = new EntityStub('m', 'ns:mob'); + + entities.set('m', back); + hooks()?.loaded(back); + + expect(back.getDynamicProperty('core-db:ns:entity::counters:doc')).toBe('{"v":1,"d":{"hits":7}}'); + }); + + it('drops a parked document past its time, with a log line', () => { + const { entities, db, tick, hooks, log } = harness(0); + const counters = db.collection('counters', { schema: schema(), coalesce: true }); + const mob = new EntityStub('m', 'ns:mob'); + const other = new EntityStub('o', 'ns:mob'); + + entities.set('m', mob); + counters.for(mob).set({ hits: 1 }); + entities.delete('m'); + tick(); + + // Any later load sweeps the parked set. + entities.set('o', other); + hooks()?.loaded(other); + + const back = new EntityStub('m', 'ns:mob'); + + entities.set('m', back); + hooks()?.loaded(back); + + expect(back.getDynamicProperty('core-db:ns:entity::counters:doc')).toBeUndefined(); + expect(log).toHaveBeenCalledTimes(1); + expect(String(log.mock.calls[0]?.[0])).toContain('parked'); + }); + + it('flushes a leaving player at once, and only that player', () => { + const { entities, db, hooks } = harness(); + const counters = db.collection('counters', { schema: schema(), coalesce: true }); + const leaving = new EntityStub('p1', 'minecraft:player'); + const staying = new EntityStub('p2', 'minecraft:player'); + + entities.set('p1', leaving); + entities.set('p2', staying); + counters.for(leaving).set({ hits: 1 }); + counters.for(staying).set({ hits: 2 }); + + hooks()?.leaving(leaving); + + expect(leaving.getDynamicProperty('core-db:ns:entity::counters:doc')).toBe('{"v":1,"d":{"hits":1}}'); + expect(staying.getDynamicProperty('core-db:ns:entity::counters:doc')).toBeUndefined(); + + db.flush(); + expect(staying.getDynamicProperty('core-db:ns:entity::counters:doc')).toBe('{"v":1,"d":{"hits":2}}'); + }); + + it('coalesces on the world, refuses a block entity and a slot', () => { + const { world, db, tick } = harness(); + const counters = db.collection('counters', { schema: schema(), coalesce: true }); + const lift = new BlockStub('papi:elevator', { x: 0, y: 0, z: 0 }, { id: 'overworld' }, new ComponentStub()); + + counters.for(world).set({ hits: 3 }); + expect(world.getDynamicProperty('core-db:ns:world::counters:doc')).toBeUndefined(); + tick(); + expect(world.getDynamicProperty('core-db:ns:world::counters:doc')).toBe('{"v":1,"d":{"hits":3}}'); + + expect(counters.where(lift)).toMatchObject({ ok: false, reason: expect.stringContaining('coalesce') }); + }); + + it('a delete cancels a pending write', () => { + const { entities, db, tick } = harness(); + const counters = db.collection('counters', { schema: schema(), coalesce: true }); + const mob = new EntityStub('m', 'ns:mob'); + + entities.set('m', mob); + counters.for(mob).set({ hits: 1 }); + counters.for(mob).delete(); + tick(); + + expect(mob.getDynamicPropertyIds()).toEqual([]); + expect(counters.size).toBe(0); + }); + + it('re-resolves a handle once per tick', () => { + const { entities, db, tick } = harness(); + const plain = db.collection('plain', { schema: schema() }); + const mob = new EntityStub('m', 'ns:mob'); + + entities.set('m', mob); + + const handle = plain.for(mob); + + expect(handle.available).toBe(true); + entities.delete('m'); + // Same tick: the memo still trusts the target. + expect(handle.available).toBe(true); + tick(); + expect(handle.available).toBe(false); + expect(handle.get()).toBeUndefined(); + }); + + it('never attaches to the lifecycle without a coalescing collection', () => { + const { db, lifecycle, entities } = harness(); + const plain = db.collection('plain', { schema: schema() }); + const mob = new EntityStub('m', 'ns:mob'); + + entities.set('m', mob); + plain.for(mob).set({ hits: 1 }); + + expect(lifecycle.attach).not.toHaveBeenCalled(); + // Only the index chunk is scheduled; the document itself was written through. + expect(lifecycle.schedule).toHaveBeenCalledTimes(1); + expect(mob.getDynamicProperty('core-db:ns:entity::plain:doc')).toBe('{"v":1,"d":{"hits":1}}'); + }); +}); diff --git a/packages/db/test/collection.spec.ts b/packages/db/test/collection.spec.ts new file mode 100644 index 0000000..cf8f495 --- /dev/null +++ b/packages/db/test/collection.spec.ts @@ -0,0 +1,430 @@ +/** + * Collections over the resolver: typed handles, accept and require refusals with reasons, the + * validity gate on every operation, proxied documents readable while the target is unloaded, + * caching and local change events, and two collections on one target staying apart. + */ +import { describe, expect, it, vi } from 'vitest'; +import { DbTargetError, accepting, accepts, allOf, anyOf, blockTypes, createDb, dimensions, entityTypes, except, players, schema, slots, worldTarget } from '../src/index'; +import type { Collection, Requirements, Rule, Schema } from '../src/index'; +import { BlockStub, ComponentStub, DimensionStub, EntityStub, ItemStackStub, SlotStub, WorldStub } from './stubs'; + +interface Balance { + gold: number; + lastSeen: number; +} + +interface Elevator { + configured: boolean; + facing: string; +} + +interface Settings { + economy: { taxRate: number; currency: string }; + tags: string[]; +} + +/** The stubs are not engine classes, so the typed `for()` is widened for these tests. */ +interface LooseDb { + collection( + name: string, + options: { schema: Schema; accept?: Rule; require?: Requirements }, + ): Collection; +} + +function db(world = new WorldStub()): { world: WorldStub; db: LooseDb; log: ReturnType void>> } { + const log = vi.fn<(message: string) => void>(); + + return { world, db: createDb({ world, namespace: 'ns', log }), log }; +} + +describe('documents on own hosts', () => { + it('reads undefined, then the written document, then a patched one', () => { + const { db: store } = db(); + const balances = store.collection('balances', { schema: schema() }); + const player = new EntityStub('p1', 'minecraft:player'); + const doc = balances.for(player); + + expect(doc.available).toBe(true); + expect(doc.get()).toBeUndefined(); + + doc.set({ gold: 10, lastSeen: 1 }); + expect(doc.get()).toEqual({ gold: 10, lastSeen: 1 }); + + doc.patch({ gold: 11 }); + expect(doc.get()).toEqual({ gold: 11, lastSeen: 1 }); + expect(player.getDynamicPropertyIds()).toEqual(['core-db:ns:entity::balances:doc']); + }); + + it('patches over defaults when there is no document yet, and stores only what was written', () => { + const { db: store } = db(); + const balances = store.collection('balances', { schema: schema({ defaults: { gold: 0, lastSeen: 0 } }) }); + const player = new EntityStub('p1', 'minecraft:player'); + const doc = balances.for(player); + + doc.patch({ gold: 5 }); + expect(doc.get()).toEqual({ gold: 5, lastSeen: 0 }); + expect(player.getDynamicProperty('core-db:ns:entity::balances:doc')).toBe('{"v":1,"d":{"gold":5}}'); + }); + + it('fills defaults at every depth without persisting them', () => { + const { db: store } = db(); + const settings = store.collection('settings', { + schema: schema({ defaults: { economy: { taxRate: 0.05, currency: 'emerald' }, tags: [] } }), + }); + const player = new EntityStub('p1', 'minecraft:player'); + const doc = settings.for(player); + + doc.patch({ economy: { taxRate: 0.2 } }); + + expect(doc.get()).toEqual({ economy: { taxRate: 0.2, currency: 'emerald' }, tags: [] }); + expect(player.getDynamicProperty('core-db:ns:entity::settings:doc')).toBe('{"v":1,"d":{"economy":{"taxRate":0.2}}}'); + }); + + it('patches deep: nested objects merge, arrays replace, undefined deletes', () => { + const { db: store } = db(); + const settings = store.collection('settings', { schema: schema({ defaults: { economy: { taxRate: 0.05, currency: 'emerald' }, tags: [] } }) }); + const player = new EntityStub('p1', 'minecraft:player'); + const doc = settings.for(player); + const seen: (Settings | undefined)[] = []; + + doc.subscribe((next) => { seen.push(next); }); + doc.set({ economy: { taxRate: 0.2, currency: 'gold' }, tags: ['a', 'b'] }); + doc.patch({ economy: { taxRate: 0.3 }, tags: ['c'] }); + + expect(doc.get()).toEqual({ economy: { taxRate: 0.3, currency: 'gold' }, tags: ['c'] }); + + // Deleting a key puts it back to its default, and the bytes no longer carry it. + doc.patch({ economy: { currency: undefined } }); + + expect(doc.get()).toEqual({ economy: { taxRate: 0.3, currency: 'emerald' }, tags: ['c'] }); + expect(player.getDynamicProperty('core-db:ns:entity::settings:doc')).toBe('{"v":1,"d":{"economy":{"taxRate":0.3},"tags":["c"]}}'); + // Subscribers see the document as `get` gives it, defaults filled. + expect(seen.at(-1)).toEqual({ economy: { taxRate: 0.3, currency: 'emerald' }, tags: ['c'] }); + }); + + it('tells a subscriber attached before the first read when the document loads', () => { + const { db: store } = db(); + const balances = store.collection('balances', { schema: schema() }); + const player = new EntityStub('p1', 'minecraft:player'); + + balances.for(player).set({ gold: 3, lastSeen: 0 }); + balances.forget(player); + + const seen: (Balance | undefined)[] = []; + + balances.for(player).subscribe((next) => { seen.push(next); }); + expect(seen).toEqual([]); + + expect(balances.for(player).get()).toEqual({ gold: 3, lastSeen: 0 }); + expect(seen).toEqual([{ gold: 3, lastSeen: 0 }]); + + // Later reads are cached and say nothing. + balances.for(player).get(); + expect(seen).toHaveLength(1); + }); + + it('runs normalize over every write, before defaults', () => { + const { db: store } = db(); + const normalize = vi.fn((doc: Balance): Balance => ({ ...doc, gold: Math.min(doc.gold, 100) })); + const balances = store.collection('balances', { schema: schema({ defaults: { gold: 0, lastSeen: 0 }, normalize }) }); + const player = new EntityStub('p1', 'minecraft:player'); + const doc = balances.for(player); + + doc.set({ gold: 500, lastSeen: 1 }); + expect(doc.get()).toEqual({ gold: 100, lastSeen: 1 }); + + doc.patch({ gold: 900 }); + expect(doc.get()).toEqual({ gold: 100, lastSeen: 1 }); + expect(player.getDynamicProperty('core-db:ns:entity::balances:doc')).toBe('{"v":1,"d":{"gold":100,"lastSeen":1}}'); + // Once per write, over the document as it will be stored. + expect(normalize).toHaveBeenCalledTimes(2); + expect(normalize.mock.calls[1]?.[0]).toEqual({ gold: 900, lastSeen: 1 }); + }); + + it('keeps two collections and two targets apart', () => { + const { db: store } = db(); + const balances = store.collection('balances', { schema: schema() }); + const homes = store.collection('homes', { schema: schema<{ x: number }>() }); + const a = new EntityStub('a', 'minecraft:player'); + const b = new EntityStub('b', 'minecraft:player'); + + balances.for(a).set({ gold: 1, lastSeen: 0 }); + balances.for(b).set({ gold: 2, lastSeen: 0 }); + homes.for(a).set({ x: 9 }); + + expect(balances.for(a).get()).toEqual({ gold: 1, lastSeen: 0 }); + expect(balances.for(b).get()).toEqual({ gold: 2, lastSeen: 0 }); + expect(homes.for(a).get()).toEqual({ x: 9 }); + expect(homes.for(b).get()).toBeUndefined(); + }); + + it('stores a block entity document on the block itself under the short prefix', () => { + const { db: store, world } = db(); + const component = new ComponentStub(); + const block = new BlockStub('papi:elevator', { x: 1, y: 2, z: 3 }, { id: 'overworld' }, component); + const elevators = store.collection('elevators', { schema: schema(), require: { own: true } }); + + elevators.for(block).set({ configured: true, facing: 'north' }); + + expect(component.get('elevators:doc')).toBe('{"v":1,"d":{"configured":true,"facing":"north"}}'); + // Only the index touches the world: the document is on the block. + expect(world.getDynamicPropertyIds()).toEqual(['core-db:ns:index:elevators:0']); + expect(world.getDynamicProperty('core-db:ns:index:elevators:0')).toBe('block:overworld:1,2,3:papi:elevator'); + expect(elevators.where(block)).toMatchObject({ ok: true, kind: 'block', caps: { own: true, enumerable: false } }); + }); + + it('deletes and reports through where()', () => { + const { db: store } = db(); + const balances = store.collection('balances', { schema: schema() }); + const player = new EntityStub('p1', 'minecraft:player'); + + balances.for(player).set({ gold: 1, lastSeen: 0 }); + balances.for(player).delete(); + + expect(balances.for(player).get()).toBeUndefined(); + expect(player.getDynamicPropertyIds()).toEqual([]); + expect(balances.where(player)).toMatchObject({ ok: true, kind: 'entity', caps: { own: true } }); + }); +}); + +describe('accept and require', () => { + it('refuses a target the acceptor does not list, with a reason naming the collection', () => { + const { db: store } = db(); + const elevators = store.collection('elevators', { schema: schema(), accept: blockTypes('papi:elevator') }); + const stone = new BlockStub('minecraft:stone', { x: 0, y: 0, z: 0 }, { id: 'overworld' }, undefined); + const lift = new BlockStub('papi:elevator', { x: 0, y: 0, z: 0 }, { id: 'overworld' }, new ComponentStub()); + const player = new EntityStub('p1', 'minecraft:player'); + + expect(elevators.where(stone)).toMatchObject({ ok: false, reason: expect.stringContaining('elevators') }); + expect(elevators.where(player)).toMatchObject({ ok: false, kind: 'entity' }); + expect(elevators.where(lift)).toMatchObject({ ok: true }); + + const refused = elevators.for(stone); + + expect(refused.available).toBe(false); + expect(refused.reason).toContain('minecraft:stone'); + expect(() => refused.set({ configured: true, facing: 'north' })).toThrow(DbTargetError); + }); + + it('asks the acceptor once per type', () => { + const { db: store } = db(); + const test = vi.fn((typeId: string) => typeId.startsWith('ns:')); + const mobs = store.collection('mobs', { schema: schema(), accept: accepting(test, ['entity']) }); + + expect(mobs.where(new EntityStub('1', 'ns:mob')).ok).toBe(true); + expect(mobs.where(new EntityStub('2', 'ns:mob')).ok).toBe(true); + expect(mobs.where(new EntityStub('3', 'minecraft:cow')).ok).toBe(false); + expect(test).toHaveBeenCalledTimes(2); + }); + + it('refuses require.own on a proxied host instead of storing on the world', () => { + const { db: store, world } = db(); + const strict = store.collection('strict', { schema: schema(), require: { own: true } }); + const stone = new BlockStub('minecraft:stone', { x: 0, y: 0, z: 0 }, { id: 'overworld' }, undefined); + + expect(strict.where(stone)).toMatchObject({ ok: false, reason: expect.stringContaining('require.own') }); + expect(() => strict.for(stone).set({ configured: false, facing: 'n' })).toThrow(/require.own/); + expect(world.getDynamicPropertyIds()).toEqual([]); + }); + + it('refuses require.enumerable on a block entity and require.readableWhenUnloaded on an entity', () => { + const { db: store } = db(); + const listed = store.collection('listed', { schema: schema(), require: { enumerable: true } }); + const offline = store.collection('offline', { schema: schema(), require: { readableWhenUnloaded: true } }); + const lift = new BlockStub('papi:elevator', { x: 0, y: 0, z: 0 }, { id: 'overworld' }, new ComponentStub()); + + expect(listed.where(lift)).toMatchObject({ ok: false, reason: expect.stringContaining('require.enumerable') }); + expect(offline.where(new EntityStub('p', 'minecraft:player'))).toMatchObject({ ok: false, reason: expect.stringContaining('readableWhenUnloaded') }); + }); + + it('composes acceptors: anyOf unions kinds, allOf intersects them, except subtracts a rule', () => { + const merchantsOrPlayers = anyOf(players(), entityTypes('papi:merchant'), blockTypes('papi:stall')); + + expect([...merchantsOrPlayers.kinds].sort()).toEqual(['block', 'entity']); + expect(accepts(merchantsOrPlayers, 'minecraft:player', 'entity')).toBe(true); + expect(accepts(merchantsOrPlayers, 'papi:merchant', 'entity')).toBe(true); + expect(accepts(merchantsOrPlayers, 'papi:stall', 'block')).toBe(true); + expect(accepts(merchantsOrPlayers, 'papi:stall', 'entity')).toBe(false); + expect(accepts(merchantsOrPlayers, 'minecraft:cow', 'entity')).toBe(false); + + const namespaced = allOf(entityTypes(), accepting(id => id.startsWith('papi:'))); + + expect(namespaced.kinds).toEqual(['entity']); + expect(accepts(namespaced, 'papi:merchant', 'entity')).toBe(true); + expect(accepts(namespaced, 'minecraft:cow', 'entity')).toBe(false); + expect(accepts(namespaced, 'papi:stall', 'block')).toBe(false); + + const mobs = except(entityTypes(), players()); + + expect(accepts(mobs, 'minecraft:cow', 'entity')).toBe(true); + expect(accepts(mobs, 'minecraft:player', 'entity')).toBe(false); + + // Nested: (own mobs, except bosses) or own blocks. + const nested = anyOf(except(allOf(entityTypes(), accepting(id => id.startsWith('papi:'))), entityTypes('papi:boss')), blockTypes('papi:stall')); + + expect(accepts(nested, 'papi:merchant', 'entity')).toBe(true); + expect(accepts(nested, 'papi:boss', 'entity')).toBe(false); + expect(accepts(nested, 'papi:stall', 'block')).toBe(true); + expect(accepts(nested, 'minecraft:cow', 'entity')).toBe(false); + }); + + it('a composed acceptor refuses through the collection with a reason', () => { + const { db: store } = db(); + const mobs = store.collection('mobs', { schema: schema(), accept: except(entityTypes(), players()) }); + const player = new EntityStub('p1', 'minecraft:player'); + + expect(mobs.where(new EntityStub('c1', 'minecraft:cow'))).toMatchObject({ ok: true }); + expect(mobs.where(player)).toMatchObject({ ok: false, reason: expect.stringContaining('minecraft:player') }); + expect(() => mobs.for(player).set({ gold: 1, lastSeen: 0 })).toThrow(DbTargetError); + }); + + it('built-in acceptors narrow by kind and type', () => { + expect(blockTypes('a:b').kinds).toEqual(['block']); + expect(accepts(blockTypes('a:b'), 'a:b', 'block')).toBe(true); + expect(accepts(blockTypes('a:b'), 'a:c', 'block')).toBe(false); + expect(accepts(blockTypes('a:b'), 'a:b', 'entity')).toBe(false); + expect(accepts(blockTypes(), 'anything', 'block')).toBe(true); + expect(accepts(entityTypes('ns:mob'), 'ns:mob', 'entity')).toBe(true); + expect(accepts(players(), 'minecraft:player', 'entity')).toBe(true); + expect(accepts(players(), 'minecraft:cow', 'entity')).toBe(false); + expect(slots('ns:sword').kinds).toEqual(['slot']); + expect(worldTarget().kinds).toEqual(['world']); + expect(accepts(dimensions('minecraft:nether'), 'minecraft:nether', 'dimension')).toBe(true); + expect(accepts(dimensions(), 'minecraft:the_end', 'dimension')).toBe(true); + }); +}); + +describe('validity', () => { + it('reads undefined and throws on write once an entity is gone', () => { + const { db: store } = db(); + const balances = store.collection('balances', { schema: schema() }); + const mob = new EntityStub('m1', 'ns:mob'); + const doc = balances.for(mob); + + doc.set({ gold: 1, lastSeen: 0 }); + mob.isValid = false; + + expect(doc.available).toBe(false); + expect(doc.reason).toContain('not loaded or no longer exists'); + expect(doc.get()).toBeUndefined(); + expect(() => doc.set({ gold: 2, lastSeen: 0 })).toThrow(DbTargetError); + expect(() => doc.patch({ gold: 2 })).toThrow(/balances/); + expect(() => doc.delete()).not.toThrow(); + expect(mob.getDynamicPropertyIds()).toEqual(['core-db:ns:entity::balances:doc']); + }); + + it('keeps a proxied document readable and writable while its target is unloaded', () => { + const { db: store, world } = db(); + const regions = store.collection('regions', { schema: schema<{ name: string }>() }); + const nether = new DimensionStub('minecraft:nether'); + const handle = regions.for(nether); + + handle.set({ name: 'hell' }); + + const gone: LooseDb = createDb({ + world, + namespace: 'ns', + locate: { bind: () => (): unknown => undefined, fromIdentity: () => undefined }, + }); + const unloaded = gone.collection('regions', { schema: schema<{ name: string }>() }).for(nether); + + expect(unloaded.available).toBe(true); + expect(unloaded.get()).toEqual({ name: 'hell' }); + unloaded.set({ name: 'nether' }); + expect(world.getDynamicProperty('core-db:ns:dimension:minecraft:nether:regions:doc')).toContain('nether'); + unloaded.delete(); + expect(world.getDynamicPropertyIds()).toEqual(['core-db:ns:index:regions:0']); + expect(world.getDynamicProperty('core-db:ns:index:regions:0')).toBe(''); + expect(unloaded.get()).toBeUndefined(); + }); + + it('refuses a slot that emptied or now holds a stackable item', () => { + const { db: store } = db(); + const gems = store.collection('gems', { schema: schema<{ level: number }>() }); + const slot = new SlotStub(new ItemStackStub('ns:sword', 1)); + const doc = gems.for(slot); + + doc.set({ level: 3 }); + expect(doc.get()).toEqual({ level: 3 }); + + slot.isValid = false; + expect(doc.available).toBe(false); + expect(doc.get()).toBeUndefined(); + + const empty = gems.for(new SlotStub(undefined)); + + expect(empty.available).toBe(false); + expect(empty.reason).toContain('empty'); + expect(() => empty.set({ level: 1 })).toThrow(DbTargetError); + }); + + it('never resolves an ItemStack', () => { + const { db: store } = db(); + const gems = store.collection('gems', { schema: schema<{ level: number }>() }); + + expect(gems.where(new ItemStackStub('ns:sword', 1))).toMatchObject({ ok: false, reason: expect.stringContaining('ContainerSlot') }); + }); +}); + +describe('cache and change events', () => { + it('reads the property once per identity and serves the cached document after', () => { + const { db: store } = db(); + const balances = store.collection('balances', { schema: schema() }); + const player = new EntityStub('p1', 'minecraft:player'); + + balances.for(player).set({ gold: 1, lastSeen: 0 }); + + const spy = vi.spyOn(player, 'getDynamicProperty'); + + balances.for(player).get(); + balances.for(player).get(); + + expect(spy).not.toHaveBeenCalled(); + + balances.forget(player); + balances.for(player).get(); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('notifies subscribers on set, patch and delete through any handle of the same target', () => { + const { db: store } = db(); + const balances = store.collection('balances', { schema: schema() }); + const player = new EntityStub('p1', 'minecraft:player'); + const seen: (Balance | undefined)[] = []; + const stop = balances.for(player).subscribe(doc => seen.push(doc)); + + balances.for(player).set({ gold: 1, lastSeen: 0 }); + balances.for(player).patch({ gold: 2 }); + balances.for(player).delete(); + + expect(seen).toEqual([{ gold: 1, lastSeen: 0 }, { gold: 2, lastSeen: 0 }, undefined]); + + stop(); + balances.for(player).set({ gold: 3, lastSeen: 0 }); + expect(seen).toHaveLength(3); + }); + + it('gives a refused handle a subscribe that never fires', () => { + const { db: store } = db(); + const gems = store.collection('gems', { schema: schema<{ level: number }>() }); + const listener = vi.fn(); + const stop = gems.for(new SlotStub(undefined)).subscribe(listener); + + stop(); + expect(listener).not.toHaveBeenCalled(); + }); +}); + +describe('quarantine surfaces through the collection', () => { + it('returns undefined once and logs when the bytes are unreadable', () => { + const { db: store, log } = db(); + const balances = store.collection('balances', { schema: schema() }); + const player = new EntityStub('p1', 'minecraft:player'); + + player.setDynamicProperty('core-db:ns:entity::balances:doc', '{broken'); + + expect(balances.for(player).get()).toBeUndefined(); + expect(log).toHaveBeenCalledTimes(1); + expect(player.getDynamicProperty('core-db:ns:entity::balances:doc#bad')).toBe('{broken'); + }); +}); diff --git a/packages/db/test/document.spec.ts b/packages/db/test/document.spec.ts new file mode 100644 index 0000000..0282493 --- /dev/null +++ b/packages/db/test/document.spec.ts @@ -0,0 +1,172 @@ +/** + * The document codec on a direct host and on a component host: the envelope, lazy migration, + * quarantine instead of loss, chunking under the per-value cap, and refusal on a block entity's + * budget. + */ +import { describe, expect, it, vi } from 'vitest'; +import { COMPONENT_BUDGET, DIRECT_BUDGET, DbBudgetError, componentHost, createDocumentStore, directHost, prefixed } from '../src/index'; +import { ComponentStub, EntityStub } from './stubs'; + +interface Doc { + gold: number; + name: string; + tags?: string[]; +} + +function onEntity(options: Partial>[1]> = {}): { entity: EntityStub; store: ReturnType>; log: ReturnType void>> } { + const entity = new EntityStub('e1', 'ns:mob'); + const log = vi.fn<(message: string) => void>(); + const store = createDocumentStore(prefixed(directHost(entity), 'core-db:ns:entity::balances:'), { collection: 'balances', log, ...options }); + + return { entity, store, log }; +} + +describe('envelope', () => { + it('writes one JSON string carrying the version and reads it back', () => { + const { entity, store } = onEntity({ version: 2 }); + + store.write('doc', { gold: 5, name: 'a' }); + + expect(entity.getDynamicProperty('core-db:ns:entity::balances:doc')).toBe('{"v":2,"d":{"gold":5,"name":"a"}}'); + expect(store.read('doc')).toEqual({ gold: 5, name: 'a' }); + }); + + it('answers undefined for a missing document and removes cleanly', () => { + const { entity, store } = onEntity(); + + expect(store.read('doc')).toBeUndefined(); + store.write('doc', { gold: 1, name: 'x' }); + store.remove('doc'); + expect(store.read('doc')).toBeUndefined(); + expect(entity.getDynamicPropertyIds()).toEqual([]); + }); + + it('lists document keys only, not chunks or quarantines', () => { + const { entity, store } = onEntity(); + + store.write('doc', { gold: 1, name: 'a' }); + entity.setDynamicProperty('core-db:ns:entity::balances:doc#bad', 'x'); + entity.setDynamicProperty('core-db:ns:entity::balances:other#0', 'x'); + entity.setDynamicProperty('core-db:ns:entity::other:doc', 'x'); + + expect(store.keys()).toEqual(['doc']); + }); +}); + +describe('migration', () => { + it('migrates an old document lazily on read, step by step, and rewrites it once', () => { + const { entity, store: v1 } = onEntity({ version: 1 }); + + v1.write('doc', { gold: 7, name: 'old' }); + + const steps: number[] = []; + const v3 = createDocumentStore(prefixed(directHost(entity), 'core-db:ns:entity::balances:'), { + collection: 'balances', + version: 3, + migrate: { + 2: (doc) => { + steps.push(2); + + return { ...doc, name: `${String(doc.name)}!` }; + }, + 3: (doc) => { + steps.push(3); + + return { ...doc, tags: ['migrated'] }; + }, + }, + }); + + expect(v3.read('doc')).toEqual({ gold: 7, name: 'old!', tags: ['migrated'] }); + expect(steps).toEqual([2, 3]); + expect(entity.getDynamicProperty('core-db:ns:entity::balances:doc')).toContain('"v":3'); + + v3.read('doc'); + expect(steps).toEqual([2, 3]); + }); + + it('quarantines when a step is missing or throws, and when the document is newer than the code', () => { + const { entity, log } = onEntity(); + + entity.setDynamicProperty('core-db:ns:entity::balances:doc', '{"v":1,"d":{"gold":1,"name":"a"}}'); + + const gap = createDocumentStore(prefixed(directHost(entity), 'core-db:ns:entity::balances:'), { collection: 'balances', version: 3, migrate: { 3: doc => doc }, log }); + + expect(gap.read('doc')).toBeUndefined(); + expect(entity.getDynamicProperty('core-db:ns:entity::balances:doc')).toBeUndefined(); + expect(entity.getDynamicProperty('core-db:ns:entity::balances:doc#bad')).toBe('{"v":1,"d":{"gold":1,"name":"a"}}'); + expect(log).toHaveBeenCalledTimes(1); + expect(String(log.mock.calls[0]?.[0])).toContain('balances'); + + entity.setDynamicProperty('core-db:ns:entity::balances:doc', '{"v":9,"d":{}}'); + + const older = createDocumentStore(prefixed(directHost(entity), 'core-db:ns:entity::balances:'), { collection: 'balances', version: 2, log }); + + expect(older.read('doc')).toBeUndefined(); + expect(entity.getDynamicProperty('core-db:ns:entity::balances:doc#bad')).toBe('{"v":9,"d":{}}'); + }); + + it('quarantines bytes that are not JSON or not an envelope', () => { + const { entity, store, log } = onEntity(); + + entity.setDynamicProperty('core-db:ns:entity::balances:doc', 'not json'); + expect(store.read('doc')).toBeUndefined(); + expect(entity.getDynamicProperty('core-db:ns:entity::balances:doc#bad')).toBe('not json'); + + entity.setDynamicProperty('core-db:ns:entity::balances:doc', '[1,2]'); + expect(store.read('doc')).toBeUndefined(); + expect(log).toHaveBeenCalledTimes(2); + }); +}); + +describe('budget', () => { + it('chunks a document past the per-value cap on a direct host and reassembles it', () => { + const { entity, store } = onEntity(); + const name = 'x'.repeat(DIRECT_BUDGET * 2 + 10); + + store.write('doc', { gold: 1, name }); + + const ids = entity.getDynamicPropertyIds().sort(); + + expect(ids).toEqual([ + 'core-db:ns:entity::balances:doc', + 'core-db:ns:entity::balances:doc#0', + 'core-db:ns:entity::balances:doc#1', + 'core-db:ns:entity::balances:doc#2', + ]); + expect(entity.getDynamicProperty('core-db:ns:entity::balances:doc')).toBe('#3'); + expect(store.read('doc')).toEqual({ gold: 1, name }); + expect(store.keys()).toEqual(['doc']); + }); + + it('drops stale chunks when a document shrinks, and all of them on remove', () => { + const { entity, store } = onEntity(); + + store.write('doc', { gold: 1, name: 'x'.repeat(DIRECT_BUDGET * 2) }); + store.write('doc', { gold: 1, name: 'small' }); + + expect(entity.getDynamicPropertyIds()).toEqual(['core-db:ns:entity::balances:doc']); + expect(store.read('doc')).toEqual({ gold: 1, name: 'small' }); + + store.write('doc', { gold: 1, name: 'x'.repeat(DIRECT_BUDGET * 3) }); + store.remove('doc'); + + expect(entity.getDynamicPropertyIds()).toEqual([]); + }); + + it('refuses a document over a block entity budget before touching the engine', () => { + const component = new ComponentStub(); + const store = createDocumentStore(prefixed(componentHost(component), 'elevators:'), { collection: 'elevators' }); + + store.write('doc', { gold: 1, name: 'fits' }); + expect(store.read('doc')).toEqual({ gold: 1, name: 'fits' }); + + const big = (): void => { + store.write('doc', { gold: 1, name: 'x'.repeat(COMPONENT_BUDGET) }); + }; + + expect(big).toThrow(DbBudgetError); + expect(big).toThrow(/elevators/); + expect(store.read('doc')).toEqual({ gold: 1, name: 'fits' }); + }); +}); diff --git a/packages/db/test/indexed.spec.ts b/packages/db/test/indexed.spec.ts new file mode 100644 index 0000000..ce55363 --- /dev/null +++ b/packages/db/test/indexed.spec.ts @@ -0,0 +1,211 @@ +/** + * The chunked index on its own, and `all()` walking it through a stub locator: entries appear on + * the first write and vanish on delete, chunks split under the per-value cap, a replaced block heals + * out of the index with its world-kept document, and `blockRemoved` does the same from `onBreak`. + */ +import { describe, expect, it } from 'vitest'; +import { blockTypes, createDb, createIndexSet, directHost, prefixed, schema } from '../src/index'; +import type { Collection, Locator, Schema, TargetKind } from '../src/index'; +import { BlockStub, ComponentStub, DimensionStub, EntityStub, WorldStub } from './stubs'; + +describe('index set', () => { + it('adds, removes and reloads from the world', () => { + const world = new WorldStub(); + const host = prefixed(directHost(world), 'core-db:ns:index:things:'); + const index = createIndexSet(host); + + expect(index.size).toBe(0); + index.add('entity:1'); + index.add('entity:2'); + index.add('entity:1'); + + expect(index.size).toBe(2); + expect(index.has('entity:2')).toBe(true); + expect(world.getDynamicProperty('core-db:ns:index:things:0')).toBe('entity:1\nentity:2'); + + index.remove('entity:1'); + expect(world.getDynamicProperty('core-db:ns:index:things:0')).toBe('entity:2'); + + const reloaded = createIndexSet(host); + + expect([...reloaded.entries()]).toEqual(['entity:2']); + }); + + it('opens a new chunk when the next entry would not fit', () => { + const world = new WorldStub(); + const index = createIndexSet(prefixed(directHost(world), 'i:'), { budget: 30 }); + + index.add('a'.repeat(12)); + index.add('b'.repeat(12)); + index.add('c'.repeat(12)); + + expect(world.getDynamicProperty('i:0')).toBe(`${'a'.repeat(12)}\n${'b'.repeat(12)}`); + expect(world.getDynamicProperty('i:1')).toBe('c'.repeat(12)); + expect(index.size).toBe(3); + + index.remove('b'.repeat(12)); + expect(world.getDynamicProperty('i:0')).toBe('a'.repeat(12)); + expect([...createIndexSet(prefixed(directHost(world), 'i:'), { budget: 30 }).entries()].sort()).toEqual(['a'.repeat(12), 'c'.repeat(12)]); + }); +}); + +// ─── all() through a stub world ──────────────────────────────────────────────── + +interface Elevator { + facing: string; +} + +interface LooseDb { + collection(name: string, options: { schema: Schema; accept?: { kinds: readonly TargetKind[]; test(typeId: string, kind: TargetKind): boolean } }): Collection; + blockRemoved(dimensionId: string, location: { x: number; y: number; z: number }, typeId: string): void; +} + +/** A tiny world: entities by id, blocks by position, dimensions by id. */ +function stubWorld(): { world: WorldStub; entities: Map; blocks: Map; dimensions: Map; db: LooseDb } { + const world = new WorldStub(); + const entities = new Map(); + const blocks = new Map(); + const dimensions = new Map(); + const locate: Locator = { + bind: target => (): unknown => { + if (target instanceof EntityStub) { + return entities.get(target.id); + } + + if (target instanceof BlockStub) { + return blocks.get(`${target.dimension.id}:${target.location.x},${target.location.y},${target.location.z}`); + } + + return target; + }, + fromIdentity: (kind, identity): unknown => { + if (kind === 'entity') { + return entities.get(identity); + } + + if (kind === 'dimension') { + return dimensions.get(identity); + } + + if (kind === 'block') { + const match = /^(.*):(-?\d+),(-?\d+),(-?\d+):(.*)$/.exec(identity); + + return match === null ? undefined : blocks.get(`${match[1]}:${match[2]},${match[3]},${match[4]}`); + } + + return undefined; + }, + }; + + return { world, entities, blocks, dimensions, db: createDb({ world, namespace: 'ns', locate }) }; +} + +function placeBlock(blocks: Map, typeId: string, x: number, y: number, z: number, component: ComponentStub | undefined): BlockStub { + const block = new BlockStub(typeId, { x, y, z }, { id: 'overworld' }, component); + + blocks.set(`overworld:${x},${y},${z}`, block); + + return block; +} + +describe('all()', () => { + it('yields every indexed document and forgets deleted ones', () => { + const { entities, db } = stubWorld(); + const balances = db.collection('balances', { schema: schema<{ gold: number }>() }); + const a = new EntityStub('a', 'minecraft:player'); + const b = new EntityStub('b', 'minecraft:player'); + + entities.set('a', a); + entities.set('b', b); + balances.for(a).set({ gold: 1 }); + balances.for(b).set({ gold: 2 }); + balances.for(b).patch({ gold: 3 }); + + expect(balances.size).toBe(2); + expect([...balances.all()].map(doc => [doc.identity, doc.get()?.gold])).toEqual([['a', 1], ['b', 3]]); + + balances.for(a).delete(); + expect([...balances.all()].map(doc => doc.identity)).toEqual(['b']); + }); + + it('yields an unreachable handle for an unloaded entity and keeps the entry', () => { + const { entities, db } = stubWorld(); + const balances = db.collection('balances', { schema: schema<{ gold: number }>() }); + const a = new EntityStub('a', 'minecraft:player'); + + entities.set('a', a); + balances.for(a).set({ gold: 1 }); + entities.delete('a'); + + const [doc] = [...balances.all()]; + + expect(doc?.identity).toBe('a'); + expect(doc?.available).toBe(false); + expect(doc?.get()).toBeUndefined(); + expect(balances.size).toBe(1); + }); + + it('reads a proxied document through the index while its target is unloaded', () => { + const { dimensions, db } = stubWorld(); + const regions = db.collection('regions', { schema: schema<{ name: string }>() }); + const nether = new DimensionStub('minecraft:nether'); + + dimensions.set('minecraft:nether', nether); + regions.for(nether).set({ name: 'hell' }); + dimensions.clear(); + + const [doc] = [...regions.all()]; + + expect(doc?.available).toBe(true); + expect(doc?.get()).toEqual({ name: 'hell' }); + }); + + it('drops a block replaced by another type, and its world-kept document', () => { + const { world, blocks, db } = stubWorld(); + const signs = db.collection('signs', { schema: schema(), accept: blockTypes('minecraft:oak_sign', 'minecraft:stone') }); + const sign = placeBlock(blocks, 'minecraft:oak_sign', 1, 2, 3, undefined); + + signs.for(sign).set({ facing: 'north' }); + expect(world.getDynamicProperty('core-db:ns:block:overworld:1,2,3:minecraft:oak_sign:signs:doc')).toBeDefined(); + + placeBlock(blocks, 'minecraft:stone', 1, 2, 3, undefined); + + expect([...signs.all()]).toEqual([]); + expect(signs.size).toBe(0); + expect(world.getDynamicProperty('core-db:ns:block:overworld:1,2,3:minecraft:oak_sign:signs:doc')).toBeUndefined(); + }); + + it('keeps a block entity document reachable through the index and heals on blockRemoved', () => { + const { world, blocks, db } = stubWorld(); + const elevators = db.collection('elevators', { schema: schema(), accept: blockTypes('papi:elevator') }); + const others = db.collection('others', { schema: schema(), accept: blockTypes('papi:elevator') }); + const lift = placeBlock(blocks, 'papi:elevator', 5, 6, 7, new ComponentStub()); + + elevators.for(lift).set({ facing: 'east' }); + others.for(lift).set({ facing: 'west' }); + + const [doc] = [...elevators.all()]; + + expect(doc?.kind).toBe('block'); + expect(doc?.get()).toEqual({ facing: 'east' }); + + blocks.delete('overworld:5,6,7'); + db.blockRemoved('overworld', { x: 5, y: 6, z: 7 }, 'papi:elevator'); + + expect(elevators.size).toBe(0); + expect(others.size).toBe(0); + expect([...elevators.all()]).toEqual([]); + expect(world.getDynamicPropertyIds().filter(id => !id.includes(':index:'))).toEqual([]); + }); + + it('never indexes the world or a slot', () => { + const { world, db } = stubWorld(); + const settings = db.collection('settings', { schema: schema<{ on: boolean }>() }); + + settings.for(world).set({ on: true }); + + expect(settings.size).toBe(0); + expect([...settings.all()]).toEqual([]); + expect(world.getDynamicPropertyIds()).toEqual(['core-db:ns:world::settings:doc']); + }); +}); diff --git a/packages/db/test/resolve.spec.ts b/packages/db/test/resolve.spec.ts new file mode 100644 index 0000000..107a962 --- /dev/null +++ b/packages/db/test/resolve.spec.ts @@ -0,0 +1,277 @@ +/** + * The resolver against stubs shaped like the engine's classes, with the behaviour the ABI survey + * measured: a stackable item throws on write, a non-stackable stack writes to a copy, a block + * entity answers through a three-method component, a dimension holds nothing, and `getComponent` + * throws in an unloaded chunk. + */ +import { describe, expect, it } from 'vitest'; +import { COMPONENT_BUDGET, DIRECT_BUDGET, createResolver, prefixed } from '../src/index'; +import { BlockStub, ComponentStub, DimensionStub, EntityStub, ItemStackStub, SlotStub, WorldStub } from './stubs'; + +function resolver(world = new WorldStub()): { world: WorldStub; resolve: ReturnType } { + return { world, resolve: createResolver({ world, namespace: 'ns' }) }; +} + +// ─── Refusals ────────────────────────────────────────────────────────────────── + +describe('refusals', () => { + it('refuses an ItemStack by name, whatever its stackability', () => { + const { resolve } = resolver(); + const stackable = resolve.resolve(new ItemStackStub('minecraft:stone', 64)); + const single = resolve.resolve(new ItemStackStub('minecraft:diamond_sword', 1)); + + expect(stackable).toMatchObject({ ok: false, kind: 'itemStack' }); + expect(single).toMatchObject({ ok: false, kind: 'itemStack' }); + expect(single.ok ? '' : single.reason).toContain('ContainerSlot'); + }); + + it('refuses a slot holding a stackable item, and an empty slot', () => { + const { resolve } = resolver(); + + expect(resolve.resolve(new SlotStub(new ItemStackStub('minecraft:stone', 64)))).toMatchObject({ ok: false, kind: 'slot' }); + expect(resolve.resolve(new SlotStub(undefined))).toMatchObject({ ok: false, kind: 'slot' }); + }); + + it('refuses what it does not recognize', () => { + const { resolve } = resolver(); + + expect(resolve.resolve(42)).toMatchObject({ ok: false, kind: 'unknown' }); + expect(resolve.resolve({ hello: 'world' })).toMatchObject({ ok: false, kind: 'unknown' }); + }); +}); + +// ─── Own hosts ───────────────────────────────────────────────────────────────── + +describe('own hosts', () => { + it('resolves the world to a direct host that is readable when unloaded', () => { + const { world, resolve } = resolver(); + const r = resolve.resolve(world); + + expect(r).toMatchObject({ ok: true, kind: 'world', identity: '' }); + + if (!r.ok) { return; } + + expect(r.host.abi).toBe('direct'); + expect(r.host.caps).toMatchObject({ own: true, enumerable: true, readableWhenUnloaded: true, batch: true, budget: DIRECT_BUDGET }); + r.host.write('k', 'v'); + + expect(world.getDynamicProperty('k')).toBe('v'); + }); + + it('resolves an entity to a direct host keyed by its id', () => { + const { resolve } = resolver(); + const entity = new EntityStub('-42', 'minecraft:armor_stand'); + const r = resolve.resolve(entity); + + expect(r).toMatchObject({ ok: true, kind: 'entity', identity: '-42', typeKey: 'entity:minecraft:armor_stand' }); + + if (!r.ok) { return; } + + expect(r.host.caps.readableWhenUnloaded).toBe(false); + r.host.write('k', 1); + + expect(entity.getDynamicProperty('k')).toBe(1); + expect(r.host.keys()).toEqual(['k']); + }); + + it('resolves a slot holding a non-stackable item to a direct host', () => { + const { resolve } = resolver(); + const slot = new SlotStub(new ItemStackStub('minecraft:diamond_sword', 1)); + const r = resolve.resolve(slot); + + expect(r).toMatchObject({ ok: true, kind: 'slot', typeKey: 'slot:minecraft:diamond_sword' }); + + if (!r.ok) { return; } + + r.host.write('k', true); + + expect(slot.getDynamicProperty('k')).toBe(true); + }); + + it('resolves a block with a dynamic-properties component to a component host', () => { + const { resolve } = resolver(); + const component = new ComponentStub(); + const block = new BlockStub('papi:elevator', { x: 1, y: 2, z: 3 }, { id: 'minecraft:overworld' }, component); + const r = resolve.resolve(block); + + expect(r).toMatchObject({ ok: true, kind: 'block', identity: 'minecraft:overworld:1,2,3:papi:elevator', typeKey: 'block:papi:elevator' }); + + if (!r.ok) { return; } + + expect(r.host.abi).toBe('component'); + expect(r.host.caps).toMatchObject({ own: true, enumerable: false, batch: false, budget: COMPONENT_BUDGET }); + expect(r.host.keys()).toBeUndefined(); + r.host.write('doc', '{}'); + + expect(component.get('doc')).toBe('{}'); + }); +}); + +// ─── Proxied hosts ───────────────────────────────────────────────────────────── + +describe('proxied hosts', () => { + it('proxies a dimension onto the world under an identity prefix', () => { + const { world, resolve } = resolver(); + const r = resolve.resolve(new DimensionStub('minecraft:nether')); + + expect(r).toMatchObject({ ok: true, kind: 'dimension', identity: 'minecraft:nether' }); + + if (!r.ok) { return; } + + expect(r.host.abi).toBe('proxied'); + expect(r.host.caps).toMatchObject({ own: false, enumerable: true, readableWhenUnloaded: true }); + r.host.write('doc', 'x'); + + expect(world.getDynamicProperty('core-db:ns:dimension:minecraft:nether:doc')).toBe('x'); + expect(r.host.keys()).toEqual(['doc']); + }); + + it('proxies a vanilla block and keeps keys per block', () => { + const { world, resolve } = resolver(); + const a = resolve.resolve(new BlockStub('minecraft:stone', { x: 0, y: 0, z: 0 }, { id: 'minecraft:overworld' }, undefined)); + const b = resolve.resolve(new BlockStub('minecraft:stone', { x: 0, y: 0, z: 1 }, { id: 'minecraft:overworld' }, undefined)); + + if (!a.ok || !b.ok) { throw new Error('expected both to resolve'); } + + a.host.write('doc', 'A'); + b.host.write('doc', 'B'); + + expect(a.host.read('doc')).toBe('A'); + expect(b.host.read('doc')).toBe('B'); + expect(a.host.keys()).toEqual(['doc']); + expect(world.getDynamicPropertyIds()).toHaveLength(2); + }); + + it('a block of another type at the same position never inherits the document', () => { + const { resolve } = resolver(); + const at = { x: 5, y: 64, z: 5 }; + const dim = { id: 'minecraft:overworld' }; + const stone = resolve.resolve(new BlockStub('minecraft:stone', at, dim, undefined)); + + if (!stone.ok) { throw new Error('expected stone to resolve'); } + + stone.host.write('doc', 'stone-data'); + + const dirt = resolve.resolve(new BlockStub('minecraft:dirt', at, dim, undefined)); + + if (!dirt.ok) { throw new Error('expected dirt to resolve'); } + + expect(dirt.host.read('doc')).toBeUndefined(); + expect(dirt.host.keys()).toEqual([]); + expect(stone.identity).toBe('minecraft:overworld:5,64,5:minecraft:stone'); + expect(dirt.identity).toBe('minecraft:overworld:5,64,5:minecraft:dirt'); + }); +}); + +// ─── The cache ───────────────────────────────────────────────────────────────── + +describe('per-type cache', () => { + it('probes a block type once and reuses the decision for every instance', () => { + const { resolve } = resolver(); + const first = new BlockStub('papi:elevator', { x: 0, y: 0, z: 0 }, { id: 'd' }, new ComponentStub()); + const second = new BlockStub('papi:elevator', { x: 9, y: 9, z: 9 }, { id: 'd' }, new ComponentStub()); + + resolve.resolve(first); + resolve.resolve(second); + + expect(resolve.decision('block:papi:elevator')).toBe('component'); + // The decision is cached; the component itself is fetched per instance because it is bound to one block. + expect(first.getComponent).toHaveBeenCalledTimes(1); + expect(second.getComponent).toHaveBeenCalledTimes(1); + }); + + it('never caches a throw', () => { + const { resolve } = resolver(); + const block = new BlockStub('papi:elevator', { x: 0, y: 0, z: 0 }, { id: 'd' }, new ComponentStub()); + + block.getComponent.mockImplementationOnce(() => { throw new Error('LocationInUnloadedChunkError'); }); + + const unloaded = resolve.resolve(block); + + expect(unloaded.ok).toBe(false); + expect(resolve.decision('block:papi:elevator')).toBeUndefined(); + + const loaded = resolve.resolve(block); + + expect(loaded.ok).toBe(true); + expect(resolve.decision('block:papi:elevator')).toBe('component'); + }); + + it('caches proxied for a type with no component', () => { + const { resolve } = resolver(); + + resolve.resolve(new BlockStub('minecraft:stone', { x: 0, y: 0, z: 0 }, { id: 'd' }, undefined)); + + expect(resolve.decision('block:minecraft:stone')).toBe('proxied'); + }); +}); + +// ─── Prefixing ───────────────────────────────────────────────────────────────── + +describe('prefixed hosts', () => { + it('keeps two collections on one target apart and hides foreign keys from keys()', () => { + const { world, resolve } = resolver(); + + world.setDynamicProperty('core-cfg:s:ns:taxRate', 0.05); // config's key space + + const r = resolve.resolve(world); + + if (!r.ok) { throw new Error('expected the world to resolve'); } + + const balances = prefixed(r.host, r.prefixFor('balances')); + const events = prefixed(r.host, r.prefixFor('events')); + + balances.write('doc', 'B'); + events.write('doc', 'E'); + + expect(balances.read('doc')).toBe('B'); + expect(events.read('doc')).toBe('E'); + expect(balances.keys()).toEqual(['doc']); + expect(events.keys()).toEqual(['doc']); + expect(world.getDynamicProperty('core-db:ns:world::balances:doc')).toBe('B'); + }); + + it('a world collection named like a kind never sees the proxied documents of that kind', () => { + const { world, resolve } = resolver(); + const dim = resolve.resolve(new DimensionStub('minecraft:nether')); + const w = resolve.resolve(world); + + if (!dim.ok || !w.ok) { throw new Error('expected both to resolve'); } + + prefixed(dim.host, dim.prefixFor('events')).write('doc', 'proxied'); + + const dimensionCollectionOnWorld = prefixed(w.host, w.prefixFor('dimension')); + + dimensionCollectionOnWorld.write('doc', 'own'); + + expect(dimensionCollectionOnWorld.keys()).toEqual(['doc']); + expect(dimensionCollectionOnWorld.read('doc')).toBe('own'); + expect(world.getDynamicProperty('core-db:ns:dimension:minecraft:nether:events:doc')).toBe('proxied'); + expect(world.getDynamicProperty('core-db:ns:world::dimension:doc')).toBe('own'); + }); + + it('a proxied host is a prefixed world host with proxied capabilities', () => { + const { world, resolve } = resolver(); + const r = resolve.resolve(new DimensionStub('minecraft:the_end')); + + if (!r.ok) { throw new Error('expected the dimension to resolve'); } + + const events = prefixed(r.host, r.prefixFor('events')); + + events.writeMany({ a: 1, b: 2 }); + + expect(r.host.abi).toBe('proxied'); + expect(r.host.caps.own).toBe(false); + expect(world.getDynamicProperty('core-db:ns:dimension:minecraft:the_end:events:a')).toBe(1); + expect([...events.keys() ?? []].sort()).toEqual(['a', 'b']); + }); + + it('a block entity collection prefix is just the collection, to spare the 950-byte budget', () => { + const { resolve } = resolver(); + const r = resolve.resolve(new BlockStub('papi:elevator', { x: 0, y: 0, z: 0 }, { id: 'd' }, new ComponentStub())); + + if (!r.ok) { throw new Error('expected the block to resolve'); } + + expect(r.prefixFor('elevators')).toBe('elevators:'); + }); +}); diff --git a/packages/db/test/stubs.ts b/packages/db/test/stubs.ts new file mode 100644 index 0000000..205a8c4 --- /dev/null +++ b/packages/db/test/stubs.ts @@ -0,0 +1,124 @@ +/** + * Stubs shaped like the engine's classes, with the behaviour the ABI survey measured: a stackable + * item throws on write, a non-stackable stack writes to a copy, a block entity answers through a + * three-method component, a dimension holds nothing, and `getComponent` throws in an unloaded chunk. + * `isValid` is settable so a test can make a target vanish. + */ +import { vi } from 'vitest'; +import type { DirectDp, DpValue } from '../src/index'; + +export class DirectStub implements DirectDp { + private readonly _props = new Map(); + + getDynamicProperty(id: string): DpValue | undefined { + return this._props.get(id); + } + + setDynamicProperty(id: string, value?: DpValue): void { + if (value === undefined) { + this._props.delete(id); + } else { + if (typeof value === 'string' && value.length > 32_767) { + throw new Error('ArgumentOutOfBoundsError'); + } + + this._props.set(id, value); + } + } + + getDynamicPropertyIds(): string[] { + return [...this._props.keys()]; + } + + getDynamicPropertyTotalByteCount(): number { + return [...this._props.entries()].reduce((n, [k, v]) => n + k.length + String(v).length, 0); + } + + setDynamicProperties(values: Record): void { + for (const key of Object.keys(values)) { + this.setDynamicProperty(key, values[key]); + } + } +} + +export class WorldStub extends DirectStub { + getAllPlayers(): unknown[] { + return []; + } +} + +export class EntityStub extends DirectStub { + isValid = true; + + constructor(readonly id: string, readonly typeId: string) { + super(); + } +} + +export class ItemStackStub extends DirectStub { + constructor(readonly typeId: string, readonly maxAmount: number) { + super(); + } + + clone(): ItemStackStub { + return new ItemStackStub(this.typeId, this.maxAmount); + } +} + +export class SlotStub extends DirectStub { + readonly maxAmount = 64; + isValid = true; + + constructor(private readonly _item: ItemStackStub | undefined) { + super(); + } + + getItem(): ItemStackStub | undefined { + return this._item; + } +} + +export class ComponentStub { + private readonly _props = new Map(); + + get(key: string): DpValue | undefined { + return this._props.get(key); + } + + set(key: string, value?: DpValue): void { + if (value === undefined) { + this._props.delete(key); + } else { + if (this.totalByteCount() + key.length + String(value).length > 1024) { + throw new Error('World metadata storage limit exceeded'); + } + + this._props.set(key, value); + } + } + + totalByteCount(): number { + return [...this._props.entries()].reduce((n, [k, v]) => n + k.length + String(v).length, 0); + } +} + +export class BlockStub { + readonly permutation = {}; + isValid = true; + readonly getComponent = vi.fn((id: string): unknown => (id === 'minecraft:dynamic_properties' ? this._component : undefined)); + + constructor( + readonly typeId: string, + readonly location: { x: number; y: number; z: number }, + readonly dimension: { id: string }, + private readonly _component: ComponentStub | undefined, + ) {} +} + +export class DimensionStub { + constructor(readonly id: string) {} + + getBlock(): undefined { + return undefined; + } +} diff --git a/packages/db/test/types/collection.types.ts b/packages/db/test/types/collection.types.ts new file mode 100644 index 0000000..11fc297 --- /dev/null +++ b/packages/db/test/types/collection.types.ts @@ -0,0 +1,119 @@ +/** + * Compile-time assertions for the collection API: the acceptor brands `for()`, and `require` is + * checked against what the target type can possibly do. Compiled by `yarn test:types`, never run. + */ +import type { Block, Dimension, Entity, ItemStack, Player, World } from '@minecraft/server'; +import { accepting, allOf, anyOf, blockTypes, dimensions, entityTypes, except, players, schema, slots, worldTarget, type Db } from '../../src/index'; + +declare const db: Db; +declare const block: Block; +declare const player: Player; +declare const entity: Entity; +declare const dimension: Dimension; +declare const world: World; +declare const stack: ItemStack; + +interface Doc { + n: number; +} + +// The acceptor fixes the target type; the document type rides the schema. +const elevators = db.collection('elevators', { schema: schema({ version: 2 }), accept: blockTypes('papi:elevator') }); + +elevators.for(block); +// @ts-expect-error a block collection takes no player +elevators.for(player); + +const balances = db.collection('balances', { schema: schema(), accept: players() }); + +balances.for(player); +// @ts-expect-error a player collection takes no plain entity +balances.for(entity); + +// No acceptor: anything storable, never an ItemStack. +const loose = db.collection('loose', { schema: schema({ version: 2, defaults: { n: 0 } }) }); + +// @ts-expect-error the schema names the document type; there is no other place for it +db.collection('untyped', { accept: blockTypes() }); +// @ts-expect-error an explicit type argument switches inference off, so the acceptor cannot type the target +db.collection('mixed', { schema: schema(), accept: blockTypes() }); + +loose.for(block); +loose.for(player); +loose.for(world); +loose.for(dimension); +// @ts-expect-error an ItemStack is a detached copy +loose.for(stack); + +// Requirements the type can always satisfy compile. +db.collection('a', { schema: schema(), accept: worldTarget(), require: { own: true } }); +db.collection('b', { schema: schema(), accept: entityTypes('ns:mob'), require: { own: true, enumerable: true } }); +db.collection('c', { schema: schema(), accept: dimensions(), require: { readableWhenUnloaded: true } }); + +// Requirements only the instance can satisfy compile too; the resolver refuses at runtime. +db.collection('d', { schema: schema(), accept: blockTypes(), require: { own: true } }); +db.collection('e', { schema: schema(), accept: slots(), require: { own: true } }); + +// Requirements the type can never satisfy do not compile. +// @ts-expect-error a dimension never holds its own properties +db.collection('f', { schema: schema(), accept: dimensions(), require: { own: true } }); +// @ts-expect-error an entity is unreachable while unloaded +db.collection('g', { schema: schema(), accept: entityTypes(), require: { readableWhenUnloaded: true } }); +// @ts-expect-error own and readableWhenUnloaded exclude each other +db.collection('h', { schema: schema(), accept: blockTypes(), require: { own: true, readableWhenUnloaded: true } }); + +// The escape hatch carries the caller's type. +const custom = db.collection('i', { schema: schema(), accept: accepting(id => id.startsWith('ns:')) }); + +custom.for(block); +// @ts-expect-error typed by the caller as Block +custom.for(player); + +// Composition: anyOf is the union, allOf the intersection, except keeps the base type. +const mixed = db.collection('j', { schema: schema(), accept: anyOf(players(), blockTypes('papi:elevator')) }); + +mixed.for(player); +mixed.for(block); +// @ts-expect-error neither a player nor a block +mixed.for(dimension); + +const namespaced = db.collection('k', { schema: schema(), accept: allOf(entityTypes(), accepting(id => id.startsWith('papi:'))) }); + +namespaced.for(entity); +namespaced.for(player); +// @ts-expect-error an entity collection takes no block +namespaced.for(block); + +const mobs = db.collection('l', { schema: schema(), accept: except(entityTypes(), players()) }); + +mobs.for(entity); +mobs.for(player); // compiles: the exclusion is a runtime refusal, TypeScript cannot subtract a subclass +// @ts-expect-error still an entity collection +mobs.for(block); + +const nested = db.collection('o', { schema: schema(), accept: anyOf(except(entityTypes(), players()), allOf(blockTypes(), accepting(id => id.startsWith('papi:')))) }); + +nested.for(entity); +nested.for(block); +// @ts-expect-error not in the union +nested.for(world); + +// Composition keeps the require check: a dimension in the union can still never be own... but a +// block can, so the union is `boolean` and compiles; a pure dimension composition does not. +db.collection('m', { schema: schema(), accept: anyOf(dimensions(), blockTypes()), require: { own: true } }); +// @ts-expect-error every member of the union is statically not own +db.collection('n', { schema: schema(), accept: anyOf(dimensions('minecraft:nether'), dimensions('minecraft:the_end')), require: { own: true } }); + +// Documents are typed. +const doc = elevators.for(block).get(); + +if (doc) { + const n: number = doc.n; + + void n; +} + +// @ts-expect-error wrong shape +elevators.for(block).set({ m: 1 }); +// @ts-expect-error wrong shape +elevators.for(block).patch({ n: 'x' }); diff --git a/packages/server/tsconfig.json b/packages/db/tsconfig.json similarity index 100% rename from packages/server/tsconfig.json rename to packages/db/tsconfig.json diff --git a/packages/db/tsconfig.typecheck.json b/packages/db/tsconfig.typecheck.json new file mode 100644 index 0000000..82a9192 --- /dev/null +++ b/packages/db/tsconfig.typecheck.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "." + }, + "include": [ + "src/**/*", + "test/**/*", + "examples/**/*", + "../../types/globals.d.ts" + ] +} diff --git a/packages/db/vitest.config.ts b/packages/db/vitest.config.ts new file mode 100644 index 0000000..2cef901 --- /dev/null +++ b/packages/db/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['test/**/*.spec.ts'], + }, +}); diff --git a/packages/i18n/CHANGELOG.md b/packages/i18n/CHANGELOG.md new file mode 100644 index 0000000..58a5d9e --- /dev/null +++ b/packages/i18n/CHANGELOG.md @@ -0,0 +1,30 @@ +# @bedrock-core/i18n + +## 0.1.0 + +### Minor Changes + +- [`d0ad2c6`](https://github.com/bedrock-core/ui/commit/d0ad2c695f8b2173875a511b00c7b40f96163799) Thanks [@drav0011](https://github.com/drav0011)! - Initial release. + + TS-first localization for Bedrock addons, the runtime half of the `i18n` Regolith filter: nested TypeScript objects are the source of truth, and everything — keys, interpolation variables, plural forms — autocompletes and type-checks. + + ```ts + import bundle from "@bedrock-core/generated/i18n"; + import { createI18n } from "@bedrock-core/i18n"; + + export const i18n = createI18n(bundle); + + const { t, key, raw } = i18n.forPlayer(player); + t(($) => $.shop.bought, { item: "Apple", price: 5 }); // server-resolved, filled string + key(($) => $.shop.title); // the real .lang key + raw(($) => $.shop.bought, { item: "Apple", price: 5 }); // Minecraft RawMessage — client resolves + ``` + + - **Three verbs, one idea** — prefer the client, fall back to the server. `key()` and `raw()` resolve on the client (per-player language for free, no 80-byte cap); `t()` resolves server-side for code that needs the string now. Every verb takes a selector (`$ => $.shop.bought`) or the equivalent typed dot string. + - **Typed interpolation** — `{{var}}` placeholders in the authored template become required, closed argument properties. `raw()` arguments additionally accept any RawMessage part (nested `raw()`, `score`, `selector`) and travel as rawtext parameters. + - **Plurals without Intl** — `_one`/`_other` (and `_zero`/`_two`/`_few`/`_many`) author-side collapse into one leaf taking `count`; the suffix is chosen by a built-in CLDR rule table, since Bedrock's engine does not guarantee `Intl.PluralRules`. + - **Locale chain** — persisted per-player override (`setLocale`, survives rejoin) → client language → sibling region of that language (`es_MX` → `es_ES` before English) → default → any. `forPlayer` / `forLocale` return bound verb sets. + - **`resolve(realKey)`** — the lazy measurement lookup: inverse-maps a real `.lang` key into the bundle and converts the one template it needs. No tables are materialized anywhere. + - **`display(value)`** — bound on every verb set: any `DisplayText` (`string | RawMessage`, the shared union every text channel uses) to a plain string, for the places a key must BECOME text — breadcrumb trails, native modal headings, chat prefixes. The `resolveDisplay(resolve, value)` free function stays as the primitive for hosts binding over a custom resolver. + - **`createResourceBundle`** — the same bundle shape built from objects at runtime, for libraries shipping their own strings and for addons not (yet) running the filter. + - Creating the addon's instance registers it as the default translation source — `@bedrock-core/ui` measures localized `Text` children through it with zero wiring. diff --git a/packages/i18n/README.md b/packages/i18n/README.md new file mode 100644 index 0000000..dcf87fc --- /dev/null +++ b/packages/i18n/README.md @@ -0,0 +1,56 @@ +# @bedrock-core/i18n + +![Logo](https://raw.githubusercontent.com/bedrock-core/ui/main/assets/logo/title.png) + +Localization for Minecraft Bedrock addons: typed keys, typed interpolation and plurals, resolved +on the **client** in each player's own language wherever possible and on the **server** whenever +your code needs the actual string — the runtime half of a two-part system, paired with the `i18n` +Regolith filter that turns your locale modules into `.lang` files, a runtime bundle, and the types +every verb below infers from. + +## Install + +Already have `@bedrock-core/server`? It is reachable as `@bedrock-core/server/i18n` — nothing to add. +Standalone: + +```bash +yarn add @bedrock-core/i18n +``` + +## Usage + +```ts +// packs/data/i18n/en_US.ts — `as const` is what lets the compiler infer the key space +export default { + shop: { + title: 'Shop', + bought: 'You bought {{item}} for {{price}} emeralds.', + }, +} as const; +``` + +```ts +// BP/scripts/i18n.ts — the addon's one instance +import { createI18n } from '@bedrock-core/i18n'; +import bundle from '@bedrock-core/generated/i18n'; +import type { Player } from '@minecraft/server'; + +export const i18n = createI18n(bundle); + +export function receipt(player: Player, item: string, price: number): void { + // Bind the verbs to this player's locale chain. + const { key, raw, t } = i18n.forPlayer(player); + + key($ => $.shop.title); // 'drav0011_shop.shop.title' — the client resolves it + raw($ => $.shop.bought, { item, price }); // RawMessage — the client resolves and fills it + t($ => $.shop.bought, { item, price }); // 'You bought Apple for 5 emeralds.' — here, now +} +``` + +## Documentation + +https://bedrock-core.drav.dev/docs/i18n + +## License + +MIT diff --git a/packages/i18n/eslint.config.mjs b/packages/i18n/eslint.config.mjs new file mode 100644 index 0000000..dc350e7 --- /dev/null +++ b/packages/i18n/eslint.config.mjs @@ -0,0 +1,25 @@ +import { dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { defineConfig } from 'eslint/config'; +import baseConfig from '../../eslint.config.mjs'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +export default defineConfig([ + ...baseConfig, + { + // Test bodies reach past the public API — casts onto the package's internal + // shapes to assert on them — which the type-aware rules, no-unsafe-type-assertion + // above all, read as errors. + ignores: ['**/__tests__/**'], + }, + { + files: ['**/*.ts', '**/*.tsx'], + languageOptions: { + parserOptions: { + tsconfigRootDir: __dirname, + }, + }, + }, +]); diff --git a/packages/i18n/package.json b/packages/i18n/package.json new file mode 100644 index 0000000..091d50d --- /dev/null +++ b/packages/i18n/package.json @@ -0,0 +1,57 @@ +{ + "name": "@bedrock-core/i18n", + "version": "0.1.0", + "description": "Localization for Minecraft Bedrock: typed keys, interpolation and plurals, resolved client-side per player", + "keywords": [ + "i18n", + "i18next", + "localization", + "minecraft", + "bedrock", + "typescript" + ], + "license": "MIT", + "author": "DrAv0011", + "contributors": [ + { + "name": "DrAv0011", + "email": "contact@drav.dev", + "url": "https://drav.dev" + } + ], + "repository": "github:bedrock-core/server", + "type": "module", + "main": "src/index.ts", + "types": "src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + } + }, + "publishConfig": { + "access": "public" + }, + "files": [ + "src/**/*", + "!src/**/__tests__/**" + ], + "sideEffects": false, + "scripts": { + "build": "tsc -p tsconfig.json", + "lint": "eslint .", + "test": "vitest run", + "coverage": "vitest run --coverage" + }, + "devDependencies": { + "@minecraft/server": "*", + "@stylistic/eslint-plugin": "*", + "eslint": "*", + "typescript": "*", + "typescript-eslint": "*", + "vitest": "*" + }, + "peerDependencies": { + "@minecraft/server": ">=2.9.0 || >=2.10.0-0" + } +} diff --git a/packages/i18n/src/__tests__/contract.test.ts b/packages/i18n/src/__tests__/contract.test.ts new file mode 100644 index 0000000..2186cfa --- /dev/null +++ b/packages/i18n/src/__tests__/contract.test.ts @@ -0,0 +1,73 @@ +/** + * The interpolation contract with the i18n Regolith filter. The filter + * converts {{var}} templates to positional %N$s when writing .lang files; the + * runtime performs the identical conversion lazily in `resolve()` — one + * template per lookup, no tables materialized anywhere. Both test suites pin + * the SAME table — regolith-filters/i18n/test/contract.test.js carries the + * counterpart — so a drift on either side fails a build instead of landing + * arguments in wrong placeholders. + */ +import { describe, expect, it } from 'vitest'; + +import { createI18n } from '../createI18n'; +import { toPositional } from '../interpolate'; +import { bundle } from './fixture'; + +export const INTERPOLATION_CONTRACT = [ + { template: 'You bought {{item}} for {{price}} emeralds.', order: ['item', 'price'], positional: 'You bought %1$s for %2$s emeralds.' }, + { template: 'Por {{price}} esmeraldas compraste {{item}}.', order: ['item', 'price'], positional: 'Por %2$s esmeraldas compraste %1$s.' }, + { template: '{{ count }} left in stock', order: ['count'], positional: '%1$s left in stock' }, + { template: 'Version: {{version}}', order: ['version'], positional: 'Version: %1$s' }, + { template: 'No variables here.', order: [], positional: 'No variables here.' }, + { template: '{{a}}{{b}}{{a}}', order: ['a', 'b'], positional: '%1$s%2$s%1$s' }, +] as const; + +describe('interpolation contract', () => { + for (const { template, order, positional } of INTERPOLATION_CONTRACT) { + it(`"${template}" → "${positional}"`, () => { + expect(toPositional(template, order)).toBe(positional); + }); + } +}); + +describe('resolve() — the lazy measurement lookup', () => { + const i18n = createI18n(bundle, { asDefault: false }); + + it('resolves own keys in the .lang positional form, per locale', () => { + expect(i18n.resolve('drav0011_economy.shop.bought')).toBe('You bought %1$s for %2$s emeralds.'); + expect(i18n.resolve('drav0011_economy.shop.stock_one')).toBe('%1$s left in stock'); + expect(i18n.forLocale('es_ES').resolve('drav0011_economy.shop.bought')) + .toBe('Por %2$s esmeraldas compraste %1$s.'); + }); + + it('resolves library keys as-is and vanilla keys under their branch', () => { + expect(i18n.resolve('core.addons.title')).toBe('Mods'); + expect(i18n.resolve('core.addons.version')).toBe('Version: %1$s'); + expect(i18n.resolve('item.apple.name')).toBe('Apple'); + expect(i18n.forLocale('es_ES').resolve('item.apple.name')).toBe('Manzana'); + }); + + it('falls back to the default locale per key for partial locales', () => { + expect(i18n.forLocale('cs_CZ').resolve('drav0011_economy.shop.title')).toBe('Shop'); + }); + + it('reads the .lang passthrough underneath, path-derived entries winning', () => { + const withExtra = createI18n({ + ...bundle, + extra: { + en_US: { + 'bcg.demo.intro': 'Guide prose', + 'drav0011_economy.shop.title': 'Hand-written, must lose', + }, + }, + }, { asDefault: false }); + + expect(withExtra.resolve('bcg.demo.intro')).toBe('Guide prose'); + expect(withExtra.resolve('drav0011_economy.shop.title')).toBe('Shop'); + }); + + it('returns undefined for keys this bundle does not carry', () => { + expect(i18n.resolve('some_other_addon.thing')).toBeUndefined(); + expect(i18n.resolve('shop.title')).toBeUndefined(); + }); +}); diff --git a/packages/i18n/src/__tests__/engine.test.ts b/packages/i18n/src/__tests__/engine.test.ts new file mode 100644 index 0000000..1d37dff --- /dev/null +++ b/packages/i18n/src/__tests__/engine.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from 'vitest'; + +import { createI18n, LOCALE_PROPERTY } from '../createI18n'; +import { bundle, fakePlayer } from './fixture'; + +const i18n = createI18n(bundle); + +describe('t()', () => { + it('resolves and interpolates via selector', () => { + expect(i18n.t($ => $.shop.bought, { item: 'Apple', price: 5 })) + .toBe('You bought Apple for 5 emeralds.'); + }); + + it('resolves the identical dot-string form', () => { + expect(i18n.t('shop.bought', { item: 'Apple', price: 5 })) + .toBe('You bought Apple for 5 emeralds.'); + }); + + it('lets a locale reorder text while arguments stay put', () => { + expect(i18n.forLocale('es_ES').t($ => $.shop.bought, { item: 'Apple', price: 5 })) + .toBe('Por 5 esmeraldas compraste Apple.'); + }); + + it('resolves library and vanilla branches', () => { + expect(i18n.t($ => $.core.addons.title)).toBe('Mods'); + expect(i18n.forLocale('es_ES').t($ => $.core.addons.title)).toBe('Addons'); + expect(i18n.t($ => $.vanilla.item.apple.name)).toBe('Apple'); + expect(i18n.forLocale('es_ES').t($ => $.vanilla.item.apple.name)).toBe('Manzana'); + }); + + it('returns the real key when nothing resolves, mirroring the client', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- deliberately unknown path + expect((i18n.t as any)('shop.nope')).toBe('drav0011_economy.shop.nope'); + }); + + it('falls back to the default locale per key for partial locales', () => { + expect(i18n.forLocale('cs_CZ').t($ => $.shop.title)).toBe('Shop'); + }); +}); + +describe('plurals', () => { + it('selects one/other in English', () => { + expect(i18n.t($ => $.shop.stock, { count: 1 })).toBe('1 left in stock'); + expect(i18n.t($ => $.shop.stock, { count: 3 })).toBe('3 left in stock'); + }); + + it('selects few in Czech, falling back to other past four', () => { + const cs = i18n.forLocale('cs_CZ'); + expect(cs.t($ => $.shop.stock, { count: 1 })).toBe('Zbývá 1 kus'); + expect(cs.t($ => $.shop.stock, { count: 2 })).toBe('Zbývají 2 kusy'); + expect(cs.t($ => $.shop.stock, { count: 7 })).toBe('Zbývá 7 kusů'); + }); +}); + +describe('key()', () => { + it('prefixes own keys with the addon namespace', () => { + expect(i18n.key($ => $.shop.title)).toBe('drav0011_economy.shop.title'); + }); + + it('keeps library keys and strips the vanilla branch', () => { + expect(i18n.key($ => $.core.addons.title)).toBe('core.addons.title'); + expect(i18n.key($ => $.vanilla.item.apple.name)).toBe('item.apple.name'); + }); + + it('appends the plural suffix the count selects', () => { + expect(i18n.key($ => $.shop.stock, { count: 1 })).toBe('drav0011_economy.shop.stock_one'); + expect(i18n.key($ => $.shop.stock, { count: 9 })).toBe('drav0011_economy.shop.stock_other'); + }); +}); + +describe('raw()', () => { + it('builds with in the recorded argument order', () => { + expect(i18n.raw($ => $.shop.bought, { item: 'Apple', price: 5 })) + .toEqual({ translate: 'drav0011_economy.shop.bought', with: ['Apple', '5'] }); + }); + + it('omits with when the key takes no arguments', () => { + expect(i18n.raw($ => $.shop.title)).toEqual({ translate: 'drav0011_economy.shop.title' }); + }); + + it('passes positional arrays through for vanilla keys', () => { + expect(i18n.raw($ => $.vanilla.item.apple.name, ['x'])) + .toEqual({ translate: 'item.apple.name', with: ['x'] }); + }); + + it('keeps count in with for locale-only plural variants', () => { + expect(i18n.forLocale('cs_CZ').raw($ => $.shop.stock, { count: 2 })) + .toEqual({ translate: 'drav0011_economy.shop.stock_few', with: ['2'] }); + }); + + it('borrows the _other argument order when a hand-built bundle omits variant args', () => { + const { 'shop.stock_few': _dropped, ...args } = bundle.args; + const handBuilt = createI18n({ ...bundle, args }, { asDefault: false }); + expect(handBuilt.forLocale('cs_CZ').raw($ => $.shop.stock, { count: 2 })) + .toEqual({ translate: 'drav0011_economy.shop.stock_few', with: ['2'] }); + }); + + it('switches to rawtext parameters when an argument is itself a translate', () => { + expect(i18n.raw($ => $.shop.bought, { item: i18n.raw($ => $.vanilla.item.apple.name), price: 5 })) + .toEqual({ + translate: 'drav0011_economy.shop.bought', + with: { rawtext: [{ translate: 'item.apple.name' }, { text: '5' }] }, + }); + }); +}); + +describe('locale resolution', () => { + it('uses the client locale when authored', () => { + expect(i18n.forPlayer(fakePlayer({ locale: 'es_ES' })).locale).toBe('es_ES'); + }); + + it('falls back to the default locale for unauthored client locales', () => { + expect(i18n.forPlayer(fakePlayer({ locale: 'fr_FR' })).locale).toBe('en_US'); + }); + + it('lets a persisted override win over the client locale', () => { + expect(i18n.forPlayer(fakePlayer({ locale: 'en_US', override: 'es_ES' })).locale).toBe('es_ES'); + }); + + it('ignores an override pointing at an unauthored locale', () => { + expect(i18n.forPlayer(fakePlayer({ locale: 'es_ES', override: 'fr_FR' })).locale).toBe('es_ES'); + }); + + it('setLocale persists and clearLocale removes the override', () => { + const player = fakePlayer({ locale: 'en_US' }); + i18n.setLocale(player, 'es_ES'); + expect(player.properties.get(LOCALE_PROPERTY)).toBe('es_ES'); + expect(i18n.forPlayer(player).locale).toBe('es_ES'); + i18n.clearLocale(player); + expect(player.properties.has(LOCALE_PROPERTY)).toBe(false); + expect(i18n.forPlayer(player).locale).toBe('en_US'); + }); + + it('top-level verbs are bound to the default locale', () => { + expect(i18n.locale).toBe('en_US'); + }); +}); diff --git a/packages/i18n/src/__tests__/fixture.ts b/packages/i18n/src/__tests__/fixture.ts new file mode 100644 index 0000000..de8b58c --- /dev/null +++ b/packages/i18n/src/__tests__/fixture.ts @@ -0,0 +1,90 @@ +/** + * A bundle mirroring what the i18n Regolith filter generates for the e2e + * fixture addon: own keys, a `core` library branch (with an en_US-only + * override — "Mods"), a referenced vanilla key, and a Czech partial with a + * `few` plural. `resources` is typed the way the generated declaration types + * it, so the type tests exercise the same shapes real projects see. + */ +import type { Player } from '@minecraft/server'; +import type { I18nBundle } from '../bundle'; + +const en_US = { + shop: { + title: 'Shop', + bought: 'You bought {{item}} for {{price}} emeralds.', + stock_one: '{{count}} left in stock', + stock_other: '{{count}} left in stock', + }, +} as const; + +export type Resources = typeof en_US & { + readonly core: { + readonly addons: { + readonly title: 'Addons'; + readonly version: 'Version: {{version}}'; + }; + }; + readonly vanilla: { + readonly item: { + readonly apple: { readonly name: string }; + }; + }; +}; + +export const bundle: I18nBundle & { readonly resources?: Resources } = { + namespace: 'drav0011_economy', + defaultLocale: 'en_US', + libs: ['core'], + args: { + 'core.addons.version': ['version'], + 'shop.bought': ['item', 'price'], + 'shop.stock_few': ['count'], + 'shop.stock_one': ['count'], + 'shop.stock_other': ['count'], + }, + locales: { + en_US: { + 'core.addons.title': 'Mods', + 'core.addons.version': 'Version: {{version}}', + 'shop.bought': 'You bought {{item}} for {{price}} emeralds.', + 'shop.stock_one': '{{count}} left in stock', + 'shop.stock_other': '{{count}} left in stock', + 'shop.title': 'Shop', + 'vanilla.item.apple.name': 'Apple', + }, + es_ES: { + 'core.addons.title': 'Addons', + 'core.addons.version': 'Version: {{version}}', + 'shop.bought': 'Por {{price}} esmeraldas compraste {{item}}.', + 'shop.stock_one': 'Queda {{count}} en stock', + 'shop.stock_other': 'Quedan {{count}} en stock', + 'shop.title': 'Tienda', + 'vanilla.item.apple.name': 'Manzana', + }, + cs_CZ: { + 'shop.stock_one': 'Zbývá {{count}} kus', + 'shop.stock_few': 'Zbývají {{count}} kusy', + 'shop.stock_other': 'Zbývá {{count}} kusů', + }, + }, +}; + +/** A minimal Player test double — the engine reads locale + dynamic properties only. */ +export type FakePlayer = Player & { readonly properties: Map }; + +export function fakePlayer(options: { locale?: string, override?: string } = {}): FakePlayer { + const properties = new Map(); + + if (options.override !== undefined) { properties.set('bedrock_core:i18n_locale', options.override); } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- minimal Player stub; only locale + dynamic properties are touched + return { + clientSystemInfo: { locale: options.locale }, + properties, + getDynamicProperty: (id: string) => properties.get(id), + setDynamicProperty: (id: string, value?: string) => { + if (value === undefined) { properties.delete(id); } + else { properties.set(id, value); } + }, + } as unknown as FakePlayer; +} diff --git a/packages/i18n/src/__tests__/interpolate.test.ts b/packages/i18n/src/__tests__/interpolate.test.ts new file mode 100644 index 0000000..34c22ba --- /dev/null +++ b/packages/i18n/src/__tests__/interpolate.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; + +import { interpolate, toPositional } from '../interpolate'; + +describe('interpolate', () => { + it('fills named markers from a record', () => { + expect(interpolate('You bought {{item}} for {{ price }}.', { item: 'Apple', price: 5 })) + .toBe('You bought Apple for 5.'); + }); + + it('leaves unknown markers intact', () => { + expect(interpolate('{{known}} {{unknown}}', { known: 'x' })).toBe('x {{unknown}}'); + }); + + it('fills indexed positional slots from an array', () => { + expect(interpolate('%2$s then %1$s', ['a', 'b'])).toBe('b then a'); + }); + + it('fills bare %s slots in appearance order', () => { + expect(interpolate('%s and %s', ['a', 'b'])).toBe('a and b'); + }); + + it('leaves out-of-range slots intact', () => { + expect(interpolate('%1$s %3$s', ['a'])).toBe('a %3$s'); + }); + + it('returns the template untouched without arguments', () => { + expect(interpolate('plain {{x}}')).toBe('plain {{x}}'); + }); +}); + +describe('toPositional', () => { + it('maps every occurrence to its recorded slot', () => { + expect(toPositional('{{a}}{{b}}{{a}}', ['a', 'b'])).toBe('%1$s%2$s%1$s'); + }); + + it('leaves unrecorded variables intact', () => { + expect(toPositional('{{ghost}}', [])).toBe('{{ghost}}'); + }); +}); diff --git a/packages/i18n/src/__tests__/locale.test.ts b/packages/i18n/src/__tests__/locale.test.ts new file mode 100644 index 0000000..8ea0e7d --- /dev/null +++ b/packages/i18n/src/__tests__/locale.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; + +import { createI18n } from '../createI18n'; +import { pickLocale } from '../locale'; +import { createResourceBundle } from '../resources'; +import { bundle, fakePlayer } from './fixture'; + +describe('pickLocale', () => { + const available = ['en_US', 'es_ES', 'pt_BR']; + + it('prefers an exact match', () => { + expect(pickLocale(available, ['es_ES'], 'en_US')).toBe('es_ES'); + }); + + it('falls back to a sibling region of the same language before the default', () => { + expect(pickLocale(available, ['es_MX'], 'en_US')).toBe('es_ES'); + expect(pickLocale(available, ['pt_PT'], 'en_US')).toBe('pt_BR'); + }); + + it('walks candidates in order', () => { + expect(pickLocale(available, [undefined, 'fr_FR', 'es_ES'], 'en_US')).toBe('es_ES'); + }); + + it('falls back to the default locale, then to anything', () => { + expect(pickLocale(available, ['ja_JP'], 'en_US')).toBe('en_US'); + expect(pickLocale(['de_DE'], ['ja_JP'], 'en_US')).toBe('de_DE'); + expect(pickLocale([], ['ja_JP'], 'en_US')).toBeUndefined(); + }); +}); + +describe('sibling-region resolution inside the engine', () => { + it('binds es_MX players to es_ES rather than the default', () => { + expect(createI18n(bundle).forPlayer(fakePlayer({ locale: 'es_MX' })).locale).toBe('es_ES'); + }); +}); + +describe('createResourceBundle', () => { + const libBundle = createResourceBundle('core', { + en_US: { addons: { title: 'Addons', version: 'Version: {{version}}' } }, + es_ES: { addons: { title: 'Addons', version: 'Versión: {{version}}' } }, + }); + const lib = createI18n(libBundle); + + it('flattens, records argument order, and namespaces real keys', () => { + expect(libBundle.locales['en_US']).toEqual({ + 'addons.title': 'Addons', + 'addons.version': 'Version: {{version}}', + }); + expect(libBundle.args).toEqual({ 'addons.version': ['version'] }); + expect(lib.key($ => $.addons.title)).toBe('core.addons.title'); + }); + + it('drives fully typed verbs without any filter involved', () => { + expect(lib.t($ => $.addons.version, { version: '1.0' })).toBe('Version: 1.0'); + expect(lib.forLocale('es_ES').t($ => $.addons.version, { version: '1.0' })).toBe('Versión: 1.0'); + expect(lib.raw($ => $.addons.version, { version: '1.0' })) + .toEqual({ translate: 'core.addons.version', with: ['1.0'] }); + }); +}); diff --git a/packages/i18n/src/__tests__/plural.test.ts b/packages/i18n/src/__tests__/plural.test.ts new file mode 100644 index 0000000..b411a58 --- /dev/null +++ b/packages/i18n/src/__tests__/plural.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; + +import { pluralCategory } from '../plural'; + +describe('pluralCategory', () => { + it('en/de/es family: one at exactly 1', () => { + expect(pluralCategory('en_US', 1)).toBe('one'); + expect(pluralCategory('de_DE', 0)).toBe('other'); + expect(pluralCategory('es_ES', 2)).toBe('other'); + expect(pluralCategory('en_US', 1.5)).toBe('other'); + }); + + it('fr: 0 and 1 are one', () => { + expect(pluralCategory('fr_FR', 0)).toBe('one'); + expect(pluralCategory('fr_FR', 1)).toBe('one'); + expect(pluralCategory('fr_FR', 2)).toBe('other'); + }); + + it('ja/ko/zh/id: no distinction', () => { + expect(pluralCategory('ja_JP', 1)).toBe('other'); + expect(pluralCategory('zh_CN', 1)).toBe('other'); + }); + + it('cs/sk: few between 2 and 4', () => { + expect(pluralCategory('cs_CZ', 1)).toBe('one'); + expect(pluralCategory('cs_CZ', 3)).toBe('few'); + expect(pluralCategory('sk_SK', 5)).toBe('other'); + }); + + it('pl: few by tens digit, many otherwise', () => { + expect(pluralCategory('pl_PL', 1)).toBe('one'); + expect(pluralCategory('pl_PL', 3)).toBe('few'); + expect(pluralCategory('pl_PL', 13)).toBe('many'); + expect(pluralCategory('pl_PL', 22)).toBe('few'); + expect(pluralCategory('pl_PL', 5)).toBe('many'); + }); + + it('ru/uk: one at …1 except …11, few at …2-4 except …12-14, else many', () => { + expect(pluralCategory('ru_RU', 1)).toBe('one'); + expect(pluralCategory('ru_RU', 21)).toBe('one'); + expect(pluralCategory('ru_RU', 11)).toBe('many'); + expect(pluralCategory('uk_UA', 3)).toBe('few'); + expect(pluralCategory('ru_RU', 14)).toBe('many'); + expect(pluralCategory('ru_RU', 25)).toBe('many'); + }); + + it('negative counts categorize by magnitude', () => { + expect(pluralCategory('en_US', -1)).toBe('one'); + }); +}); diff --git a/packages/i18n/src/__tests__/types.test.ts b/packages/i18n/src/__tests__/types.test.ts new file mode 100644 index 0000000..ce2f989 --- /dev/null +++ b/packages/i18n/src/__tests__/types.test.ts @@ -0,0 +1,61 @@ +/** + * Compile-time behavior. `yarn build` (tsc, noEmit) is the real assertion + * layer here: every @ts-expect-error line fails the build if the type + * machinery stops rejecting it. The runtime expectations just keep vitest + * happy and prove the loosely-typed calls still behave. + */ +import { describe, expect, expectTypeOf, it } from 'vitest'; + +import { createI18n } from '../createI18n'; +import type { RawMessage } from '@minecraft/server'; +import { bundle } from './fixture'; + +const i18n = createI18n(bundle); + +describe('type machinery', () => { + it('selector and string forms type-check symmetrically', () => { + expectTypeOf(i18n.t($ => $.shop.title)).toEqualTypeOf(); + expectTypeOf(i18n.t('shop.title')).toEqualTypeOf(); + expectTypeOf(i18n.raw($ => $.shop.title)).toEqualTypeOf(); + + // @ts-expect-error — unknown selector path + void (($: Parameters[0]) => $)($ => $.shop.nope); + // @ts-expect-error — unknown string path + expect(i18n.t('shop.nope')).toBe('drav0011_economy.shop.nope'); + }); + + it('interpolation variables are required and closed', () => { + expect(i18n.t($ => $.shop.bought, { item: 'Apple', price: 5 })).toContain('Apple'); + + // @ts-expect-error — arguments are required when the template has variables + void i18n.t($ => $.shop.bought); + // @ts-expect-error — missing variable: price + void i18n.t($ => $.shop.bought, { item: 'Apple' }); + // @ts-expect-error — unknown variable: cost + void i18n.t($ => $.shop.bought, { item: 'Apple', price: 5, cost: 1 }); + // @ts-expect-error — no arguments allowed on a variable-free template + void i18n.t($ => $.shop.title, { item: 'x' }); + }); + + it('plural groups collapse to one leaf demanding count', () => { + expect(i18n.t($ => $.shop.stock, { count: 2 })).toBe('2 left in stock'); + expect(i18n.t('shop.stock', { count: 2 })).toBe('2 left in stock'); + + // @ts-expect-error — count is required on a plural leaf + void i18n.t($ => $.shop.stock, {}); + // @ts-expect-error — count must be a number + void i18n.t($ => $.shop.stock, { count: 'two' }); + // @ts-expect-error — suffixed variants are hidden behind the collapsed leaf + void i18n.t($ => $.shop.stock_one, { count: 1 }); + }); + + it('library and vanilla branches participate', () => { + expect(i18n.t($ => $.core.addons.version, { version: '1.0.0' })).toBe('Version: 1.0.0'); + // Vanilla leaves are non-literal: optional loose arguments only. + expect(i18n.t($ => $.vanilla.item.apple.name)).toBe('Apple'); + expect(i18n.t('vanilla.item.apple.name')).toBe('Apple'); + + // @ts-expect-error — missing variable: version + void i18n.t($ => $.core.addons.version, {}); + }); +}); diff --git a/packages/i18n/src/bundle.ts b/packages/i18n/src/bundle.ts new file mode 100644 index 0000000..3547823 --- /dev/null +++ b/packages/i18n/src/bundle.ts @@ -0,0 +1,51 @@ +/** + * The runtime bundle the i18n Regolith filter generates + * (`@bedrock-core/generated/i18n`, inlined by the bundler). + * + * `resources` is a type-only phantom: the generated declaration narrows it to + * the authored resource tree (own keys at the root, library and vanilla + * branches grafted on) so selectors and interpolation infer, but the JSON + * never materializes it at runtime. + */ +/** + * `.lang` lines as data: REAL key → display string, one locale. This flat + * shape exists ONLY where Bedrock itself is flat — the filter's `.lang` + * output and the `extra` passthrough it carries. Nothing at runtime + * materializes or merges maps of it; resolution is lazy against the bundle. + */ +export type LangEntries = Record; + +export interface I18nBundle { + readonly namespace: string; + readonly defaultLocale: string; + readonly libs: readonly string[]; + /** locale → flat path → template (`{{var}}` form; vanilla entries only where referenced). */ + readonly locales: Readonly>>>; + /** flat path → interpolation argument order (default locale appearance order). */ + readonly args: Readonly>; + /** + * locale → REAL key → value: `.lang` passthrough (guide prose, hand-written + * entries) the filter carries so the layout engine can still measure keys + * that never were resource paths. Optional — runtime-built bundles skip it. + */ + readonly extra?: Readonly>; + /** Type-only: the tree the t()/key()/raw() selectors navigate. Absent at runtime. */ + readonly resources?: unknown; +} + +/** + * The `.lang` key a flat path resolves to. Three path spaces, one rule each: + * own keys get the addon namespace prefixed, a library branch's first segment + * IS its real prefix, and `vanilla.` strips off because those keys are the + * client's own. + */ +export function realKeyFor(bundle: Pick, path: string): string { + const dot = path.indexOf('.'); + const first = dot === -1 ? path : path.slice(0, dot); + + if (first === 'vanilla') { return path.slice('vanilla.'.length); } + + if (bundle.libs.includes(first)) { return path; } + + return `${bundle.namespace}.${path}`; +} diff --git a/packages/i18n/src/createI18n.ts b/packages/i18n/src/createI18n.ts new file mode 100644 index 0000000..11b770e --- /dev/null +++ b/packages/i18n/src/createI18n.ts @@ -0,0 +1,307 @@ +/** + * The runtime engine. A few KB, zero dependencies: flat-table lookup, {{var}} + * interpolation, plural-suffix selection via the built-in CLDR table, and the + * locale chain (per-player override → client language → default → any). + * + * Three verbs, one idea — prefer the client, fall back to the server: + * `key()` returns the namespaced .lang key (client resolves), `raw()` returns + * a translate/with RawMessage (client resolves, server supplies arguments in + * the recorded order), `t()` resolves the string server-side now. + */ +import type { Player, RawMessage } from '@minecraft/server'; +import { realKeyFor, type I18nBundle } from './bundle'; +import { resolveDisplay, type DisplayText } from './display'; +import { interpolate, isNamedArgs, toPositional } from './interpolate'; +import { pickLocale } from './locale'; +import { pluralCategory } from './plural'; +import type { AnyLeaf, ArgsOf, Interp, PathsOf, ResolvePath, SelectorTree } from './types'; + +/** Dynamic property a per-player language override persists under. */ +export const LOCALE_PROPERTY = 'bedrock_core:i18n_locale'; + +/** + * Both call shapes of a verb: selector (`$ => $.shop.bought`) or dot path. + * `V` is what interpolation arguments accept — `raw()` widens it to allow + * nested translates. + */ +export interface TranslateFn { + (selector: ($: SelectorTree) => L, ...args: ArgsOf): Out; +

& string>(path: P, ...args: ArgsOf, V>): Out; +} + +/** + * Resolve a REAL `.lang` key (`drav0011_shop.shop.title`, `core.addons.title`, + * a vanilla or passthrough key) to its display string, or `undefined` when the + * source doesn't carry it. This is the measurement contract: no tables are + * built or merged anywhere — each call reads the bundle's own objects and + * converts the one template it needs. + */ +export type TranslationResolver = (key: string) => string | undefined; + +/** The verb set bound to one resolved locale. */ +export interface BoundI18n { + readonly locale: string; + readonly t: TranslateFn; + readonly key: TranslateFn; + /** + * Returns Minecraft's own {@link RawMessage} (`translate` + `with`) — the + * vehicle past the 80-byte text cap: keys and parameters are short, the + * resolved sentence is not, and the client resolves every part in its own + * language. Arguments accept any RawMessage part — nested `raw()`, `score`, + * `selector` — and travel as rawtext parameters the moment one appears. + */ + readonly raw: TranslateFn; + /** Lazy real-key lookup over this bundle, in this locale (default-locale fallback per key). */ + readonly resolve: TranslationResolver; + /** + * Any {@link DisplayText} to a plain string, server-side, in this locale — + * for the places a key must BECOME text: breadcrumb trails, native modal + * headings, chat prefixes. Literal strings pass through, key strings + * resolve, RawMessages resolve and fill their `with` parameters. A key + * nothing resolves comes back literally — mirroring Bedrock. + */ + readonly display: (value: DisplayText) => string; +} + +/** What {@link createI18n} returns: default-locale verbs plus the binders. */ +export interface I18n extends BoundI18n { + readonly bundle: I18nBundle; + /** Verbs pinned to one locale (logs, broadcasts, tests). */ + forLocale(locale: string): BoundI18n; + /** Verbs bound through the chain: override → client locale → default → any. */ + forPlayer(player: Player): BoundI18n; + /** Persist a per-player language override (survives rejoin). */ + setLocale(player: Player, locale: string): void; + /** Remove the override; the player's client language takes over again. */ + clearLocale(player: Player): void; +} + +export interface CreateI18nOptions { + /** + * Register this instance as the addon's default translation source, which is + * what lets `@bedrock-core/ui` resolve localized-text measurement with no + * wiring at all. Defaults to true — an addon's own `createI18n` call IS the + * registration. Libraries building internal instances (config does) pass + * false so they never shadow the host addon's bundle. + */ + readonly asDefault?: boolean; +} + +// Module scope is per-bundle in Bedrock (each addon bundles its own copy), so +// this is an addon-local default, not a cross-addon global. +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- the default is consumed untyped (measurement tables only) +let defaultInstance: I18n | undefined; + +/** + * The addon's default i18n instance — the last `createI18n` call that didn't + * opt out. `@bedrock-core/ui` reads this to auto-resolve measurement tables. + */ +export function currentI18n(): I18n | undefined { + return defaultInstance; +} + +/** + * The resource tree the generated declaration carries; the seeded (pre-first- + * build) declaration leaves it `unknown`, which degrades every verb to loosely + * typed strings instead of blocking the project from compiling. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- deliberate loose fallback +type ResourcesOf = unknown extends B['resources'] ? any : NonNullable; + +const PATH = Symbol('i18n.path'); + +type PathProxy = { readonly [PATH]: string } & Record; + +/** Lazy proxy tree recording the property chain a selector walks. */ +function makeProxy(path: string): PathProxy { + const children = new Map(); + const target: PathProxy = { [PATH]: path }; + + return new Proxy(target, { + get(_target, prop): unknown { + if (prop === PATH) { return path; } + + if (typeof prop !== 'string') { return undefined; } + + let child = children.get(prop); + + if (!child) { + child = makeProxy(path === '' ? prop : `${path}.${prop}`); + children.set(prop, child); + } + + return child; + }, + }); +} + +type LooseArgs = Readonly> | readonly Interp[] | undefined; +type LooseRawArgs = Readonly> | readonly (Interp | RawMessage)[] | undefined; + +/** What the loosely-typed implementation receives for either call shape. */ +type SelectorLike = string | (($: PathProxy) => PathProxy); + +function isRawArg(value: Interp | RawMessage): value is RawMessage { + return typeof value === 'object'; +} + +/** + * Build the `with` payload: plain strings stay a plain array; the moment any + * argument is itself a RawMessage part, everything travels as rawtext + * parameters so the client resolves the nested parts in its own language. + */ +function toWith(values: readonly (Interp | RawMessage)[]): string[] | RawMessage { + if (values.some(isRawArg)) { + return { rawtext: values.map(value => (isRawArg(value) ? value : { text: String(value) })) }; + } + + return values.map(String); +} + +export function createI18n(bundle: B, options: CreateI18nOptions = {}): I18n> { + const root = makeProxy(''); + const defaultTable = bundle.locales[bundle.defaultLocale] ?? {}; + const localeList = [...new Set([...Object.keys(bundle.locales), ...Object.keys(bundle.extra ?? {})])]; + + const pathOf = (selector: SelectorLike): string => + typeof selector === 'function' ? selector(root)[PATH] : selector; + + const PLURAL_SUFFIX_RE = /_(?:zero|one|two|few|many|other)$/; + + /** + * Argument order for a path. A locale-only plural variant (a CLDR category + * the default locale never declares, e.g. Czech `few`) may have no recorded + * entry in a hand-built bundle — its group's `_other` order applies: plural + * variants share one argument set, enforced by the filter's parity checks. + */ + const argsFor = (path: string): readonly string[] | undefined => + bundle.args[path] + ?? (PLURAL_SUFFIX_RE.test(path) ? bundle.args[path.replace(PLURAL_SUFFIX_RE, '_other')] : undefined); + + const bound = new Map>>(); + + function forLocale(locale: string): BoundI18n> { + const cached = bound.get(locale); + + if (cached) { return cached; } + + const table = bundle.locales[locale] ?? defaultTable; + const has = (path: string): boolean => path in table || path in defaultTable; + + /** Plural groups collapse at the type level; pick the suffixed key back here. */ + const variantOf = (path: string, args: LooseArgs | LooseRawArgs): string => { + if (args === undefined || !isNamedArgs(args)) { return path; } + + const count = args['count']; + + if (typeof count !== 'number' || !has(`${path}_other`)) { return path; } + + const candidate = `${path}_${pluralCategory(locale, count)}`; + + return has(candidate) ? candidate : `${path}_other`; + }; + + const t = (selector: SelectorLike, args?: LooseArgs): string => { + const variant = variantOf(pathOf(selector), args); + const template = table[variant] ?? defaultTable[variant]; + + // Mirrors how Bedrock renders an unknown .lang key: the key, literally. + if (template === undefined) { return realKeyFor(bundle, variant); } + + return interpolate(template, args); + }; + + const key = (selector: SelectorLike, args?: LooseArgs): string => + realKeyFor(bundle, variantOf(pathOf(selector), args)); + + const raw = (selector: SelectorLike, args?: LooseRawArgs): RawMessage => { + const variant = variantOf(pathOf(selector), args); + const translate = realKeyFor(bundle, variant); + + if (args !== undefined && !isNamedArgs(args)) { + return { translate, with: toWith(args) }; + } + + const order = argsFor(variant); + + if (args !== undefined && order !== undefined && order.length > 0) { + return { translate, with: toWith(order.map(name => args[name])) }; + } + + return { translate }; + }; + + /** + * Real-key lookup, lazily against the bundle's own objects: inverse-map + * the key to path space (own namespace prefix stripped, library branches + * as-is, vanilla under its branch), convert the ONE template on the way + * out, and fall back to the `.lang` passthrough. Mirrors exactly what the + * client resolves from the world-merged .lang. + */ + const resolve = (realKey: string): string | undefined => { + const ownPrefix = `${bundle.namespace}.`; + const dot = realKey.indexOf('.'); + const first = dot === -1 ? realKey : realKey.slice(0, dot); + // Both mappings can apply at once: a core-family addon (namespace + // `core`) shares its prefix with the `core` library branch, so a miss on + // the stripped own path falls through to the lib-branch full key. + const candidates = []; + + if (realKey.startsWith(ownPrefix)) { candidates.push(realKey.slice(ownPrefix.length)); } + + if (bundle.libs.includes(first)) { candidates.push(realKey); } + + for (const path of candidates) { + const template = table[path] ?? defaultTable[path]; + + if (template !== undefined) { return toPositional(template, argsFor(path) ?? []); } + } + + // Vanilla entries are stored under their branch, already client-form. + const vanilla = table[`vanilla.${realKey}`] ?? defaultTable[`vanilla.${realKey}`]; + + if (vanilla !== undefined) { return vanilla; } + + return bundle.extra?.[locale]?.[realKey] ?? bundle.extra?.[bundle.defaultLocale]?.[realKey]; + }; + + const display = (value: DisplayText): string => resolveDisplay(resolve, value); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- the implementation is loosely typed; the overloaded verb surface is enforced at every call site + const api = { locale, t, key, raw, resolve, display } as unknown as BoundI18n>; + + bound.set(locale, api); + + return api; + } + + function resolvePlayerLocale(player: Player): string { + const override = player.getDynamicProperty(LOCALE_PROPERTY); + const chosen = pickLocale(localeList, [ + typeof override === 'string' ? override : undefined, + player.clientSystemInfo?.locale, + ], bundle.defaultLocale); + + return chosen ?? bundle.defaultLocale; + } + + const defaults = forLocale(bundle.defaultLocale); + + const api: I18n> = { + bundle, + locale: defaults.locale, + t: defaults.t, + key: defaults.key, + raw: defaults.raw, + resolve: defaults.resolve, + display: defaults.display, + forLocale, + forPlayer: (player: Player): BoundI18n> => forLocale(resolvePlayerLocale(player)), + setLocale: (player: Player, locale: string): void => { player.setDynamicProperty(LOCALE_PROPERTY, locale); }, + clearLocale: (player: Player): void => { player.setDynamicProperty(LOCALE_PROPERTY, undefined); }, + }; + + if (options.asDefault ?? true) { defaultInstance = api; } + + return api; +} diff --git a/packages/i18n/src/display.ts b/packages/i18n/src/display.ts new file mode 100644 index 0000000..4814989 --- /dev/null +++ b/packages/i18n/src/display.ts @@ -0,0 +1,40 @@ +import type { RawMessage } from '@minecraft/server'; +import type { TranslationResolver } from './createI18n'; +import { interpolate } from './interpolate'; + +/** + * Player-facing text in any of its shapes: a literal string, a real `.lang` + * key (`key()` output, registry display fields), or a `raw()` RawMessage. + * THE text union — every channel that shows a player something shares it: + * `Text` children, MenuRow/Header labels, registry display fields, + * `display()` input. Which shape a string is (literal vs key) is decided + * lazily by the active resolver, never declared. + */ +export type DisplayText = string | RawMessage; + +/** + * Resolve a display field to a plain string, server-side, through a resolver — + * for the places a key must BECOME text: breadcrumb trails, native modal + * headings, chat prefixes. Accepts both shapes display fields carry: a bare + * key string (`key()` output, registry fields) or a `raw()` RawMessage, whose + * `with` parameters are filled positionally (nested translates resolve one + * level; score/selector parts have no server value and fill as ''). + * + * A key nothing resolves comes back literally — mirroring Bedrock. + */ +export function resolveDisplay(resolve: TranslationResolver | null | undefined, value: DisplayText): string { + if (typeof value === 'string') { return resolve?.(value) ?? value; } + + if (value.translate === undefined) { return value.text ?? ''; } + + const template = resolve?.(value.translate) ?? value.translate; + + if (value.with === undefined) { return template; } + + const params = Array.isArray(value.with) + ? value.with + : (value.with.rawtext ?? []).map(part => + part.text ?? (part.translate !== undefined ? (resolve?.(part.translate) ?? part.translate) : '')); + + return interpolate(template, params); +} diff --git a/packages/i18n/src/index.ts b/packages/i18n/src/index.ts new file mode 100644 index 0000000..e585d6b --- /dev/null +++ b/packages/i18n/src/index.ts @@ -0,0 +1,33 @@ +export { realKeyFor } from './bundle'; +export type { I18nBundle, LangEntries } from './bundle'; +export { + createI18n, + currentI18n, + LOCALE_PROPERTY, +} from './createI18n'; +export type { + BoundI18n, + CreateI18nOptions, + I18n, + TranslateFn, + TranslationResolver, +} from './createI18n'; +export { resolveDisplay } from './display'; +export type { DisplayText } from './display'; +export { interpolate, templateVars, toPositional } from './interpolate'; +export { pickLocale } from './locale'; +export { overlay } from './overlay'; +export { pluralCategory } from './plural'; +export type { PluralCategory } from './plural'; +export { createResourceBundle } from './resources'; +export type { ResourceBundleOptions, ResourceTree } from './resources'; +export type { + AnyLeaf, + ArgsOf, + Interp, + Leaf, + PathsOf, + ResolvePath, + SelectorTree, + TemplateVars, +} from './types'; diff --git a/packages/i18n/src/interpolate.ts b/packages/i18n/src/interpolate.ts new file mode 100644 index 0000000..4ffe186 --- /dev/null +++ b/packages/i18n/src/interpolate.ts @@ -0,0 +1,71 @@ +/** + * Runtime interpolation. Named `{{var}}` templates resolve server-side in + * `t()`; the positional `%N$s` form appears in vanilla strings (interpolated + * with an array) and in what {@link toPositional} produces when tables are + * published for other addons to measure. + * + * `toPositional` is the runtime half of a build-time contract: the i18n + * Regolith filter performs the identical conversion when writing `.lang` + * files, and both sides are pinned against the same table in their contract + * tests. + */ +import type { Interp } from './types'; + +const VAR_RE = /\{\{\s*([A-Za-z_$][A-Za-z0-9_$]*)\s*\}\}/g; +const SLOT_RE = /%(?:(\d+)\$)?s/g; + +/** Array.isArray does not narrow readonly arrays out of a union; this does. */ +export function isNamedArgs(args: Readonly> | readonly V[]): args is Readonly> { + return !Array.isArray(args); +} + +/** + * The `{{var}}` names of a template, in order of first appearance, + * deduplicated — the runtime mirror of the recorded-argument-order rule the + * filter applies at build time. + */ +export function templateVars(template: string): string[] { + const seen: string[] = []; + + for (const match of template.matchAll(VAR_RE)) { + const name = match[1]; + + if (!seen.includes(name)) { seen.push(name); } + } + + return seen; +} + +/** + * Fill a template. A record fills `{{var}}` markers (unknown markers are left + * intact — the build already guaranteed the authored set); an array fills + * `%1$s`-style (or bare `%s`, in appearance order) positional slots. + */ +export function interpolate(template: string, args?: Readonly> | readonly Interp[]): string { + if (args === undefined) { return template; } + + if (isNamedArgs(args)) { + return template.replace(VAR_RE, (marker, name: string) => (name in args ? String(args[name]) : marker)); + } + + let auto = 0; + + return template.replace(SLOT_RE, (marker, index: string | undefined) => { + const i = index === undefined ? auto++ : Number(index) - 1; + + return i >= 0 && i < args.length ? String(args[i]) : marker; + }); +} + +/** + * Rewrite `{{var}}` markers to `%N$s`, N being the 1-based slot in `order` + * (the recorded default-locale appearance order). Variables outside the order + * are left intact — build-time validation already flagged them. + */ +export function toPositional(template: string, order: readonly string[]): string { + return template.replace(VAR_RE, (marker, name: string) => { + const idx = order.indexOf(name); + + return idx === -1 ? marker : `%${idx + 1}$s`; + }); +} diff --git a/packages/i18n/src/locale.ts b/packages/i18n/src/locale.ts new file mode 100644 index 0000000..55b0626 --- /dev/null +++ b/packages/i18n/src/locale.ts @@ -0,0 +1,33 @@ +/** + * Locale policy, in one place. The ui-runtime deliberately has none — it looks + * keys up in a record; which record, and for whom, is decided here. + */ + +/** + * Pick the best available locale for an ordered list of candidates. Per + * candidate: exact match first, then a sibling region of the same language — + * a player on unauthored `es_MX` gets Spanish written for Spain rather than + * English. Then the default locale, then anything at all. + */ +export function pickLocale( + available: readonly string[], + candidates: readonly (string | undefined)[], + defaultLocale: string, +): string | undefined { + const set = new Set(available); + + for (const candidate of candidates) { + if (candidate === undefined || candidate === '') { continue; } + + if (set.has(candidate)) { return candidate; } + + const language = `${candidate.split('_')[0]}_`; + const sibling = [...available].filter(locale => locale.startsWith(language)).sort()[0]; + + if (sibling !== undefined) { return sibling; } + } + + if (set.has(defaultLocale)) { return defaultLocale; } + + return available[0]; +} diff --git a/packages/i18n/src/overlay.ts b/packages/i18n/src/overlay.ts new file mode 100644 index 0000000..d4122b0 --- /dev/null +++ b/packages/i18n/src/overlay.ts @@ -0,0 +1,63 @@ +/** + * Laying the world's published translations over a library's own. + * + * A library that draws UI ships its strings in its own bundle, keyed under a namespace it + * shares with the rest of its family. At runtime the realm has more than that: every addon + * present has announced a bundle, and one of them may carry the very same key — deliberately, + * to rename what the library calls something ("Addons" becomes "Mods"), or simply because it + * ships a locale the library does not. + * + * {@link overlay} is that precedence, as verbs: + * + * - `t()` prefers the published value wherever it carries the key, so an override and an + * unshipped locale reach the strings a script renders, not only the keys a client paints. + * - `resolve()` becomes the world's, so a key from ANY addon's bundle resolves — which is what + * a screen showing another addon's display fields needs. + * - `display()` binds to that same resolver, for the same reason. + */ +import type { BoundI18n, TranslationResolver } from './createI18n'; +import type { I18nBundle } from './bundle'; +import { resolveDisplay, type DisplayText } from './display'; +import { interpolate } from './interpolate'; +import type { Interp } from './types'; + +/** The overloaded verbs, widened to the loose shape this file calls them through. */ +type LooseVerb = (selector: unknown, args?: Readonly>) => string; + +/** + * `bound`, with `published` taking precedence for every key it carries. + * + * `bundle` is the one `bound` came from: a published value is in positional `%N$s` form, so + * interpolating it needs that bundle's recorded argument order. Returns `bound` untouched when + * nothing is published, so a realm with no bundles allocates nothing. + */ +export function overlay( + bound: BoundI18n, + published: TranslationResolver | null | undefined, + bundle: I18nBundle, +): BoundI18n { + if (!published) { return bound; } + + const prefix = `${bundle.namespace}.`; + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- widening the overloaded verbs to their loose implementation shape + const { key: keyOf, t: tOf } = bound as unknown as { key: LooseVerb; t: LooseVerb }; + + const t = (selector: unknown, args?: Readonly>): string => { + const realKey = keyOf(selector, args); + const value = published(realKey); + + if (value === undefined) { return tOf(selector, args); } + + if (args === undefined) { return value; } + + const path = realKey.startsWith(prefix) ? realKey.slice(prefix.length) : realKey; + const order = bundle.args[path]; + + return interpolate(value, order === undefined ? args : order.map(name => args[name])); + }; + + const display = (value: DisplayText): string => resolveDisplay(published, value); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- restoring the typed surface over the published-aware t + return { ...bound, t, resolve: published, display } as BoundI18n; +} diff --git a/packages/i18n/src/plural.ts b/packages/i18n/src/plural.ts new file mode 100644 index 0000000..084f158 --- /dev/null +++ b/packages/i18n/src/plural.ts @@ -0,0 +1,67 @@ +/** + * CLDR plural categories for the locales the Bedrock client ships, as a + * built-in rule table — Bedrock's script engine does not guarantee + * `Intl.PluralRules`, so the engine never reaches for it. + * + * Rules are integer-oriented (counts in game text are counts); non-integers + * take the `other` branch in the Slavic families rather than modeling CLDR's + * fraction categories. Lookup falls back `_` → `_other`, so a + * missing category never strands a string. + */ + +export type PluralCategory = 'zero' | 'one' | 'two' | 'few' | 'many' | 'other'; + +export function pluralCategory(locale: string, count: number): PluralCategory { + const lang = locale.slice(0, 2); + const n = Math.abs(count); + const int = Number.isInteger(n); + + switch (lang) { + // No plural distinction. + case 'ja': case 'ko': case 'zh': case 'id': + return 'other'; + + // i = 0 or 1 → one (CLDR: fr). + case 'fr': + return Math.trunc(n) === 0 || Math.trunc(n) === 1 ? 'one' : 'other'; + + // one / few (2–4) / other. + case 'cs': case 'sk': + if (!int) { return 'other'; } + + if (n === 1) { return 'one'; } + + return n >= 2 && n <= 4 ? 'few' : 'other'; + + // one / few (2–4 outside 12–14) / many. + case 'pl': { + if (!int) { return 'other'; } + + if (n === 1) { return 'one'; } + + const mod10 = n % 10; + const mod100 = n % 100; + + return mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14) ? 'few' : 'many'; + } + + // one (…1 outside …11) / few (…2–4 outside …12–14) / many. + case 'ru': case 'uk': + return eastSlavic(n, int); + + // The n == 1 family: en, de, es, it, pt, nl, sv, da, nb, fi, hu, el, bg, tr… + default: + return int && n === 1 ? 'one' : 'other'; + } +} + +function eastSlavic(n: number, int: boolean): PluralCategory { + if (!int) { return 'other'; } + + const mod10 = n % 10; + const mod100 = n % 100; + + if (mod10 === 1 && mod100 !== 11) { return 'one'; } + + return mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14) ? 'few' : 'many'; +} diff --git a/packages/i18n/src/resources.ts b/packages/i18n/src/resources.ts new file mode 100644 index 0000000..082e9cf --- /dev/null +++ b/packages/i18n/src/resources.ts @@ -0,0 +1,72 @@ +/** + * Build an {@link I18nBundle} from nested resource modules at runtime — no + * Regolith filter involved. Two audiences: + * + * - **Libraries** (config, future bedrock-core packages): their resources ship + * inside the package and the consuming addon's filter folds them into the + * `.lang`; the library itself still wants typed verbs over its own strings + * for breadcrumbs, native modal headings, and any place a key must become a + * string. `createResourceBundle('core', { en_US })` gives it exactly the + * bundle shape `createI18n` expects, namespaced the way the filter emits it. + * - **Addons without the filter**: everything works minus what only the build + * can do (`.lang` emission, vanilla branch, cross-locale checks). + */ +import type { I18nBundle } from './bundle'; +import { templateVars } from './interpolate'; + +/** Nested resource module shape: strings at the leaves, objects in between. */ +export interface ResourceTree { + readonly [key: string]: string | ResourceTree; +} + +function flatten(tree: ResourceTree, prefix: string, into: Record): void { + for (const [segment, value] of Object.entries(tree)) { + const path = prefix === '' ? segment : `${prefix}.${segment}`; + + if (typeof value === 'string') { into[path] = value; } else { flatten(value, path, into); } + } +} + +export interface ResourceBundleOptions { + /** The locale defining the type and the recorded argument order. Defaults to `en_US`. */ + readonly defaultLocale?: string; + /** + * `.lang`-passthrough entries (locale → REAL key → display string) to carry + * for measurement — the runtime twin of the filter's `extra` section, for + * keys that never were resource paths (config bakes the framework guide's + * keys in this way). + */ + readonly extra?: Readonly>>>; +} + +/** + * @param namespace the branch these keys live under world-wide (`core` for + * bedrock-core's own; an addon namespace otherwise) + * @param locales one nested resource object per locale; the default locale + * defines the type and the recorded argument order + */ +export function createResourceBundle( + namespace: string, + locales: Readonly> & { readonly en_US: T }, + options: ResourceBundleOptions = {}, +): I18nBundle & { readonly resources?: T } { + const { defaultLocale = 'en_US', extra } = options; + const tables: Record> = {}; + + for (const [locale, tree] of Object.entries(locales)) { + const flat: Record = {}; + + flatten(tree, '', flat); + tables[locale] = flat; + } + + const args: Record = {}; + + for (const [path, template] of Object.entries(tables[defaultLocale] ?? {})) { + const vars = templateVars(template); + + if (vars.length > 0) { args[path] = vars; } + } + + return { namespace, defaultLocale, libs: [], args, locales: tables, ...(extra !== undefined && { extra }) }; +} diff --git a/packages/i18n/src/types.ts b/packages/i18n/src/types.ts new file mode 100644 index 0000000..4594fd3 --- /dev/null +++ b/packages/i18n/src/types.ts @@ -0,0 +1,107 @@ +/** + * The compile-time half of the engine: selector trees, dot-path unions and + * interpolation-variable inference, all derived from the bundle's phantom + * `resources` type. Conventions are i18next's ({{var}}, plural suffixes, the + * selector call shape); the machinery is this package's own, sized for + * Bedrock's constraints. + */ + +/** A value an interpolation argument accepts. */ +export type Interp = string | number; + +declare const TEMPLATE: unique symbol; +declare const PLURAL: unique symbol; + +/** + * What a selector returns: a branded leaf carrying the authored template's + * literal type (which is where argument inference comes from) and whether the + * leaf is a collapsed plural group. + */ +export interface Leaf { + readonly [TEMPLATE]: S; + readonly [PLURAL]: P; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- variance: any Leaf instantiation +export type AnyLeaf = Leaf; + +type Whitespace = ' ' | '\t'; +type TrimLeft = S extends `${Whitespace}${infer R}` ? TrimLeft : S; +type TrimRight = S extends `${infer R}${Whitespace}` ? TrimRight : S; +type Trim = TrimLeft>; + +/** The `{{var}}` names in a template literal type. */ +export type TemplateVars + = S extends `${string}{{${infer V}}}${infer Rest}` ? Trim | TemplateVars : never; + +type PluralSuffix = 'zero' | 'one' | 'two' | 'few' | 'many' | 'other'; + +/** Bases of complete plural groups: every `x_other` contributes `x`. */ +type PluralBases = { [K in keyof T]: K extends `${infer B}_other` ? B : never }[keyof T]; + +/** + * Keys of a tree node. A node that is also a string (vanilla's + * leaf-and-branch case, `{ … } & string`) would leak String.prototype names — + * those are excluded only there, so an authored key named `length` still works. + */ +type KeysOf = T extends object + ? (T extends string ? Exclude : keyof T & string) + : never; + +/** The template type of a leaf value; non-literal strings stay `string`. */ +type LeafTemplate = V extends string ? (string extends V ? string : V) : string; + +/** + * The `$` a selector navigates: the resource tree with string leaves replaced + * by branded {@link Leaf}s, and plural sibling groups (`stock_one` / + * `stock_other`) collapsed into one plural leaf (`stock`). + */ +export type SelectorTree + = { [K in Exclude, `${PluralBases & string}_${PluralSuffix}`>]: + T[K] extends string + ? (T[K] extends object ? SelectorTree & Leaf : Leaf>) + : SelectorTree } + & { [B in PluralBases & string]: Leaf, true> }; + +/** + * Every valid dot path, plural groups collapsed. Instantiated only when the + * string form is used — the vanilla branch expands to a large union, and the + * selector form never pays for it. + */ +export type PathsOf + = | { [K in KeysOf]: K extends `${PluralBases & string}_${PluralSuffix}` ? never + : T[K] extends string + ? (T[K] extends object ? K | `${K}.${PathsOf}` : K) + : `${K}.${PathsOf}` + }[KeysOf] + | (PluralBases & string); + +/** Resolve a dot path to the same {@link Leaf} the selector form would return. */ +export type ResolvePath + = P extends `${infer H}.${infer Rest}` + ? (H extends KeysOf ? ResolvePath : never) + : P extends KeysOf + ? (T[P & keyof T] extends string ? Leaf> : never) + : `${P}_other` extends keyof T + ? Leaf, true> + : never; + +type VarsRecord + = { [K in Exclude, Excluded>]: V }; + +/** + * The rest-tuple of arguments a leaf demands. Literal templates make their + * variables required properties; a plural leaf additionally requires `count`; + * non-literal leaves (vanilla) optionally take a positional array for the + * client's `%1$s` slots. + * + * `V` is what an argument accepts: `Interp` for the server-resolved verbs; + * `raw()` widens it so an argument can itself be a nested translate. + */ +export type ArgsOf = L extends Leaf + ? (P extends true + ? [args: { count: number } & VarsRecord] + : string extends S + ? [args?: Readonly> | readonly V[]] + : [TemplateVars] extends [never] ? [] : [args: VarsRecord]) + : never; diff --git a/packages/i18n/tsconfig.json b/packages/i18n/tsconfig.json new file mode 100644 index 0000000..5543346 --- /dev/null +++ b/packages/i18n/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "noEmit": true + }, + "include": [ + "src/**/*", + "vitest.config.ts" + ] +} diff --git a/packages/i18n/vitest.config.ts b/packages/i18n/vitest.config.ts new file mode 100644 index 0000000..7eeb3f8 --- /dev/null +++ b/packages/i18n/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + include: ['src/**/*.test.ts'], + }, +}); diff --git a/packages/observable/README.md b/packages/observable/README.md new file mode 100644 index 0000000..c2f2c88 --- /dev/null +++ b/packages/observable/README.md @@ -0,0 +1,47 @@ +# @bedrock-core/observable + +![Logo](https://raw.githubusercontent.com/bedrock-core/server/main/assets/logo/title.png) + +The reactive primitive the bedrock-core stack notifies through: a value with three verbs — `get` / +`set` / `subscribe` — the same three a config leaf has and the same three Minecraft's data-driven +UI observables have, plus `computed`, `effect` and `batch` on top; pure TypeScript with nothing +here importing the engine, so it runs in vitest exactly as it runs in a realm, with the one bridge +to `@minecraft/server-ui` living at the separate `@bedrock-core/observable/minecraft` entry. + +## Install + +```bash +yarn add @bedrock-core/observable +``` + +`@minecraft/server-ui` is an optional peer dependency, needed only by the `/minecraft` entry. + +## Usage + +```ts +import { observable, computed, effect, batch, last } from '@bedrock-core/observable'; + +const phase = observable<'lobby' | 'fight' | 'end'>('lobby'); +const alive = observable(new Set()); +const aliveCount = computed(() => alive.get().size, [alive]); + +effect(() => bossBar.setTitle(phase.get()), [phase]); // runs now and on every change +aliveCount.subscribe(n => scoreboard.set(n)); // (next, prev) + +batch(() => { // one notification per observable + phase.set('end'); + alive.set(new Set()); +}); + +const spawn = last(world.afterEvents.playerSpawn); // undefined, then each payload +const lastName = computed(() => spawn.get()?.player.name, [spawn]); +spawn.dispose(); // releases the engine subscription +``` + +## Documentation + +https://bedrock-core.drav.dev/docs/observable + +## License + +MIT diff --git a/packages/observable/package.json b/packages/observable/package.json new file mode 100644 index 0000000..3459a8c --- /dev/null +++ b/packages/observable/package.json @@ -0,0 +1,61 @@ +{ + "name": "@bedrock-core/observable", + "version": "0.0.0", + "description": "The reactive primitive for @bedrock-core: observable values with get / set / subscribe, computed values, effects and batching, plus a bridge to Minecraft's data-driven UI observables", + "keywords": [ + "minecraft", + "bedrock", + "observable", + "reactive", + "signal", + "ddui" + ], + "license": "MIT", + "author": "DrAv0011", + "contributors": [ + { + "name": "DrAv0011", + "email": "contact@drav.dev", + "url": "https://drav.dev" + } + ], + "repository": "github:bedrock-core/server", + "type": "module", + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + }, + "./minecraft": { + "types": "./src/minecraft.ts", + "import": "./src/minecraft.ts" + } + }, + "publishConfig": { + "access": "public" + }, + "files": [ + "src/**/*" + ], + "scripts": { + "build": "tsc -p tsconfig.json", + "test": "vitest run", + "lint": "eslint ." + }, + "devDependencies": { + "@minecraft/server-ui": "*", + "@stylistic/eslint-plugin": "*", + "eslint": "*", + "typescript": "*", + "typescript-eslint": "*", + "vitest": "*" + }, + "peerDependencies": { + "@minecraft/server-ui": ">=2.1.0" + }, + "peerDependenciesMeta": { + "@minecraft/server-ui": { + "optional": true + } + } +} diff --git a/packages/observable/src/batch.ts b/packages/observable/src/batch.ts new file mode 100644 index 0000000..078c2d0 --- /dev/null +++ b/packages/observable/src/batch.ts @@ -0,0 +1,89 @@ +/** + * The scheduler behind `batch()`. + * + * Notification is synchronous by default: a listener runs inside the `set` that changed the + * value, in the same tick, because a `before` event handler that flips a value must see its + * listeners run before the event resolves. `batch` is the one place that defers — everything + * queued inside it is delivered once, at the end, in the order it was queued. + * + * A flush is itself a batching window: a listener that sets another observable during delivery + * queues it behind the current one instead of interleaving, and a `computed` whose dependencies + * both changed recomputes exactly once. Set iteration visits entries added during the loop, so + * anything queued mid-flush is delivered in the same synchronous pass. + */ + +export interface Flushable { + flush(): void; +} + +let depth = 0; +let flushing = false; +const pending = new Set(); + +/** Whether a change made now should be queued rather than delivered immediately. */ +export function isBatching(): boolean { + return depth > 0 || flushing; +} + +/** Queue a task to run at the end of the current batch. */ +export function enqueue(task: Flushable): void { + pending.add(task); +} + +/** + * Run `fn` and deliver every notification it caused once, afterwards. Nested batches flush at + * the outermost. + */ +export function batch(fn: () => void): void { + depth++; + + try { + fn(); + } finally { + depth--; + + if (depth === 0 && !flushing) { + flushPending(); + } + } +} + +function flushPending(): void { + flushing = true; + + try { + for (const task of pending) { + pending.delete(task); + task.flush(); + } + } finally { + flushing = false; + } +} + +/** + * Wrap `fn` so that, inside a batch, it runs once at flush no matter how many times it is asked + * to; outside a batch it runs immediately. The unit `computed` and `effect` are built on. + */ +export function coalesced(fn: () => void): () => void { + const task = { + queued: false, + flush(): void { + task.queued = false; + fn(); + }, + }; + + return (): void => { + if (!isBatching()) { + fn(); + + return; + } + + if (!task.queued) { + task.queued = true; + enqueue(task); + } + }; +} diff --git a/packages/observable/src/computed.ts b/packages/observable/src/computed.ts new file mode 100644 index 0000000..6a1d6c0 --- /dev/null +++ b/packages/observable/src/computed.ts @@ -0,0 +1,89 @@ +/** + * `computed` and `effect` — the two things built on an observable's subscription. + * + * Dependencies are listed, never tracked: there is no `Proxy` trap on property reads, so a read + * costs nothing on a tick. Inside a batch either one runs once at flush however many dependencies + * changed. + */ +import { coalesced } from './batch'; +import { + ObservableImpl, + reportListenerError, + type ObservableOptions, + type ReadonlyObservable, + type Unsubscribe, +} from './observable'; + +/** What `computed()` returns: a read-only value that can stop following its dependencies. */ +export interface Computed extends ReadonlyObservable { + /** Stop following the dependencies. The last value stays readable. */ + dispose(): void; +} + +function follow(deps: readonly ReadonlyObservable[], onChange: () => void): Unsubscribe { + const unsubscribes = deps.map(dep => dep.subscribe(onChange)); + + return (): void => { + for (const unsubscribe of unsubscribes) { + unsubscribe(); + } + }; +} + +/** + * A value derived from other observables, recomputed when any of them changes and notifying only + * when the result differs. A `compute` that throws is reported and the previous value kept. + */ +export function computed( + compute: () => T, + deps: readonly ReadonlyObservable[], + options?: ObservableOptions, +): Computed { + const inner = new ObservableImpl(compute(), options); + + const recompute = (): void => { + let next: T; + + try { + next = compute(); + } catch (error) { + reportListenerError(options?.label ?? 'computed', error); + + return; + } + + inner.set(next); + }; + + const dispose = follow(deps, coalesced(recompute)); + + return { + get: () => inner.get(), + subscribe: listener => inner.subscribe(listener), + dispose, + }; +} + +/** + * Run `run` now and again whenever a dependency changes. Returns the unsubscribe. A `run` that + * throws is reported and the effect stays attached. + */ +export function effect( + run: () => void, + deps: readonly ReadonlyObservable[], + options?: { label?: string }, +): Unsubscribe { + const safeRun = (): void => { + try { + run(); + } catch (error) { + reportListenerError(options?.label ?? 'effect', error); + } + }; + + const dispose = follow(deps, coalesced(safeRun)); + + safeRun(); + + return dispose; +} diff --git a/packages/observable/src/index.ts b/packages/observable/src/index.ts new file mode 100644 index 0000000..4c166ae --- /dev/null +++ b/packages/observable/src/index.ts @@ -0,0 +1,17 @@ +export { observable } from './observable'; +export type { + Equals, + Listener, + Observable, + ObservableOptions, + ReadonlyObservable, + Unsubscribe, +} from './observable'; + +export { computed, effect } from './computed'; +export type { Computed } from './computed'; + +export { batch } from './batch'; + +export { last } from './last'; +export type { Last, Signal } from './last'; diff --git a/packages/observable/src/last.ts b/packages/observable/src/last.ts new file mode 100644 index 0000000..3e10ccd --- /dev/null +++ b/packages/observable/src/last.ts @@ -0,0 +1,70 @@ +/** + * `last` — the most recent payload of an event, as an observable. + * + * An event is a stream of happenings; an observable is a value. The one-line bridge between them + * is "the last thing that happened": `undefined` until the first event, then each payload as it + * arrives, so the rest of the system — `computed`, `effect`, a UI hook, a shared key — can treat + * an engine event or an addon event as a value it watches. + * + * It takes anything with `subscribe`: the engine's signals, whose `subscribe` hands the callback + * back and whose `unsubscribe(cb)` releases it, and the framework's own, whose `subscribe` hands + * back a release function. Nothing here imports either. + * + * It subscribes when created, not when first watched — a value that skipped events while nobody + * was listening would be wrong — so `dispose()` is what ends it. Use it on `afterEvents` and + * script events; a `beforeEvents` handler runs inside the engine's read-only window, and a + * listener notified from there would be handed that window. + */ +import { ObservableImpl, type ObservableOptions, type ReadonlyObservable, type Unsubscribe } from './observable'; + +/** + * A source of events, in either shape: `subscribe` returns a release function, or it returns the + * callback and `unsubscribe(callback)` releases it. + */ +export interface Signal { + subscribe(callback: (event: E) => void, options?: O): unknown; + unsubscribe?(callback: (event: E) => void): void; +} + +/** What `last()` returns: the most recent payload, `undefined` before the first, and a way to stop. */ +export interface Last extends ReadonlyObservable { + /** Stop following the signal. The last value stays readable. */ + dispose(): void; +} + +/** + * An event as a value: the most recent payload of `signal`, `undefined` before the first. Eager — + * the subscription is taken now — and released with `dispose()`. `options.on` is handed to + * `subscribe` as its second argument, for a signal that filters. + */ +export function last(signal: Signal, options?: ObservableOptions & { on?: O }): Last { + const inner = new ObservableImpl(undefined, options); + + const callback = (event: E): void => { inner.set(event); }; + + const returned = signal.subscribe(callback, options?.on); + + let disposed = false; + + const dispose: Unsubscribe = (): void => { + if (disposed) { return; } + + disposed = true; + + // The engine hands the callback itself back; the framework hands back a release function. Both + // are functions, so identity is what tells them apart. + if (returned === callback) { + signal.unsubscribe?.(callback); + } else if (typeof returned === 'function') { + (returned as Unsubscribe)(); // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion + } else { + signal.unsubscribe?.(callback); + } + }; + + return { + get: () => inner.get(), + subscribe: listener => inner.subscribe(listener), + dispose, + }; +} diff --git a/packages/observable/src/minecraft.ts b/packages/observable/src/minecraft.ts new file mode 100644 index 0000000..bd9d442 --- /dev/null +++ b/packages/observable/src/minecraft.ts @@ -0,0 +1,44 @@ +/** + * The bridge to Minecraft's data-driven UI observables — the only file in this package that + * imports `@minecraft/server-ui`. + * + * A `CustomForm` redraws only for the engine's own `ObservableNumber` / `ObservableString` / + * `ObservableBoolean`, one scalar each, so ours cannot *be* theirs. `toNative` mints a native + * one per control and keeps it in step with ours for the form's lifetime; the binding rules are + * `bindNative`'s, in `./native`. + * + * ```ts + * const { native: volume, dispose } = toNative(config.player.for(p).volume, { clientWritable: true }); + * new CustomForm(p, 'Settings').slider('Volume', volume, 0, 100).show().then(dispose); + * ``` + */ +import { ObservableBoolean, ObservableNumber, ObservableString } from '@minecraft/server-ui'; +import { bindNative, type NativeBinding, type NativeObservable, type NativeScalar, type ToNativeOptions } from './native'; +import type { ReadonlyObservable } from './observable'; + +export { bindNative } from './native'; +export type { NativeBinding, NativeObservable, NativeScalar, ToNativeOptions } from './native'; + +/** + * Mint the engine's observable for a scalar and keep it in step with `source` for the form's + * lifetime: ours is the source of truth, the native writes back only when `clientWritable`, and + * `dispose()` releases both directions. + */ +export function toNative(source: ReadonlyObservable, options?: ToNativeOptions): NativeBinding; +export function toNative(source: ReadonlyObservable, options?: ToNativeOptions): NativeBinding; +export function toNative(source: ReadonlyObservable, options?: ToNativeOptions): NativeBinding; + +export function toNative( + source: ReadonlyObservable, + options?: ToNativeOptions, +): NativeBinding> { + const initial = source.get(); + const nativeOptions = { clientWritable: options?.clientWritable ?? false }; + const native: NativeObservable = typeof initial === 'number' + ? new ObservableNumber(initial, nativeOptions) + : typeof initial === 'string' + ? new ObservableString(initial, nativeOptions) + : new ObservableBoolean(initial, nativeOptions); + + return { native, dispose: bindNative(source, native, options) }; +} diff --git a/packages/observable/src/native.ts b/packages/observable/src/native.ts new file mode 100644 index 0000000..2fd6104 --- /dev/null +++ b/packages/observable/src/native.ts @@ -0,0 +1,76 @@ +/** + * The binding half of the DDUI bridge, written against the shape of the engine's observables + * rather than the classes, so it needs no engine to run and can be tested with a stub. + * + * - ours is the source of truth — every change of ours is pushed into the native; + * - the native writes back into ours only when `clientWritable` — that is the player moving the + * control — and only when the value actually differs, so the two never chase each other; + * - the returned unsubscribe releases both directions; the native side is released with + * `unsubscribe(cb)`, the engine's only release (its `subscribe` returns the callback, not a handle). + */ +import type { Observable, ReadonlyObservable, Unsubscribe } from './observable'; + +/** What a native observable can hold. Objects go through `computed` into one of these. */ +export type NativeScalar = number | string | boolean; + +/** The shape shared by `ObservableNumber`, `ObservableString` and `ObservableBoolean`. */ +export interface NativeObservable { + getData(): T; + setData(data: T): void; + /** The engine hands the callback back, not a handle; `unsubscribe` is the release. */ + subscribe(callback: (value: T) => void): unknown; + unsubscribe(callback: (value: T) => void): boolean; +} + +/** How a native observable is bound to one of ours. */ +export interface ToNativeOptions { + /** Let the player's control write the value back. Off, the native is a one-way view. */ + clientWritable?: boolean; +} + +/** What `toNative()` returns: the native to hand to a form control, and the release. */ +export interface NativeBinding { + native: N; + /** Release both directions. Call it when the form closes. */ + dispose: Unsubscribe; +} + +function isWritable(source: ReadonlyObservable): source is Observable { + return 'set' in source && typeof source.set === 'function'; +} + +/** Keep an existing native observable in step with one of ours. */ +export function bindNative( + source: ReadonlyObservable, + native: NativeObservable, + options?: ToNativeOptions, +): Unsubscribe { + const push = (next: T): void => { + if (!Object.is(native.getData(), next)) { + native.setData(next); + } + }; + + push(source.get()); + + const unsubscribeOurs = source.subscribe(push); + let pull: ((value: T) => void) | undefined; + + if (options?.clientWritable && isWritable(source)) { + pull = (value: T): void => { + if (!Object.is(source.get(), value)) { + source.set(value); + } + }; + + native.subscribe(pull); + } + + return (): void => { + unsubscribeOurs(); + + if (pull) { + native.unsubscribe(pull); + } + }; +} diff --git a/packages/observable/src/observable.ts b/packages/observable/src/observable.ts new file mode 100644 index 0000000..3726f0e --- /dev/null +++ b/packages/observable/src/observable.ts @@ -0,0 +1,144 @@ +/** + * The observable itself: a value with `get` / `set` / `subscribe`. + * + * Values are immutable — `set` replaces, and a store of an object gets a new object — which is + * what makes `equals` a cheap `Object.is` by default and lets `computed` trust it. Listeners are + * isolated: one that throws is reported and skipped, the rest still run, so one addon's bug does + * not silence another's subscription. + */ +import { enqueue, isBatching, type Flushable } from './batch'; + +/** What `subscribe` returns: call it to stop listening. */ +export type Unsubscribe = () => void; + +/** Told the new value and the one before it. */ +export type Listener = (next: T, prev: T) => void; + +/** Whether two values count as the same, so a `set` to an equal value notifies nobody. */ +export type Equals = (a: T, b: T) => boolean; + +/** What anything exposing a value it owns hands out: readable, watchable, not writable. */ +export interface ReadonlyObservable { + get(): T; + subscribe(listener: Listener): Unsubscribe; +} + +/** A value with the three verbs: read it, replace it, watch it. */ +export interface Observable extends ReadonlyObservable { + /** Replace the value. An updater receives the current value. Notifies unless `equals` says nothing changed. */ + set(next: T | ((prev: T) => T)): void; +} + +/** What `observable()` and the derived forms take beside the value. */ +export interface ObservableOptions { + /** Defaults to `Object.is`. */ + equals?: Equals; + /** Names the observable in the log line a throwing listener produces. */ + label?: string; +} + +/** The one log line a throwing listener produces, naming the observable when it has a label. */ +export function reportListenerError(label: string | undefined, error: unknown): void { + console.error(`[observable]${label ? ` ${label}:` : ''} listener threw`, error); +} + +/** + * `set` accepts a value or an updater. An observable whose value type is itself a function + * cannot be told apart from an updater here; wrap such a value in an updater that returns it. + */ +function isUpdater(next: T | ((prev: T) => T)): next is (prev: T) => T { + return typeof next === 'function'; +} + +/** + * The observable behind `observable()`, `computed()` and `last()`: synchronous delivery, `equals` + * gating, listener isolation, and deferral inside a batch. + */ +export class ObservableImpl implements Observable, Flushable { + private readonly _equals: Equals; + private readonly _label: string | undefined; + // Copy-on-write: subscribe and unsubscribe replace the array, so delivery iterates a stable + // reference with no per-set allocation, and a listener removed mid-delivery is still safe. + private _listeners: readonly Listener[] = []; + private _value: T; + private _batchPrev: T; + private _queued = false; + + constructor(initial: T, options?: ObservableOptions) { + this._value = initial; + this._batchPrev = initial; + this._equals = options?.equals ?? Object.is; + this._label = options?.label; + } + + get(): T { + return this._value; + } + + set(next: T | ((prev: T) => T)): void { + const value = isUpdater(next) ? next(this._value) : next; + + if (this._equals(this._value, value)) { + return; + } + + const prev = this._value; + + this._value = value; + + if (isBatching()) { + // Remember the value listeners last saw; the flush compares against that, so a value + // that changes and changes back inside one batch produces no notification at all. + if (!this._queued) { + this._queued = true; + this._batchPrev = prev; + enqueue(this); + } + + return; + } + + this._notify(value, prev); + } + + subscribe(listener: Listener): Unsubscribe { + this._listeners = [...this._listeners, listener]; + + return (): void => { + const index = this._listeners.indexOf(listener); + + if (index >= 0) { + this._listeners = [...this._listeners.slice(0, index), ...this._listeners.slice(index + 1)]; + } + }; + } + + flush(): void { + this._queued = false; + + const prev = this._batchPrev; + + if (this._equals(this._value, prev)) { + return; + } + + this._notify(this._value, prev); + } + + private _notify(next: T, prev: T): void { + const listeners = this._listeners; + + for (let i = 0; i < listeners.length; i++) { + try { + listeners[i](next, prev); + } catch (error) { + reportListenerError(this._label, error); + } + } + } +} + +/** A value with `get`, `set` and `subscribe`, delivering synchronously to isolated listeners. */ +export function observable(initial: T, options?: ObservableOptions): Observable { + return new ObservableImpl(initial, options); +} diff --git a/packages/observable/test/last.spec.ts b/packages/observable/test/last.spec.ts new file mode 100644 index 0000000..2873b43 --- /dev/null +++ b/packages/observable/test/last.spec.ts @@ -0,0 +1,119 @@ +/** + * `last` over both signal shapes: undefined before the first event, then each payload; an engine + * signal whose subscribe returns the callback is released through unsubscribe, one whose subscribe + * returns a function is released by calling it; subscribing is eager and dispose is what ends it. + */ +import { describe, expect, it } from 'vitest'; +import { computed, last } from '../src/index'; + +interface Spawn { playerId: string } + +/** The engine shape: subscribe returns the callback, unsubscribe takes it. */ +function engineSignal(): { signal: { subscribe(cb: (e: E) => void): (e: E) => void; unsubscribe(cb: (e: E) => void): void }; fire(e: E): void; listeners: number } { + const set = new Set<(e: E) => void>(); + + return { + signal: { + subscribe: (cb): ((e: E) => void) => { + set.add(cb); + + return cb; + }, + unsubscribe: (cb): void => { set.delete(cb); }, + }, + fire: (e): void => { for (const cb of set) { cb(e); } }, + get listeners(): number { return set.size; }, + }; +} + +/** The framework shape: subscribe returns a release function. */ +function ourSignal(): { signal: { subscribe(cb: (e: E) => void): () => void }; fire(e: E): void; listeners: number } { + const set = new Set<(e: E) => void>(); + + return { + signal: { subscribe: (cb): (() => void) => { + set.add(cb); + + return (): void => { set.delete(cb); }; + } }, + fire: (e): void => { for (const cb of set) { cb(e); } }, + get listeners(): number { return set.size; }, + }; +} + +describe('last', () => { + it('is undefined until the first event, then the latest payload', () => { + const { signal, fire } = engineSignal(); + const spawn = last(signal); + const seen: (Spawn | undefined)[] = []; + + spawn.subscribe(next => seen.push(next)); + + expect(spawn.get()).toBeUndefined(); + + fire({ playerId: 'a' }); + fire({ playerId: 'b' }); + + expect(spawn.get()).toEqual({ playerId: 'b' }); + expect(seen).toEqual([{ playerId: 'a' }, { playerId: 'b' }]); + }); + + it('subscribes eagerly, so nothing is missed before the first watcher', () => { + const source = engineSignal(); + const value = last(source.signal); + + expect(source.listeners).toBe(1); + source.fire(1); + expect(value.get()).toBe(1); + }); + + it('releases an engine signal through unsubscribe, and keeps the last value', () => { + const source = engineSignal(); + const value = last(source.signal); + + source.fire(3); + value.dispose(); + value.dispose(); + + expect(source.listeners).toBe(0); + source.fire(4); + expect(value.get()).toBe(3); + }); + + it('releases a framework signal by calling what subscribe returned', () => { + const source = ourSignal(); + const value = last(source.signal); + + source.fire('x'); + value.dispose(); + + expect(source.listeners).toBe(0); + expect(value.get()).toBe('x'); + }); + + it('composes like any observable', () => { + const { signal, fire } = engineSignal(); + const spawn = last(signal); + const name = computed(() => spawn.get()?.playerId ?? 'nobody', [spawn]); + + expect(name.get()).toBe('nobody'); + fire({ playerId: 'steve' }); + expect(name.get()).toBe('steve'); + }); + + it('forwards subscription options to the signal', () => { + let received: unknown; + const signal = { + subscribe: (cb: (e: number) => void, options?: { blockTypes: string[] }): ((e: number) => void) => { + received = options; + + return cb; + }, + unsubscribe: (): void => {}, + }; + + last(signal, { on: { blockTypes: ['papi:elevator'] } }); + + expect(received).toEqual({ blockTypes: ['papi:elevator'] }); + }); +}); diff --git a/packages/observable/test/native.spec.ts b/packages/observable/test/native.spec.ts new file mode 100644 index 0000000..a6fe8f2 --- /dev/null +++ b/packages/observable/test/native.spec.ts @@ -0,0 +1,121 @@ +/** + * The binding half of the DDUI bridge, against a stub with the engine's measured behaviour: a + * native observable notifies synchronously, skips an equal value, returns the callback from + * `subscribe`, and releases only through `unsubscribe(cb)`. + */ +import { describe, expect, it } from 'vitest'; +import { observable } from '../src/index'; +import { bindNative, type NativeObservable } from '../src/native'; + +class StubNative implements NativeObservable { + readonly listeners = new Set<(value: T) => void>(); + sets = 0; + private _data: T; + + constructor(data: T) { + this._data = data; + } + + getData(): T { + return this._data; + } + + setData(data: T): void { + this.sets++; + + if (Object.is(this._data, data)) { + return; + } + + this._data = data; + + for (const listener of [...this.listeners]) { + listener(data); + } + } + + subscribe(callback: (value: T) => void): (value: T) => void { + this.listeners.add(callback); + + return callback; + } + + unsubscribe(callback: (value: T) => void): boolean { + return this.listeners.delete(callback); + } +} + +describe('bindNative', () => { + it('seeds the native from ours and pushes every change', () => { + const volume = observable(10); + const native = new StubNative(0); + + bindNative(volume, native); + + expect(native.getData()).toBe(10); + + volume.set(42); + + expect(native.getData()).toBe(42); + }); + + it('does not push a value the native already holds', () => { + const volume = observable(10); + const native = new StubNative(10); + + bindNative(volume, native); + + expect(native.sets).toBe(0); + }); + + it('is one-way unless clientWritable', () => { + const volume = observable(10); + const native = new StubNative(0); + + bindNative(volume, native); + native.setData(99); + + expect(volume.get()).toBe(10); + expect(native.listeners.size).toBe(0); + }); + + it('writes the player\'s change back when clientWritable, without echoing', () => { + const volume = observable(10); + const native = new StubNative(0); + let notifications = 0; + + volume.subscribe(() => notifications++); + bindNative(volume, native, { clientWritable: true }); + native.setData(55); + + expect(volume.get()).toBe(55); + expect(notifications).toBe(1); + // The push back into the native finds an equal value and stops — no second round. + expect(native.getData()).toBe(55); + }); + + it('never writes into a readonly source, even when clientWritable', () => { + const volume = observable(10); + const readonly = { get: (): number => volume.get(), subscribe: volume.subscribe.bind(volume) }; + const native = new StubNative(0); + + bindNative(readonly, native, { clientWritable: true }); + native.setData(55); + + expect(volume.get()).toBe(10); + }); + + it('dispose releases both directions', () => { + const volume = observable(10); + const native = new StubNative(0); + const dispose = bindNative(volume, native, { clientWritable: true }); + + dispose(); + volume.set(1); + native.setData(2); + + expect(native.getData()).toBe(2); + expect(volume.get()).toBe(1); + expect(native.listeners.size).toBe(0); + }); +}); diff --git a/packages/observable/test/observable.spec.ts b/packages/observable/test/observable.spec.ts new file mode 100644 index 0000000..582a171 --- /dev/null +++ b/packages/observable/test/observable.spec.ts @@ -0,0 +1,307 @@ +/** + * The contract every other package leans on: synchronous delivery, `equals` deciding whether a + * set is a change, listeners isolated from each other, and `batch` collapsing a burst into one + * notification per observable — including none at all for a value that changed and changed back. + */ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { batch, computed, effect, observable } from '../src/index'; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('observable', () => { + it('reads the initial value and notifies with next and prev', () => { + const count = observable(1); + const seen: [number, number][] = []; + + count.subscribe((next, prev) => seen.push([next, prev])); + count.set(2); + + expect(count.get()).toBe(2); + expect(seen).toEqual([[2, 1]]); + }); + + it('delivers synchronously, inside set', () => { + const count = observable(0); + let duringSet = -1; + + count.subscribe((next) => { duringSet = next; }); + count.set(5); + + expect(duringSet).toBe(5); + }); + + it('accepts an updater', () => { + const count = observable(1); + + count.set(prev => prev + 10); + + expect(count.get()).toBe(11); + }); + + it('does not notify when equals says nothing changed', () => { + const count = observable(1); + const listener = vi.fn(); + + count.subscribe(listener); + count.set(1); + + expect(listener).not.toHaveBeenCalled(); + }); + + it('uses a custom equals', () => { + const point = observable({ x: 1 }, { equals: (a, b) => a.x === b.x }); + const listener = vi.fn(); + + point.subscribe(listener); + point.set({ x: 1 }); + point.set({ x: 2 }); + + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('unsubscribes', () => { + const count = observable(0); + const listener = vi.fn(); + const unsubscribe = count.subscribe(listener); + + unsubscribe(); + count.set(1); + + expect(listener).not.toHaveBeenCalled(); + }); + + it('lets a listener unsubscribe itself mid-delivery without skipping the others', () => { + const count = observable(0); + const second = vi.fn(); + const unsubscribeFirst = count.subscribe(() => unsubscribeFirst()); + + count.subscribe(second); + count.set(1); + + expect(second).toHaveBeenCalledTimes(1); + }); + + it('isolates a throwing listener and reports it with the label', () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const count = observable(0, { label: 'count' }); + const after = vi.fn(); + + count.subscribe(() => { throw new Error('boom'); }); + count.subscribe(after); + count.set(1); + + expect(after).toHaveBeenCalledTimes(1); + expect(error).toHaveBeenCalledTimes(1); + expect(String(error.mock.calls[0]?.[0])).toContain('count'); + }); +}); + +describe('batch', () => { + it('delivers once per observable, with the final value and the pre-batch prev', () => { + const count = observable(0); + const seen: [number, number][] = []; + + count.subscribe((next, prev) => seen.push([next, prev])); + batch(() => { + count.set(1); + count.set(2); + count.set(3); + }); + + expect(seen).toEqual([[3, 0]]); + }); + + it('delivers nothing for a value that changed and changed back', () => { + const count = observable(0); + const listener = vi.fn(); + + count.subscribe(listener); + batch(() => { + count.set(1); + count.set(0); + }); + + expect(listener).not.toHaveBeenCalled(); + }); + + it('reads the new value inside the batch even though listeners wait', () => { + const count = observable(0); + const listener = vi.fn(); + + count.subscribe(listener); + batch(() => { + count.set(1); + + expect(count.get()).toBe(1); + expect(listener).not.toHaveBeenCalled(); + }); + + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('flushes nested batches at the outermost', () => { + const count = observable(0); + const listener = vi.fn(); + + count.subscribe(listener); + batch(() => { + batch(() => { count.set(1); }); + + expect(listener).not.toHaveBeenCalled(); + }); + + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('keeps delivery order and stays synchronous when a listener sets another observable', () => { + const a = observable(0); + const b = observable(0); + const order: string[] = []; + + a.subscribe(() => { order.push('a'); b.set(1); }); + b.subscribe(() => order.push('b')); + batch(() => { a.set(1); }); + + expect(order).toEqual(['a', 'b']); + }); + + it('still flushes when fn throws', () => { + const count = observable(0); + const listener = vi.fn(); + + count.subscribe(listener); + + expect(() => batch(() => { + count.set(1); + throw new Error('boom'); + })).toThrow('boom'); + expect(listener).toHaveBeenCalledTimes(1); + }); +}); + +describe('computed', () => { + it('derives, recomputes on a dependency change and notifies only on a different result', () => { + const alive = observable(new Set(['a', 'b'])); + const count = computed(() => alive.get().size, [alive]); + const listener = vi.fn(); + + count.subscribe(listener); + + expect(count.get()).toBe(2); + + alive.set(new Set(['a', 'b', 'c'])); + + expect(count.get()).toBe(3); + expect(listener).toHaveBeenCalledWith(3, 2); + + alive.set(new Set(['x', 'y', 'z'])); + + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('recomputes exactly once per batch however many dependencies changed', () => { + const a = observable(1); + const b = observable(2); + const compute = vi.fn(() => a.get() + b.get()); + const sum = computed(compute, [a, b]); + const listener = vi.fn(); + + sum.subscribe(listener); + compute.mockClear(); + + batch(() => { + a.set(10); + b.set(20); + }); + + expect(compute).toHaveBeenCalledTimes(1); + expect(sum.get()).toBe(30); + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('chains', () => { + const base = observable(2); + const doubled = computed(() => base.get() * 2, [base]); + const quadrupled = computed(() => doubled.get() * 2, [doubled]); + + base.set(3); + + expect(quadrupled.get()).toBe(12); + }); + + it('keeps the previous value when compute throws', () => { + vi.spyOn(console, 'error').mockImplementation(() => undefined); + + const base = observable(1); + const derived = computed(() => { + if (base.get() < 0) { throw new Error('negative'); } + + return base.get() * 2; + }, [base]); + + base.set(-1); + + expect(derived.get()).toBe(2); + }); + + it('stops following after dispose', () => { + const base = observable(1); + const derived = computed(() => base.get() * 2, [base]); + + derived.dispose(); + base.set(5); + + expect(derived.get()).toBe(2); + }); +}); + +describe('effect', () => { + it('runs immediately, then on every dependency change, until unsubscribed', () => { + const phase = observable('lobby'); + const run = vi.fn(); + const stop = effect(run, [phase]); + + expect(run).toHaveBeenCalledTimes(1); + + phase.set('fight'); + + expect(run).toHaveBeenCalledTimes(2); + + stop(); + phase.set('end'); + + expect(run).toHaveBeenCalledTimes(2); + }); + + it('runs once per batch', () => { + const a = observable(0); + const b = observable(0); + const run = vi.fn(); + + effect(run, [a, b]); + run.mockClear(); + batch(() => { + a.set(1); + b.set(1); + }); + + expect(run).toHaveBeenCalledTimes(1); + }); + + it('reports a throwing run and stays attached', () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const count = observable(0); + let runs = 0; + + effect(() => { + runs++; + throw new Error('boom'); + }, [count]); + count.set(1); + + expect(runs).toBe(2); + expect(error).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/observable/tsconfig.json b/packages/observable/tsconfig.json new file mode 100644 index 0000000..5279238 --- /dev/null +++ b/packages/observable/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "noEmit": true, + "rootDir": "./src" + }, + "include": [ + "src/**/*", + "../../types/globals.d.ts" + ], + "exclude": [ + "node_modules" + ] +} diff --git a/packages/observable/vitest.config.ts b/packages/observable/vitest.config.ts new file mode 100644 index 0000000..2cef901 --- /dev/null +++ b/packages/observable/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['test/**/*.spec.ts'], + }, +}); diff --git a/packages/server-runtime/README.md b/packages/server-runtime/README.md index 21b1cca..076c5da 100644 --- a/packages/server-runtime/README.md +++ b/packages/server-runtime/README.md @@ -2,13 +2,12 @@ ![Logo](https://raw.githubusercontent.com/bedrock-core/server/main/assets/logo/title.png) -The bedrock-core **server runtime** — the framework layer addons build on. - -Every behavior pack runs its scripts in its own isolated realm, so two addons in the same world -normally cannot see each other at all. Where [`@bedrock-core/sync`](https://bedrock-core.drav.dev/docs/server/sync) -is the low-level transport that breaks that isolation, the runtime is the thing you *register into*: -an addon declares its identity and its data once, and that declaration flows into a **cross-addon -registry** — a live directory of every bedrock-core addon present in the world. +The bedrock-core **server runtime** — the framework layer addons build on, on top of +`@bedrock-core/sync`. Every behavior pack runs its scripts in its own isolated realm, so two +addons in the same world normally cannot see each other at all; where sync is the low-level +transport that breaks that isolation, the runtime is the thing you *register into* — an addon +declares its identity and its data once, and that declaration flows into a **cross-addon +registry**, a live directory of every bedrock-core addon present in the world. ## Install @@ -16,77 +15,69 @@ registry** — a live directory of every bedrock-core addon present in the world yarn add @bedrock-core/server-runtime ``` -`@minecraft/server` is a peer dependency (`>=2.8.0`) — it stays yours to pin, since the version you -build against has to match the one your pack's `manifest.json` declares. - -## What it gives you - -- **`core.register()`** — one call brings the addon online (there is no separate `start()`). - Identity is `creator` + `pack`, joined into the single namespace Bedrock requires an addon to use - for its items, its commands and its command enum -- **A registry** — `core.registry` lists every addon in the world, fires on join/leave, reports - namespace collisions, and tracks soft dependencies by namespace -- **Features** — `core.features.add()` declares a capability that auto-enables when its condition - holds, driven by registry presence, replicated state, or another addon's published features -- **Config** — declare a schema once and get typed accessor trees over three scopes (`server`, - `dimension`, `player`), persisted in dynamic properties, readable and writable cross-addon over - RPC with player-level authorization -- **Translations and guides** — publish your i18n bundle and compiled guide manifest, and resolve - any peer's strings server-side for text measurement -- **Host election** — `core.host` picks the realm running the newest runtime, with no negotiation - messages, so exactly one realm serves shared UI for the whole world -- **Messaging and state** — `core.rpc` and a `core.state` scoped to your own namespace, with the - raw sync node available at `core.node` +`@minecraft/server` is a peer dependency — it stays yours to pin, since the version you build +against has to match the one your pack's `manifest.json` declares. ## Usage ```ts -import { core } from '@bedrock-core/server-runtime'; -import bundle from '@bedrock-core/generated/i18n'; -import guides from '@bedrock-core/generated/guides'; - -// register() declares everything and brings the addon online. When `config` is given it -// returns the typed scope accessors — the same value core.config.define() would return. -const config = core.register({ - creator: 'drav0011', // creator/vendor id — [a-z0-9_]+ - pack: 'economy', // abbreviated pack id — together: namespace `drav0011_economy` - packName: 'Economy', // display label only, never part of identity - version: '1.0.0', - dependencies: ['os_shop'], // namespaces you need — soft, logs, never blocks - translations: bundle, // optional — the i18n filter's bundle - guide: guides, // optional — the guides filter's manifest - config: { // optional — config schema - server: { taxRate: { type: 'number', default: 0.05, min: 0, max: 1, label: 'Tax Rate' } }, +import { authorize, core, event, players, schema } from '@bedrock-core/server-runtime'; + +// register() declares everything and brings the addon online. It returns the typed accessors of +// what was declared, one key each. +const { shared, events } = core.register({ + manifest: { + creator: 'drav0011', // creator/vendor id — [a-z0-9_]+ + pack: 'economy', // abbreviated pack id — together: namespace `drav0011_economy` + packName: 'Economy', // display label only, never part of identity + version: '1.0.0', + dependencies: ['os_shop'], // namespaces you need — soft, logs, never blocks + }, + shared: { // optional — what every realm mirrors; only this one writes it + currency: 'gold', + event: { name: 'none', active: false }, // one key, one value, written whole + }, + events: { // optional — what this addon announces to every realm + purchase: event<{ playerId: string; gold: number }>(), }, }); -config.server.taxRate.get(); // 0.05 — typed all the way down -config.server.taxRate.subscribe((next, prev) => console.warn('tax', prev, '→', next)); +// Persisted documents keyed by target, on the target's own dynamic properties. Local. +const balances = core.db.collection('balances', { schema: schema<{ gold: number }>(), accept: players() }); +balances.for(player).patch({ gold: 10 }); + +// What peers may ask for, and the one player rule every handler applies. Export the interface so +// a peer gets a typed client from core.rpc.typed('drav0011_economy'). +export interface EconomyApi { balance(p: { playerId: string; actorId?: string }): { gold: number } | undefined } + +core.rpc.serve({ + balance: ({ playerId, actorId }) => { + authorize({ entity: playerId }, actorId, 'read'); + + return balances.for(playerOf(playerId)).get(); + }, +}); + +shared.currency.set('emerald'); // every realm sees it this tick +events.purchase.emit({ playerId: player.id, gold: 5 }); // announced once, kept by nobody +shared.event.subscribe(event => console.warn('event', event.name, event.active)); + +// A peer's shared tree, typed by the declaration the peer exports. Read-only: only an owner writes. +core.shared.of('os_shop')?.stock.subscribe(stock => console.warn('stock', stock)); + +// A peer's events. Attaching before that addon exists is fine — an event missed is missed for good. +core.events.of('os_shop').sale.subscribe(({ item }) => console.warn('sold', item)); core.registry.onRegister(addon => console.warn('joined:', addon.id)); -// Serve a method to other addons, and call one of theirs. -core.rpc.onRequest('getRate', () => config.server.taxRate.get()); -core.rpc.request('os_shop', 'getStock', {}).then(stock => console.warn('stock', stock)); +// And an action that is not data at all. +core.rpc.onRequest('openShop', ({ playerId }) => openFor(playerId)); +core.rpc.request('os_shop', 'openShop', { playerId }).catch(console.warn); ``` ## Documentation -- [server-runtime](https://bedrock-core.drav.dev/docs/server/server-runtime) — the `core` - singleton, `register()`, every manifest field, and running several runtimes in one realm -- [Registry](https://bedrock-core.drav.dev/docs/server/server-runtime/registry) · - [Features](https://bedrock-core.drav.dev/docs/server/server-runtime/features) · - [Host election](https://bedrock-core.drav.dev/docs/server/server-runtime/host) -- [Config](https://bedrock-core.drav.dev/docs/server/server-runtime/config) — schemas, the three - scopes, cross-addon access, authorization -- [Translations](https://bedrock-core.drav.dev/docs/server/server-runtime/translations) · - [Guides](https://bedrock-core.drav.dev/docs/server/server-runtime/guides) · - [Scoped state](https://bedrock-core.drav.dev/docs/server/server-runtime/scoped-state) -- [UI integration](https://bedrock-core.drav.dev/docs/server/ui-integration) — what the runtime - publishes and which UI package draws it - -`packages/test-addon` and `packages/test-addon-2` in this repository are two real addons wired to -each other, with GameTests covering discovery, RPC, state, collisions and features. +https://bedrock-core.drav.dev/docs/server ## License diff --git a/packages/server-runtime/package.json b/packages/server-runtime/package.json index 8af5689..a609457 100644 --- a/packages/server-runtime/package.json +++ b/packages/server-runtime/package.json @@ -36,20 +36,24 @@ ], "scripts": { "build": "tsc -p tsconfig.json", - "lint": "eslint ." + "lint": "eslint .", + "test": "vitest run", + "coverage": "vitest run --coverage" }, "dependencies": { - "@bedrock-core/i18n": "^0.1.0", + "@bedrock-core/db": "workspace:^", + "@bedrock-core/i18n": "workspace:^", + "@bedrock-core/observable": "workspace:^", "@bedrock-core/sync": "workspace:^" }, "devDependencies": { - "@minecraft/server": "2.8.0", - "@stylistic/eslint-plugin": "^5.10.0", - "eslint": "^10.5.0", - "typescript": "^6.0.3", - "typescript-eslint": "^8.62.0" + "@minecraft/server": "*", + "@stylistic/eslint-plugin": "*", + "eslint": "*", + "typescript": "*", + "typescript-eslint": "*" }, "peerDependencies": { - "@minecraft/server": ">=2.8.0" + "@minecraft/server": ">=2.9.0 || >=2.10.0-0" } } diff --git a/packages/server-runtime/src/announcement.ts b/packages/server-runtime/src/announcement.ts new file mode 100644 index 0000000..ea544bb --- /dev/null +++ b/packages/server-runtime/src/announcement.ts @@ -0,0 +1,74 @@ +/** + * An announcement: one small, owner-written value under a framework key on the mirror. + * + * Every cross-addon feed built on it — the i18n bundle, the feature flags, the shared shape, and + * whatever a package above the runtime announces — is the same three moves: + * the owner publishes a value under a `core-` key in its own namespace, every realm reads it + * from its local mirror, and a listener hears when any namespace's value changes. This class is + * those three moves once, typed by the value and guarded on read, so a peer publishing something + * malformed reads as "nothing published" rather than poisoning a consumer. + * + * Late joiners are covered by sync's snapshot exchange; nothing here has to replay. + */ +import { stateKey } from '@bedrock-core/sync'; +import type { State, StateKey, Unsubscribe } from '@bedrock-core/sync'; + +/** + * Mirror keys under this prefix belong to the framework, not to the addon whose namespace they + * ride in. Every announcement is minted here, and `core.shared` refuses a key that starts so. + */ +export const RESERVED_PREFIX = 'core-'; + +/** Told which namespace's announcement changed; read it back with `of(namespace)`. */ +export type AnnouncementListener = (namespace: string) => void; + +/** One owner-written value under a `core-` key: provide it, read any namespace's, hear it change. */ +export class Announcement { + /** The mirror key, `core-`, the same in every namespace. */ + readonly key: StateKey; + + private readonly _state: State; + private readonly _self: string; + private readonly _is: (value: unknown) => value is T; + + constructor(state: State, self: string, name: string, is: (value: unknown) => value is T) { + this.key = stateKey(`${RESERVED_PREFIX}${name}`); + this._state = state; + this._self = self; + this._is = is; + } + + /** Publish this addon's value; replaces the previous one. */ + provide(value: T): void { + this._state.set(this._self, this.key, value); + } + + /** This addon's own published value, or `undefined` before it published one. */ + own(): T | undefined { + return this.of(this._self); + } + + /** What `namespace` published, or `undefined` when nothing has arrived or it fails the guard. */ + of(namespace: string): T | undefined { + const value = this._state.get(namespace, this.key); + + return this._is(value) ? value : undefined; + } + + /** Every namespace whose published value passes the guard, in mirror order. */ + namespaces(): string[] { + return this._state.namespaces().filter(namespace => this.of(namespace) !== undefined); + } + + /** Notified with the namespace whenever any addon's value changes, locally or from the wire. */ + subscribe(listener: AnnouncementListener): Unsubscribe { + return this._state.subscribe((change) => { + if (change.key === this.key) { listener(change.ns); } + }); + } +} + +/** A non-null, non-array object: the envelope every announcement guard starts from. */ +export function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/packages/server-runtime/src/authorization.ts b/packages/server-runtime/src/authorization.ts new file mode 100644 index 0000000..f84246e --- /dev/null +++ b/packages/server-runtime/src/authorization.ts @@ -0,0 +1,84 @@ +/** + * Who may read and write something on behalf of a player. The one rule an rpc handler applies + * before it does anything, config's own methods included. + * + * Nothing here defends against a hostile *pack*, which runs arbitrary script and can write the + * underlying dynamic properties directly. What it enforces is that a **player** driving a UI or a + * command cannot reach what is none of their business. + * + * So authorization keys off an ACTOR — the player a request is made on behalf of — and an absent + * actor is allowed. An addon calling another addon's endpoint for its own reasons has no acting + * player, is a documented framework capability, and stays open. + */ +import { PlayerPermissionLevel, world } from '@minecraft/server'; +import type { Player } from '@minecraft/server'; +import { isUsable } from './handle'; + +/** What a request is reaching: the thing the rule is applied to. */ +export type AccessTarget + = | { world: true } + | { dimension: string } + | { entity: string } + | { block: string }; + +/** What a request does to its target. */ +export type Operation = 'read' | 'write'; + +/** + * Whether the player is a world operator. + * + * Deliberately reads `playerPermissionLevel`, which is **readonly** on `Player`, and not + * `commandPermissionLevel`, which is a mutable property any script in the world can rewrite — + * authorization must never rest on a value another addon can hand itself. + * + * `PlayerPermissionLevel.Custom` is not accepted: it is a separate bucket, not a tier above + * `Operator`, so treating it as "at least operator" would grant more than the name implies. + */ +export function isOperator(player: Player): boolean { + return isUsable(player) && player.playerPermissionLevel === PlayerPermissionLevel.Operator; +} + +/** + * Why a request made on behalf of `actorId` must be refused, or `undefined` when it is allowed. + * + * - No actor → allowed. An addon acting programmatically. See the file header. + * - Actor not in the world → refused. The actor cannot be verified, so it is not trusted. + * - Operator → allowed anywhere. + * - Anyone else → their own entity, to read and to write; and any world, dimension or block + * target to read. Those are world state — a shop's prices are not a secret from the player + * buying — but changing them is an operator's business. + */ +export function denyReason(target: AccessTarget, actorId: string | undefined, operation: Operation): string | undefined { + if (actorId === undefined) { return undefined; } + + const actor = world.getAllPlayers().find(candidate => candidate.id === actorId); + + if (!actor) { return `acting player '${actorId}' is not in the world`; } + + if (isOperator(actor)) { return undefined; } + + if (!('entity' in target)) { + return operation === 'read' ? undefined : `a ${describe(target)} may only be changed by an operator`; + } + + if (target.entity !== actorId) { return 'a non-operator may only reach their own document'; } + + return undefined; +} + +/** {@link denyReason}, thrown: what a handler calls first, so the caller's promise rejects with it. */ +export function authorize(target: AccessTarget, actorId: string | undefined, operation: Operation): void { + const reason = denyReason(target, actorId, operation); + + if (reason !== undefined) { + throw new Error(`refused: ${reason}`); + } +} + +function describe(target: AccessTarget): string { + if ('world' in target) { return 'world target'; } + + if ('dimension' in target) { return 'dimension target'; } + + return 'block target'; +} diff --git a/packages/server-runtime/src/config/__type-tests__/accessor-tree.ts b/packages/server-runtime/src/config/__type-tests__/accessor-tree.ts deleted file mode 100644 index b4a502f..0000000 --- a/packages/server-runtime/src/config/__type-tests__/accessor-tree.ts +++ /dev/null @@ -1,72 +0,0 @@ -/** - * Type-level tests for the config accessor tree. There is no runtime here — `tsc` failing IS - * the failure, and this package is checked with `noEmit`, so the file costs a compile and - * nothing else. - * - * It exists because group metadata (`$label` / `$description`) broke every one of these at once - * and nothing caught it. A named group holds a `string` beside its children, so it stops being - * a `Record` — and the inference helpers that tested for exactly that - * collapsed the group, and everything beneath it, to `never`. The accessor tree still COMPILED; - * it just typed `config.server.economy.balances.start.get()` as an error rather than a number. - * - * So: assert the shapes, and assert that `$label` is NOT among them. - */ -import type { Config } from '../../index'; - -/** - * Exported so it counts as used — it is referenced only through `typeof`, and a plain const in - * that position reads as dead code to the unused-vars rule. Exporting also leaves the fixture - * reusable if more type tests land beside this one. - */ -export const SCHEMA = { - server: { - economy: { - $label: 'Economy', - $description: 'Named group, two levels of children under it.', - balances: { - $label: 'Balances', - start: { type: 'number' as const, default: 1, min: 0, max: 9, label: 'Start' }, - }, - // Unnamed group beside a named one — both must behave identically. - currency: { - kind: { type: 'enum' as const, default: 'a' as const, options: ['a', 'b'] as const, label: 'Kind' }, - }, - }, - picks: { type: 'multiselect' as const, options: ['x', 'y'] as const, default: ['x'] as const, label: 'Picks' }, - tags: { type: 'list' as const, itemType: 'string' as const, default: [] as const, label: 'Tags' }, - }, -} as const; - -declare const config: Config; - -// ─── Leaves narrow to their real types, at any depth ────────────────────────── - -export const start: number = config.server.economy.balances.start.get(); -export const kind: 'a' | 'b' = config.server.economy.currency.kind.get(); - -/** Both array-valued types read back as arrays, not as the JSON they are stored as. */ -export const picks: string[] = config.server.picks.get(); -export const tags: string[] = config.server.tags.get(); - -// ─── A group yields its nested value shape, WITHOUT its own metadata ────────── - -const economy = config.server.economy.get(); - -export const nested: number = economy.balances.start; - -// @ts-expect-error -- $label describes the group; it is never part of the value object -export const leaked = economy.$label; - -// ─── Writes exclude metadata too ────────────────────────────────────────────── - -config.server.economy.patch({ balances: { start: 2 } }); - -// @ts-expect-error -- $label is not a setting, so there is nothing to patch -config.server.economy.patch({ $label: 'nope' }); - -// ─── Dot-paths skip metadata and reach the deep leaf ────────────────────────── - -config.server.subscribe('economy.balances.start', (next: number) => void next); - -// @ts-expect-error -- '$label' is not a dot-path -config.server.subscribe('economy.$label', (next: string) => void next); diff --git a/packages/server-runtime/src/config/authorization.ts b/packages/server-runtime/src/config/authorization.ts deleted file mode 100644 index 0b9a9bf..0000000 --- a/packages/server-runtime/src/config/authorization.ts +++ /dev/null @@ -1,66 +0,0 @@ -/** - * Who may read and write config on behalf of a player. - * - * ## What this does and does not defend against - * - * Every addon in a world runs arbitrary script and can write the underlying dynamic - * properties directly, so nothing here can stop a hostile *pack*. The boundary this enforces - * is the one that actually exists: a **player** driving the config UI or a config command - * must not be able to change settings they have no business changing. - * - * That is why authorization keys off an ACTOR — the player a request is made on behalf of — - * and why an absent actor is allowed through. `core.config.of(id).server.patch(...)` called - * by an addon for its own reasons has no acting player, is a documented framework capability, - * and stays open. - * - * @see denyReason for the rule itself. - */ -import { PlayerPermissionLevel, world } from '@minecraft/server'; -import type { Player } from '@minecraft/server'; - -/** The three config scopes, named as they appear on the wire. */ -export type ConfigScopeName = 'server' | 'dimension' | 'player'; - -/** - * Whether the player is a world operator. - * - * Deliberately reads `playerPermissionLevel`, which is **readonly** on `Player`, and not - * `commandPermissionLevel`, which is a mutable property any script in the world can rewrite — - * authorization must never rest on a value another addon can hand itself. - * - * `PlayerPermissionLevel.Custom` is not accepted: it is a separate bucket, not a tier above - * `Operator`, so treating it as "at least operator" would grant more than the name implies. - */ -export function isOperator(player: Player): boolean { - return player.playerPermissionLevel === PlayerPermissionLevel.Operator; -} - -/** - * Why a config request made on behalf of `actorId` must be refused, or `undefined` when it is - * allowed. Used for every write, and for player-scope reads (one player's settings are not - * another player's business). - * - * - No actor → allowed. An addon acting programmatically, not a player. See the file header. - * - Actor not in the world → refused. The actor cannot be verified, so it is not trusted. - * - Operator → allowed anywhere. - * - Anyone else → their own player scope only. - */ -export function denyReason( - scope: ConfigScopeName, - actorId: string | undefined, - targetId: string | undefined, -): string | undefined { - if (actorId === undefined) { return undefined; } - - const actor = world.getAllPlayers().find(candidate => candidate.id === actorId); - - if (!actor) { return `acting player '${actorId}' is not in the world`; } - - if (isOperator(actor)) { return undefined; } - - if (scope !== 'player') { return `${scope} config may only be changed by an operator`; } - - if (targetId !== actorId) { return 'a non-operator may only reach their own player config'; } - - return undefined; -} diff --git a/packages/server-runtime/src/config/broadcast.ts b/packages/server-runtime/src/config/broadcast.ts deleted file mode 100644 index 9ca01da..0000000 --- a/packages/server-runtime/src/config/broadcast.ts +++ /dev/null @@ -1,66 +0,0 @@ -/** - * Config discovery over the sync state layer. - * - * Only the schema is pushed — two small, static maps per addon: - * 'core-config/schema' → FlatSchema, scope-prefixed keys (`server.` / `dimension.` / `player.`) - * 'core-config/groups' → FlatGroups, the display strings of the groups those keys nest under - * - * Its presence is the "this addon has config" signal (drives `core.config.of()` / - * `subscribe()` and UI listings), and it lets a UI build forms without a round trip. - * Values are NOT broadcast — they are fetched on demand via the `core:config.get-*` / - * patch / set RPCs (see `rpc.ts`), which keeps steady-state traffic at zero and avoids - * serving stale values for addons that have gone offline. - */ -import { stateKey } from '@bedrock-core/sync'; -import type { State } from '@bedrock-core/sync'; -import type { FlatGroups, FlatSchema } from './schema'; - -export const CONFIG_SCHEMA_KEY = stateKey('core-config/schema'); - -/** - * Group display strings, on their own key rather than folded into the schema map. - * - * Additive on purpose: `core-config/schema` stays exactly the shape every existing consumer - * reads, and one that never learned about groups is unaffected. A UI that does read this and - * finds nothing — an addon on an older runtime, or one that names no group — falls back to the - * key-derived titles it always used. - */ -export const CONFIG_GROUPS_KEY = stateKey('core-config/groups'); - -/** Publish the schema with `server.`/`dimension.`/`player.` prefixes on every key. */ -export function broadcastSchema( - state: State, - addonId: string, - serverFlat: FlatSchema, - dimensionFlat: FlatSchema, - playerFlat: FlatSchema, -): void { - const scoped: FlatSchema = {}; - - for (const [k, v] of Object.entries(serverFlat)) { scoped[`server.${k}`] = v; } - - for (const [k, v] of Object.entries(dimensionFlat)) { scoped[`dimension.${k}`] = v; } - - for (const [k, v] of Object.entries(playerFlat)) { scoped[`player.${k}`] = v; } - - state.set(addonId, CONFIG_SCHEMA_KEY, scoped); -} - -/** Publish group display strings under the same `server.`/`dimension.`/`player.` prefixes. */ -export function broadcastGroups( - state: State, - addonId: string, - serverGroups: FlatGroups, - dimensionGroups: FlatGroups, - playerGroups: FlatGroups, -): void { - const scoped: FlatGroups = {}; - - for (const [k, v] of Object.entries(serverGroups)) { scoped[`server.${k}`] = v; } - - for (const [k, v] of Object.entries(dimensionGroups)) { scoped[`dimension.${k}`] = v; } - - for (const [k, v] of Object.entries(playerGroups)) { scoped[`player.${k}`] = v; } - - state.set(addonId, CONFIG_GROUPS_KEY, scoped); -} diff --git a/packages/server-runtime/src/config/config-registry.ts b/packages/server-runtime/src/config/config-registry.ts deleted file mode 100644 index 0762858..0000000 --- a/packages/server-runtime/src/config/config-registry.ts +++ /dev/null @@ -1,532 +0,0 @@ -/** - * ConfigRegistry — the config subsystem of the bedrock-core Runtime. - * - * Accessible as `core.config` after `core.register()`. - * - * Discovery is push, values are pull: each addon publishes only its (small, static) - * schema to replicated state; values live with the owning addon and are fetched on - * demand via RPC. Write semantics (all scopes, local and remote): `patch` deep-merges - * the provided keys; `set` replaces the whole scope — it requires the full object, and - * any schema key missing from the payload reverts to its schema default (its persisted - * override is deleted). - * - * Addon defining config (usually via the `config` field of `core.register()`, which - * delegates here and returns the same typed accessors): - * ```ts - * const config = core.register({ - * // ...identity fields... - * config: { - * server: { pricing: { taxRate: { type: 'number', default: 0.05, min: 0, max: 1, label: 'Tax Rate' } } }, - * dimension: { miningBonus: { type: 'number', default: 1.0, min: 0, max: 5, label: 'Mining Bonus' } }, - * player: { allowGifts: { type: 'boolean', default: true, label: 'Allow Gifts' } }, - * }, - * }); - * - * // Every scope is a dotted accessor tree mirroring the schema — every node, group or leaf, - * // carries get / set / subscribe (groups also patch), in the style of - * // world.afterEvents.playerSpawn.subscribe(...). Entity scopes pick the entity with for(). - * config.server.pricing.taxRate.get() // number — local, sync - * config.server.pricing.taxRate.set(0.1) - * config.server.pricing.taxRate.subscribe((next, prev) => { ... }) - * config.server.pricing.subscribe(pricing => { ... }) - * config.player.for(player).allowGifts.get() - * - * config.server.get() // { pricing: { taxRate: number } } — whole scope - * config.server.patch({ pricing: { taxRate: 0.1 } }) - * config.dimension.patch(dim, { miningBonus: 2.0 }) - * config.server.subscribe('pricing.taxRate', (next, prev) => { ... }) // runtime-computed path - * config.server.subscribe(full => console.warn(full.pricing.taxRate)) - * ``` - * - * Cross-addon access (reads and writes go over RPC): - * ```ts - * const shopCfg = core.config.of('vendor_shop'); - * await shopCfg?.server.get() // structured, typed - * await shopCfg?.server.patch({ pricing: { taxRate: 0.1 } }) - * ``` - */ -import { system, world } from '@minecraft/server'; -import type { Dimension, Player } from '@minecraft/server'; -import type { SyncNode, Unsubscribe } from '@bedrock-core/sync'; -import { - type ConfigDefinition, - type ConfigValue, - type FlatSchema, - type SchemaNode, - type SchemaToValue, - type DeepPartial, - type ServerScopeSchema, - type DimensionScopeSchema, - type PlayerScopeSchema, - type FlatGroups, - flattenGroups, - flattenSchema, - validateConfigSchema, -} from './schema'; -import { - loadServerValues, - loadDimensionValues, - loadPlayerValues, - saveServerValue, - saveDimensionValue, - savePlayerValue, - loadedDimensionIds, -} from './persistence'; -import { broadcastGroups, broadcastSchema, CONFIG_GROUPS_KEY, CONFIG_SCHEMA_KEY } from './broadcast'; -import { ServerConfigScope, EntityConfigScope, type ServerConfigTree } from './scopes'; -import { buildNestedObject, flattenObject } from './scopes/utils'; -import { registerConfigRpc } from './rpc'; -import { denyReason, type ConfigScopeName } from './authorization'; - -type Flat = Record; - -/** True when an RPC response is a flat dot-path → primitive config map. */ -function isFlatValues(value: unknown): value is Flat { - if (typeof value !== 'object' || value === null || Array.isArray(value)) { return false; } - - for (const entry of Object.values(value)) { - if (typeof entry !== 'boolean' && typeof entry !== 'number' && typeof entry !== 'string') { return false; } - } - - return true; -} - -// ─── Return types ────────────────────────────────────────────────────────────── - -type SafeServer - = NonNullable extends ServerScopeSchema ? NonNullable : Record; -type SafeDimension - = NonNullable extends DimensionScopeSchema ? NonNullable : Record; -type SafePlayer - = NonNullable extends PlayerScopeSchema ? NonNullable : Record; - -/** - * This addon's own scopes, as returned by `register({ config })` / `define()`. - * - * `server` is the scope *and* its accessor tree — `config.server.get()` alongside - * `config.server.pricing.taxRate.get()`. The entity scopes select an entity first - * (`config.player.for(player).allowGifts.get()`), which yields the identical tree shape. - */ -export interface Config { - server: ServerConfigTree>; - dimension: EntityConfigScope, Dimension>; - player: EntityConfigScope, Player>; -} - -// ─── Remote config accessor (untyped) ───────────────────────────────────────── - -/** - * Untyped view of another addon's config. The schema is read synchronously from the - * state mirror; values are fetched (and written) via RPC — `patch` merges, `set` - * replaces (missing keys revert to schema defaults). Writes resolve with the updated - * effective flat values. - * - * An accessor obtained with an `actorId` acts **on behalf of that player**, and the owning - * addon authorizes every request against them (see `authorization.ts`). Without one the - * accessor acts as the addon itself, which is unrestricted — see that file for why. - * - * The actor rides along in the request payload, which is why server-scope writes wrap their - * flat map in `values` instead of being the params (see `rpc.ts`). - */ -export class RemoteConfigAccessor { - private readonly _node: SyncNode; - private readonly _addonId: string; - private readonly _actorId: string | undefined; - - readonly server = { - get: async (): Promise => this.nested(await this._node.rpc.request(this._addonId, 'core:config.get-server', {})), - patch: async (value: Record): Promise => this._node.rpc.request(this._addonId, 'core:config.patch', { values: Object.fromEntries(flattenObject(value)), actorId: this._actorId }), - set: async (value: Record): Promise => this._node.rpc.request(this._addonId, 'core:config.set', { values: Object.fromEntries(flattenObject(value)), actorId: this._actorId }), - }; - - readonly dimension = { - get: async (dimId: string): Promise => this.nested(await this._node.rpc.request(this._addonId, 'core:config.get-dim', { dimId })), - patch: async (dimId: string, value: Record): Promise => this._node.rpc.request(this._addonId, 'core:config.patch-dim', { dimId, values: Object.fromEntries(flattenObject(value)), actorId: this._actorId }), - set: async (dimId: string, value: Record): Promise => this._node.rpc.request(this._addonId, 'core:config.set-dim', { dimId, values: Object.fromEntries(flattenObject(value)), actorId: this._actorId }), - }; - - readonly player = { - get: async (playerId: string): Promise => this.nested(await this._node.rpc.request(this._addonId, 'core:config.get-player', { playerId, actorId: this._actorId })), - patch: async (playerId: string, value: Record): Promise => this._node.rpc.request(this._addonId, 'core:config.patch-player', { playerId, values: Object.fromEntries(flattenObject(value)), actorId: this._actorId }), - set: async (playerId: string, value: Record): Promise => this._node.rpc.request(this._addonId, 'core:config.set-player', { playerId, values: Object.fromEntries(flattenObject(value)), actorId: this._actorId }), - }; - - constructor(node: SyncNode, addonId: string, actorId?: string) { - this._node = node; - this._addonId = addonId; - this._actorId = actorId; - } - - /** Schema with `server.`/`dimension.`/`player.` prefixes on every key. Used by UI to determine scope. */ - get scopedSchema(): FlatSchema { - return this._node.state.get(this._addonId, CONFIG_SCHEMA_KEY) ?? {}; - } - - /** - * Group display strings with the same scope prefixes {@link scopedSchema} carries. - * - * Empty for an addon that names no group, and for one running a runtime that predates the - * key — both mean the same thing to a reader: fall back to the key-derived title. - */ - get scopedGroups(): FlatGroups { - return this._node.state.get(this._addonId, CONFIG_GROUPS_KEY) ?? {}; - } - - /** Unprefixed flat schema, derived from {@link scopedSchema} by stripping the scope segment. */ - get schema(): FlatSchema { - const flat: FlatSchema = {}; - - for (const [key, entry] of Object.entries(this.scopedSchema)) { - const dot = key.indexOf('.'); - - flat[dot === -1 ? key : key.slice(dot + 1)] = entry; - } - - return flat; - } - - /** Shape a get-response into the nested value object, or `undefined` on a malformed payload. */ - private nested(response: unknown): Record | undefined { - return isFlatValues(response) ? buildNestedObject(response, this.schema) : undefined; - } -} - -// ─── Typed remote config ─────────────────────────────────────────────────────── - -/** - * Typed view of another addon's config. Obtain via `core.config.of()` or - * `core.config.subscribe()`. Reads and writes go over RPC; `patch` merges a - * partial, `set` replaces the whole scope (full object required). `get` resolves - * `undefined` on a malformed response. - */ -export type TypedRemoteConfig = { - server: { - get(): Promise> | undefined>; - patch(partial: DeepPartial>>): Promise; - set(value: SchemaToValue>): Promise; - }; - dimension: { - get(dimId: string): Promise> | undefined>; - patch(dimId: string, partial: DeepPartial>>): Promise; - set(dimId: string, value: SchemaToValue>): Promise; - }; - player: { - get(playerId: string): Promise> | undefined>; - patch(playerId: string, partial: DeepPartial>>): Promise; - set(playerId: string, value: SchemaToValue>): Promise; - }; - schema: FlatSchema; -}; - -// ─── ConfigRegistry ──────────────────────────────────────────────────────────── - -/** - * This addon's own scopes, narrowed to what a generic consumer can use without knowing the - * schema's type. `core.config.local` hands these out so tooling built on top of the runtime — - * config commands, debug screens — can enumerate and edit local config from a plain `Runtime`, - * which the strongly-typed value `register()` returns is not reachable from. - * - * Writes go through the same `patch` the typed accessors use, so persistence, change events - * and revert-to-default all behave identically. - */ -export interface LocalConfigScopes { - server: { readonly schema: FlatSchema; get(): unknown; patch(partial: Record): void }; - dimension: { readonly schema: FlatSchema; get(entity: Dimension): unknown; patch(entity: Dimension, partial: Record): void }; - player: { readonly schema: FlatSchema; get(entity: Player): unknown; patch(entity: Player, partial: Record): void }; -} - -/** Options for {@link ConfigRegistry.of}. */ -export interface ConfigAccessOptions { - /** - * The player this access is made on behalf of. Present for anything a player drives (a UI - * screen, a config command); absent for an addon acting on its own behalf. - */ - actorId?: string; -} - -export class ConfigRegistry { - private readonly _node: SyncNode; - private readonly _addonId: string; - private _defined = false; - private _local: LocalConfigScopes | undefined; - private readonly _addonConfigListeners = new Map void>>(); - private readonly _disposers: Unsubscribe[] = []; - private readonly _onlinePlayers = new Map(); - - constructor(node: SyncNode, addonId: string) { - this._node = node; - this._addonId = addonId; - } - - start(): void { - this._disposers.push( - this._node.state.subscribe((change) => { - if (change.ns !== this._addonId && change.key === CONFIG_SCHEMA_KEY && !change.deleted) { - const listeners = this._addonConfigListeners.get(change.ns); - - if (listeners?.size) { - const accessor = new RemoteConfigAccessor(this._node, change.ns); - - for (const l of listeners) { l(accessor); } - } - } - }), - ); - } - - stop(): void { - for (const d of this._disposers.splice(0)) { d(); } - } - - /** - * Define this addon's config. Call once — usually implicitly, via the `config` field of - * `core.register()`; call directly only to define late. Returns typed scope accessors - * (`config.server`, `config.dimension`, `config.player`). - */ - define(input: I): Config { - if (this._defined) { throw new Error('core.config.define() called more than once'); } - - const serverTree = (input.server ?? {}) as Record; - const dimensionTree = (input.dimension ?? {}) as Record; - const playerTree = (input.player ?? {}) as Record; - - // Before anything is built, and before the registry marks itself defined: a key that - // collides with an accessor verb has no sane runtime recovery, so the declaration is - // rejected outright. - validateConfigSchema('server', serverTree); - validateConfigSchema('dimension', dimensionTree); - validateConfigSchema('player', playerTree); - - this._defined = true; - - const serverFlat = flattenSchema(serverTree); - const dimensionFlat = flattenSchema(dimensionTree); - const playerFlat = flattenSchema(playerTree); - - const serverGroups = flattenGroups(serverTree); - const dimensionGroups = flattenGroups(dimensionTree); - const playerGroups = flattenGroups(playerTree); - - const serverValues = new Map( - Object.entries(serverFlat).map(([k, e]) => [k, e.default]), - ); - const dimensionValues = new Map>(); - const playerValues = new Map>(); - - // ─── Scope accessors ──────────────────────────────────────────────────────── - // Each scope hands applied batches back here for persistence; an `undefined` - // value in a batch deletes the persisted override (set-revert). No broadcast — - // consumers fetch values via the RPC handlers below. - - // The constructor assigns one accessor node per top-level schema key onto the instance; - // TS cannot see properties produced by a schema walk, so this widening is inherent. - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - const serverScope = new ServerConfigScope>( - serverTree, - serverFlat, - serverValues, - (changes) => { - for (const [key, value] of changes) { saveServerValue(this._addonId, key, value); } - }, - ) as ServerConfigTree>; - - const dimensionScope = new EntityConfigScope, Dimension>( - dimensionTree, - dimensionFlat, - dimensionValues, - (dimId, changes) => { - for (const [key, value] of changes) { saveDimensionValue(this._addonId, dimId, key, value); } - }, - ); - - const playerScope = new EntityConfigScope, Player>( - playerTree, - playerFlat, - playerValues, - (playerId, changes) => { - const player = this._onlinePlayers.get(playerId); - - if (!player) { - console.warn(`[bedrock-core] '${this._addonId}' config: player '${playerId}' is offline; values not persisted`); - - return; - } - - for (const [key, value] of changes) { savePlayerValue(player, this._addonId, key, value); } - }, - ); - - // ─── RPC handlers ─────────────────────────────────────────────────────────── - // Every handler responds with the scope's updated effective values, so remote - // callers get read-after-write in one round trip. - - const requireOnline = (playerId: string, method: string): boolean => { - if (this._onlinePlayers.has(playerId)) { return true; } - - console.warn(`[bedrock-core] '${this._addonId}' config: ${method} for offline player '${playerId}' ignored`); - - return false; - }; - - // Throwing rejects the RPC, so the caller learns why instead of watching a write silently - // do nothing. A request with no actor is an addon acting for itself and passes untouched. - const requireAllowed = (scope: ConfigScopeName, actorId: string | undefined, targetId?: string): void => { - const reason = denyReason(scope, actorId, targetId); - - if (reason !== undefined) { - throw new Error(`'${this._addonId}' config: ${scope} request refused - ${reason}`); - } - }; - - registerConfigRpc(this._node.rpc, { - onGetServer: () => serverScope.getFlat(), - onPatchServer: (flat, actorId) => { - requireAllowed('server', actorId); - serverScope.applyRemotePatch(flat); - - return serverScope.getFlat(); - }, - onSetServer: (flat, actorId) => { - requireAllowed('server', actorId); - serverScope.applyRemoteSet(flat); - - return serverScope.getFlat(); - }, - onGetDimension: dimId => dimensionScope.getFlat(dimId), - onPatchDimension: (dimId, flat, actorId) => { - requireAllowed('dimension', actorId); - dimensionScope.applyRemotePatch(dimId, flat); - - return dimensionScope.getFlat(dimId); - }, - onSetDimension: (dimId, flat, actorId) => { - requireAllowed('dimension', actorId); - dimensionScope.applyRemoteSet(dimId, flat); - - return dimensionScope.getFlat(dimId); - }, - // Reads are unrestricted for server and dimension — those are world settings, not - // secrets. One player's settings are another matter, so this scope checks reads too. - onGetPlayer: (playerId, actorId) => { - requireAllowed('player', actorId, playerId); - - return playerScope.getFlat(playerId); - }, - onPatchPlayer: (playerId, flat, actorId) => { - requireAllowed('player', actorId, playerId); - - if (requireOnline(playerId, 'patch')) { playerScope.applyRemotePatch(playerId, flat); } - - return playerScope.getFlat(playerId); - }, - onSetPlayer: (playerId, flat, actorId) => { - requireAllowed('player', actorId, playerId); - - if (requireOnline(playerId, 'set')) { playerScope.applyRemoteSet(playerId, flat); } - - return playerScope.getFlat(playerId); - }, - }); - - // ─── Deferred DP loading ──────────────────────────────────────────────────── - // Dynamic properties are readable from tick 1 onward. Loading emits change events - // for keys whose persisted value differs from the schema default, so subscribers - // attached right after define() still learn the real values. - - system.run(() => { - serverScope.loadInitial(loadServerValues(this._addonId, serverFlat)); - - if (Object.keys(dimensionFlat).length > 0) { - for (const dimId of loadedDimensionIds(this._addonId, dimensionFlat)) { - const loaded = loadDimensionValues(this._addonId, dimId, dimensionFlat); - - if (loaded.size > 0) { dimensionScope.loadInitial(dimId, loaded); } - } - } - - // Seed players that are already connected — after a script reload (e.g. /reload) - // no playerSpawn fires for them, so relying on the event alone would leave - // player-scope config dead until they rejoin. - for (const player of world.getAllPlayers()) { - this._onlinePlayers.set(player.id, player); - - if (Object.keys(playerFlat).length > 0) { - playerScope.init(player.id, loadPlayerValues(player, this._addonId, playerFlat)); - } - } - - broadcastSchema(this._node.state, this._addonId, serverFlat, dimensionFlat, playerFlat); - broadcastGroups(this._node.state, this._addonId, serverGroups, dimensionGroups, playerGroups); - }); - - // ─── Player lifecycle ──────────────────────────────────────────────────────── - - const onSpawn = world.afterEvents.playerSpawn.subscribe(({ player, initialSpawn }) => { - if (!initialSpawn) { return; } - - this._onlinePlayers.set(player.id, player); - - if (Object.keys(playerFlat).length === 0) { return; } - - playerScope.init(player.id, loadPlayerValues(player, this._addonId, playerFlat)); - }); - - this._disposers.push(() => { world.afterEvents.playerSpawn.unsubscribe(onSpawn); }); - - const onLeave = world.afterEvents.playerLeave.subscribe(({ playerId }) => { - this._onlinePlayers.delete(playerId); - - if (Object.keys(playerFlat).length === 0) { return; } - - playerScope.clear(playerId); - }); - - this._disposers.push(() => { world.afterEvents.playerLeave.unsubscribe(onLeave); }); - - this._local = { server: serverScope, dimension: dimensionScope, player: playerScope }; - - return { server: serverScope, dimension: dimensionScope, player: playerScope }; - } - - /** - * This addon's own config scopes, or `undefined` before `define()` has run. Available - * synchronously — unlike {@link of}, which needs the schema to have reached replicated state - * one tick later — so startup-time consumers such as command registration can read it. - */ - get local(): LocalConfigScopes | undefined { - return this._local; - } - - /** - * View another addon's config. Pass `{ actorId }` when the reads and writes are being made - * on behalf of a player — a UI screen, a command — so the owning addon authorizes them - * against that player. Omit it for programmatic access, which is unrestricted. - */ - of(addonId: string, options?: ConfigAccessOptions): RemoteConfigAccessor | undefined; - of(addonId: string, options?: ConfigAccessOptions): TypedRemoteConfig | undefined; - of(addonId: string, options?: ConfigAccessOptions): unknown { - if (this._node.state.get(addonId, CONFIG_SCHEMA_KEY) === undefined) { return undefined; } - - return new RemoteConfigAccessor(this._node, addonId, options?.actorId); - } - - subscribe(addonId: string, listener: (cfg: RemoteConfigAccessor) => void): Unsubscribe; - subscribe(addonId: string, listener: (cfg: TypedRemoteConfig) => void): Unsubscribe; - subscribe(addonId: string, listener: unknown): Unsubscribe { - // TypedRemoteConfig is a compile-time view over RemoteConfigAccessor (the same - // runtime object); the accessor's private fields keep TS from relating the two types. - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - const cb = listener as (cfg: RemoteConfigAccessor) => void; - let set = this._addonConfigListeners.get(addonId); - - if (!set) { set = new Set(); this._addonConfigListeners.set(addonId, set); } - - set.add(cb); - - if (this._node.state.get(addonId, CONFIG_SCHEMA_KEY) !== undefined) { - cb(new RemoteConfigAccessor(this._node, addonId)); - } - - return () => { this._addonConfigListeners.get(addonId)?.delete(cb); }; - } -} diff --git a/packages/server-runtime/src/config/persistence.ts b/packages/server-runtime/src/config/persistence.ts deleted file mode 100644 index e6c84bc..0000000 --- a/packages/server-runtime/src/config/persistence.ts +++ /dev/null @@ -1,152 +0,0 @@ -/** - * Dynamic property persistence for config values. - * - * Key scheme: - * server scope: world.getDynamicProperty('core-cfg:s::') - * dimension scope: world.getDynamicProperty('core-cfg:d:::') - * player scope: player.getDynamicProperty('core-cfg:p::') - * - * All config values are stored as primitives (boolean | number | string). - * All functions that touch DPs must only be called from tick 1 onward. - */ -import { world } from '@minecraft/server'; -import type { Player } from '@minecraft/server'; -import type { ConfigValue, FlatSchema, SerializedEntry } from './schema'; - -// ─── Key builders ────────────────────────────────────────────────────────────── - -export function serverDpKey(addonId: string, key: string): string { - return `core-cfg:s:${addonId}:${key}`; -} - -export function dimensionDpKey(addonId: string, dimId: string, key: string): string { - return `core-cfg:d:${addonId}:${dimId}:${key}`; -} - -export function playerDpKey(addonId: string, key: string): string { - return `core-cfg:p:${addonId}:${key}`; -} - -// ─── Load helpers ────────────────────────────────────────────────────────────── - -export function loadServerValues(addonId: string, schema: FlatSchema): Map { - const values = new Map(); - - for (const [key, entry] of Object.entries(schema)) { - const raw = toPrimitive(world.getDynamicProperty(serverDpKey(addonId, key))); - - values.set(key, raw !== undefined ? coerce(raw, entry) : entry.default); - } - - return values; -} - -/** Returns only keys that have a stored DP value. Fallback is handled by the scope. */ -export function loadDimensionValues( - addonId: string, - dimId: string, - schema: FlatSchema, -): Map { - const values = new Map(); - - for (const [key, entry] of Object.entries(schema)) { - const raw = toPrimitive(world.getDynamicProperty(dimensionDpKey(addonId, dimId, key))); - - if (raw !== undefined) { values.set(key, coerce(raw, entry)); } - } - - return values; -} - -/** Returns only keys that have a stored DP value on the player entity. */ -export function loadPlayerValues( - player: Player, - addonId: string, - schema: FlatSchema, -): Map { - const values = new Map(); - - for (const [key, entry] of Object.entries(schema)) { - const raw = toPrimitive(player.getDynamicProperty(playerDpKey(addonId, key))); - - if (raw !== undefined) { values.set(key, coerce(raw, entry)); } - } - - return values; -} - -// ─── Save helpers ────────────────────────────────────────────────────────────── -// `value: undefined` deletes the stored override (used by `set`, which reverts -// keys missing from its input to their schema defaults). - -export function saveServerValue(addonId: string, key: string, value: ConfigValue | undefined): void { - world.setDynamicProperty(serverDpKey(addonId, key), value); -} - -export function saveDimensionValue( - addonId: string, - dimId: string, - key: string, - value: ConfigValue | undefined, -): void { - world.setDynamicProperty(dimensionDpKey(addonId, dimId, key), value); -} - -export function savePlayerValue( - player: Player, - addonId: string, - key: string, - value: ConfigValue | undefined, -): void { - player.setDynamicProperty(playerDpKey(addonId, key), value); -} - -/** - * Scans world dynamic property IDs to discover all dimension IDs that have persisted - * config for this addon. Handles custom dimensions (namespaced like `mypack:dim`) by - * matching known schema keys as suffixes rather than splitting on `:`. - */ -export function loadedDimensionIds(addonId: string, schema: FlatSchema): string[] { - const prefix = `core-cfg:d:${addonId}:`; - const schemaKeys = Object.keys(schema); - const dimIds = new Set(); - - for (const dpKey of world.getDynamicPropertyIds()) { - if (!dpKey.startsWith(prefix)) { continue; } - - const rest = dpKey.slice(prefix.length); - - for (const schemaKey of schemaKeys) { - const suffix = `:${schemaKey}`; - - if (rest.endsWith(suffix)) { - dimIds.add(rest.slice(0, rest.length - suffix.length)); - break; - } - } - } - - return [...dimIds]; -} - -// ─── Internal ───────────────────────────────────────────────────────────────── - -/** Drop Vector3 values — config only stores primitives or serialized JSON strings. */ -function toPrimitive( - raw: string | number | boolean | { x: number } | undefined, -): string | number | boolean | undefined { - if (raw === undefined || typeof raw === 'object') { return undefined; } - - return raw; -} - -function coerce( - raw: string | number | boolean, - entry: SerializedEntry, -): ConfigValue { - if (entry.type === 'boolean') { return typeof raw === 'boolean' ? raw : raw === 'true'; } - - if (entry.type === 'number') { return typeof raw === 'number' ? raw : Number(raw); } - - return typeof raw === 'string' ? raw : String(raw); -} diff --git a/packages/server-runtime/src/config/rpc.ts b/packages/server-runtime/src/config/rpc.ts deleted file mode 100644 index a591f34..0000000 --- a/packages/server-runtime/src/config/rpc.ts +++ /dev/null @@ -1,74 +0,0 @@ -/** - * RPC handlers auto-registered by ConfigRegistry.define(). - * - * Values are not broadcast — consumers fetch them on demand with the `get-*` methods. - * `patch` merges the provided keys; `set` replaces the whole scope — every schema key - * missing from the payload reverts to its schema default (persisted override deleted). - * Every write resolves with the updated effective flat map, so callers get - * read-after-write in one round trip. - * - * Every request may carry an `actorId`: the player it is made on behalf of, which the handlers - * check via `denyReason`. Absent means an addon acting for itself, which is unrestricted — see - * `authorization.ts`. Server-scope writes wrap their payload in `values` for exactly this - * reason: with the flat map as the params, an `actorId` field would be indistinguishable from - * a schema key of that name. - * - * Methods (all return the scope's effective flat values): - * core:config.get-server {} → read server values - * core:config.patch { values, actorId? } → merge server values - * core:config.set { values, actorId? } → replace server values - * core:config.get-dim { dimId } → read per-dimension values - * core:config.patch-dim { dimId, values, actorId? } → merge per-dimension values - * core:config.set-dim { dimId, values, actorId? } → replace per-dimension values - * core:config.get-player { playerId, actorId? } → read player values - * core:config.patch-player { playerId, values, actorId? } → merge player values - * core:config.set-player { playerId, values, actorId? } → replace player values - */ -import type { Rpc, RPCHandlerMap } from '@bedrock-core/sync'; -import type { ConfigValue } from './schema'; - -type Flat = Record; - -interface ConfigRpcInterface { - 'core:config.get-server': (params: Record) => Flat; - 'core:config.patch': (params: { values: Flat; actorId?: string }) => Flat; - 'core:config.set': (params: { values: Flat; actorId?: string }) => Flat; - 'core:config.get-dim': (params: { dimId: string }) => Flat; - 'core:config.patch-dim': (params: { dimId: string; values: Flat; actorId?: string }) => Flat; - 'core:config.set-dim': (params: { dimId: string; values: Flat; actorId?: string }) => Flat; - 'core:config.get-player': (params: { playerId: string; actorId?: string }) => Flat; - 'core:config.patch-player': (params: { playerId: string; values: Flat; actorId?: string }) => Flat; - 'core:config.set-player': (params: { playerId: string; values: Flat; actorId?: string }) => Flat; -} - -/** - * `actorId` is the player a request is made on behalf of, or `undefined` for a programmatic - * addon-to-addon call. Handlers are expected to refuse when `denyReason` says so. - */ -export interface ConfigRpcHandlers { - onGetServer(): Flat; - onPatchServer(flat: Flat, actorId?: string): Flat; - onSetServer(flat: Flat, actorId?: string): Flat; - onGetDimension(dimId: string): Flat; - onPatchDimension(dimId: string, flat: Flat, actorId?: string): Flat; - onSetDimension(dimId: string, flat: Flat, actorId?: string): Flat; - onGetPlayer(playerId: string, actorId?: string): Flat; - onPatchPlayer(playerId: string, flat: Flat, actorId?: string): Flat; - onSetPlayer(playerId: string, flat: Flat, actorId?: string): Flat; -} - -export function registerConfigRpc(rpc: Rpc, handlers: ConfigRpcHandlers): void { - const map: RPCHandlerMap = { - 'core:config.get-server': () => handlers.onGetServer(), - 'core:config.patch': ({ values, actorId }) => handlers.onPatchServer(values, actorId), - 'core:config.set': ({ values, actorId }) => handlers.onSetServer(values, actorId), - 'core:config.get-dim': ({ dimId }) => handlers.onGetDimension(dimId), - 'core:config.patch-dim': ({ dimId, values, actorId }) => handlers.onPatchDimension(dimId, values, actorId), - 'core:config.set-dim': ({ dimId, values, actorId }) => handlers.onSetDimension(dimId, values, actorId), - 'core:config.get-player': ({ playerId, actorId }) => handlers.onGetPlayer(playerId, actorId), - 'core:config.patch-player': ({ playerId, values, actorId }) => handlers.onPatchPlayer(playerId, values, actorId), - 'core:config.set-player': ({ playerId, values, actorId }) => handlers.onSetPlayer(playerId, values, actorId), - }; - - rpc.serve(map); -} diff --git a/packages/server-runtime/src/config/schema.ts b/packages/server-runtime/src/config/schema.ts deleted file mode 100644 index ebd55e7..0000000 --- a/packages/server-runtime/src/config/schema.ts +++ /dev/null @@ -1,402 +0,0 @@ -/** - * Config schema types and compile-time inference helpers. - * - * `type` is a reserved key — do not use it as a group name. So are the accessor tree's own - * verbs, at any depth — see {@link RESERVED_KEYS} and {@link validateConfigSchema}. - */ - -// ─── Value type ──────────────────────────────────────────────────────────────── - -export type ConfigValue = boolean | number | string; - -// ─── Entry definitions ───────────────────────────────────────────────────────── - -export type BooleanEntry = { - type: 'boolean'; - default: boolean; - label: string; - description?: string; -}; - -export type NumberEntry = { - type: 'number'; - default: number; - min: number; - max: number; - step?: number; - label: string; - description?: string; -}; - -export type StringEntry = { - type: 'string'; - default: string; - maxLength?: number; - label: string; - description?: string; -}; - -export type EnumEntry = { - type: 'enum'; - default: O[number]; - options: O; - label: string; - description?: string; -}; - -export type ListEntry = { - type: 'list'; - itemType: 'string' | 'enum'; - options?: readonly string[]; - maxItems?: number; - default: readonly string[]; - label: string; - description?: string; -}; - -/** - * Any number of a fixed option set — a checkbox group. - * - * Distinct from {@link ListEntry}, which is an open-ended collection an addon can only cap: - * a multiselect's whole option set is known at declaration, so every choice fits on screen and - * the modal can draw it as one checkbox per option. A list cannot, which is why it has no native - * control at all and gets a screen of its own instead. - */ -export type MultiselectEntry = { - type: 'multiselect'; - options: readonly string[]; - default: readonly string[]; - label: string; - description?: string; -}; - -export type ConfigEntry = BooleanEntry | NumberEntry | StringEntry | EnumEntry | ListEntry | MultiselectEntry; - -// ─── Schema node types ───────────────────────────────────────────────────────── - -export type SchemaNode = ConfigEntry | SchemaGroup; - -/** - * A group's own display strings, declared alongside its children. - * - * The `$` sigil is what keeps them out of the child namespace: a group is otherwise an open - * record of `SchemaNode`, so any bare name we reserved (`label`, `meta`) would be a name an - * addon could plausibly want for a setting. `$` cannot start a schema key — `validateConfigSchema` - * rejects it — so the two spaces never meet. - */ -export type GroupMeta = { - $label?: string; - $description?: string; -}; - -export type SchemaGroup = GroupMeta & { [key: string]: SchemaNode | string | undefined }; - -export type ServerScopeSchema = { [key: string]: SchemaNode }; -export type DimensionScopeSchema = { [key: string]: SchemaNode }; -export type PlayerScopeSchema = { [key: string]: SchemaNode }; - -export interface ConfigDefinition { - server?: ServerScopeSchema; - dimension?: DimensionScopeSchema; - player?: PlayerScopeSchema; -} - -// ─── Structured value inference ──────────────────────────────────────────────── - -/** - * The child keys of a schema group — everything except the group's own `$label`/`$description`. - * - * Every inference helper below walks this rather than `keyof S`, so a group that names itself - * does not grow a phantom `$label: string` in its value object or a `$label` dot-path. - * - * Those helpers also test a group with `Record` rather than - * `Record`: a named group holds `$label: string` beside its children, which - * is not a `SchemaNode`, and the stricter test collapsed the whole group — and everything under - * it — to `never`. The leaf arms are tested first, so "object" is as precise as it needs to be. - */ -export type ChildKeys = Exclude; - -/** Convert a schema tree into its runtime value shape (nested object). */ -export type SchemaToValue = { - [K in ChildKeys]: S[K] extends { type: 'boolean' } ? boolean - : S[K] extends { type: 'number' } ? number - : S[K] extends { type: 'string' } ? string - : S[K] extends { type: 'enum'; options: readonly (infer O)[] } ? O - : S[K] extends { type: 'list' | 'multiselect' } ? string[] - : S[K] extends Record ? SchemaToValue - : never -}; - -/** All valid subscribe paths in S — includes both leaf keys and group keys. */ -export type DotPath = ChildKeys | { - [K in ChildKeys]: S[K] extends { type: string } ? never - : S[K] extends Record ? `${K}.${DotPath}` - : never -}[ChildKeys]; - -/** Value type at dot-path P within schema S. Works for both leaves and groups. */ -export type PathValue - = P extends keyof S & string - ? S[P] extends { type: 'boolean' } ? boolean - : S[P] extends { type: 'number' } ? number - : S[P] extends { type: 'string' } ? string - : S[P] extends { type: 'enum'; options: readonly (infer O)[] } ? O - : S[P] extends { type: 'list' | 'multiselect' } ? string[] - : S[P] extends Record ? SchemaToValue - : never - : P extends `${infer Head}.${infer Tail}` - ? Head extends keyof S & string - ? PathValue - : never - : never; - -/** Recursively-partial version of a schema value type — used for patch inputs. */ -export type DeepPartial = { - [K in keyof T]?: T[K] extends Record ? DeepPartial : T[K] -}; - -// ─── Internal flat-key inference (used by DP key generation) ────────────────── - -export type FlatKeys = { - [K in ChildKeys]: T[K] extends { type: 'boolean' | 'number' | 'string' | 'enum' | 'list' | 'multiselect' } - ? (P extends '' ? K : `${P}.${K}`) - : T[K] extends object - ? FlatKeys - : never -}[ChildKeys]; - -export type FlatValue - = K extends keyof T & string - ? T[K] extends { type: 'boolean' } ? boolean - : T[K] extends { type: 'number' } ? number - : T[K] extends { type: 'string' } ? string - : T[K] extends { type: 'enum'; options: readonly (infer O)[] } ? O - : never - : K extends `${infer Head}.${infer Tail}` - ? Head extends keyof T & string ? FlatValue : never - : never; - -// ─── Serialized form (broadcast) ────────────────────────────────────────────── - -export type SerializedEntry - = | { type: 'boolean'; default: boolean; label: string; description?: string } - | { type: 'number'; default: number; min: number; max: number; step?: number; label: string; description?: string } - | { type: 'string'; default: string; maxLength?: number; label: string; description?: string } - | { type: 'enum'; default: string; options: readonly string[]; label: string; description?: string } - | { type: 'list'; itemType: 'string' | 'enum'; options?: readonly string[]; maxItems?: number; default: string; label: string; description?: string } - | { type: 'multiselect'; options: readonly string[]; default: string; label: string; description?: string }; - -export type FlatSchema = Record; - -/** One group's display strings as they travel, keyed by the group's dot-path. */ -export type SerializedGroup = { label?: string; description?: string }; - -/** Group metadata, dot-path → strings. Groups that declare none are absent, not empty. */ -export type FlatGroups = Record; - -// ─── Reserved keys ───────────────────────────────────────────────────────────── - -/** - * The verbs the accessor tree hangs on **every** node (`config.server.economy.currency.get()`), - * plus `for`, which the entity scopes use to select an entity. A schema key with one of these - * names would shadow the method on its own node, so none of them may be used at any depth. - */ -export const RESERVED_KEYS = ['get', 'set', 'patch', 'subscribe', 'for'] as const; - -const RESERVED = new Set(RESERVED_KEYS); - -/** - * Reject schema keys that collide with the accessor tree's own verbs. Runs at registration, - * before anything is materialized, so a bad schema fails at the declaration instead of - * silently shadowing `get` or `subscribe` somewhere deep in a tree — a failure that would - * otherwise surface as a `TypeError` at some unrelated call site much later. - * - * `scope` leads the reported path (`server.economy.set`) so the error points straight at the - * declaration, matching how the schema is addressed everywhere else it is published. - */ -export function validateConfigSchema(scope: string, schema: SchemaGroup, prefix = ''): void { - for (const [key, node] of Object.entries(schema)) { - const path = prefix ? `${prefix}.${key}` : key; - - // A group's own display strings, not a child. They are strings rather than nodes, so they - // are checked here and skipped rather than walked into. - if (isGroupMetaKey(key)) { - if (!GROUP_META_KEYS.has(key)) { - throw new Error( - `config schema: "${scope}.${path}" is not a group property; $-prefixed keys are ${[...GROUP_META_KEYS].join(', ')}`, - ); - } - - if (node !== undefined && typeof node !== 'string') { - throw new Error(`config schema: "${scope}.${path}" must be a string`); - } - - continue; - } - - if (RESERVED.has(key)) { - throw new Error( - `config schema: "${scope}.${path}" uses the reserved key "${key}"; reserved keys are ${RESERVED_KEYS.join(', ')}`, - ); - } - - if (typeof node !== 'object' || node === null) { - throw new Error(`config schema: "${scope}.${path}" is neither an entry nor a group`); - } - - if (!isEntry(node)) { validateConfigSchema(scope, node, path); } - } -} - -/** The group display keys, and the sigil test that keeps them out of the child namespace. */ -const GROUP_META_KEYS = new Set(['$label', '$description']); - -export function isGroupMetaKey(key: string): boolean { - return key.startsWith('$'); -} - -/** - * A group's children — its `$`-prefixed display strings dropped, and the rest narrowed back to - * `SchemaNode`. - * - * Every walk over a schema tree goes through this. The alternative was for each of them to - * re-derive the same skip, and a walker that forgot would grow a phantom `$label` node in the - * accessor tree or a phantom `$label` key in a value object — a defect that only shows up at the - * far end, in the shape a caller reads back. - */ -export function childEntries(group: SchemaGroup): [string, SchemaNode][] { - const out: [string, SchemaNode][] = []; - - for (const [key, node] of Object.entries(group)) { - if (isGroupMetaKey(key) || typeof node !== 'object' || node === null) { continue; } - - out.push([key, node]); - } - - return out; -} - -/** A group's child node by key, or `undefined` for a missing key or a `$` display string. */ -export function childNode(group: SchemaGroup, key: string): SchemaNode | undefined { - if (isGroupMetaKey(key)) { return undefined; } - - const node = group[key]; - - // The index signature already says `SchemaNode | string | undefined`, so ruling out the two - // non-node cases IS the narrowing — no assertion needed. - return typeof node === 'object' && node !== null ? node : undefined; -} - -export function flattenSchema(schema: SchemaGroup, prefix = ''): FlatSchema { - const result: FlatSchema = {}; - - for (const [key, node] of childEntries(schema)) { - const path = prefix ? `${prefix}.${key}` : key; - - if (isEntry(node)) { - result[path] = serializeEntry(node); - } else { - Object.assign(result, flattenSchema(node, path)); - } - } - - return result; -} - -/** - * The group half of {@link flattenSchema}: every group that declares a display string, keyed by - * the same dot-path its children are keyed under. - * - * Kept separate from the entry map rather than folded in as a pseudo-entry, because the two are - * read by different things — `buildNestedObject` and the accessor tree walk entries and would - * have to learn to skip a node that is not a value. A group with nothing to say is omitted - * entirely, so a schema that declares no metadata flattens to `{}` and costs nothing on the wire. - */ -export function flattenGroups(schema: SchemaGroup, prefix = ''): FlatGroups { - const result: FlatGroups = {}; - const label = schema.$label; - const description = schema.$description; - - if (prefix !== '' && (typeof label === 'string' || typeof description === 'string')) { - result[prefix] = { - ...(typeof label === 'string' ? { label } : {}), - ...(typeof description === 'string' ? { description } : {}), - }; - } - - for (const [key, node] of childEntries(schema)) { - if (isEntry(node)) { continue; } - - const path = prefix ? `${prefix}.${key}` : key; - - Object.assign(result, flattenGroups(node, path)); - } - - return result; -} - -export function isEntry(node: unknown): node is ConfigEntry { - if (typeof node !== 'object' || node === null) { return false; } - - const t = (node as { type?: unknown }).type; - - return t === 'boolean' || t === 'number' || t === 'string' || t === 'enum' || t === 'list' || t === 'multiselect'; -} - -function serializeEntry(entry: ConfigEntry): SerializedEntry { - const common = { - label: entry.label, - ...(entry.description ? { description: entry.description } : {}), - }; - - switch (entry.type) { - case 'boolean': - return { - type: 'boolean', - default: entry.default, - ...common, - }; - case 'number': - return { - type: 'number', - default: entry.default, - min: entry.min, - max: entry.max, - ...(entry.step !== undefined ? { step: entry.step } : {}), - ...common, - }; - case 'string': - return { - type: 'string', - default: entry.default, - ...(entry.maxLength !== undefined ? { maxLength: entry.maxLength } : {}), - ...common, - }; - case 'enum': - return { - type: 'enum', - default: entry.default, - options: entry.options, - ...common, - }; - case 'list': - // Store the default as a JSON string — list values travel as serialized arrays. - return { - type: 'list', - itemType: entry.itemType, - default: JSON.stringify(entry.default), - ...(entry.options ? { options: entry.options } : {}), - ...(entry.maxItems !== undefined ? { maxItems: entry.maxItems } : {}), - ...common, - }; - case 'multiselect': - // Same JSON-string storage as a list: the value is an array, and a stored value is one - // of `ConfigValue`'s three scalars. - return { - type: 'multiselect', - options: entry.options, - default: JSON.stringify(entry.default), - ...common, - }; - } -} diff --git a/packages/server-runtime/src/config/scopes/accessor.ts b/packages/server-runtime/src/config/scopes/accessor.ts deleted file mode 100644 index 43a9b43..0000000 --- a/packages/server-runtime/src/config/scopes/accessor.ts +++ /dev/null @@ -1,181 +0,0 @@ -/** - * The **accessor tree** — the dotted node tree each config scope hands out, mirroring the - * schema one-for-one: - * - * ```ts - * config.server.economy.currency.get() // 'emerald' | 'gold' | 'diamond' - * config.server.economy.currency.set('gold') - * config.server.economy.subscribe(economy => { ... }) - * config.player.for(player).notifyOnLogin.get() - * ``` - * - * Every node — group or leaf — carries its own verbs, in the style of - * `world.afterEvents.playerSpawn.subscribe(...)`. - * - * The tree is **materialized once, at registration** (see {@link buildAccessorChildren}), not - * proxied: the schema is static and fully known when `define()` runs, so the walk happens - * exactly once and every later property access is an ordinary object lookup. In a tick-driven - * environment that difference is the whole point — a `Proxy` would run a trap on every segment - * of every read, every tick. - * - * Nodes hold no values. Each one closes over its own dot-path and calls back through an - * {@link AccessorBackend} supplied by the owning scope, which is why the server scope and each - * entity's tree share one implementation and behave identically. - */ -import type { Unsubscribe } from '@bedrock-core/sync'; -import type { ChildKeys, DeepPartial, DotPath, PathValue, SchemaToValue } from '../schema'; -import { childEntries, isEntry } from '../schema'; -import type { ChangeListener } from './change-emitter'; -import type { SchemaTree } from './utils'; - -// ─── Node types ──────────────────────────────────────────────────────────────── - -/** Structural shape of a schema leaf — the compile-time mirror of the runtime `isEntry` check. */ -type LeafEntry = { type: 'boolean' | 'number' | 'string' | 'enum' | 'list' | 'multiselect' }; - -/** - * The value type of **one** schema node. Defers to {@link SchemaToValue} by wrapping the node in - * a single-key schema, so a leaf narrows exactly as it does inside a group — enums to their - * literal union, `list` to `string[]` — and a group yields its whole nested shape. - */ -export type NodeValue = SchemaToValue<{ node: N }>['node']; - -/** A leaf node: read, write and watch one entry. */ -export interface ConfigLeafAccessor { - get(): NodeValue; - set(value: NodeValue): void; - subscribe(listener: ChangeListener>): Unsubscribe; -} - -/** - * The verbs a group node carries, the scope root included. `subscribe` keeps the typed - * dot-path overload as an escape hatch for paths computed at runtime; the path is resolved - * relative to the node it is called on. - */ -export interface ConfigGroupAccessor { - get(): SchemaToValue; - set(value: SchemaToValue): void; - patch(partial: DeepPartial>): void; - subscribe(listener: ChangeListener>): Unsubscribe; - subscribe

>(path: P, listener: ChangeListener>): Unsubscribe; -} - -/** - * One accessor node per schema key, keyed exactly as the schema is — minus a group's own - * `$label`/`$description`, which are strings describing the node rather than nodes of their own. - */ -export type ConfigChildren = { [K in ChildKeys]: ConfigNode }; - -/** - * A node of the tree: a leaf accessor, or a group's verbs plus its own children. - * - * The group arm tests `Record` rather than `Record`: a - * group that names itself holds `$label: string` alongside its children, which is not a - * `SchemaNode`, and the stricter test collapsed every such group — and everything under it — - * to `never`. Anything reaching this arm has already failed the leaf test, so "object" is as - * precise as the distinction needs to be. - */ -export type ConfigNode - = N extends LeafEntry ? ConfigLeafAccessor - : N extends Record ? ConfigGroupAccessor & ConfigChildren - : never; - -/** - * A whole scope as a tree: the root verbs plus every top-level node. This is what - * `EntityConfigScope.for(entity)` returns, and what the server scope's own instance is - * widened to — so both scopes read identically past that point. - */ -export type ConfigTree = ConfigGroupAccessor & ConfigChildren; - -// ─── Backend ─────────────────────────────────────────────────────────────────── - -/** - * What a materialized tree needs from the scope that owns it. Every call is addressed by flat - * dot-path, where `''` means the whole scope, so one shape serves the server scope (values - * keyed by path) and an entity's tree (values keyed by path within that entity). - */ -export interface AccessorBackend { - - /** Effective value at `path`, reconstructed from stored values and schema defaults. */ - read(path: string): unknown; - - /** Deep-merge `value` in at `path`. Only the keys it names change. */ - patch(path: string, value: unknown): void; - - /** Replace the subtree at `path`; schema keys under it that `value` omits revert to their defaults. */ - replace(path: string, value: unknown): void; - - /** Attach a listener to exactly `path`. */ - on(path: string, listener: ChangeListener): Unsubscribe; -} - -// ─── Materialization ─────────────────────────────────────────────────────────── - -/** - * Walk a schema subtree once and build a real object per group and per leaf, each closed over - * its own dot-path. Returns the children only — the caller decides what carries the verbs for - * `prefix` itself (the server scope is its own root; `for(entity)` uses - * {@link buildAccessorNode}). - */ -export function buildAccessorChildren( - tree: SchemaTree, - prefix: string, - backend: AccessorBackend, -): Record { - const children: Record = {}; - - for (const [key, node] of childEntries(tree)) { - const path = prefix ? `${prefix}.${key}` : key; - - children[key] = isEntry(node) - ? leafVerbs(path, backend) - : { ...groupVerbs(path, backend), ...buildAccessorChildren(node, path, backend) }; - } - - return children; -} - -/** A schema subtree as one node: the group verbs at `prefix`, plus its children. */ -export function buildAccessorNode(tree: SchemaTree, prefix: string, backend: AccessorBackend): unknown { - return { ...groupVerbs(prefix, backend), ...buildAccessorChildren(tree, prefix, backend) }; -} - -/** - * The shared `subscribe(listener)` / `subscribe(path, listener)` dispatch. A dot-path is - * resolved relative to `prefix`, so the escape hatch reads the same on the scope root - * (`subscribe('economy.currency', cb)`) as on a nested group (`economy.subscribe('currency', cb)`). - */ -export function subscribeAt( - backend: AccessorBackend, - prefix: string, - pathOrListener: string | ChangeListener, - listener?: ChangeListener, -): Unsubscribe { - if (typeof pathOrListener === 'function') { - return backend.on(prefix, pathOrListener); - } - - if (!listener) { throw new Error('subscribe(path, listener): listener is required'); } - - return backend.on(prefix ? `${prefix}.${pathOrListener}` : pathOrListener, listener); -} - -/** A leaf's verbs. `set` is a single-key write — there is no subtree under it to revert. */ -function leafVerbs(path: string, backend: AccessorBackend): Record { - return { - get: () => backend.read(path), - set: (value: unknown): void => { backend.patch(path, value); }, - subscribe: (listener: ChangeListener) => backend.on(path, listener), - }; -} - -/** A group's verbs, with the same `patch` / `set` semantics the scope root has, scoped to `prefix`. */ -function groupVerbs(prefix: string, backend: AccessorBackend): Record { - return { - get: () => backend.read(prefix), - set: (value: unknown): void => { backend.replace(prefix, value); }, - patch: (partial: unknown): void => { backend.patch(prefix, partial); }, - subscribe: (pathOrListener: string | ChangeListener, listener?: ChangeListener) => - subscribeAt(backend, prefix, pathOrListener, listener), - }; -} diff --git a/packages/server-runtime/src/config/scopes/change-emitter.ts b/packages/server-runtime/src/config/scopes/change-emitter.ts deleted file mode 100644 index 7db9da5..0000000 --- a/packages/server-runtime/src/config/scopes/change-emitter.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { Unsubscribe } from '@bedrock-core/sync'; - -/** - * A path-change listener. Declared through the "bivariance hack" so a listener typed for a - * specific reconstructed value (e.g. `ChangeListener>`) is assignable to - * the emitter's `ChangeListener` storage: the scopes' public overloads pick the - * value type, while the emitter itself only ever sees `unknown`. - */ -export type ChangeListener = { - bivarianceHack(next: V, prev: V | undefined): void; -}['bivarianceHack']; - -export class ChangeEmitter { - private readonly _listeners = new Map>(); - - on(path: string, fn: ChangeListener): Unsubscribe { - let set = this._listeners.get(path); - - if (!set) { set = new Set(); this._listeners.set(path, set); } - - set.add(fn); - - return () => { this._listeners.get(path)?.delete(fn); }; - } - - emit(path: string, next: unknown, prev: unknown): void { - this._listeners.get(path)?.forEach(fn => fn(next, prev)); - } - - has(path: string): boolean { - return (this._listeners.get(path)?.size ?? 0) > 0; - } - - hasAny(): boolean { - return this._listeners.size > 0; - } - - clear(): void { - this._listeners.clear(); - } -} diff --git a/packages/server-runtime/src/config/scopes/entity-scope.ts b/packages/server-runtime/src/config/scopes/entity-scope.ts deleted file mode 100644 index f320fe6..0000000 --- a/packages/server-runtime/src/config/scopes/entity-scope.ts +++ /dev/null @@ -1,228 +0,0 @@ -import type { Unsubscribe } from '@bedrock-core/sync'; -import type { - ConfigValue, - FlatSchema, - SchemaToValue, - DeepPartial, -} from '../schema'; -import { ChangeEmitter } from './change-emitter'; -import { buildAccessorNode, type AccessorBackend, type ConfigTree } from './accessor'; -import { - buildTreeValue, - emitAffected, - flattenAt, - flattenObject, - valueAtPath, - withRevertsUnder, - type SchemaTree, -} from './utils'; - -/** - * A batch of applied flat changes for one entity, handed to the owner for persistence + - * broadcast. A value of `undefined` means the key reverted to its schema default — the - * persisted per-entity override must be deleted. - */ -export type EntityApplyListener = (entityId: string, changes: ReadonlyMap) => void; - -/** - * Generic per-entity config scope — works for any entity with an `id` string - * (`Dimension`, `Player`, etc.). - * - * Value resolution: per-entity stored value → schema default. - * - * {@link EntityConfigScope.for} is the way in: it returns the same accessor tree the server - * scope is, bound to one entity, so both scopes read identically past that point - * (`config.player.for(player).notify.onLogin.get()`). The entity-first `get` / `patch` / `set` - * remain for callers holding an untyped scope, such as `core.config.local`. - */ -export class EntityConfigScope, E extends { id: string }> { - private readonly _tree: SchemaTree; - private readonly _flatSchema: FlatSchema; - private readonly _values: Map>; - private readonly _emitters = new Map(); - private readonly _trees = new Map>(); - private readonly _onApply: EntityApplyListener; - - readonly schema: FlatSchema; - - constructor( - tree: SchemaTree, - flatSchema: FlatSchema, - values: Map>, - onApply: EntityApplyListener, - ) { - this._tree = tree; - this._flatSchema = flatSchema; - this._values = values; - this._onApply = onApply; - this.schema = flatSchema; - } - - /** - * The accessor tree for one entity — the same shape the server scope has, so everything - * past this call reads identically across scopes: - * - * ```ts - * config.player.for(player).notify.onLogin.get() - * config.player.for(player).notify.onLogin.subscribe(cb) - * config.player.for(player).get() // the whole scope for that player - * config.player.for(player).subscribe('notify.onLogin', cb) - * ``` - * - * Trees are cached per entity id and dropped in {@link clear}. Caching cannot go stale: a - * node stores nothing, it resolves values and its emitter through this scope by id on every - * call — so a tree obtained before a rejoin keeps working, and only the walk is saved. - */ - for(entity: E): ConfigTree { - let tree = this._trees.get(entity.id); - - if (!tree) { tree = this.buildTree(entity.id); this._trees.set(entity.id, tree); } - - return tree; - } - - /** Return the full current config for an entity as a typed nested object. */ - get(entity: E): SchemaToValue { - // The tree walk reconstructs exactly the shape SchemaToValue describes; TS cannot - // verify an object assembled key-by-key at runtime, so this assertion is inherent. - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - return buildTreeValue(this._tree, '', this._flatSchema, this.valuesFor(entity.id)) as SchemaToValue; - } - - /** Deep-merge a partial update for a specific entity. Only the provided keys are changed. */ - patch(entity: E, partial: DeepPartial>): void { - this.applyForEntity(entity.id, flattenObject(partial)); - } - - /** - * Replace the full config for a specific entity. Requires the whole object; every schema - * key not present in the input reverts to its schema default and its persisted per-entity - * override is deleted. - */ - set(entity: E, value: SchemaToValue): void { - this.applyForEntity(entity.id, withRevertsUnder('', flattenObject(value), this._flatSchema)); - } - - /** @internal Load initial entity values on join. Does not emit (no listener can predate the entity). */ - init(entityId: string, entityValues: Map): void { - this._values.set(entityId, entityValues); - } - - /** - * @internal Load persisted entity values (deferred one tick from `define()`). Emits change - * events for keys whose loaded value differs from what reads returned so far. Does not - * persist or broadcast. - */ - loadInitial(entityId: string, loaded: Map): void { - if (!loaded.size) { return; } - - const entityMap = this.ensureValues(entityId); - const prev = new Map(entityMap); - const changed: string[] = []; - - for (const [key, value] of loaded) { - if (entityMap.get(key) !== value) { changed.push(key); } - - entityMap.set(key, value); - } - - const emitter = this._emitters.get(entityId); - - if (emitter && changed.length > 0) { - emitAffected(emitter, changed, this._tree, this._flatSchema, entityMap, prev); - } - } - - /** @internal Clear entity data on leave. */ - clear(entityId: string): void { - this._values.delete(entityId); - this._emitters.get(entityId)?.clear(); - this._emitters.delete(entityId); - this._trees.delete(entityId); - } - - /** @internal Apply a remote merge-patch for an entity from RPC (fires change events). */ - applyRemotePatch(entityId: string, flat: Record): void { - this.applyForEntity(entityId, new Map(Object.entries(flat))); - } - - /** @internal Apply a remote full replace for an entity from RPC (fires change events). */ - applyRemoteSet(entityId: string, flat: Record): void { - this.applyForEntity(entityId, withRevertsUnder('', new Map(Object.entries(flat)), this._flatSchema)); - } - - /** @internal Return all effective flat values for an entity (used for RPC response). */ - getFlat(entityId: string): Record { - const entityMap = this._values.get(entityId); - const result: Record = {}; - - for (const key of Object.keys(this._flatSchema)) { - result[key] = entityMap?.get(key) ?? this._flatSchema[key]?.default; - } - - return result; - } - - /** - * Walk the schema once for one entity. Every node closes over `entityId` and resolves values - * and its emitter through this scope on each call, so the result never holds stale state. - */ - private buildTree(entityId: string): ConfigTree { - const backend: AccessorBackend = { - read: (path): unknown => valueAtPath(this._tree, path, this._flatSchema, this.valuesFor(entityId)), - patch: (path, value): void => { this.applyForEntity(entityId, flattenAt(path, value)); }, - replace: (path, value): void => { - this.applyForEntity(entityId, withRevertsUnder(path, flattenAt(path, value), this._flatSchema)); - }, - on: (path, listener): Unsubscribe => this.emitterFor(entityId).on(path, listener), - }; - - // The walk reproduces exactly the shape ConfigTree describes; TS cannot verify an object - // assembled key-by-key at runtime, so this assertion is inherent. - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - return buildAccessorNode(this._tree, '', backend) as ConfigTree; - } - - private applyForEntity(entityId: string, changes: Map): void { - if (!changes.size) { return; } - - const entityMap = this.ensureValues(entityId); - const prev = new Map(entityMap); - - for (const [key, value] of changes) { - if (value !== undefined) { - entityMap.set(key, value); - } else { - entityMap.delete(key); - } - } - - this._onApply(entityId, changes); - - const emitter = this._emitters.get(entityId); - - if (emitter) { - emitAffected(emitter, changes.keys(), this._tree, this._flatSchema, entityMap, prev); - } - } - - private valuesFor(entityId: string): Map { - return this._values.get(entityId) ?? new Map(); - } - - private ensureValues(entityId: string): Map { - let entityMap = this._values.get(entityId); - - if (!entityMap) { entityMap = new Map(); this._values.set(entityId, entityMap); } - - return entityMap; - } - - private emitterFor(entityId: string): ChangeEmitter { - let e = this._emitters.get(entityId); - - if (!e) { e = new ChangeEmitter(); this._emitters.set(entityId, e); } - - return e; - } -} diff --git a/packages/server-runtime/src/config/scopes/index.ts b/packages/server-runtime/src/config/scopes/index.ts deleted file mode 100644 index 0bc7ba2..0000000 --- a/packages/server-runtime/src/config/scopes/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -export { ServerConfigScope } from './server-scope'; -export type { ServerConfigTree } from './server-scope'; -export { EntityConfigScope } from './entity-scope'; -export type { - ConfigChildren, - ConfigGroupAccessor, - ConfigLeafAccessor, - ConfigNode, - ConfigTree, - NodeValue, -} from './accessor'; diff --git a/packages/server-runtime/src/config/scopes/server-scope.ts b/packages/server-runtime/src/config/scopes/server-scope.ts deleted file mode 100644 index a4c7af2..0000000 --- a/packages/server-runtime/src/config/scopes/server-scope.ts +++ /dev/null @@ -1,176 +0,0 @@ -import type { Unsubscribe } from '@bedrock-core/sync'; -import type { - ConfigValue, - FlatSchema, - SchemaToValue, - DotPath, - PathValue, - DeepPartial, -} from '../schema'; -import { ChangeEmitter, type ChangeListener } from './change-emitter'; -import { - buildAccessorChildren, - subscribeAt, - type AccessorBackend, - type ConfigChildren, -} from './accessor'; -import { - emitAffected, - flattenAt, - valueAtPath, - withRevertsUnder, - type SchemaTree, -} from './utils'; - -/** - * A batch of applied flat changes, handed to the owner for persistence + broadcast. - * A value of `undefined` means the key reverted to its schema default — the persisted - * override must be deleted. - */ -export type ApplyListener = (changes: ReadonlyMap) => void; - -/** - * The server scope as `define()` hands it out: the scope's own verbs plus the materialized - * accessor node for every top-level schema key, so `config.server.economy.currency.get()` - * type-checks alongside `config.server.get()`. - * - * The nodes are real own properties assigned in the constructor — TypeScript cannot see - * properties produced by a schema walk, which is what this intersection states. - */ -export type ServerConfigTree> = ServerConfigScope & ConfigChildren; - -export class ServerConfigScope> { - private readonly _tree: SchemaTree; - private readonly _flatSchema: FlatSchema; - private readonly _values: Map; - private readonly _emitter = new ChangeEmitter(); - private readonly _onApply: ApplyListener; - private readonly _backend: AccessorBackend; - - readonly schema: FlatSchema; - - constructor( - tree: SchemaTree, - flatSchema: FlatSchema, - values: Map, - onApply: ApplyListener, - ) { - this._tree = tree; - this._flatSchema = flatSchema; - this._values = values; - this._onApply = onApply; - this.schema = flatSchema; - this._backend = { - read: (path): unknown => valueAtPath(this._tree, path, this._flatSchema, this._values), - patch: (path, value): void => { this.applyAndEmit(flattenAt(path, value)); }, - replace: (path, value): void => { this.applyAndEmit(withRevertsUnder(path, flattenAt(path, value), this._flatSchema)); }, - on: (path, listener): Unsubscribe => this._emitter.on(path, listener), - }; - - // Materialized here, once: the schema is fully known at registration, so the whole tree is - // built up front and every later `config.server.a.b.get()` is a plain property lookup. - const children = buildAccessorChildren(tree, '', this._backend); - - // The tree is assigned ONTO the scope, so a top-level key that matches any member of this - // class would silently overwrite it. `validateConfigSchema` already rejects the documented - // verbs with a better message; this catches everything else (`schema` and the transport - // methods) and keeps covering new members automatically as they are added. - for (const key of Object.keys(children)) { - if (key in this) { - throw new Error( - `config schema: top-level server key "${key}" collides with a config scope member of the same name — rename it`, - ); - } - } - - Object.assign(this, children); - } - - /** Return the full current config as a typed nested object. */ - get(): SchemaToValue { - // The tree walk reconstructs exactly the shape SchemaToValue describes; TS cannot - // verify an object assembled key-by-key at runtime, so this assertion is inherent. - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - return this._backend.read('') as SchemaToValue; - } - - /** Deep-merge a partial update. Only the provided keys are changed. */ - patch(partial: DeepPartial>): void { - this._backend.patch('', partial); - } - - /** - * Replace the full config. Requires the whole object; every schema key not present in - * the input reverts to its schema default and its persisted override is deleted. - */ - set(value: SchemaToValue): void { - this._backend.replace('', value); - } - - /** - * Watch the whole scope, or one dot-path within it. The path form is the escape hatch for - * paths computed at runtime — when the path is a literal, prefer the node it names - * (`config.server.economy.currency.subscribe(...)`), which needs no path at all. - */ - subscribe(listener: ChangeListener>): Unsubscribe; - subscribe

>(path: P, listener: ChangeListener>): Unsubscribe; - subscribe(pathOrListener: string | ChangeListener, listener?: ChangeListener): Unsubscribe { - return subscribeAt(this._backend, '', pathOrListener, listener); - } - - /** - * @internal Load persisted values (deferred one tick from `define()`). Emits change - * events for keys whose loaded value differs from what reads returned so far, so a - * subscriber attached right after `define()` still learns the real values. Does not - * persist or broadcast. - */ - loadInitial(values: Map): void { - const prev = new Map(this._values); - const changed: string[] = []; - - for (const [key, value] of values) { - if (this._values.get(key) !== value) { changed.push(key); } - - this._values.set(key, value); - } - - if (changed.length > 0) { - emitAffected(this._emitter, changed, this._tree, this._flatSchema, this._values, prev); - } - } - - /** @internal All effective flat values (used for RPC responses). */ - getFlat(): Record { - return Object.fromEntries(this._values); - } - - /** @internal Apply a remote merge-patch from RPC (fires change events). */ - applyRemotePatch(flat: Record): void { - this.applyAndEmit(new Map(Object.entries(flat))); - } - - /** @internal Apply a remote full replace from RPC (fires change events). */ - applyRemoteSet(flat: Record): void { - this.applyAndEmit(withRevertsUnder('', new Map(Object.entries(flat)), this._flatSchema)); - } - - private applyAndEmit(changes: Map): void { - if (!changes.size) { return; } - - const prev = new Map(this._values); - - for (const [key, value] of changes) { - if (value !== undefined) { - this._values.set(key, value); - continue; - } - - const fallback = this._flatSchema[key]?.default; - - if (fallback !== undefined) { this._values.set(key, fallback); } - } - - this._onApply(changes); - emitAffected(this._emitter, changes.keys(), this._tree, this._flatSchema, this._values, prev); - } -} diff --git a/packages/server-runtime/src/config/scopes/utils.ts b/packages/server-runtime/src/config/scopes/utils.ts deleted file mode 100644 index 9d6deba..0000000 --- a/packages/server-runtime/src/config/scopes/utils.ts +++ /dev/null @@ -1,263 +0,0 @@ -import type { ConfigValue, FlatSchema, SchemaGroup } from '../schema'; -import { childEntries, childNode, isEntry } from '../schema'; -import type { ChangeEmitter } from './change-emitter'; - -/** - * A schema subtree: group keys → nested groups or leaf entries, plus the group's own - * `$label`/`$description`. Walk it with `childEntries`/`childNode`, never `Object.entries` — - * those are what drop the display strings back out. - */ -export type SchemaTree = SchemaGroup; - -/** Flat dot-path → stored value view, as kept by the scopes. */ -export type FlatValues = ReadonlyMap; - -/** Non-null object viewed as a string-indexed record (arrays included, as before). */ -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null; -} - -function isConfigValue(value: unknown): value is ConfigValue { - return typeof value === 'boolean' || typeof value === 'number' || typeof value === 'string'; -} - -function isUnknownArray(value: unknown): value is unknown[] { - return Array.isArray(value); -} - -/** - * Flatten a nested plain object to dot-path → primitive pairs. - * Arrays are leaf values (stored as JSON strings); leaves that are not valid - * config values (null, undefined, functions, …) are skipped. - */ -export function flattenObject( - obj: Record, - prefix = '', -): Map { - const result = new Map(); - - for (const [key, val] of Object.entries(obj)) { - const path = prefix ? `${prefix}.${key}` : key; - - if (Array.isArray(val)) { - result.set(path, JSON.stringify(val)); - } else if (isRecord(val)) { - for (const [k, v] of flattenObject(val, path)) { - result.set(k, v); - } - } else if (isConfigValue(val)) { - result.set(path, val); - } - } - - return result; -} - -/** - * Flatten a value written **at** `path` into the flat changes it implies — the write half of - * {@link valueAtPath}. `''` is the whole scope (the value is a nested object), a group path - * prefixes the flattened object, and a leaf path takes the value as-is; arrays are stored as - * their JSON string, exactly as {@link flattenObject} does. - */ -export function flattenAt(path: string, value: unknown): Map { - if (Array.isArray(value)) { return new Map([[path, JSON.stringify(value)]]); } - - if (isRecord(value)) { return flattenObject(value, path); } - - return isConfigValue(value) ? new Map([[path, value]]) : new Map(); -} - -/** - * Mark every schema key under `path` that `changes` does not set as a revert-to-default — - * the `set` half of the write semantics. `''` covers the whole scope, a group path covers its - * subtree, and a leaf path covers only itself. - */ -export function withRevertsUnder( - path: string, - changes: Map, - flatSchema: FlatSchema, -): Map { - const full = new Map(changes); - const prefix = `${path}.`; - - for (const key of Object.keys(flatSchema)) { - if (path !== '' && key !== path && !key.startsWith(prefix)) { continue; } - - if (!full.has(key)) { full.set(key, undefined); } - } - - return full; -} - -/** - * Given the set of changed flat paths, return every affected path from deepest - * to shallowest (leaf → ancestor groups → root ''). - * Listeners are fired in that order. - */ -function collectAffectedPaths(changedKeys: Iterable): string[] { - const paths = new Set(['']); - - for (const key of changedKeys) { - paths.add(key); - const parts = key.split('.'); - - for (let i = 1; i < parts.length; i++) { paths.add(parts.slice(0, i).join('.')); } - } - - return [...paths].sort((a, b) => pathDepth(b) - pathDepth(a)); -} - -function pathDepth(path: string): number { - return path === '' ? 0 : path.split('.').length; -} - -/** - * Parse a list value back from its stored JSON-string form. - * Malformed JSON or a non-array payload yields `[]`. - */ -export function parseListValue(raw: string): unknown[] { - try { - const parsed: unknown = JSON.parse(raw); - - return isUnknownArray(parsed) ? parsed : []; - } catch { - return []; - } -} - -/** - * Reconstruct a nested object from a flat dot-path → value record. - * Pass a FlatSchema so list entries (stored as JSON strings) are parsed back to arrays. - */ -export function buildNestedObject(flat: Record, schema?: FlatSchema): Record { - const result: Record = {}; - - for (const [path, value] of Object.entries(flat)) { - const parts = path.split('.'); - let obj = result; - - for (let i = 0; i < parts.length - 1; i++) { - const existing = obj[parts[i]]; - - if (isRecord(existing)) { - obj = existing; - } else { - const child: Record = {}; - - obj[parts[i]] = child; - obj = child; - } - } - - const leaf = parts[parts.length - 1]; - - if (isArrayValued(schema?.[path]?.type) && typeof value === 'string') { - obj[leaf] = parseListValue(value); - } else { - obj[leaf] = value; - } - } - - return result; -} - -// ─── Tree reconstruction (shared by both config scopes) ──────────────────────── - -/** - * The entry types whose value is an ARRAY, and so travels as that array's JSON. - * - * A stored value is one of `ConfigValue`'s three scalars, so both of these round-trip through a - * string — the parse back into an array has to key off the schema, since the stored form of an - * empty list and the stored form of the literal text `[]` are the same two characters. - */ -function isArrayValued(type: string | undefined): boolean { - return type === 'list' || type === 'multiselect'; -} - -/** Resolve one leaf: stored value → schema default; array-valued entries parse back to arrays. */ -function resolveLeaf(path: string, flatSchema: FlatSchema, values: FlatValues): unknown { - const raw = values.get(path) ?? flatSchema[path]?.default; - - if (isArrayValued(flatSchema[path]?.type) && typeof raw === 'string') { - return parseListValue(raw); - } - - return raw; -} - -/** Reconstruct the nested value object for a schema subtree from flat values. */ -export function buildTreeValue( - tree: SchemaTree, - prefix: string, - flatSchema: FlatSchema, - values: FlatValues, -): Record { - const result: Record = {}; - - for (const [key, node] of childEntries(tree)) { - const path = prefix ? `${prefix}.${key}` : key; - - if (isEntry(node)) { - result[key] = resolveLeaf(path, flatSchema, values); - } else { - result[key] = buildTreeValue(node, path, flatSchema, values); - } - } - - return result; -} - -/** The value at a dot-path within the tree — `''` is the whole tree; unknown paths yield `undefined`. */ -export function valueAtPath( - tree: SchemaTree, - path: string, - flatSchema: FlatSchema, - values: FlatValues, -): unknown { - if (path === '') { return buildTreeValue(tree, '', flatSchema, values); } - - const parts = path.split('.'); - let subtree: SchemaTree = tree; - let prefix = ''; - - for (let i = 0; i < parts.length - 1; i++) { - const node = childNode(subtree, parts[i]); - - if (!node || isEntry(node)) { return undefined; } - - prefix = prefix ? `${prefix}.${parts[i]}` : parts[i]; - subtree = node; - } - - const last = parts[parts.length - 1]; - const node = childNode(subtree, last); - - if (!node) { return undefined; } - - const nodePath = prefix ? `${prefix}.${last}` : last; - - if (isEntry(node)) { return resolveLeaf(nodePath, flatSchema, values); } - - return buildTreeValue(node, nodePath, flatSchema, values); -} - -/** - * Fire the emitter for every path affected by a batch of flat-key changes (deepest first), - * reconstructing the next/prev value at each subscribed path. - */ -export function emitAffected( - emitter: ChangeEmitter, - changedKeys: Iterable, - tree: SchemaTree, - flatSchema: FlatSchema, - next: FlatValues, - prev: FlatValues, -): void { - if (!emitter.hasAny()) { return; } - - for (const path of collectAffectedPaths(changedKeys)) { - if (!emitter.has(path)) { continue; } - - emitter.emit(path, valueAtPath(tree, path, flatSchema, next), valueAtPath(tree, path, flatSchema, prev)); - } -} diff --git a/packages/server-runtime/src/declaration.ts b/packages/server-runtime/src/declaration.ts new file mode 100644 index 0000000..4384ef7 --- /dev/null +++ b/packages/server-runtime/src/declaration.ts @@ -0,0 +1,33 @@ +/** + * A declaration: something an addon hands to {@link Runtime.register} that installs itself and + * returns the accessor it is read through. + * + * The runtime knows nothing about what a declaration builds. `register()` walks the options bag, + * installs every declaration it finds in the order the keys were written, and returns each + * `install` result under the key it was declared as — so a package outside this repository can add + * a field to `register()` and have its own types come back typed, without the floor importing them. + * + * ```ts + * const { config, shared } = core.register({ manifest, config: registerConfig(definition), shared: registerShared(keys) }); + * ``` + * + * Lifecycle stays with the runtime: `register()` installs, and `Runtime.stop()` calls `stop?()` on + * every declaration it installed, so a declaration needs no disposal hook of its own. + */ +import type { Runtime } from './runtime'; + +/** What a `register()` field holds: an installer returning the accessor, and optional teardown. */ +export interface Declaration { + /** Build the subsystem on `core` and return what the addon reads it through. */ + install(core: Runtime): T; + + /** Release what `install` built. Called by {@link Runtime.stop}. */ + stop?(): void; +} + +/** Whether an options value is a declaration — an object with an `install` method. */ +export function isDeclaration(value: unknown): value is Declaration { + return typeof value === 'object' + && value !== null + && typeof (value as { install?: unknown }).install === 'function'; +} diff --git a/packages/server-runtime/src/events/declaration.ts b/packages/server-runtime/src/events/declaration.ts new file mode 100644 index 0000000..5711f85 --- /dev/null +++ b/packages/server-runtime/src/events/declaration.ts @@ -0,0 +1,20 @@ +/** + * `registerEvents(tree)` — the events declaration an addon passes to `register()`. + * + * ```ts + * const { events } = core.register({ manifest, events: registerEvents({ restocked: event<{ item: string }>() }) }); + * + * events.restocked.emit({ item: 'diamond' }); + * core.events.of('os_shop').sale.subscribe(listener); + * ``` + * + * Installing defines the tree on `core.events` — the view over the runtime's node events, which a + * peer's listener materializes with or without this declaration — and returns its typed tree. + */ +import type { Declaration } from '../declaration'; +import type { EventsDef, EventsTree } from './tree'; + +/** Declare what this addon announces to every realm: `{ purchase: event<{ playerId: string }>() }`. */ +export function registerEvents(tree: Def): Declaration> { + return { install: core => core.events.define(tree) }; +} diff --git a/packages/server-runtime/src/events/events-registry.ts b/packages/server-runtime/src/events/events-registry.ts new file mode 100644 index 0000000..77dc969 --- /dev/null +++ b/packages/server-runtime/src/events/events-registry.ts @@ -0,0 +1,82 @@ +/** + * `core.events` — what an addon announces to every realm, and what it listens for. + * + * The owner declares its events as `events: registerEvents(tree)` in `register()` and gets the typed tree + * back; a peer reaches another addon's with `core.events.of(ns)`, typed by the declaration that + * addon exports. Nothing is announced and nothing is stored: an event is a name, a payload and the + * namespace that sent it. + * + * `of()` never answers `undefined`. A subscription is a filter on namespace and name, so a + * listener attached before the owning addon has registered — or before it is even installed — + * simply hears the first event it announces. That is the difference from the mirror, where being + * early costs nothing but being late costs nothing either. + */ +import type { Events, Unsubscribe } from '@bedrock-core/sync'; +import { + materialize, + materializePeer, + type EventsDef, + type EventsTree, + type PeerEventsTree, +} from './tree'; + +/** What `new EventsRegistry()` takes. */ +export interface EventsRegistryOptions { + events: Events; + namespace: string; +} + +/** This addon's events as a typed tree, and any other addon's as one that only listens. */ +export class EventsRegistry { + private readonly _events: Events; + private readonly _namespace: string; + private readonly _peers = new Map(); + private _own: unknown; + + constructor(options: EventsRegistryOptions) { + this._events = options.events; + this._namespace = options.namespace; + } + + /** This addon's tree, once declared. */ + get own(): EventsTree | undefined { + return this._own as EventsTree | undefined; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion + } + + /** Declare this addon's events. Once per addon. */ + define(def: Def): EventsTree { + if (this._own !== undefined) { + throw new Error('[events] already declared for this addon'); + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const tree = materialize(this._events, this._namespace, def) as EventsTree; + + this._own = tree; + + return tree; + } + + /** + * Another addon's events, typed by the declaration it exports. Always a tree: listening for + * something nobody announces is simply a listener that never fires. + */ + of(namespace: string): PeerEventsTree { + let tree = this._peers.get(namespace); + + if (tree === undefined) { + tree = materializePeer(this._events, namespace); + this._peers.set(namespace, tree); + } + + return tree as PeerEventsTree; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion + } + + /** + * Listen without a declaration: the escape hatch for a name computed at runtime, and what the + * typed tree is built on. + */ + on(namespace: string, name: string, listener: (payload: unknown, from: string) => void): Unsubscribe { + return this._events.on(namespace, name, listener); + } +} diff --git a/packages/server-runtime/src/events/index.ts b/packages/server-runtime/src/events/index.ts new file mode 100644 index 0000000..346e76d --- /dev/null +++ b/packages/server-runtime/src/events/index.ts @@ -0,0 +1,15 @@ +export { EventsRegistry } from './events-registry'; +export { registerEvents } from './declaration'; +export type { EventsRegistryOptions } from './events-registry'; + +export { event, materialize, materializePeer } from './tree'; +export type { + EventListener, + EventMarker, + EventsDef, + EventsTree, + OwnEvent, + PayloadOf, + PeerEvent, + PeerEventsTree, +} from './tree'; diff --git a/packages/server-runtime/src/events/tree.ts b/packages/server-runtime/src/events/tree.ts new file mode 100644 index 0000000..e5b3bcf --- /dev/null +++ b/packages/server-runtime/src/events/tree.ts @@ -0,0 +1,115 @@ +/** + * The events tree: a declaration of what an addon announces, materialized into one node per name. + * + * A declaration says only what a payload looks like — `event<{ playerId: string }>()` — because + * that is the entire contract. An event has no value to store, no default and no shape to + * announce: what crosses is the name and the payload, and the type the owner exports is what makes + * a peer's listener typed. + * + * The owner's nodes carry `emit` and `subscribe`; a peer's carry `subscribe` alone. A peer's tree + * is built lazily, so a listener can be attached before the owning addon is in the world — which + * matters for events in a way it does not for the mirror, since an event missed is missed for + * good. + */ +import type { Events, Unsubscribe } from '@bedrock-core/sync'; + +declare const payloadType: unique symbol; + +/** + * One declared event, carrying its payload type and nothing else. + * + * The phantom property holds `T` directly rather than a function of it, so a marker for a specific + * payload is assignable to `EventMarker` and a declaration satisfies {@link EventsDef}. + */ +export interface EventMarker { + readonly [payloadType]?: T; +} + +/** Declare an event and the shape it carries: `purchase: event<{ playerId: string }>()`. */ +export function event(): EventMarker { + return {}; +} + +/** A declaration: names to markers. */ +export type EventsDef = Readonly>>; + +/** The payload type an `event()` marker carries. */ +export type PayloadOf = M extends EventMarker ? T : never; + +/** The listener an event takes: its payload, and the namespace that announced it. */ +export type EventListener = (payload: T, from: string) => void; + +/** The owner's view of one event. */ +export interface OwnEvent { + /** Announce it. Every realm's listeners fire, this one's first and synchronously. */ + emit(payload: T): void; + subscribe(listener: EventListener): Unsubscribe; +} + +/** A peer's view of one event: listening only — an event is the owner's to announce. */ +export interface PeerEvent { + subscribe(listener: EventListener): Unsubscribe; +} + +/** The owner's tree: one node per declared event, each with `emit` and `subscribe`. */ +export type EventsTree = { readonly [K in keyof Def]: OwnEvent> }; + +/** A peer's tree: the same nodes, `subscribe` alone. */ +export type PeerEventsTree = { readonly [K in keyof Def]: PeerEvent> }; + +// ─── Materialization ─────────────────────────────────────────────────────────── + +class OwnNode { + constructor(private readonly _events: Events, private readonly _namespace: string, private readonly _name: string) {} + + emit(payload: unknown): void { + this._events.emit(this._name, payload); + } + + subscribe(listener: EventListener): Unsubscribe { + return this._events.on(this._namespace, this._name, listener); + } +} + +class PeerNode { + constructor(private readonly _events: Events, private readonly _namespace: string, private readonly _name: string) {} + + subscribe(listener: EventListener): Unsubscribe { + return this._events.on(this._namespace, this._name, listener); + } +} + +/** The owner's tree: one node per declared name. */ +export function materialize(events: Events, namespace: string, def: EventsDef): Record { + const tree: Record = {}; + + for (const name of Object.keys(def)) { + tree[name] = new OwnNode(events, namespace, name); + } + + return tree; +} + +/** + * A peer's tree. Nothing is announced for events, so the names are not known here — the node for + * a name is built the first time it is asked for and kept from then on. A `Proxy`, like the typed + * rpc client: touched when a listener is attached, never on a tick. + */ +export function materializePeer(events: Events, namespace: string): Record { + const nodes = new Map(); + + return new Proxy({}, { + get: (_target, property): unknown => { + if (typeof property !== 'string') { return undefined; } + + let node = nodes.get(property); + + if (node === undefined) { + node = new PeerNode(events, namespace, property); + nodes.set(property, node); + } + + return node; + }, + }); +} diff --git a/packages/server-runtime/src/features.ts b/packages/server-runtime/src/features.ts index 296bea5..a13f96b 100644 --- a/packages/server-runtime/src/features.ts +++ b/packages/server-runtime/src/features.ts @@ -1,94 +1,90 @@ /** - * Togglable features driven by a condition over registry + state. + * `core.features` — togglable behavior driven by a condition over the registry and the mirror. * - * A feature declares a `condition(ctx): boolean`; the runtime re-evaluates it on every - * registry or state change and edge-triggers `onEnable`/`onDisable` when the result flips. - * Each feature's enabled state is published to sync state so other addons can observe it. + * A feature declares a `condition(ctx): boolean`; the runtime re-evaluates it on every registry + * or mirror change and edge-triggers `onEnable` / `onDisable` when the result flips. The enabled + * flags are announced under `core-feature/flags` as one record, so a peer's condition can depend + * on them. * - * Local feature: * ```ts * core.features.add('leaderboard-sync', { * condition: ctx => ctx.registry.has('other_studio_leaderboard'), * onEnable() { startSync(); }, * onDisable() { stopSync(); }, * }); - * ``` * - * Cross-addon feature check (in a condition): - * ```ts * core.features.add('cross-pvp', { - * condition: ctx => - * ctx.registry.has('other_studio_pvp') && - * ctx.feature('other_studio_pvp', 'arena-mode'), - * onEnable() { /* … *\/ }, - * onDisable() { /* … *\/ }, + * condition: ctx => ctx.feature('other_studio_pvp', 'arena-mode'), + * onEnable() { … }, + * onDisable() { … }, * }); - * ``` * - * Typed cross-addon read (outside a condition): - * ```ts * const pvp = core.features.of('other_studio_pvp'); - * pvp.isEnabled('arena-mode'); // type-checked + * pvp.isEnabled('arena-mode'); * ``` */ -import { stateKey } from '@bedrock-core/sync'; -import type { State, StateKey, Unsubscribe } from '@bedrock-core/sync'; +import type { State, Unsubscribe } from '@bedrock-core/sync'; +import { Announcement, isRecord } from './announcement'; import type { Registry } from './registry'; -const FEATURE_STATE_PREFIX = 'core-feature/'; +/** Feature id → enabled, the whole record announced on every flip. */ +export type FeatureFlags = Record; -/** Published enabled-flag for one feature of one addon. */ -const featureStateKey = (featureId: string): StateKey => stateKey(`${FEATURE_STATE_PREFIX}${featureId}`); - -// ─── Public types ───────────────────────────────────────────────────────────── +function isFeatureFlags(value: unknown): value is FeatureFlags { + return isRecord(value) && Object.values(value).every(flag => typeof flag === 'boolean'); +} +/** What a condition is evaluated against. */ export interface FeatureConditionContext { registry: Registry; state: State; + /** Whether a peer's feature is enabled, read from its announced flags. */ feature(addonId: string, featureId: string): boolean; } +/** One feature: when it is on, and what to do at each flip. */ export interface FeatureSpec { /** - * Whether the feature should be enabled right now. Re-evaluated on **every** registry - * and state change, so it must be a cheap, pure predicate over `ctx` — no side effects, - * no expensive work. + * Whether the feature should be enabled right now. Re-evaluated on **every** registry and + * mirror change, so it must be a cheap, pure predicate over `ctx` — no side effects, no + * expensive work. */ condition(ctx: FeatureConditionContext): boolean; onEnable(): void; onDisable(): void; } +/** A typed reader over one addon's flags. */ export interface TypedFeatureAccessor { isEnabled(id: T): boolean } -// ─── FeatureManager ─────────────────────────────────────────────────────────── - interface FeatureState { spec: FeatureSpec; enabled: boolean; } +/** Features that switch themselves on and off with a condition, announcing their flags. */ export class FeatureManager { + /** Every addon's flags, this one's included. */ + readonly flags: Announcement; + private readonly _registry: Registry; private readonly _state: State; - private readonly _addonId: string; private readonly _features = new Map(); private readonly _disposers: Unsubscribe[] = []; constructor(registry: Registry, state: State, addonId: string) { this._registry = registry; this._state = state; - this._addonId = addonId; + this.flags = new Announcement(state, addonId, 'feature/flags', isFeatureFlags); } start(): void { this._disposers.push( - this._registry.onRegister(() => this.evaluateAll()), - this._registry.onUnregister(() => this.evaluateAll()), - // Re-evaluate on every state change (conditions may read any published value, - // including other addons' config). Feature-flag writes triggered by evaluation - // can't loop: evaluate() short-circuits when the condition result hasn't flipped. + // Who is present, as a value: one subscription covers an addon arriving and one leaving. + this._registry.addons.subscribe(() => this.evaluateAll()), + // Every mirror change, since a condition may read any announced value. Announcing a flag + // cannot loop: evaluate() returns before publishing when the result has not flipped. this._state.subscribe(() => this.evaluateAll()), ); this.evaluateAll(); @@ -98,7 +94,7 @@ export class FeatureManager { for (const dispose of this._disposers.splice(0)) { dispose(); } } - /** Declare a feature. Evaluated immediately, then on every registry or state change. */ + /** Declare a feature. Evaluated immediately, then on every registry or mirror change. */ add(id: string, spec: FeatureSpec): void { this._features.set(id, { spec, enabled: false }); this.evaluate(id); @@ -109,12 +105,13 @@ export class FeatureManager { return this._features.get(id)?.enabled ?? false; } - /** - * Returns a typed accessor for reading another addon's feature flags. - * Reads are synchronous from the in-memory state mirror. - */ + /** A typed reader over another addon's announced flags; synchronous, from the local mirror. */ of(addonId: string): TypedFeatureAccessor { - return { isEnabled: (id: T) => this._state.get(addonId, featureStateKey(id)) === true }; + return { isEnabled: (id: T) => this.flagOf(addonId, id) }; + } + + private flagOf(addonId: string, featureId: string): boolean { + return this.flags.of(addonId)?.[featureId] === true; } private evaluateAll(): void { @@ -129,7 +126,7 @@ export class FeatureManager { const ctx: FeatureConditionContext = { registry: this._registry, state: this._state, - feature: (addonId, featureId) => this._state.get(addonId, featureStateKey(featureId)) === true, + feature: (addonId, featureId) => this.flagOf(addonId, featureId), }; const available = feature.spec.condition(ctx); @@ -137,7 +134,12 @@ export class FeatureManager { if (available === feature.enabled) { return; } feature.enabled = available; - this._state.set(this._addonId, featureStateKey(id), available); + + const flags: FeatureFlags = {}; + + for (const [featureId, entry] of this._features) { flags[featureId] = entry.enabled; } + + this.flags.provide(flags); if (available) { feature.spec.onEnable(); diff --git a/packages/server-runtime/src/guides/guides-registry.ts b/packages/server-runtime/src/guides/guides-registry.ts deleted file mode 100644 index e957da5..0000000 --- a/packages/server-runtime/src/guides/guides-registry.ts +++ /dev/null @@ -1,124 +0,0 @@ -/** - * Cross-addon guide sync. - * - * A guide is a compiled {@link GuideManifest} (the `guides` Regolith filter output, - * `@bedrock-core/generated/guides`). Each addon publishes its manifest to replicated state, - * so the elected host realm can list and render every addon's guide locally. - * - * Each addon publishes under its own namespace, so sync's late-join snapshot - * exchange replicates guides for free: - * - * ```ts - * import guides from '@bedrock-core/generated/guides'; - * - * core.register({ ..., guide: guides }); // manifest from the filter - * core.guides.provideManifest(guides); // or publish/replace later - * ``` - * - * Late joiners are covered by sync's state snapshot exchange. - */ -import { stateKey } from '@bedrock-core/sync'; -import type { State, Unsubscribe } from '@bedrock-core/sync'; -import type { GuideManifest } from './types'; - -/** State key an addon publishes its compiled manifest under (namespace = the addon's namespace). */ -const GUIDE_MANIFEST_STATE_KEY = stateKey('core-guide/manifest'); - -export type GuidesChangeListener = () => void; - -export class GuidesRegistry { - private readonly _state: State; - private readonly _addonId: string; - private readonly _listeners = new Set(); - private readonly _disposers: Unsubscribe[] = []; - private _addons: string[] | undefined; - - constructor(state: State, addonId: string) { - this._state = state; - this._addonId = addonId; - } - - start(): void { - this._disposers.push( - this._state.subscribe((change) => { - if (change.key !== GUIDE_MANIFEST_STATE_KEY) { return; } - - this._addons = undefined; - this.emitChange(); - }), - ); - } - - stop(): void { - for (const dispose of this._disposers.splice(0)) { dispose(); } - - this._listeners.clear(); - this._addons = undefined; - } - - /** - * Publish this addon's compiled guide manifest to replicated state so peers can render it - * without an RPC round-trip. Usually declared up front via `core.register({ guide })`; - * call directly to publish late or replace it. - */ - provideManifest(manifest: GuideManifest): void { - this._state.set(this._addonId, GUIDE_MANIFEST_STATE_KEY, manifest); - } - - /** This addon's own manifest, or `undefined` if it hasn't published one. */ - own(): GuideManifest | undefined { - return this.of(this._addonId); - } - - /** The manifest another addon published, or `undefined`. Local-mirror read, shallow-guarded. */ - of(addonId: string): GuideManifest | undefined { - return this.manifestFor(addonId); - } - - /** Every addon that published a guide manifest. Cached; rebuilt on change. */ - addonsWithGuides(): string[] { - if (this._addons) { return this._addons; } - - const addons: string[] = []; - - for (const ns of this._state.namespaces()) { - if (this.manifestFor(ns) !== undefined) { addons.push(ns); } - } - - this._addons = addons; - - return addons; - } - - /** Whether the given addon published a guide manifest. */ - has(addonId: string): boolean { - return this.manifestFor(addonId) !== undefined; - } - - /** Notified when any addon's published guide changes (coarse — re-read via `of`/`addonsWithGuides`). */ - subscribe(listener: GuidesChangeListener): Unsubscribe { - this._listeners.add(listener); - - return (): void => { - this._listeners.delete(listener); - }; - } - - /** - * Shallow envelope check only — the runtime deliberately does not understand the IR. A - * renderer narrows the payload properly with `isGuideManifest` from `@bedrock-core/guides`. - */ - private manifestFor(ns: string): GuideManifest | undefined { - const value = this._state.get(ns, GUIDE_MANIFEST_STATE_KEY); - - if (typeof value !== 'object' || value === null) { return undefined; } - - if (!('tree' in value) || !('pages' in value)) { return undefined; } - - return value; - } - - private emitChange(): void { - for (const listener of this._listeners) { listener(); } - } -} diff --git a/packages/server-runtime/src/guides/types.ts b/packages/server-runtime/src/guides/types.ts deleted file mode 100644 index 05c3c2b..0000000 --- a/packages/server-runtime/src/guides/types.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * What the runtime knows about a guide manifest: that it has a sidebar tree and a page table. - * - * The framework stores and replicates manifests without ever looking inside one — it never - * walks a block, resolves a link, or reads a localization key. So the detailed IR is owned by - * the renderer, `@bedrock-core/guides`, which is the only code that interprets it; keeping a - * copy here would mean maintaining types this package cannot use and cannot check. - * - * That leaves this shape as the storage contract, and it is exactly what - * {@link GuidesRegistry} already validates on read. Consumers that need the real thing narrow - * with `isGuideManifest` from `@bedrock-core/guides` at the point of rendering — where the - * data stops being an opaque payload and starts being a document. - * - * Structural on purpose, with no index signature: the renderer's richer `GuideManifest` is an - * interface, and an interface has no implicit index signature, so adding one here would make - * `core.register({ guide })` reject the very manifests the filter produces. The extra fields - * (`v`, `ns`, `defaultLocale`, `locales`) ride along unmentioned and untouched. - */ -export interface GuideManifest { - - /** Sidebar entries in display order. `GuideTreeNode[]` to the renderer. */ - tree: unknown; - - /** Page id → page data. `Record` to the renderer. */ - pages: unknown; -} diff --git a/packages/server-runtime/src/handle.ts b/packages/server-runtime/src/handle.ts new file mode 100644 index 0000000..39bf83b --- /dev/null +++ b/packages/server-runtime/src/handle.ts @@ -0,0 +1,34 @@ +/** + * Guarding engine handles. + * + * `Entity`, `Player`, `Block`, `Camera`, `Component`, `Container`, `ContainerSlot`, `Effect`, + * `ScoreboardIdentity`, `ScoreboardObjective`, `ScreenDisplay`, `Structure` and `Waypoint` all + * expose a readonly `isValid`. It goes false once the thing the handle points at is gone — + * despawned, disconnected, or moved into an unloaded chunk — and from then on every other member + * throws `InvalidEntityError` on access. + * + * This matters most for a handle that outlives the moment it was obtained: one stored in a map, or + * one delivered by an `afterEvents` subscriber, which by definition runs after the fact. A throw + * from inside an event subscriber is not caught by the caller; the engine catches it, logs it + * against the *pack's* name, and continues. For a library that means an addon depending on this + * runtime gets errors attributed to itself, so nothing here may throw out of a subscriber. + * + * `Entity.id` is the documented exception: it stays readable when `isValid` is false, which is why + * id-keyed bookkeeping still works for a handle that has gone stale. + */ + +/** Anything the engine can invalidate. Plain objects, which have no `isValid`, are always usable. */ +export interface EngineHandle { + readonly isValid?: boolean; +} + +/** + * Whether a handle can still be touched. + * + * False for `null`/`undefined` and for an invalidated handle; true for everything else, including + * objects that have no `isValid` at all. Narrows away the nullish case, so a stored handle can be + * looked up and checked in one step. + */ +export function isUsable(handle: T | null | undefined): handle is T { + return handle != null && handle.isValid !== false; +} diff --git a/packages/server-runtime/src/host.ts b/packages/server-runtime/src/host.ts index c05c775..ad0fa29 100644 --- a/packages/server-runtime/src/host.ts +++ b/packages/server-runtime/src/host.ts @@ -1,125 +1,97 @@ /** * Host election — which realm should do the work that exactly one realm may do. * - * Some jobs can't be done by every addon at once: rendering the shared config/guide UI is - * the motivating one. Bedrock's custom-command registry is world-global and `registerCommand` - * throws on a duplicate name, so whichever realm loads first *owns* `core:config` forever — - * there is no unregister API and registration only happens during the startup event. That - * ownership is immovable. - * - * What IS movable is who does the rendering. This election picks the realm running the - * NEWEST `@bedrock-core/server-runtime` ({@link RUNTIME_VERSION}), so a world with an addon - * built last year and one built today serves today's UI. The command owner becomes a router: - * it forwards to {@link HostElection.hostId} rather than rendering locally. + * Some jobs cannot be done by every addon in a world at once. This election picks the realm + * running the NEWEST `@bedrock-core/server-runtime` ({@link RUNTIME_VERSION}), so a world with + * an addon built last year and one built today gets today's behaviour for whatever is elected. * * ```ts - * if (core.host.isHost) { renderLocally(player); } - * else { core.rpc.request(core.host.hostId, 'core:open-ui', { playerId: player.id }); } + * if (core.host.isHost) { doTheWork(player); } + * else { core.rpc.request(core.host.id.get(), 'core:do-the-work', { playerId: player.id }); } * - * core.host.subscribe(hostId => console.warn('UI host is now', hostId)); + * core.host.id.subscribe(hostId => console.warn('the host is now', hostId)); * ``` * - * The result is deterministic and needs no negotiation messages: every realm sees the same - * registry and applies the same rule — highest runtime version, ties broken by the lowest - * namespace — so they all independently agree on the same winner. Re-elected whenever a - * peer appears or disappears. + * The winner is a pure function of the registry, so it is expressed as one: {@link HostElection.id} + * is `computed` over `registry.addons` and re-derives itself whenever an addon appears or + * disappears. Nothing is exchanged to reach it — every realm sees the same registry and applies the + * same rule (highest runtime version, ties broken by the lowest namespace), so they all + * independently agree on the same winner without a negotiation message. + * + * **Nothing uses it today.** The UI draws a screen in the realm whose pack holds it and asks + * that realm over RPC, which needs no single winner. It stays because one owner per job is what + * a capability needs — energy, fluids, physics: a single writer of a shared simulation — and + * that model is not measured yet. Read it as a mechanism waiting for its first caller, not as a + * description of how anything currently behaves. */ +import { computed, type Computed, type ReadonlyObservable } from '@bedrock-core/observable'; import type { Unsubscribe } from '@bedrock-core/sync'; import type { RegisteredAddon, Registry } from './registry'; import { compareVersions } from './version'; -/** Notified with the new host's namespace, and the previous one when there was one. */ -export type HostListener = (hostId: string, previousHostId: string | undefined) => void; +/** Notified with the new host's namespace, and the one it replaced. */ +export type HostListener = (hostId: string, previousHostId: string) => void; + +/** + * Highest runtime version wins; equal versions are broken by the lowest namespace. + * The tie-break must be total and stable or two realms could each believe they won. + */ +function elect(addons: readonly RegisteredAddon[], fallbackId: string): string { + let winner: RegisteredAddon | undefined; + + for (const addon of addons) { + if (winner === undefined) { + winner = addon; + continue; + } + + const byVersion = compareVersions(addon.runtimeVersion, winner.runtimeVersion); + + if (byVersion > 0 || (byVersion === 0 && addon.id < winner.id)) { winner = addon; } + } + + return winner?.id ?? fallbackId; +} +/** Which realm does the work only one realm may do: a value derived from the registry. */ export class HostElection { + /** + * Transport id of the realm running the newest runtime, as an observable. Falls back to this + * addon's own id when the registry is somehow empty, so callers always have a target. + */ + readonly id: ReadonlyObservable; + private readonly _registry: Registry; private readonly _selfId: string; - private readonly _listeners = new Set(); - private readonly _disposers: Unsubscribe[] = []; - private _hostId: string; + private readonly _id: Computed; constructor(registry: Registry, selfId: string) { this._registry = registry; this._selfId = selfId; - this._hostId = this.elect(); - } - - /** Re-elect whenever the set of live addons changes. */ - start(): void { - this._disposers.push( - this._registry.onRegister(() => this.reelect()), - this._registry.onUnregister(() => this.reelect()), - ); - - this.reelect(); + this._id = computed(() => elect(registry.addons.get(), selfId), [registry.addons], { label: 'host' }); + this.id = this._id; } + /** Stop following the registry. The last elected host stays readable. */ stop(): void { - for (const dispose of this._disposers.splice(0)) { dispose(); } - - this._listeners.clear(); - } - - /** - * Transport id of the realm running the newest runtime. Falls back to this addon's own id - * when the registry is somehow empty, so callers always have a target. - */ - get hostId(): string { - return this._hostId; + this._id.dispose(); } /** Whether this realm is the current host and should do the work itself. */ get isHost(): boolean { - return this._hostId === this._selfId; + return this._id.get() === this._selfId; } /** The elected host's registry entry, when it is still present. */ get host(): RegisteredAddon | undefined { - return this._registry.get(this._hostId); + return this._registry.get(this._id.get()); } /** - * Notified when hosting moves — including the initial election, if you subscribe before - * peers appear. Fires only on an actual change. Returns an unsubscribe function. + * Notified when hosting moves. Fires only on an actual change; subscribing does not deliver the + * current host, which {@link HostElection.id} already has. Returns an unsubscribe function. */ subscribe(listener: HostListener): Unsubscribe { - this._listeners.add(listener); - - return (): void => { - this._listeners.delete(listener); - }; - } - - /** - * Highest runtime version wins; equal versions are broken by the lowest namespace. - * The tie-break must be total and stable or two realms could each believe they won. - */ - private elect(): string { - let winner: RegisteredAddon | undefined; - - for (const addon of this._registry.all()) { - if (winner === undefined) { - winner = addon; - continue; - } - - const byVersion = compareVersions(addon.runtimeVersion, winner.runtimeVersion); - - if (byVersion > 0 || (byVersion === 0 && addon.id < winner.id)) { winner = addon; } - } - - return winner?.id ?? this._selfId; - } - - private reelect(): void { - const next = this.elect(); - - if (next === this._hostId) { return; } - - const previous = this._hostId; - - this._hostId = next; - - for (const listener of this._listeners) { listener(next, previous); } + return this._id.subscribe(listener); } } diff --git a/packages/server-runtime/src/index.ts b/packages/server-runtime/src/index.ts index c09d3a0..07e3436 100644 --- a/packages/server-runtime/src/index.ts +++ b/packages/server-runtime/src/index.ts @@ -1,104 +1,68 @@ -/** - * `@bedrock-core/server-runtime` — the bedrock-core server runtime. - * - * Addons register their identity + base data with the runtime, which flows into a - * cross-addon registry built on `@bedrock-core/sync`. Typical usage: - * - * ```ts - * import { core } from '@bedrock-core/server-runtime'; - * import bundle from '@bedrock-core/generated/i18n'; - * import guides from '@bedrock-core/generated/guides'; - * - * // register() brings the addon online — there is no separate start(). Everything the - * // addon declares rides in the one call; translations/guide/config are all optional. - * const config = core.register({ - * creator: 'bt', // creator/vendor id, lowercase a-z0-9_ - * pack: 'gc_shop', // abbreviated pack id — together: namespace `bt_gc_shop` - * packName: 'My Cool Shop', // display label only - * version: '1.2.0', - * dependencies: ['os_economy'], // namespaces (`creator_pack`) - * optionalDependencies: ['os_leaderboards'], - * translations: bundle, // the i18n filter's bundle, shared with other addons' UIs - * guide: guides, // compiled guide manifest from the guides filter - * config: { server: { taxRate: { type: 'number', default: 0.05, min: 0, max: 1, label: 'Tax Rate' } } }, - * }); - * - * config.server.get().taxRate; // typed accessors, same as core.config.define()'s return - * core.registry.onRegister(addon => console.warn('registered', addon.id)); - * core.registry.onNamespaceCollision(info => console.error('collision', info.id)); - * core.features.add('leaderboard-sync', { - * condition: ctx => ctx.registry.has('os_leaderboards'), - * onEnable() { console.warn('leaderboards available'); }, - * onDisable() { console.warn('leaderboards gone'); }, - * }); - * core.rpc.onRequest('buy', params => purchase(params)); - * core.state.set('price', 10); // namespace pre-filled from core.namespace - * ``` - */ -export { Runtime, core } from './runtime'; -export type { RegisterOptions } from './runtime'; +export { core, Runtime } from './runtime'; +export type { Declared, RegisterOptions, RuntimeSlots } from './runtime'; + +export type { Declaration } from './declaration'; export { RUNTIME_VERSION } from './runtime-version'; -export { compareVersions } from './version'; +export { Announcement, isRecord } from './announcement'; +export type { AnnouncementListener } from './announcement'; + +export type { HostElection, HostListener } from './host'; -export { HostElection } from './host'; -export type { HostListener } from './host'; +export type { IncompatibleListener, IncompatiblePeer } from '@bedrock-core/sync'; +export type { AddonListener, CollisionListener, RegisteredAddon, Registry } from './registry'; -export { Registry } from './registry'; -export type { RegisteredAddon, AddonListener, CollisionListener } from './registry'; +export type { FeatureConditionContext, FeatureFlags, FeatureManager, FeatureSpec, TypedFeatureAccessor } from './features'; -export { FeatureManager } from './features'; -export type { FeatureSpec, FeatureConditionContext, TypedFeatureAccessor } from './features'; +// What an addon needs to declare a collection on `core.db`; the package itself is the source for the rest. +export { + accepting, + allOf, + anyOf, + blockTypes, + DbBudgetError, + DbTargetError, + dimensions, + entityTypes, + except, + players, + schema, + slots, + worldTarget, +} from '@bedrock-core/db'; +export type { Collection, Db, Document, IndexedDocument, Schema } from '@bedrock-core/db'; -export { ScopedState, RESERVED_STATE_PREFIX, isReservedStateKey } from './scoped-state'; +export { event, registerEvents } from './events'; +export type { + EventListener, + EventMarker, + EventsDef, + EventsRegistry, + EventsTree, + OwnEvent, + PayloadOf, + PeerEvent, + PeerEventsTree, +} from './events'; + +export { registerShared } from './shared'; +export type { + PeerSharedTree, + PeerValue, + Shape, + SharedDef, SharedRegistry, SharedTree, + SharedValue, +} from './shared'; -export { addonNamespace, validateManifest } from './manifest'; export type { AddonManifest, ManifestMeta } from './manifest'; -export type { TypedClient, RPCHandlerMap } from '@bedrock-core/sync'; +export type { RPCHandlerMap, TypedClient } from '@bedrock-core/sync'; -export { TranslationsRegistry } from './translations'; -export type { TranslationsChangeListener } from './translations'; -export type { I18nBundle, TranslationResolver } from '@bedrock-core/i18n'; +export { isUsable, type EngineHandle } from './handle'; -export { GuidesRegistry } from './guides/guides-registry'; -export type { GuidesChangeListener } from './guides/guides-registry'; -export type { GuideManifest } from './guides/types'; +export type { I18nBundle, TranslationResolver } from '@bedrock-core/i18n'; +export type { TranslationsRegistry } from './translations'; -export { ConfigRegistry } from './config/config-registry'; -export type { Config, ConfigAccessOptions, LocalConfigScopes, RemoteConfigAccessor, TypedRemoteConfig } from './config/config-registry'; -export { denyReason, isOperator } from './config/authorization'; -export type { ConfigScopeName } from './config/authorization'; -export { EntityConfigScope } from './config/scopes'; -export { ServerConfigScope } from './config/scopes'; -export type { - ServerConfigTree, - ConfigTree, - ConfigNode, - ConfigChildren, - ConfigGroupAccessor, - ConfigLeafAccessor, - NodeValue, -} from './config/scopes'; -export { RESERVED_KEYS, flattenGroups, flattenSchema, isGroupMetaKey, validateConfigSchema } from './config/schema'; -export type { - ConfigDefinition, - ConfigEntry, - ConfigValue, - BooleanEntry, - NumberEntry, - StringEntry, - EnumEntry, - ListEntry, - MultiselectEntry, - FlatSchema, - FlatGroups, - GroupMeta, - SerializedEntry, - SerializedGroup, - SchemaToValue, - DotPath, - PathValue, - DeepPartial, -} from './config/schema'; +export { authorize, denyReason, isOperator } from './authorization'; +export type { AccessTarget, Operation } from './authorization'; diff --git a/packages/server-runtime/src/manifest.ts b/packages/server-runtime/src/manifest.ts index c3df236..cf328d3 100644 --- a/packages/server-runtime/src/manifest.ts +++ b/packages/server-runtime/src/manifest.ts @@ -17,6 +17,7 @@ */ import { RUNTIME_VERSION } from './runtime-version'; +/** Who an addon is: identity, display labels, version and dependencies. */ export interface AddonManifest { /** Creator/vendor id, lowercase alphanumeric + underscores (e.g. `bt` for Bedrock Tweaks). */ diff --git a/packages/server-runtime/src/registry.ts b/packages/server-runtime/src/registry.ts index 6965cec..cefea07 100644 --- a/packages/server-runtime/src/registry.ts +++ b/packages/server-runtime/src/registry.ts @@ -4,11 +4,32 @@ * an {@link AddonManifest}; the registry merges the local addon (self) with all live peers, * keyed by the unique namespace (`creator_pack`, e.g. `bt_gc_economy`). * + * {@link Registry.addons} is the directory itself: an observable list derived from discovery, so + * it can be watched or `computed` over exactly like a config leaf or a shared value. The registry + * holds no directory state of its own — `all()`, `get()` and `has()` all read that one list — so + * there is never a cached answer to "who is here" that can disagree with the live one. + * * A collision (two addons with the same namespace) is surfaced via * {@link Registry.onNamespaceCollision} and logged. Dependencies are declared and matched * by namespace and are soft: a missing one warns but never blocks. + * + * An addon whose transport is too far from this one's to negotiate is not a registry entry — there + * is no manifest to read without a conversation — but it is not silence either. + * {@link Registry.incompatible} lists what was heard and could not be reached, so the addon list + * can show a row saying so instead of leaving one out. */ -import type { CollisionInfo, Discovery, PeerInfo, Unsubscribe } from '@bedrock-core/sync'; +import { + PROTOCOL_MAX, + PROTOCOL_MIN, + type CollisionInfo, + type Discovery, + type IncompatibleListener, + type IncompatiblePeer, + type PeerInfo, + type ReadonlyObservable, + type Unsubscribe, +} from '@bedrock-core/sync'; +import { computed, type Computed } from '@bedrock-core/observable'; import { addonNamespace, type AddonManifest, manifestFromPeer, runtimeVersionFromPeer } from './manifest'; import { RUNTIME_VERSION } from './runtime-version'; @@ -19,36 +40,72 @@ import { RUNTIME_VERSION } from './runtime-version'; */ export type RegisteredAddon = AddonManifest & { id: string; self: boolean; runtimeVersion: string }; +/** Told an addon that registered or unregistered. */ export type AddonListener = (addon: RegisteredAddon) => void; + +/** Told two live addons claim the same namespace. */ export type CollisionListener = (info: CollisionInfo) => void; +function sameIds(a: readonly string[], b: readonly string[]): boolean { + return a.length === b.length && a.every((id, i) => id === b[i]); +} + +/** Every bedrock-core addon in the world, from discovery: presence, dependencies, collisions. */ export class Registry { + /** + * Every registered addon — the local addon plus all live peers — as an observable list. + * + * Derived from `discovery.peers`, so it republishes exactly when the world changes and not on + * the heartbeats that say nothing new. The local addon is always first. + */ + readonly addons: ReadonlyObservable; + + /** + * This addon's declared dependencies that are not currently present, as an observable list. + * Empty means satisfied. + */ + readonly missing: ReadonlyObservable; + private readonly _discovery: Discovery; private readonly _self: RegisteredAddon; - private readonly _onRegister = new Set(); - private readonly _onUnregister = new Set(); + private readonly _addons: Computed; + private readonly _missing: Computed; + private readonly _satisfied: Computed; private readonly _onCollision = new Set(); + private readonly _onIncompatible = new Set(); private readonly _onDepsSatisfied = new Set<() => void>(); private readonly _disposers: Unsubscribe[] = []; - private _depsSatisfied: boolean; - private _missingSinceLastCheck: string[]; constructor(discovery: Discovery, self: AddonManifest) { this._discovery = discovery; this._self = { ...self, id: addonNamespace(self), self: true, runtimeVersion: RUNTIME_VERSION }; - this._missingSinceLastCheck = this.missingDependencies(); - this._depsSatisfied = this._missingSinceLastCheck.length === 0; + + this._addons = computed( + () => [this._self, ...discovery.peers.get().map(peer => this.peerToAddon(peer))], + [discovery.peers], + { label: 'registry.addons' }, + ); + this._missing = computed( + () => (this._self.dependencies ?? []).filter(id => !this.has(id)), + [this._addons], + { equals: sameIds, label: 'registry.missing' }, + ); + this._satisfied = computed(() => this._missing.get().length === 0, [this._missing]); + + this.addons = this._addons; + this.missing = this._missing; } - /** Bridge discovery events into registry events and do an initial dependency check. */ + /** Bridge discovery's deltas into registry events and start reporting on dependencies. */ start(): void { this._disposers.push( - this._discovery.onPeerUp(peer => this.handlePeerUp(peer)), - this._discovery.onPeerDown(peer => this.handlePeerDown(peer)), this._discovery.onCollision(info => this.handleCollision(info)), + this._discovery.onIncompatible(peer => this.handleIncompatible(peer)), + this._satisfied.subscribe(satisfied => this.handleSatisfied(satisfied)), + this._missing.subscribe((next, previous) => this.reportDependencies(next, previous)), ); - const missing = this.missingDependencies(); + const missing = this._missing.get(); if (missing.length > 0) { console.info(`[bedrock-core] '${this._self.id}' missing dependencies: ${missing.join(', ')}`); @@ -57,20 +114,20 @@ export class Registry { stop(): void { for (const dispose of this._disposers.splice(0)) { dispose(); } + + this._satisfied.dispose(); + this._missing.dispose(); + this._addons.dispose(); } - /** Every registered addon: the local addon plus all live peers. */ - all(): RegisteredAddon[] { - return [this._self, ...this._discovery.peers.map(peer => this.peerToAddon(peer))]; + /** Every registered addon: the local addon plus all live peers. A snapshot of {@link Registry.addons}. */ + all(): readonly RegisteredAddon[] { + return this._addons.get(); } /** Look up an addon by its namespace (`creator_pack`, e.g. `bt_gc_economy`). */ get(id: string): RegisteredAddon | undefined { - if (id === this._self.id) { return this._self; } - - const peer = this._discovery.peers.find(p => p.id === id); - - return peer ? this.peerToAddon(peer) : undefined; + return this._addons.get().find(addon => addon.id === id); } /** Whether an addon with the given namespace is present. */ @@ -78,21 +135,35 @@ export class Registry { return this.get(id) !== undefined; } - /** Notified when a peer addon registers (becomes visible). Returns an unsubscribe function. */ + /** + * Notified when a peer addon registers (becomes visible). Returns an unsubscribe function. + * + * A delta, not a value: to react to who is present rather than to each arrival, subscribe to + * {@link Registry.addons}. + */ onRegister(listener: AddonListener): Unsubscribe { - this._onRegister.add(listener); - - return (): void => { - this._onRegister.delete(listener); - }; + return this._discovery.onPeerUp(peer => listener(this.peerToAddon(peer))); } /** Notified when a peer addon unregisters (goes away). Returns an unsubscribe function. */ onUnregister(listener: AddonListener): Unsubscribe { - this._onUnregister.add(listener); + return this._discovery.onPeerDown(peer => listener(this.peerToAddon(peer))); + } + + /** + * Addons heard on the bus that this build cannot talk to, because the protocol ranges the two + * were built with do not overlap. Present in the world, absent from {@link Registry.all}. + */ + incompatible(): readonly IncompatiblePeer[] { + return this._discovery.incompatiblePeers.get(); + } + + /** Notified the first time an unreachable addon is heard. Returns an unsubscribe function. */ + onIncompatible(listener: IncompatibleListener): Unsubscribe { + this._onIncompatible.add(listener); return (): void => { - this._onUnregister.delete(listener); + this._onIncompatible.delete(listener); }; } @@ -106,10 +177,8 @@ export class Registry { } /** This addon's declared dependencies (namespaces) that are not currently present. */ - missingDependencies(): string[] { - const deps = this._self.dependencies ?? []; - - return deps.filter(id => !this.has(id)); + missingDependencies(): readonly string[] { + return this._missing.get(); } /** @@ -120,7 +189,7 @@ export class Registry { onDependenciesSatisfied(listener: () => void): Unsubscribe { this._onDepsSatisfied.add(listener); - if (this._depsSatisfied) { listener(); } + if (this._satisfied.get()) { listener(); } return (): void => { this._onDepsSatisfied.delete(listener); @@ -133,44 +202,35 @@ export class Registry { return { ...manifest, id: peer.id, self: false, runtimeVersion: runtimeVersionFromPeer(peer.meta) }; } - private handlePeerUp(peer: PeerInfo): void { - const addon = this.peerToAddon(peer); - - for (const listener of this._onRegister) { listener(addon); } + /** The edge a derived value cannot deliver on its own: the moment the last dependency arrives. */ + private handleSatisfied(satisfied: boolean): void { + if (!satisfied) { return; } - this.evaluateDependencies(); + for (const listener of this._onDepsSatisfied) { listener(); } } - private handlePeerDown(peer: PeerInfo): void { - const addon = this.peerToAddon(peer); + private reportDependencies(missing: readonly string[], previous: readonly string[]): void { + if (missing.length === 0) { + console.info(`[bedrock-core] '${this._self.id}' dependencies resolved: ${previous.join(', ')}`); - for (const listener of this._onUnregister) { listener(addon); } + return; + } - this.evaluateDependencies(); + console.info(`[bedrock-core] '${this._self.id}' missing dependencies: ${missing.join(', ')}`); } - private handleCollision(info: CollisionInfo): void { - console.error(`[bedrock-core] collision: another instance shares identity '${info.id}'`); + private handleIncompatible(peer: IncompatiblePeer): void { + console.warn( + `[bedrock-core] '${peer.id}' speaks sync protocol ${peer.pmin}-${peer.pmax}, this addon speaks ` + + `${PROTOCOL_MIN}-${PROTOCOL_MAX}; the two cannot talk. Update whichever is older.`, + ); - for (const listener of this._onCollision) { listener(info); } + for (const listener of this._onIncompatible) { listener(peer); } } - private evaluateDependencies(): void { - const missing = this.missingDependencies(); - const satisfied = missing.length === 0; - - if (satisfied === this._depsSatisfied) { return; } - - this._depsSatisfied = satisfied; - - if (satisfied) { - console.info(`[bedrock-core] '${this._self.id}' dependencies resolved: ${this._missingSinceLastCheck.join(', ')}`); - - for (const listener of this._onDepsSatisfied) { listener(); } - } else { - console.info(`[bedrock-core] '${this._self.id}' missing dependencies: ${missing.join(', ')}`); - } + private handleCollision(info: CollisionInfo): void { + console.error(`[bedrock-core] collision: another instance shares identity '${info.id}'`); - this._missingSinceLastCheck = missing; + for (const listener of this._onCollision) { listener(info); } } } diff --git a/packages/server-runtime/src/runtime-version.ts b/packages/server-runtime/src/runtime-version.ts index 0726575..a343c6c 100644 --- a/packages/server-runtime/src/runtime-version.ts +++ b/packages/server-runtime/src/runtime-version.ts @@ -9,4 +9,4 @@ * - `@bedrock-core/config` renders it as the synthetic bedrock-core entry in the addon list. * - {@link HostElection} elects the highest version present as the UI host. */ -export const RUNTIME_VERSION = '0.1.0'; +export const RUNTIME_VERSION = '0.2.0'; diff --git a/packages/server-runtime/src/runtime.ts b/packages/server-runtime/src/runtime.ts index a2da7d0..035575c 100644 --- a/packages/server-runtime/src/runtime.ts +++ b/packages/server-runtime/src/runtime.ts @@ -3,68 +3,87 @@ * * An addon registers itself once — that's it. {@link Runtime.register} validates the manifest * and immediately brings the addon online (no separate `start()`). Everything the addon - * *declares* rides in that one call: identity, plus the optional `translations`, `guide`, and - * `config` fields (see {@link RegisterOptions}). The runtime wraps a single sync `SyncNode` - * and exposes the cross-addon {@link Registry}, the {@link FeatureManager}, and - * messaging/state passthroughs. + * *declares* rides in that one call: identity in `manifest`, and beside it any number of + * {@link Declaration} fields, each installing a subsystem and handing back its typed accessor + * (see {@link RegisterOptions}). The runtime wraps a single sync `SyncNode` and exposes the + * cross-addon {@link Registry}, the {@link FeatureManager} and the messaging passthroughs. * * The default export is the `core` singleton — `import { core } from '@bedrock-core/server-runtime'`. * The `Runtime` class stands alone, so tests (and GameTests) can create several runtimes in one * script realm; they all talk over the real `system` script-event bus. */ +import type { Db } from '@bedrock-core/db'; +import { createEngineDb } from '@bedrock-core/db/minecraft'; +import { currentI18n } from '@bedrock-core/i18n'; +import type { Rpc } from '@bedrock-core/sync'; import { SyncNode } from '@bedrock-core/sync'; -import { addonNamespace, type AddonManifest, manifestToMeta, validateManifest } from './manifest'; +import { system } from '@minecraft/server'; +import { type Declaration, isDeclaration } from './declaration'; +import { EventsRegistry } from './events/events-registry'; import { FeatureManager } from './features'; +import { HostElection } from './host'; +import { type AddonManifest, addonNamespace, manifestToMeta, validateManifest } from './manifest'; import { Registry } from './registry'; -import { ScopedState } from './scoped-state'; -import { ConfigRegistry, type Config } from './config/config-registry'; -import type { ConfigDefinition } from './config/schema'; -import type { I18nBundle } from '@bedrock-core/i18n'; +import { SharedRegistry } from './shared/shared-registry'; import { TranslationsRegistry } from './translations'; -import { GuidesRegistry } from './guides/guides-registry'; -import { HostElection } from './host'; -import type { GuideManifest } from './guides/types'; -import type { Rpc } from '@bedrock-core/sync'; /** - * Everything an addon declares when it registers: the identity manifest plus the optional - * cross-addon data. One bag — "tell core what you are" — then run your own code. Each optional - * field is sugar for the corresponding post-register call and behaves identically: + * Everything an addon declares when it registers: the identity `manifest`, plus one field per + * declaration — `config: registerConfig(definition)`, `shared: registerShared(keys)`, `events: registerEvents(tree)`. + * One bag — "tell core what you are" — then run your own code. * - * - `translations` → `core.translations.provide()` - * - `guide` → `core.guides.provideManifest()` - * - `config` → `core.config.define()` (its typed accessors become `register()`'s return value) - * - * The standalone calls remain available for addons that need to publish late or replace data - * at runtime. + * A field holding a {@link Declaration} is installed and its accessor comes back under that same + * key; anything else in the bag is ignored. Declarations install in the order their keys were + * written, onto an already-live runtime. */ -export interface RegisterOptions extends AddonManifest { +export interface RegisterOptions { - /** - * This addon's i18n bundle (`@bedrock-core/generated/i18n`, or a - * `createResourceBundle` result), published to replicated state so other - * addons' UIs can resolve and measure its strings — and get verbs over them - * via `core.translations.of()`. - */ - translations?: I18nBundle; + /** Who this addon is: creator, pack, display names, version, dependencies. */ + manifest: AddonManifest; +} - /** This addon's compiled guide manifest (`@bedrock-core/generated/guides`), published for the elected host to render. */ - guide?: GuideManifest; +/** + * What `register()` hands back: one entry per declaration in the options bag, under the key it was + * declared as, typed by what that declaration's `install` returns. + */ +export type Declared = { + [K in Exclude as O[K] extends Declaration ? K : never]: + O[K] extends Declaration ? T : never +}; - /** This addon's config schema. When given, `register()` returns the typed scope accessors. */ - config?: I; -} +/** + * The subsystems a package outside this repository parks on the runtime, keyed by name. + * + * Empty here, and filled in by module augmentation from the package that owns each subsystem — the + * floor never imports what it holds: + * + * ```ts + * declare module '@bedrock-core/server-runtime' { + * interface RuntimeSlots { 'acme:widgets': WidgetRegistry } + * } + * ``` + * + * A key is namespaced the way a feed or an rpc method is, so two packages that both augment this + * never collide. A declaration fills its slot from `install`, through + * {@link Runtime.fill}, and the owning package reads it back with {@link Runtime.slot} — deciding + * for itself what an unfilled slot means, since only it knows whether absence is an error or the + * ordinary state of an addon that declared nothing. + */ +export interface RuntimeSlots {} +/** An addon's handle to the framework; `core` is the one a pack uses. */ export class Runtime { private _node: SyncNode | undefined; private _registry: Registry | undefined; private _features: FeatureManager | undefined; private _manifest: AddonManifest | undefined; - private _state: ScopedState | undefined; - private _config: ConfigRegistry | undefined; + private _db: Db | undefined; private _translations: TranslationsRegistry | undefined; - private _guides: GuidesRegistry | undefined; private _host: HostElection | undefined; + private _shared: SharedRegistry | undefined; + private _events: EventsRegistry | undefined; + private _slots = new Map(); + private readonly _declarations: Declaration[] = []; /** Whether the addon has been registered (and is therefore live). */ get registered(): boolean { @@ -74,7 +93,7 @@ export class Runtime { /** * This addon's namespace: `creator_pack` (e.g. `bt_gc_economy`). * - * The one identifier it is known by — RPC targeting, state keys, dependency declarations, + * The one identifier it is known by — RPC targeting, mirror keys, dependency declarations, * and the namespace of any custom command or command enum it registers. */ get id(): string { @@ -101,29 +120,48 @@ export class Runtime { return this.require(this._features, 'features'); } - /** The config registry — declare this addon's config via `register({ config })` (or `core.config.define()` for late definition). */ - get config(): ConfigRegistry { - return this.require(this._config, 'config'); - } - - /** Cross-addon i18n bundles — publish via `register({ translations })` (or `core.translations.provide()`); `forPlayer(player)` for the chained resolver, `of(addonId)` for a peer's verbs. */ + /** Cross-addon i18n bundles — publish via `core.translations.provide()`; `forPlayer(player)` for the chained resolver, `of(addonId)` for a peer's verbs. */ get translations(): TranslationsRegistry { return this.require(this._translations, 'translations'); } - /** Cross-addon guides — publish via `register({ guide })` (or `core.guides.provideManifest()` to replace at runtime), `core.guides.of()` for cross-addon reads. */ - get guides(): GuidesRegistry { - return this.require(this._guides, 'guides'); - } - - /** Host election — `core.host.isHost` tells you whether this realm should do the work only one realm may do (e.g. render the shared UI). */ + /** Host election — `core.host.isHost` tells you whether this realm should do the work only one realm may do, and `core.host.id` is that realm as an observable. Nothing in the stack elects anything today; see `host.ts`. */ get host(): HostElection { return this.require(this._host, 'host'); } - /** Replicated state scoped to this addon's namespace — no need to pass the namespace on every call. For cross-namespace reads use `core.node.state`. */ - get state(): ScopedState { - return this.require(this._state, 'state'); + /** + * The shared mirror as typed trees: this addon's own comes back from its `shared: registerShared(keys)` + * declaration, a peer's from `core.shared.of(ns)` — which an addon that declares nothing of + * its own may read too. Every node has `get` / `subscribe`; own nodes and opened peer leaves also + * `set`. + */ + get shared(): SharedRegistry { + this._shared ??= new SharedRegistry({ state: this.requireNode().state, namespace: this.namespace }); + + return this._shared; + } + + /** + * Events as typed trees: this addon's own comes back from its `events: registerEvents(tree)` declaration, + * another addon's from `core.events.of(ns)` — open to an addon that announces nothing itself. + * An owner's node has `emit` and `subscribe`, a peer's `subscribe` alone, and a listener may be + * attached before the announcing addon exists. + */ + get events(): EventsRegistry { + this._events ??= new EventsRegistry({ events: this.requireNode().events, namespace: this.namespace }); + + return this._events; + } + + /** + * Persisted documents for this addon, keyed by target — players, entities, blocks, the world — + * on whatever dynamic properties the target itself can hold: `core.db.collection(name, { schema, accept })`. + * Its keys live under this addon's namespace, so two addons never meet. Local: nothing here is + * reachable from another realm unless this addon serves it. + */ + get db(): Db { + return this.require(this._db, 'db'); } /** RPC messaging (passthrough to the underlying sync node). */ @@ -140,83 +178,124 @@ export class Runtime { * Declare this addon and bring it online. Call exactly once. Throws on an invalid manifest * or a second registration. No separate start step is needed. * - * Beyond identity, the options bag carries everything the addon declares up front: - * `translations`, `guide`, and `config` (see {@link RegisterOptions}). When `config` is - * given, the typed scope accessors are returned — the same value `core.config.define()` - * would return. + * Beyond identity, the options bag carries every {@link Declaration} the addon makes. Each + * installs onto the live runtime in the order its key was written, and the result holds that + * declaration's accessor under the same key. */ - register(options: RegisterOptions & { config: I }): Config; - register(options: RegisterOptions): void; - register(options: RegisterOptions): Config | undefined { + register(options: O): Declared; + register(options: RegisterOptions): Record { if (this._manifest) { throw new Error('runtime is already registered'); } - // validateManifest() copies only the identity fields, so the extra declaration - // fields never leak into the stored manifest or the discovery meta blob. - const validated = validateManifest(options); + const validated = validateManifest(options.manifest); this._manifest = validated; - // One namespace for everything this addon owns — transport id, state, config, guides. + // One namespace for everything this addon owns — transport id, mirror, config, guides. const namespace = addonNamespace(validated); const node = new SyncNode({ id: namespace, version: validated.version, meta: manifestToMeta(validated), - // Only owned namespaces are answered in sync's late-join snapshot exchange. - ownedNamespaces: [namespace], }); const registry = new Registry(node.discovery, validated); const features = new FeatureManager(registry, node.state, namespace); - const config = new ConfigRegistry(node, namespace); + // Local persistence, live before any declaration: a declaration stores through it. + const db = createEngineDb(namespace, message => console.warn(message)); const translations = new TranslationsRegistry(node.state, namespace); - const guides = new GuidesRegistry(node.state, namespace); const host = new HostElection(registry, namespace); this._node = node; this._registry = registry; this._features = features; - this._state = new ScopedState(node.state, namespace); - this._config = config; + this._db = db; this._translations = translations; - this._guides = guides; this._host = host; node.start(); registry.start(); features.start(); - config.start(); translations.start(); - guides.start(); - host.start(); - if (options.translations) { translations.provide(options.translations); } + const declared: Record = {}; + + for (const [key, value] of Object.entries(options)) { + if (key === 'manifest' || !isDeclaration(value)) { continue; } + + this._declarations.push(value); + declared[key] = value.install(this); + } + + // This addon's own strings, on the first tick. `createI18n(bundle)` runs in a module the + // entry imports, so the instance exists by now; publishing it from the floor is what lets an + // addon that draws nothing still have the translation keys in its manifest — `packName`, + // `creatorName`, `description` — resolved by whatever realm lists it. + system.run(() => { this.publishOwnTranslations(); }); + + return declared; + } + + /** + * What is parked under `key`, or `undefined` when nothing filled that slot. The package that + * augmented {@link RuntimeSlots} with the key is the one that reads it, and the one that decides + * what absence means. + */ + slot(key: K): RuntimeSlots[K] | undefined { + // Only `fill` writes the map, and it writes `RuntimeSlots[K]` under `K`. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + return this._slots.get(key) as RuntimeSlots[K] | undefined; + } - if (options.guide) { guides.provideManifest(options.guide); } + /** + * Park a subsystem under `key`. Called by a declaration's `install`, which owns building the + * thing; the runtime only hands it back. A slot is filled once — a second fill throws rather + * than leaving two owners of one key. + */ + fill(key: K, value: RuntimeSlots[K]): void { + if (this._slots.has(key)) { throw new Error(`runtime slot '${String(key)}' is already filled`); } - return options.config ? config.define(options.config) : undefined; + this._slots.set(key, value); } /** Take the addon offline. Safe to call before registering (no-op). */ stop(): void { + // Reverse install order: a declaration comes down before what it was built on. + for (const declaration of this._declarations.splice(0).reverse()) { + declaration.stop?.(); + } + this._host?.stop(); - this._guides?.stop(); this._translations?.stop(); - this._config?.stop(); this._features?.stop(); this._registry?.stop(); this._node?.stop(); + this._slots.clear(); + this._events = undefined; + this._shared = undefined; this._host = undefined; - this._guides = undefined; this._translations = undefined; - this._config = undefined; this._features = undefined; this._registry = undefined; - this._state = undefined; + this._db = undefined; this._node = undefined; this._manifest = undefined; } + /** + * Announce the bundle this addon's default i18n instance was created with. + * + * Nothing happens for an addon that created none, and nothing happens if the runtime was + * stopped before the tick arrived. A package above the floor may publish a different bundle + * afterwards; the last one announced is what peers read. + */ + private publishOwnTranslations(): void { + const bundle = currentI18n()?.bundle; + + if (bundle === undefined || this._translations === undefined) { return; } + + this._translations.provide(bundle); + } + private requireManifest(): AddonManifest { return this.require(this._manifest, 'manifest'); } diff --git a/packages/server-runtime/src/scoped-state.ts b/packages/server-runtime/src/scoped-state.ts deleted file mode 100644 index c7619d0..0000000 --- a/packages/server-runtime/src/scoped-state.ts +++ /dev/null @@ -1,94 +0,0 @@ -import type { State, StateChangeListener, StateKey, Unsubscribe } from '@bedrock-core/sync'; - -/** - * Keys under this prefix belong to the framework, not to the addon. - * - * An addon's namespace carries more than the addon put there: the config schema, the by-locale - * translation keys, the compiled guide and the feature states all replicate under the same - * namespace (`core-config/`, `core-i18n/`, `core-guide/`, `core-feature/`). Those are large — a compiled - * guide alone dwarfs a dynamic property's 32 KB limit — so an addon that reasonably treats - * "my namespace" as "my data" and persists it would blow up on the first write. - * - * {@link ScopedState} therefore hides them: it reads, reports and enumerates only what the addon - * itself wrote, and refuses to let it write into the reserved space. Reach the raw namespace, - * framework keys included, through `core.node.state`. - */ -export const RESERVED_STATE_PREFIX = 'core-'; - -/** Whether a state key belongs to the framework rather than to the addon that owns the namespace. */ -export function isReservedStateKey(key: string): boolean { - return key.startsWith(RESERVED_STATE_PREFIX); -} - -/** - * A thin wrapper around {@link State} that pre-fills the namespace with the addon's own id, so - * callers don't have to repeat it on every call, and that hides the framework's own keys — see - * {@link RESERVED_STATE_PREFIX}. The raw `State` is still accessible via `core.node.state` for - * reading other addons' namespaces, or this one's framework keys. - */ -export class ScopedState { - private readonly _state: State; - private readonly _ns: string; - - constructor(state: State, ns: string) { - this._state = state; - this._ns = ns; - } - - set(key: StateKey, value: NoInfer): void; - set(key: string, value: unknown): void { - this.requireOwnKey(key, 'set'); - this._state.set(this._ns, key, value); - } - - get(key: StateKey): NoInfer | undefined; - get(key: string): unknown | undefined { - return isReservedStateKey(key) ? undefined : this._state.get(this._ns, key); - } - - delete(key: string): void { - this.requireOwnKey(key, 'delete'); - this._state.delete(this._ns, key); - } - - /** - * Everything this addon has written, without the framework's own keys — safe to serialize and - * persist whole, which is the usual reason to ask for it. - */ - getNamespace(): Record { - const own: Record = {}; - - for (const [key, value] of Object.entries(this._state.getNamespace(this._ns))) { - if (!isReservedStateKey(key)) { own[key] = value; } - } - - return own; - } - - /** All namespaces currently present in the mirror (own + peers). */ - namespaces(): string[] { - return this._state.namespaces(); - } - - /** - * Notified when this addon's own state changes, local or remote. Framework keys and other - * addons' namespaces are filtered out, so a listener that persists {@link getNamespace} does - * not also fire on every schema, translation and guide broadcast. Use `core.node.state` to - * observe everything. - */ - subscribe(listener: StateChangeListener): Unsubscribe { - return this._state.subscribe((change) => { - if (change.ns !== this._ns || isReservedStateKey(change.key)) { return; } - - listener(change); - }); - } - - private requireOwnKey(key: string, operation: string): void { - if (isReservedStateKey(key)) { - throw new Error( - `cannot ${operation} state key '${key}': '${RESERVED_STATE_PREFIX}' is reserved for bedrock-core`, - ); - } - } -} diff --git a/packages/server-runtime/src/shared/__type-tests__/shared-tree.ts b/packages/server-runtime/src/shared/__type-tests__/shared-tree.ts new file mode 100644 index 0000000..235a95c --- /dev/null +++ b/packages/server-runtime/src/shared/__type-tests__/shared-tree.ts @@ -0,0 +1,75 @@ +/** + * Type-level tests for the shared tree: `tsc` failing IS the failure. Every declared key becomes + * one node typed by its value, an object included; a peer's tree has the same keys, reads them as + * possibly-undefined and cannot write any of them; and `register()` hands back what was declared. + */ +import type { PeerSharedTree, SharedTree } from '../tree'; +import type { AddonManifest } from '../../manifest'; +import type { Runtime } from '../../runtime'; +import { event as declaredEvent, registerEvents } from '../../events'; +import { registerShared } from '../declaration'; + +export const SHARED = { + spawnRate: 5, + name: 'lobby', + tags: ['a', 'b'], + event: { name: 'none', active: false, votes: 0 }, +}; + +declare const own: SharedTree; +declare const peer: PeerSharedTree; + +// Every key carries its declared type, an object as one value. +const rate: number = own.spawnRate.get(); +const label: string = own.name.get(); +const tags: string[] = own.tags.get(); +const event: { name: string; active: boolean; votes: number } = own.event.get(); + +own.spawnRate.set(6); +own.event.set({ name: 'race', active: true, votes: 0 }); + +// @ts-expect-error a node takes its own type +own.spawnRate.set('fast'); +// @ts-expect-error an object is one value, not a branch of nodes +void own.event.active; +// @ts-expect-error there is no partial write; a whole value replaces it +own.event.set({ active: true }); + +// Every node is an observable: the listener takes the new value and the one before it. +own.spawnRate.subscribe((next: number, prev: number) => next + prev); +own.event.subscribe((next: { active: boolean }) => next); + +// A peer reads the same keys, and may read nothing yet. +const peerRate: number | undefined = peer.spawnRate.get(); +const peerEvent: { name: string; active: boolean; votes: number } | undefined = peer.event.get(); + +peer.spawnRate.subscribe((next: number | undefined) => next); + +// @ts-expect-error a peer's tree never writes: it asks the owner over rpc instead +peer.spawnRate.set(1); + +// register() returns the declared trees, each under the key it was declared as. Never called: the +// body is the test. +declare const MANIFEST: AddonManifest; + +export function declaresTrees(core: Runtime): void { + const both = core.register({ + manifest: MANIFEST, + shared: registerShared(SHARED), + events: registerEvents({ restocked: declaredEvent<{ item: string }>() }), + }); + const onlyShared = core.register({ manifest: MANIFEST, shared: registerShared(SHARED) }); + + both.shared.spawnRate.set(1); + both.events.restocked.emit({ item: 'diamond' }); + onlyShared.shared.event.get(); + // @ts-expect-error no events were declared + void onlyShared.events; +} + +void rate; +void label; +void tags; +void event; +void peerRate; +void peerEvent; diff --git a/packages/server-runtime/src/shared/declaration.ts b/packages/server-runtime/src/shared/declaration.ts new file mode 100644 index 0000000..6df99a3 --- /dev/null +++ b/packages/server-runtime/src/shared/declaration.ts @@ -0,0 +1,20 @@ +/** + * `registerShared(keys)` — the shared-mirror declaration an addon passes to `register()`. + * + * ```ts + * const { shared } = core.register({ manifest, shared: registerShared({ price: 10 }) }); + * + * shared.price.set(12); // every realm reads it this tick + * core.shared.of('os_shop'); + * ``` + * + * Installing defines the keys on `core.shared` — the view over the runtime's node state, which a + * peer read materializes with or without this declaration — and returns their typed tree. + */ +import type { Declaration } from '../declaration'; +import type { SharedDef, SharedTree } from './tree'; + +/** Declare this addon's shared keys: a flat record every realm mirrors and only this addon writes. */ +export function registerShared(keys: Def): Declaration> { + return { install: core => core.shared.define(keys) }; +} diff --git a/packages/server-runtime/src/shared/index.ts b/packages/server-runtime/src/shared/index.ts new file mode 100644 index 0000000..3a99e27 --- /dev/null +++ b/packages/server-runtime/src/shared/index.ts @@ -0,0 +1,15 @@ +export { SharedRegistry } from './shared-registry'; +export { registerShared } from './declaration'; +export type { SharedRegistryOptions } from './shared-registry'; + +export { isShape, materialize } from './tree'; +export type { + Listener, + PeerSharedTree, + PeerValue, + Shape, + SharedBackend, + SharedDef, + SharedTree, + SharedValue, +} from './tree'; diff --git a/packages/server-runtime/src/shared/shared-registry.ts b/packages/server-runtime/src/shared/shared-registry.ts new file mode 100644 index 0000000..bcd1d86 --- /dev/null +++ b/packages/server-runtime/src/shared/shared-registry.ts @@ -0,0 +1,142 @@ +/** + * `core.shared` — the replicated mirror every realm holds, as typed trees. + * + * The owner declares a flat record as `shared: registerShared(keys)` in `register()` and gets its tree back; + * the registry writes the initial values and announces the key names under `core-shared/shape`. A + * peer's tree, `core.shared.of(ns)`, is materialized from that announcement and reads the same + * mirror. + * + * The mirror does exactly one job: the owner sets a value, every realm can read it now, and is + * told when it changes. It is not storage — nothing here touches the world. A value that must + * survive a restart lives in a db document, and the owner maps it across in one line: + * + * ```ts + * settings.for(world).subscribe(doc => shared.event.set(doc.event)); + * ``` + * + * A document's subscriber hears the document load, so that line is correct at boot as well as on + * every later change. + * + * Only the owner writes: sync applies a value for namespace `ns` only when the sender is `ns`, and + * a peer's tree has no `set` at all. A peer that wants a change asks the owner over rpc. + */ +import type { State, StateChange, Unsubscribe } from '@bedrock-core/sync'; +import { Announcement, RESERVED_PREFIX } from '../announcement'; +import { + isShape, + materialize, + type PeerSharedTree, + type Shape, + type SharedBackend, + type SharedDef, + type SharedTree, +} from './tree'; + +/** What `new SharedRegistry()` takes. */ +export interface SharedRegistryOptions { + state: State; + namespace: string; +} + +/** This addon's shared keys as a typed tree, and any other addon's as a read-only one. */ +export class SharedRegistry { + /** The key names each owner announces, under `core-shared/shape`; what a peer's tree is built from. */ + readonly shape: Announcement; + + private readonly _state: State; + private readonly _namespace: string; + private readonly _peers = new Map(); + private _own: unknown; + + constructor(options: SharedRegistryOptions) { + this._state = options.state; + this._namespace = options.namespace; + this.shape = new Announcement(options.state, options.namespace, 'shared/shape', isShape); + } + + /** This addon's tree, once declared. */ + get own(): SharedTree | undefined { + return this._own as SharedTree | undefined; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion + } + + /** + * Declare this addon's shared keys: writes the initial values and announces the key names. + * Once per addon. + */ + define(def: Def): SharedTree { + if (this._own !== undefined) { + throw new Error('[shared] already declared for this addon'); + } + + const keys = Object.keys(def); + + for (const key of keys) { + if (key.startsWith(RESERVED_PREFIX)) { + throw new Error(`[shared] '${key}' is reserved: keys beginning '${RESERVED_PREFIX}' belong to the framework`); + } + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const tree = materialize(this._ownBackend(), keys, def) as SharedTree; + + this._own = tree; + + for (const key of keys) { + this._state.set(this._namespace, key, def[key]); + } + + this.shape.provide(keys); + + return tree; + } + + /** + * A peer's tree, typed by the declaration the peer exports, or `undefined` until the peer has + * announced its keys. Read-only: only the owner writes its own namespace. + */ + of(namespace: string): PeerSharedTree | undefined { + const announced = this.shape.of(namespace); + + if (announced === undefined) { + return undefined; + } + + const cached = this._peers.get(namespace); + + if (cached !== undefined && cached.shape === announced) { + return cached.tree as PeerSharedTree; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion + } + + const tree = materialize(this._peerBackend(namespace), announced); + + this._peers.set(namespace, { shape: announced, tree }); + + return tree as PeerSharedTree; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion + } + + private _ownBackend(): SharedBackend { + const namespace = this._namespace; + + return { + read: (key): unknown => this._state.get(namespace, key), + write: (key, value): void => { this._state.set(namespace, key, value); }, + onChange: (key, listener) => this._changesOf(namespace, key, listener), + }; + } + + /** No `write`: a peer's node refuses at the call, and sync would drop the message anyway. */ + private _peerBackend(namespace: string): SharedBackend { + return { + read: (key): unknown => this._state.get(namespace, key), + onChange: (key, listener) => this._changesOf(namespace, key, listener), + }; + } + + private _changesOf(namespace: string, key: string, listener: () => void): Unsubscribe { + return this._state.subscribe((change: StateChange) => { + if (change.ns === namespace && change.key === key) { + listener(); + } + }); + } +} diff --git a/packages/server-runtime/src/shared/tree.ts b/packages/server-runtime/src/shared/tree.ts new file mode 100644 index 0000000..a1c0381 --- /dev/null +++ b/packages/server-runtime/src/shared/tree.ts @@ -0,0 +1,158 @@ +/** + * The shared tree: one node per declared key, each an observable over the mirror. + * + * A declaration is a flat record — every top-level key is one value, an object included, written + * and replicated whole. There is no nesting, no dotted key, no branch: a shape that wants + * structure puts an object in one key and pays for it on every write, which is the honest cost + * (publishing serializes on the owner's tick). + * + * A node's `get` / `subscribe` pair is `@bedrock-core/observable`'s `ReadonlyObservable`, so a + * shared value can be `computed` over or handed to a UI hook exactly like a config leaf or a db + * document. The owner's nodes also have `set`; a peer's never do — a peer that wants a change asks + * the owner over rpc. + * + * Nothing here knows the engine or the transport: a tree talks to a {@link SharedBackend}, which + * the registry implements over sync's `State`. That is what lets the tree be tested in vitest and + * lets the same code build a peer's read-only tree from an announced shape. + */ +import type { Unsubscribe } from '@bedrock-core/sync'; + +// ─── Types ───────────────────────────────────────────────────────────────────── + +/** A declaration: a flat record of keys to their initial values. */ +export type SharedDef = Readonly>; + +/** The observable listener signature: the new value and the one before it. */ +export type Listener = (next: T, prev: T) => void; + +/** The owner's view of one key. */ +export interface SharedValue { + get(): T; + set(value: T): void; + subscribe(listener: Listener): Unsubscribe; +} + +/** A peer's view of one key: readable, and `undefined` until the owner's value has arrived. */ +export interface PeerValue { + get(): T | undefined; + subscribe(listener: Listener): Unsubscribe; +} + +/** The owner's tree: the declaration, typed. */ +export type SharedTree = { readonly [K in keyof Def]: SharedValue }; + +/** A peer's tree: the same keys, read-only. */ +export type PeerSharedTree = { readonly [K in keyof Def]: PeerValue }; + +/** What the owner announces: its key names. Values travel as themselves, never here. */ +export type Shape = readonly string[]; + +/** The guard an announced shape is read through. */ +export function isShape(value: unknown): value is Shape { + return Array.isArray(value) && value.every(key => typeof key === 'string'); +} + +// ─── The backend ─────────────────────────────────────────────────────────────── + +/** What a tree needs from the mirror. */ +export interface SharedBackend { + read(key: string): unknown; + /** Only an owner's backend has one; a peer's tree never writes. */ + write?(key: string, value: unknown): void; + /** Called whenever `key` changes in this namespace, local or remote. */ + onChange(key: string, listener: () => void): Unsubscribe; +} + +// ─── The node ────────────────────────────────────────────────────────────────── + +/** + * One key, as an observable. + * + * The mirror is the value: `get` reads it every time rather than caching, so a node is always + * right even before anything has subscribed. The backend subscription is attached with the first + * listener and released with the last, so a tree nobody watches costs nothing per change. + */ +class Node { + private readonly _listeners = new Set>(); + private _release: Unsubscribe | undefined; + private _prev: unknown; + + constructor( + private readonly _backend: SharedBackend, + private readonly _key: string, + private readonly _initial: unknown, + private readonly _hasInitial: boolean, + ) {} + + get(): unknown { + const value = this._backend.read(this._key); + + return value === undefined && this._hasInitial ? this._initial : value; + } + + set(value: unknown): void { + const write = this._backend.write; + + if (write === undefined) { + throw new Error(`[shared] '${this._key}' belongs to another addon: only its owner may write it`); + } + + write.call(this._backend, this._key, value); + } + + subscribe(listener: Listener): Unsubscribe { + if (this._listeners.size === 0) { + this._prev = this.get(); + this._release = this._backend.onChange(this._key, () => { this._fire(); }); + } + + this._listeners.add(listener); + + let released = false; + + return (): void => { + if (released) { return; } + + released = true; + this._listeners.delete(listener); + + if (this._listeners.size === 0 && this._release !== undefined) { + this._release(); + this._release = undefined; + } + }; + } + + /** One listener that throws must not stop the others, and must not escape into the engine. */ + private _fire(): void { + const next = this.get(); + const prev = this._prev; + + this._prev = next; + + for (const listener of [...this._listeners]) { + try { + listener(next, prev); + } catch (error) { + console.warn(`[shared] a listener for '${this._key}' threw: ${String(error)}`); + } + } + } +} + +/** + * A node per key. + * + * `initial` is the owner's declared values, read while the mirror has none — the owner's tree + * answers correctly before its first write lands. A peer passes nothing and reads `undefined` + * there instead, since it never knows what the owner declared. + */ +export function materialize(backend: SharedBackend, keys: Shape, initial?: SharedDef): Record { + const tree: Record = {}; + + for (const key of keys) { + tree[key] = new Node(backend, key, initial?.[key], initial !== undefined); + } + + return tree; +} diff --git a/packages/server-runtime/src/translations.ts b/packages/server-runtime/src/translations.ts index de1c33a..7a36ed4 100644 --- a/packages/server-runtime/src/translations.ts +++ b/packages/server-runtime/src/translations.ts @@ -1,93 +1,66 @@ /** - * Cross-addon translation sync — i18n-bundle native, no tables anywhere. + * `core.translations` — every addon's i18n bundle, announced, with resolvers over all of them. * - * Each addon publishes its {@link I18nBundle} — the module the i18n Regolith - * filter generates, or `createResourceBundle`'s runtime equivalent — via - * `core.register({ translations: bundle })`. The registry replicates the - * bundle itself: templates stay in `{{var}}` form with their recorded argument - * order. Peers get two views, both lazy over the bundles: + * Each addon announces its {@link I18nBundle} — the module the i18n Regolith filter generates, + * or `createResourceBundle`'s runtime equivalent — under `core-i18n/bundle` via + * `core.translations.provide(bundle)`. The bundle itself travels: templates stay in + * `{{var}}` form with their recorded argument order. Peers get two views, both lazy over the + * announced bundles: * - * - **Verbs** — `of(addonId)` wraps a peer's bundle in `createI18n`, giving - * `t()`/`key()`/`raw()`/`resolve()` over another addon's strings (loosely - * typed: their resource tree's types never travel). - * - **Resolution** — `forLocale()`/`forPlayer()` return a - * {@link TranslationResolver} that chains every addon's bundle, later - * registrations overriding earlier ones the way Bedrock's world-level - * `.lang` merge does. Nothing is flattened or copied; each lookup reads the - * winning bundle's objects and converts the one template it needs. + * - **Verbs** — `i18n(addonId)` wraps a peer's bundle in `createI18n`, giving `t()` / `key()` / + * `raw()` / `resolve()` over another addon's strings, loosely typed since their resource + * tree's types never travel. + * - **Resolution** — `forLocale()` / `forPlayer()` return a {@link TranslationResolver} that + * chains every addon's bundle, later registrations overriding earlier ones the way Bedrock's + * world-level `.lang` merge does. Nothing is flattened or copied; each lookup reads the winning + * bundle's objects and converts the one template it needs. * - * Registry display fields (`packName`, `description`, `creatorName`) ARE - * translation keys shipped in each addon's generated `.lang` — this registry - * is what lets one addon's UI measure and resolve another addon's keys - * server-side. Late joiners are covered by sync's state snapshot exchange. + * Registry display fields (`packName`, `description`, `creatorName`) are translation keys + * shipped in each addon's generated `.lang`, so this is what lets one addon's UI resolve + * another addon's keys server-side. */ import { createI18n, LOCALE_PROPERTY, pickLocale } from '@bedrock-core/i18n'; import type { I18n, I18nBundle, TranslationResolver } from '@bedrock-core/i18n'; -import { stateKey } from '@bedrock-core/sync'; import type { State, Unsubscribe } from '@bedrock-core/sync'; import type { Player } from '@minecraft/server'; +import { Announcement, isRecord } from './announcement'; +import { isUsable } from './handle'; /** Locale `forPlayer` falls back to when no candidate locale is published. */ const DEFAULT_LOCALE = 'en_US'; -/** State key each addon publishes its bundle under (namespace = the addon's namespace). */ -const TRANSLATIONS_STATE_KEY = stateKey('core-i18n/bundle'); - -export type TranslationsChangeListener = () => void; - -export class TranslationsRegistry { - private readonly _state: State; - private readonly _addonId: string; - private readonly _listeners = new Set(); - private readonly _disposers: Unsubscribe[] = []; - /** Caches over replicated bundles, cleared whenever any addon re-publishes. */ +/** Each addon's i18n bundle, announced, with resolvers over all of them. */ +export class TranslationsRegistry extends Announcement { + /** Caches over announced bundles, cleared whenever any addon re-publishes. */ private readonly _verbs = new Map | undefined>(); private readonly _resolvers = new Map(); private _locales: Set | undefined; + private _release: Unsubscribe | undefined; constructor(state: State, addonId: string) { - this._state = state; - this._addonId = addonId; + super(state, addonId, 'i18n/bundle', isBundle); } start(): void { - this._disposers.push( - this._state.subscribe((change) => { - if (change.key !== TRANSLATIONS_STATE_KEY) { return; } - - this.invalidate(); - this.emitChange(); - }), - ); + this._release = this.subscribe(() => this.invalidate()); } stop(): void { - for (const dispose of this._disposers.splice(0)) { dispose(); } - - this._listeners.clear(); + this._release?.(); + this._release = undefined; this.invalidate(); } /** - * Publish this addon's bundle to replicated state so every addon can resolve - * its strings. Usually declared up front via `core.register({ translations })`; - * call directly to publish late or replace the bundle. - */ - provide(bundle: I18nBundle): void { - this._state.set(this._addonId, TRANSLATIONS_STATE_KEY, bundle); - } - - /** - * The verbs over one addon's published strings — `t()`, `key()`, `raw()`, - * `resolve()`, `forPlayer()` — exactly what `createI18n` gives that addon - * locally, minus its compile-time resource types (those never travel; paths - * are plain strings here). `undefined` until that addon publishes. + * The verbs over one addon's announced strings — `t()`, `key()`, `raw()`, `resolve()`, + * `forPlayer()` — exactly what `createI18n` gives that addon locally, minus its compile-time + * resource types. `undefined` until that addon publishes. */ - of(addonId: string): I18n | undefined { + i18n(addonId: string): I18n | undefined { if (this._verbs.has(addonId)) { return this._verbs.get(addonId); } - const bundle = this.publishedBundle(addonId); - // NEVER the default instance — these are peers' bundles, not this addon's. + const bundle = this.of(addonId); + // Never the default instance: these are peers' bundles, not this addon's. const verbs = bundle ? createI18n(bundle, { asDefault: false }) : undefined; this._verbs.set(addonId, verbs); @@ -95,16 +68,10 @@ export class TranslationsRegistry { return verbs; } - /** The raw bundle an addon published, or `undefined`. Local-mirror read. */ - bundleOf(addonId: string): I18nBundle | undefined { - return this.publishedBundle(addonId); - } - /** - * One resolver over every published bundle, for a SINGLE locale. Later - * registrations win collisions (mirroring Bedrock's world-level `.lang` - * merge), so the chain probes namespaces in reverse. Cached per locale; - * rebuilt when any addon re-publishes. + * One resolver over every announced bundle, for a single locale. Later registrations win + * collisions, mirroring Bedrock's world-level `.lang` merge, so the chain probes namespaces in + * reverse. Cached per locale; rebuilt when any addon re-publishes. */ forLocale(locale: string): TranslationResolver { const cached = this._resolvers.get(locale); @@ -113,8 +80,8 @@ export class TranslationsRegistry { const chain: TranslationResolver[] = []; - for (const ns of [...this._state.namespaces()].reverse()) { - const verbs = this.of(ns); + for (const ns of this.namespaces().reverse()) { + const verbs = this.i18n(ns); if (verbs) { chain.push(verbs.forLocale(locale).resolve); } } @@ -135,13 +102,16 @@ export class TranslationsRegistry { } /** - * The chained resolver for a specific player, through the same chain the - * i18n engine uses: persisted override → client locale → sibling region of - * that language → `defaultLocale` → anything published. Resolves nothing - * when nothing is published — a missing key already falls back to rendering - * the literal key. + * The chained resolver for a specific player, through the same chain the i18n engine uses: + * persisted override → client locale → sibling region of that language → `defaultLocale` → + * anything published. Resolves nothing when nothing is published — a missing key already + * falls back to rendering the literal key. */ forPlayer(player: Player, defaultLocale = DEFAULT_LOCALE): TranslationResolver { + // Both reads below throw on an invalidated handle, and callers reach this from event + // subscribers. A player who is gone has no locale to prefer, so fall back to the default. + if (!isUsable(player)) { return this.forLocale(defaultLocale); } + const override = player.getDynamicProperty(LOCALE_PROPERTY); const chosen = pickLocale([...this.availableLocales()], [ typeof override === 'string' ? override : undefined, @@ -151,23 +121,14 @@ export class TranslationsRegistry { return this.forLocale(chosen ?? defaultLocale); } - /** Notified when any addon's published bundle changes (coarse — rebuild via `forLocale`/`forPlayer`/`of`). */ - subscribe(listener: TranslationsChangeListener): Unsubscribe { - this._listeners.add(listener); - - return (): void => { - this._listeners.delete(listener); - }; - } - /** Every locale any addon has published (resource locales and passthrough alike). */ private availableLocales(): Set { if (this._locales) { return this._locales; } const locales = new Set(); - for (const ns of this._state.namespaces()) { - const bundle = this.publishedBundle(ns); + for (const ns of this.namespaces()) { + const bundle = this.of(ns); if (!bundle) { continue; } @@ -181,37 +142,14 @@ export class TranslationsRegistry { return locales; } - /** - * The bundle an addon published under this namespace, or `undefined` if - * none/malformed — one addon publishing a bad payload can't poison the - * chain for everyone else. - */ - private publishedBundle(ns: string): I18nBundle | undefined { - const value = this._state.get(ns, TRANSLATIONS_STATE_KEY); - - return isBundle(value) ? value : undefined; - } - private invalidate(): void { this._verbs.clear(); this._resolvers.clear(); this._locales = undefined; } - - private emitChange(): void { - for (const listener of this._listeners) { listener(); } - } } -/** True for any non-null, non-array object. */ -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - -/** - * True when `value` is a locale table: a record whose every value is a string. - * An empty record qualifies — nothing contradicts it, and merging it is a no-op. - */ +/** A locale table: a record whose every value is a string. An empty record qualifies. */ function isFlatMap(value: unknown): value is Record { if (!isRecord(value)) { return false; } @@ -222,15 +160,11 @@ function isFlatMap(value: unknown): value is Record { return true; } -/** True when every value of a record satisfies {@link isFlatMap}. */ function isFlatMapRecord(value: unknown): value is Record> { return isRecord(value) && Object.values(value).every(isFlatMap); } -/** - * Structural validation of a replicated bundle — the shape `createI18n` - * relies on. Narrows so callers avoid an `as` cast. - */ +/** The shape `createI18n` relies on, so one addon's bad payload cannot poison the chain. */ function isBundle(value: unknown): value is I18nBundle { if (!isRecord(value)) { return false; } diff --git a/packages/server-runtime/test/authorization.spec.ts b/packages/server-runtime/test/authorization.spec.ts new file mode 100644 index 0000000..9f15eba --- /dev/null +++ b/packages/server-runtime/test/authorization.spec.ts @@ -0,0 +1,79 @@ +/** + * Who may reach what on behalf of a player. The rule is the interesting half of every served + * endpoint and the half a GameTest cannot cover: it turns on `playerPermissionLevel`, and a + * headless world has no players to be operators. So the engine is mocked and the rule is checked + * directly. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const players: { id: string; playerPermissionLevel: number; isValid: boolean }[] = []; + +vi.mock('@minecraft/server', () => ({ + // The real enum: Operator is 2, and Custom is a separate bucket rather than a tier above it. + PlayerPermissionLevel: { Visitor: 0, Member: 1, Operator: 2, Custom: 3 }, + world: { getAllPlayers: (): typeof players => players }, +})); + +const { authorize, denyReason } = await import('../src/authorization'); + +function player(id: string, level: number): void { + players.push({ id, playerPermissionLevel: level, isValid: true }); +} + +describe('authorization', () => { + beforeEach(() => { players.length = 0; }); + + it('allows an addon acting for itself, with no actor', () => { + expect(denyReason({ entity: 'someone-else' }, undefined, 'write')).toBeUndefined(); + }); + + it('refuses an actor who is not in the world', () => { + expect(denyReason({ entity: 'p1' }, 'ghost', 'read')).toMatch(/not in the world/); + }); + + it('allows an operator anywhere', () => { + player('op', 2); + + expect(denyReason({ entity: 'someone-else' }, 'op', 'write')).toBeUndefined(); + expect(denyReason({ block: 'overworld:1,2,3:papi:elevator' }, 'op', 'write')).toBeUndefined(); + expect(denyReason({ world: true }, 'op', 'write')).toBeUndefined(); + }); + + it('lets a non-operator reach their own entity', () => { + player('p1', 1); + + expect(denyReason({ entity: 'p1' }, 'p1', 'read')).toBeUndefined(); + expect(denyReason({ entity: 'p1' }, 'p1', 'write')).toBeUndefined(); + }); + + it('refuses a non-operator reaching another player, even to read', () => { + player('p1', 1); + + expect(denyReason({ entity: 'p2' }, 'p1', 'read')).toMatch(/only reach their own/); + expect(denyReason({ entity: 'p2' }, 'p1', 'write')).toMatch(/only reach their own/); + }); + + it('lets a non-operator read a target with no owning player, and refuses the write', () => { + player('p1', 1); + + expect(denyReason({ world: true }, 'p1', 'read')).toBeUndefined(); + expect(denyReason({ dimension: 'nether' }, 'p1', 'read')).toBeUndefined(); + expect(denyReason({ block: 'x' }, 'p1', 'read')).toBeUndefined(); + expect(denyReason({ world: true }, 'p1', 'write')).toMatch(/only be changed by an operator/); + expect(denyReason({ dimension: 'nether' }, 'p1', 'write')).toMatch(/only be changed by an operator/); + expect(denyReason({ block: 'x' }, 'p1', 'write')).toMatch(/only be changed by an operator/); + }); + + it('does not treat the Custom permission bucket as operator', () => { + player('custom', 3); + + expect(denyReason({ block: 'anything' }, 'custom', 'write')).toMatch(/operator/); + }); + + it('authorize throws the reason, so an rpc caller receives it', () => { + player('p1', 1); + + expect(() => { authorize({ entity: 'p2' }, 'p1', 'read'); }).toThrow(/refused: a non-operator/); + expect(() => { authorize({ entity: 'p1' }, 'p1', 'write'); }).not.toThrow(); + }); +}); diff --git a/packages/server-runtime/test/events.spec.ts b/packages/server-runtime/test/events.spec.ts new file mode 100644 index 0000000..80de5f1 --- /dev/null +++ b/packages/server-runtime/test/events.spec.ts @@ -0,0 +1,167 @@ +/** + * The events registry over the sync layer: the owner's tree emits and hears its own, a peer's tree + * hears the owner's, a peer's tree exists for an addon that has announced nothing, and neither + * tree offers a way to emit under someone else's namespace. + */ +import { describe, expect, it } from 'vitest'; +import { Events } from '../../sync/src/events'; +import type { Bus, EnvelopeHandler, Unsubscribe } from '../../sync/src/bus'; +import { PROTOCOL_MAX } from '../../sync/src/constants'; +import type { Envelope } from '../../sync/src/envelope'; +import { EventsRegistry } from '../src/events/events-registry'; +import { event } from '../src/events/tree'; + +interface Wire { + attach(id: string, handlers: Map>): void; + deliver(from: string, type: string, data: unknown): void; +} + +function wire(): Wire { + const nodes = new Map>>(); + + return { + attach: (id, handlers): void => { nodes.set(id, handlers); }, + deliver: (from, type, data): void => { + const envelope: Envelope = { v: PROTOCOL_MAX, src: from, iid: `${from}-iid`, type, mid: `${from}/1`, data }; + + for (const [id, handlers] of nodes) { + if (id === from) { continue; } + + for (const handler of handlers.get(type) ?? []) { handler(envelope); } + } + }, + }; +} + +function fakeBus(w: Wire, id: string): Bus { + const handlers = new Map>(); + + w.attach(id, handlers); + + const bus = { + on: (type: string, handler: EnvelopeHandler): Unsubscribe => { + let set = handlers.get(type); + + if (set === undefined) { + set = new Set(); + handlers.set(type, set); + } + + set.add(handler); + + return (): void => { set.delete(handler); }; + }, + send: (options: { type: string; data?: unknown }): string => { + w.deliver(id, options.type, options.data); + + return 'mid'; + }, + }; + + return bus as unknown as Bus; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion +} + +function realm(w: Wire, id: string): EventsRegistry { + const events = new Events(fakeBus(w, id), id); + + events.start(); + + return new EventsRegistry({ events, namespace: id }); +} + +const DEF = { + purchase: event<{ playerId: string; gold: number }>(), + levelUp: event<{ playerId: string; level: number }>(), +}; + +describe('EventsRegistry', () => { + it('delivers the owner an event to itself and to a peer, in the same tick', () => { + const w = wire(); + const owner = realm(w, 'drav0011_economy'); + const peer = realm(w, 'os_shop'); + const economy = owner.define(DEF); + const here: unknown[] = []; + const there: unknown[] = []; + const senders: string[] = []; + + economy.purchase.subscribe(payload => here.push(payload)); + peer.of('drav0011_economy').purchase.subscribe((payload, from) => { + there.push(payload); + senders.push(from); + }); + + economy.purchase.emit({ playerId: 'p1', gold: 5 }); + + expect(here).toEqual([{ playerId: 'p1', gold: 5 }]); + expect(there).toEqual([{ playerId: 'p1', gold: 5 }]); + expect(senders).toEqual(['drav0011_economy']); + }); + + it('fires only the name that was announced', () => { + const w = wire(); + const owner = realm(w, 'drav0011_economy'); + const peer = realm(w, 'os_shop'); + const economy = owner.define(DEF); + const seen: string[] = []; + const tree = peer.of('drav0011_economy'); + + tree.purchase.subscribe(() => seen.push('purchase')); + tree.levelUp.subscribe(() => seen.push('levelUp')); + + economy.levelUp.emit({ playerId: 'p1', level: 2 }); + + expect(seen).toEqual(['levelUp']); + }); + + it('hands out a tree for an addon that is not in the world, and it fires when it arrives', () => { + const w = wire(); + const peer = realm(w, 'os_shop'); + const seen: unknown[] = []; + + // Nobody has registered this namespace yet — an event missed is missed for good, so the + // listener has to be attachable first. + peer.of('drav0011_economy').purchase.subscribe(payload => seen.push(payload)); + + const owner = realm(w, 'drav0011_economy'); + + owner.define(DEF).purchase.emit({ playerId: 'p1', gold: 1 }); + + expect(seen).toEqual([{ playerId: 'p1', gold: 1 }]); + expect(peer.of('drav0011_economy')).toBe(peer.of('drav0011_economy')); + }); + + it('releases a listener, and refuses a second declaration', () => { + const w = wire(); + const owner = realm(w, 'drav0011_economy'); + const economy = owner.define(DEF); + const seen: unknown[] = []; + const release = economy.purchase.subscribe(payload => seen.push(payload)); + + economy.purchase.emit({ playerId: 'p1', gold: 1 }); + release(); + economy.purchase.emit({ playerId: 'p1', gold: 2 }); + + expect(seen).toEqual([{ playerId: 'p1', gold: 1 }]); + expect(owner.own).toBe(economy); + expect(() => owner.define(DEF)).toThrow(/already declared/); + }); + + it('gives a peer no way to emit under the owner namespace', () => { + const w = wire(); + const owner = realm(w, 'drav0011_economy'); + const peer = realm(w, 'os_shop'); + const economy = owner.define(DEF); + const seen: unknown[] = []; + const foreign: { emit?(payload: unknown): void } = peer.of('drav0011_economy').purchase; + + economy.purchase.subscribe(payload => seen.push(payload)); + + expect(foreign.emit).toBeUndefined(); + + // The escape hatch listens; it never announces. + peer.on('drav0011_economy', 'purchase', payload => seen.push(payload)); + economy.purchase.emit({ playerId: 'p1', gold: 3 }); + + expect(seen).toEqual([{ playerId: 'p1', gold: 3 }, { playerId: 'p1', gold: 3 }]); + }); +}); diff --git a/packages/server-runtime/test/shared.spec.ts b/packages/server-runtime/test/shared.spec.ts new file mode 100644 index 0000000..064f9a6 --- /dev/null +++ b/packages/server-runtime/test/shared.spec.ts @@ -0,0 +1,305 @@ +/** + * The shared tree and its registry, without the engine: a node reads the mirror and falls back to + * the declared value, notifies with the new value and the one before it, holds one backend + * subscription for as long as it has listeners, and isolates a listener that throws. Two + * registries over a synchronous fake bus behave as owner and peer — initial values, the announced + * key names, and a peer that can read everything and write nothing. + */ +import { describe, expect, it, vi } from 'vitest'; +import type { Bus, EnvelopeHandler, Unsubscribe } from '../../sync/src/bus'; +import { PROTOCOL_MAX } from '../../sync/src/constants'; +import type { Envelope } from '../../sync/src/envelope'; +import { State } from '../../sync/src/state'; +import { materialize, type SharedBackend, type SharedTree } from '../src/shared/tree'; + +// The registry reaches `stateKey` through the sync barrel, which imports the engine; none of it runs here. +vi.mock('@minecraft/server', () => ({ system: {} })); + +const { SharedRegistry } = await import('../src/shared/shared-registry'); + +const DEF = { + spawnRate: 5, + tags: ['a', 'b'], + event: { name: 'none', active: false }, +}; + +// ─── The tree over a fake backend ────────────────────────────────────────────── + +interface Fake { + backend: SharedBackend; + values: Map; + emit(key: string): void; + /** How many backend subscriptions are open right now. */ + readonly attached: number; +} + +function fakeBackend(): Fake { + const values = new Map(); + const listeners = new Map void>>(); + + const fake: Fake = { + values, + emit: (key): void => { + for (const listener of [...listeners.get(key) ?? []]) { listener(); } + }, + get attached(): number { + let total = 0; + + for (const set of listeners.values()) { total += set.size; } + + return total; + }, + backend: { + read: key => values.get(key), + write: (key, value): void => { + values.set(key, value); + fake.emit(key); + }, + onChange: (key, listener): Unsubscribe => { + let set = listeners.get(key); + + if (set === undefined) { + set = new Set(); + listeners.set(key, set); + } + + set.add(listener); + + return (): void => { set.delete(listener); }; + }, + }, + }; + + return fake; +} + +function tree(): { t: SharedTree; fake: Fake } { + const fake = fakeBackend(); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + return { t: materialize(fake.backend, Object.keys(DEF), DEF) as SharedTree, fake }; +} + +describe('tree', () => { + it('reads the declared value until the mirror has one, then the mirror', () => { + const { t, fake } = tree(); + + expect(t.spawnRate.get()).toBe(5); + expect(t.tags.get()).toEqual(['a', 'b']); + expect(t.event.get()).toEqual({ name: 'none', active: false }); + + fake.values.set('spawnRate', 9); + expect(t.spawnRate.get()).toBe(9); + }); + + it('writes whole values through the backend, an object included', () => { + const { t, fake } = tree(); + + t.spawnRate.set(6); + t.event.set({ name: 'race', active: true }); + + expect(fake.values.get('spawnRate')).toBe(6); + expect(fake.values.get('event')).toEqual({ name: 'race', active: true }); + }); + + it('notifies with the new value and the previous one, and only for its own key', () => { + const { t, fake } = tree(); + const seen: [number, number][] = []; + + t.spawnRate.subscribe((next, prev) => { seen.push([next, prev]); }); + t.event.subscribe(() => { throw new Error('the sibling must not fire'); }); + + t.spawnRate.set(6); + t.spawnRate.set(7); + + expect(seen).toEqual([[6, 5], [7, 6]]); + expect(fake.values.get('event')).toBeUndefined(); + }); + + it('holds one backend subscription while it has listeners, and drops it with the last', () => { + const { t, fake } = tree(); + const stopA = t.spawnRate.subscribe(() => {}); + const stopB = t.spawnRate.subscribe(() => {}); + + expect(fake.attached).toBe(1); + + stopA(); + expect(fake.attached).toBe(1); + + stopB(); + expect(fake.attached).toBe(0); + + // Releasing twice is a no-op, not a second decrement. + stopB(); + expect(fake.attached).toBe(0); + }); + + it('isolates a listener that throws', () => { + const { t } = tree(); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const seen: number[] = []; + + t.spawnRate.subscribe(() => { throw new Error('boom'); }); + t.spawnRate.subscribe((next) => { seen.push(next); }); + + expect(() => { t.spawnRate.set(6); }).not.toThrow(); + expect(seen).toEqual([6]); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('spawnRate')); + + warn.mockRestore(); + }); + + it('satisfies the observable shape', () => { + const { t } = tree(); + const readable: { get(): number; subscribe(l: (next: number, prev: number) => void): Unsubscribe } = t.spawnRate; + + expect(readable.get()).toBe(5); + }); +}); + +// ─── Two registries over a fake bus ──────────────────────────────────────────── + +interface Wire { + attach(id: string, handlers: Map>): void; + deliver(from: string, dst: string | undefined, type: string, data: unknown): void; +} + +function wire(): Wire { + const nodes = new Map>>(); + + return { + attach: (id, handlers): void => { + nodes.set(id, handlers); + }, + deliver: (from, dst, type, data): void => { + const envelope: Envelope = { v: PROTOCOL_MAX, src: from, iid: `${from}-iid`, type, mid: `${from}/${Math.random()}`, data }; + + for (const [id, handlers] of nodes) { + if (id === from || (dst !== undefined && dst !== id)) { + continue; + } + + for (const handler of handlers.get(type) ?? []) { + handler(envelope); + } + } + }, + }; +} + +function fakeBus(w: Wire, id: string): Bus { + const handlers = new Map>(); + + w.attach(id, handlers); + + const bus = { + on: (type: string, handler: EnvelopeHandler): Unsubscribe => { + let set = handlers.get(type); + + if (set === undefined) { + set = new Set(); + handlers.set(type, set); + } + + set.add(handler); + + return (): void => { + set.delete(handler); + }; + }, + send: (options: { dst?: string; type: string; data?: unknown }): string => { + w.deliver(id, options.dst, options.type, options.data); + + return 'mid'; + }, + }; + + return bus as unknown as Bus; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion +} + +function realm(w: Wire, id: string): { state: State; registry: SharedRegistry } { + const state = new State(fakeBus(w, id), id); + + state.start(); + + return { state, registry: new SharedRegistry({ state, namespace: id }) }; +} + +interface ShopShared { + stock: number; + sale: { active: boolean; percent: number }; +} + +describe('SharedRegistry', () => { + it('writes initial values, announces the key names, and a peer reads through its typed tree', () => { + const w = wire(); + const owner = realm(w, 'os_shop'); + const peer = realm(w, 'bt_hud'); + const shop = owner.registry.define({ stock: 3, sale: { active: false, percent: 0 } }); + + expect(owner.state.get('os_shop', 'stock')).toBe(3); + expect(owner.state.get('os_shop', 'core-shared/shape')).toEqual(['stock', 'sale']); + + const mirror = peer.registry.of('os_shop'); + + expect(mirror).toBeDefined(); + expect(mirror?.stock.get()).toBe(3); + expect(mirror?.sale.get()).toEqual({ active: false, percent: 0 }); + + const seen: (number | undefined)[] = []; + + mirror?.stock.subscribe(n => seen.push(n)); + shop.stock.set(2); + shop.sale.set({ active: true, percent: 20 }); + + expect(seen).toEqual([2]); + expect(mirror?.sale.get()).toEqual({ active: true, percent: 20 }); + expect(owner.registry.own).toBe(shop); + }); + + it('has no tree for an addon that has announced nothing, and reuses the one it built', () => { + const w = wire(); + const owner = realm(w, 'os_shop'); + const peer = realm(w, 'bt_hud'); + + expect(peer.registry.of('os_shop')).toBeUndefined(); + + owner.registry.define({ stock: 3 }); + + const mirror = peer.registry.of('os_shop'); + + expect(mirror).toBeDefined(); + expect(peer.registry.of('os_shop')).toBe(mirror); + expect(peer.registry.of('nobody')).toBeUndefined(); + }); + + it('gives a peer no way to write, and the mirror refuses one that tries anyway', () => { + const w = wire(); + const owner = realm(w, 'os_shop'); + const peer = realm(w, 'bt_hud'); + + owner.registry.define({ stock: 3 }); + + const mirror = peer.registry.of<{ stock: number }>('os_shop'); + const forced: { set?(value: number): void } | undefined = mirror?.stock; + + expect(forced?.set).toBeTypeOf('function'); + expect(() => forced?.set?.(1)).toThrow(/only its owner/); + + // Even reaching past the tree to the mirror itself changes nothing anywhere. + peer.state.set('os_shop', 'stock', 99); + expect(owner.state.get('os_shop', 'stock')).toBe(3); + expect(peer.state.get('os_shop', 'stock')).toBe(3); + expect(peer.state.droppedForeign).toBe(1); + }); + + it('refuses a second declaration and a key in the framework prefix', () => { + const w = wire(); + const owner = realm(w, 'os_shop'); + + expect(() => owner.registry.define({ 'core-shared/shape': 1 })).toThrow(/reserved/); + + owner.registry.define({ stock: 3 }); + expect(() => owner.registry.define({ other: 1 })).toThrow(/already declared/); + }); +}); diff --git a/packages/server-runtime/vitest.config.ts b/packages/server-runtime/vitest.config.ts new file mode 100644 index 0000000..2cef901 --- /dev/null +++ b/packages/server-runtime/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['test/**/*.spec.ts'], + }, +}); diff --git a/packages/server/LICENSE b/packages/server/LICENSE deleted file mode 100644 index 54154b1..0000000 --- a/packages/server/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2026 bedrock-core - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/packages/server/README.md b/packages/server/README.md deleted file mode 100644 index 7930b6e..0000000 --- a/packages/server/README.md +++ /dev/null @@ -1,57 +0,0 @@ -# @bedrock-core/server - -![Logo](https://raw.githubusercontent.com/bedrock-core/server/main/assets/logo/title.png) - -The meta package for the [bedrock-core](https://github.com/bedrock-core/server) server stack. One -dependency gets you a matching, known-good set of the server packages — each release pins the exact -versions of the underlying ones, so upgrading `@bedrock-core/server` moves the whole stack together. - -## Install - -```bash -yarn add @bedrock-core/server -``` - -`@minecraft/server` is a peer dependency (`>=2.8.0`) — it stays yours to pin, since the version you -build against has to match the one your pack's `manifest.json` declares. - -## What you get - -- **`@bedrock-core/server`** re-exports - [`@bedrock-core/server-runtime`](https://bedrock-core.drav.dev/docs/server/server-runtime) — the - framework runtime: addon registration, the cross-addon registry, features, config, guides, - translations and RPC. This is what most addons import. -- **`@bedrock-core/server/sync`** re-exports - [`@bedrock-core/sync`](https://bedrock-core.drav.dev/docs/server/sync) — the low-level cross-addon - transport (message bus, peer discovery, RPC, replicated state), for when you need to talk to it - directly rather than through the runtime. - -## Usage - -```ts -import { core } from '@bedrock-core/server'; - -core.register({ - creator: 'ms', - creatorName: 'My Studio', - pack: 'shop', - packName: 'My Shop', - version: '1.0.0', -}); - -// Only if you need the transport itself: -// import { createSync } from '@bedrock-core/server/sync'; -``` - -## Documentation - -- [Get started](https://bedrock-core.drav.dev/docs/server/get-started/overview) — two addons - talking to each other, end to end -- [server-runtime](https://bedrock-core.drav.dev/docs/server/server-runtime) · - [sync](https://bedrock-core.drav.dev/docs/server/sync) -- [UI integration](https://bedrock-core.drav.dev/docs/server/ui-integration) — pairing the server - packages with the `@bedrock-core` UI packages - -## License - -MIT diff --git a/packages/server/package.json b/packages/server/package.json deleted file mode 100644 index b1b8a6f..0000000 --- a/packages/server/package.json +++ /dev/null @@ -1,60 +0,0 @@ -{ - "name": "@bedrock-core/server", - "version": "0.1.0", - "description": "The bedrock-core server meta package: one install for the cross-addon server stack", - "keywords": [ - "minecraft", - "bedrock", - "cross-addon", - "framework", - "runtime", - "meta" - ], - "license": "MIT", - "author": "DrAv0011", - "contributors": [ - { - "name": "DrAv0011", - "email": "contact@drav.dev", - "url": "https://drav.dev" - } - ], - "repository": "github:bedrock-core/server", - "type": "module", - "exports": { - ".": { - "types": "./src/index.ts", - "import": "./src/index.ts" - }, - "./sync": { - "types": "./src/sync.ts", - "import": "./src/sync.ts" - } - }, - "publishConfig": { - "access": "public" - }, - "files": [ - "src", - "tsconfig.json", - "README.md" - ], - "scripts": { - "build": "tsc -p tsconfig.json", - "lint": "eslint ." - }, - "dependencies": { - "@bedrock-core/server-runtime": "workspace:*", - "@bedrock-core/sync": "workspace:*" - }, - "devDependencies": { - "@minecraft/server": "2.8.0", - "@stylistic/eslint-plugin": "^5.10.0", - "eslint": "^10.5.0", - "typescript": "^6.0.3", - "typescript-eslint": "^8.62.0" - }, - "peerDependencies": { - "@minecraft/server": ">=2.8.0" - } -} diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts deleted file mode 100644 index 7a973c3..0000000 --- a/packages/server/src/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** - * `@bedrock-core/server` — the meta package for the bedrock-core server stack. - * - * A single dependency that curates the matching versions of the server packages and - * re-exports the framework runtime. Most addons only need: - * - * ```ts - * import { core } from '@bedrock-core/server'; - * - * const config = core.register({ creator: 'ms', pack: 'shop', packName: 'My Shop', version: '1.0.0' }); - * ``` - * - * The lower-level transport (bus, discovery, RPC, replicated state) is available at - * the `@bedrock-core/server/sync` subpath when you need it directly rather than - * through the runtime. - */ -export * from '@bedrock-core/server-runtime'; diff --git a/packages/sync/README.md b/packages/sync/README.md index ecc1b3c..e1b6f3d 100644 --- a/packages/sync/README.md +++ b/packages/sync/README.md @@ -2,16 +2,13 @@ ![Logo](https://raw.githubusercontent.com/bedrock-core/server/main/assets/logo/title.png) -> **For framework and library developers.** -> If you are building a Bedrock addon, you do not need this package directly — use -> [`@bedrock-core/server-runtime`](https://bedrock-core.drav.dev/docs/server/server-runtime) -> instead. The runtime creates and manages the one sync node for you, and raw transport access is -> available as `core.node` whenever you want it. - -Cross-addon transport for Minecraft Bedrock. Every behavior pack runs its scripts in its **own -isolated QuickJS realm** — the only things that cross between realms are script events and -scoreboards. `@bedrock-core/sync` builds a usable layer on top of script events so addons in -separate realms can actually talk. +Cross-addon transport for Minecraft Bedrock, and the low-level layer `@bedrock-core/server-runtime` +is built on — most addon code should reach for the runtime instead, or for `core.node` when raw +transport access is genuinely needed. Every behavior pack runs its scripts in its own isolated +QuickJS realm, and the only things that cross between realms are script events and scoreboards; +this package builds a usable layer of discovery, RPC and replicated state on top of script events +so addons in separate realms can actually talk. It is in-memory only — persistence is each addon's +own responsibility. ## Install @@ -19,29 +16,11 @@ separate realms can actually talk. yarn add @bedrock-core/sync ``` -`@minecraft/server` is a peer dependency (`>=2.8.0`) — it stays yours to pin, since the version you -build against has to match the one your pack's `manifest.json` declares. - -## What it gives you - -- **Discovery** — find the other bedrock-core nodes in the world, with heartbeats, a startup whois - so late loaders catch up, and TTL eviction for nodes that go quiet -- **RPC** — call a named method on another node and await the reply. Requests always time out, so - an absent peer can never hang your code; a node may address itself, and that call is delivered - locally on the next tick -- **State** — a replicated key/value store every node mirrors in full. Reads are local and - synchronous, writes broadcast a delta, conflicts resolve last-write-wins on a Lamport clock, and - `stateKey()` makes a key's value type check at compile time -- **Framing you do not have to think about** — payloads above the engine's frame limit are split - and reassembled transparently - -sync does **not** touch dynamic properties. It is in-memory only; persistence is each addon's own -responsibility (subscribe, write to your pack's dynamic properties, re-publish on load). +`@minecraft/server` is a peer dependency — it stays yours to pin, since the version you build +against has to match the one your pack's `manifest.json` declares. ## Usage -Create **one** `SyncNode` for the realm and call `start()` once on boot: - ```ts import { createSync, stateKey } from '@bedrock-core/sync'; @@ -57,29 +36,16 @@ sync.start(); // after this, sync.discovery / sync.rpc / sync.state are live sync.discovery.onPeerUp(peer => console.warn(`${peer.id} v${peer.version} joined`)); -sync.state.set(NS, SPAWN_RATE, 5); // value must be a number +sync.state.set(NS, SPAWN_RATE, 5); // value must be a number; mirrors take it because NS is ours sync.state.subscribe(change => { /* persist your own namespace here */ }); sync.rpc.onRequest('getSpawnRate', () => sync.state.get(NS, SPAWN_RATE)); sync.rpc.request('economy', 'getBalance', { player: 'Steve' }).then(b => console.warn(b)); ``` -Writes are open — any node may write any namespace — and timing is tick-based, so you will never -get an RPC reply on the tick you sent it. - ## Documentation -- [sync](https://bedrock-core.drav.dev/docs/server/sync) — when to use it directly, `SyncNodeOptions`, - the `SyncNode` surface -- [Discovery](https://bedrock-core.drav.dev/docs/server/sync/discovery) · - [RPC](https://bedrock-core.drav.dev/docs/server/sync/rpc) · - [State](https://bedrock-core.drav.dev/docs/server/sync/state) -- [Protocol](https://bedrock-core.drav.dev/docs/server/sync/protocol) — the envelope, framing and - wire behavior - -sync is tested **in-game with GameTests**: several nodes in one realm share the real `system` bus, -so a test can assert discovery, RPC and state convergence for real. See `packages/test-addon` and -`packages/test-addon-2` in this repository. +https://bedrock-core.drav.dev/docs/sync ## License diff --git a/packages/sync/bench/fixtures/registry-16kb.json b/packages/sync/bench/fixtures/registry-16kb.json new file mode 100644 index 0000000..0ee8f17 --- /dev/null +++ b/packages/sync/bench/fixtures/registry-16kb.json @@ -0,0 +1,40 @@ +{ + "metadata": { + "configuration": { + "checksum_algo": "sha256", + "compression": null, + "encryption": false, + "export_settings": { + "batch_size": 1000, + "retry_attempts": 3, + "timeout_seconds": 300 + }, + "include_nulls": true, + "locale": "ro", + "pretty_print": true, + "source_systems": [ + "crm", + "erp", + "analytics", + "warehouse" + ] + }, + "generated_at": "2026-01-23T16:49:48+01:00", + "generator": "jsongen", + "schema_version": 5, + "statistics": { + "processing_time": null, + "total_records": 0, + "validation_score": 0.98 + }, + "version": "2.1.0" + }, + "iot_devices": [ + {"capabilities":{"auto_lock":true,"pin_code":false},"firmware":"1.16.46","id":"b505ef68-49d9-4461-8d07-d1ed6f790f45","last_seen":"1985-03-12T00:02:37Z","location":{"floor":2,"room":"Garage"},"manufacturer":"eScholar LLC.","model":"CW-2206","name":"yearly door_lock","network":{"ip":"245.105.84.191","mac":"00:6a:c0:49:8a:77","signal":-50,"wifi_ssid":"I-Network"},"online":true,"state":{"last_activity":"1979-07-02T19:03:53Z","locked":true},"telemetry":[[1769183388837,61.56990588344415],[1769183328837,16.92080689745481],[1769183268837,56.75961121798585],[1769183208837,10.842974959956216],[1769183148837,56.041593135079125],[1769183088837,76.64215992642225],[1769183028837,58.77466847386976],[1769182968837,1.4393768337754336],[1769182908837,73.48083373264211],[1769182848837,56.35725451351467],[1769182788837,9.520277213351504],[1769182728837,60.105724587748554],[1769182668837,73.4510898040668],[1769182608837,51.376043791540305],[1769182548837,10.122918749728923],[1769182488837,79.86622302873089],[1769182428837,33.48913749282068],[1769182368837,62.13413657622885],[1769182308837,92.52207031566199],[1769182248837,38.86023456357962],[1769182188837,48.38165930113641],[1769182128837,48.641206470800505],[1769182068837,26.19552820049377],[1769182008837,91.35004175536754],[1769181948837,5.689219356137399],[1769181888837,67.82738544275111],[1769181828837,52.20100335455332],[1769181768837,52.54484838739155],[1769181708837,96.38541779997634],[1769181648837,64.77009714542284],[1769181588837,11.422481021790256],[1769181528837,2.9820365400045588],[1769181468837,24.361737406724725],[1769181408837,54.14450221194783],[1769181348837,87.96867013886056],[1769181288837,42.4162692504619],[1769181228837,21.37308567292924],[1769181168837,64.58601004830754],[1769181108837,63.53048056596741],[1769181048837,26.49276634431229],[1769180988837,64.73117684062522],[1769180928837,43.22623730931004],[1769180868837,3.349258039012304],[1769180808837,10.745212143884707],[1769180748837,42.063973655174],[1769180688837,2.887108766093995],[1769180628837,12.300644137144534],[1769180568837,65.74531990072005],[1769180508837,0.4555786360266937],[1769180448837,63.973999905278035],[1769180388837,84.05177714060413],[1769180328837,7.930525684101022],[1769180268837,85.04795666151904],[1769180208837,51.33622421066051],[1769180148837,99.9163369500107],[1769180088837,95.44019302601652],[1769180028837,88.90942893256741],[1769179968837,26.35144870152403],[1769179908837,39.55872306740243],[1769179848837,83.00004886491371],[1769179788837,33.98671955661339],[1769179728837,46.31463077002567],[1769179668837,37.2087476964786],[1769179608837,48.66033105233236],[1769179548837,85.84212311591583],[1769179488837,2.513358176630448],[1769179428837,46.356453199408506],[1769179368837,98.17485337253777],[1769179308837,76.02627083169776],[1769179248837,29.231497925485332],[1769179188837,59.55797043363614],[1769179128837,42.17533755793697],[1769179068837,40.50909592793458],[1769179008837,56.31616067860426],[1769178948837,23.676874557125338],[1769178888837,55.353043853846465],[1769178828837,4.33896192881561],[1769178768837,42.04187422213068],[1769178708837,77.01705630378432],[1769178648837,98.8954141562863],[1769178588837,64.20902161206375]],"type":"door_lock"}, + {"capabilities":{},"firmware":"3.3.100","id":"e01c12ed-5d53-4c3b-81fc-48edb9b2735c","last_seen":"2008-11-07T02:41:31Z","location":{"floor":1,"room":"Living Room"},"manufacturer":"Ecodesk","model":"CL-5017","name":"then smoke_detector","network":{"ip":"144.153.214.156","mac":"d5:53:a6:78:27:3f","signal":-44,"wifi_ssid":"words-Network"},"online":true,"state":{"active":false,"battery":53},"telemetry":[[1769183388837,54.96120471182226],[1769183328837,71.7025821902086],[1769183268837,41.631894843012255],[1769183208837,18.81174892289125],[1769183148837,43.532930462289734],[1769183088837,90.5556017828741],[1769183028837,34.912692976468],[1769182968837,8.354432770206708],[1769182908837,28.811353378220815],[1769182848837,31.65968760507528],[1769182788837,38.26147027473057],[1769182728837,32.98257374341414],[1769182668837,50.88079990842323],[1769182608837,40.62191009071735],[1769182548837,14.451680453808008],[1769182488837,92.9523381897816],[1769182428837,55.92122640114282],[1769182368837,69.85999676746843],[1769182308837,32.82170310142075],[1769182248837,27.416547437209836],[1769182188837,46.27916757726547],[1769182128837,50.90966655724357],[1769182068837,96.01338339190265],[1769182008837,55.838474115686545],[1769181948837,92.6847377279278],[1769181888837,32.37068142706816],[1769181828837,72.07774017371791],[1769181768837,36.29941033471681],[1769181708837,80.42584493406387],[1769181648837,22.826447661607993],[1769181588837,37.38687770364401],[1769181528837,22.486562016453984],[1769181468837,13.699642469778581],[1769181408837,50.8794959373343],[1769181348837,7.764296384726133],[1769181288837,30.665411840872846],[1769181228837,72.35079539230142],[1769181168837,97.42921775866208],[1769181108837,24.40982324743395],[1769181048837,68.17113930554946],[1769180988837,2.087120028557032],[1769180928837,4.144730045526399],[1769180868837,40.40111996571014],[1769180808837,54.72115900644003],[1769180748837,9.81238127400625],[1769180688837,66.14728435257591],[1769180628837,31.401105111949228],[1769180568837,11.861461546329801],[1769180508837,99.32643630002576],[1769180448837,95.26188216584303],[1769180388837,27.85236250816652],[1769180328837,54.13633107475889],[1769180268837,45.2341550576846],[1769180208837,17.210791321310307],[1769180148837,41.09637003867119],[1769180088837,91.92229188635224],[1769180028837,30.164356163649376],[1769179968837,89.79632964849124],[1769179908837,10.995231221430881],[1769179848837,37.66551730393715],[1769179788837,57.843910653200346],[1769179728837,53.57744309874741],[1769179668837,54.518760642274145],[1769179608837,13.641518756615806],[1769179548837,72.58747181672909],[1769179488837,50.49862422129049],[1769179428837,3.5877453814575544],[1769179368837,34.81637280418408],[1769179308837,34.775255553176684],[1769179248837,68.42713795314563],[1769179188837,64.99362585879544]],"type":"smoke_detector"}, + {"capabilities":{"color":true,"dimmable":true},"firmware":"1.15.81","id":"17031841-372a-4f55-88b0-4ae2320574e4","last_seen":"1903-12-18T09:24:03Z","location":{"floor":3,"room":"Office"},"manufacturer":"Lucid","model":"DY-4723","name":"where light","network":{"ip":"237.120.226.58","mac":"b2:5d:bb:f7:45:b6","signal":-60,"wifi_ssid":"kuban-Network"},"online":true,"state":{"brightness":29,"color":"#860F0C","on":true},"telemetry":[[1769183388837,17.31460817647093],[1769183328837,47.08296734906524],[1769183268837,53.306601374394106],[1769183208837,1.8910104142083162],[1769183148837,97.46770173730759],[1769183088837,4.68802011092218],[1769183028837,21.473927280810376],[1769182968837,72.21831200467057],[1769182908837,90.10389390330205],[1769182848837,14.166111756102909],[1769182788837,19.892445853300405],[1769182728837,30.77715690541793],[1769182668837,43.485408345982776],[1769182608837,43.04013786538341],[1769182548837,81.93088848903041],[1769182488837,41.30980130253399],[1769182428837,23.56816036575814],[1769182368837,41.373617653199496],[1769182308837,84.76531883997511],[1769182248837,28.603142910896416],[1769182188837,13.269644804975012],[1769182128837,45.75257856722173],[1769182068837,90.25062234649575],[1769182008837,71.38073105463761],[1769181948837,54.62621473601785],[1769181888837,43.44614695626684],[1769181828837,15.65517234642828],[1769181768837,67.0603707505395],[1769181708837,26.921939848520292],[1769181648837,31.667374157972173],[1769181588837,19.126532741453605],[1769181528837,70.16653569339397],[1769181468837,64.21305394818758],[1769181408837,57.827699611283144],[1769181348837,95.74056039118506],[1769181288837,98.70681851183872],[1769181228837,38.4269354274931],[1769181168837,61.06519177495395],[1769181108837,3.3623190536872514],[1769181048837,29.095276133773208],[1769180988837,83.97702622103057],[1769180928837,22.849851204056396],[1769180868837,2.5194258780172283],[1769180808837,25.798264601686625],[1769180748837,38.18693980722879],[1769180688837,32.05121793236245],[1769180628837,34.50882932672295],[1769180568837,32.20934489478304],[1769180508837,88.92429124620588],[1769180448837,7.378047219065036],[1769180388837,3.1559320896147387],[1769180328837,85.35135511560478],[1769180268837,11.712506190601744],[1769180208837,57.81704550892817],[1769180148837,98.71645386467395],[1769180088837,7.287094421232271],[1769180028837,7.42563795986781],[1769179968837,4.9796560553111995],[1769179908837,77.27434286637592],[1769179848837,16.32713475268636],[1769179788837,66.14564729044453],[1769179728837,63.732250023485626],[1769179668837,38.92484092933164],[1769179608837,96.59244110016975],[1769179548837,74.26884708402184],[1769179488837,57.71375909838336],[1769179428837,97.92647015828467],[1769179368837,35.485853584681905],[1769179308837,2.124136094931667],[1769179248837,52.080646274616086],[1769179188837,14.21175507890149],[1769179128837,73.60853772912856],[1769179068837,52.29162393931147],[1769179008837,17.46509050556325]],"type":"light"}, + {"capabilities":{"max_temp":35,"min_temp":10,"modes":["heat","cool","auto","off"]},"firmware":"1.9.53","id":"ae0f9675-3fc6-4ee3-ad1a-e733517520dc","last_seen":"1932-12-28T11:56:56Z","location":{"floor":1,"room":"Kitchen"},"manufacturer":"HelloWallet","model":"SO-6364","name":"tonight thermostat","network":{"ip":"194.0.188.235","mac":"e9:72:a5:65:cd:fc","signal":-71,"wifi_ssid":"fish-Network"},"online":true,"state":{"current_temp":15.171532533248955,"humidity":48,"mode":"off","target_temp":23.078449101141675},"type":"thermostat"}, + {"capabilities":{"auto_lock":true,"pin_code":false},"firmware":"1.5.58","id":"9c0e2d04-09c7-4388-87db-1db01fae0d3c","last_seen":"1920-05-03T20:50:33Z","location":{"floor":1,"room":"Living Room"},"manufacturer":"MetLife","model":"GQ-1013","name":"her door_lock","network":{"ip":"39.206.18.137","mac":"b1:12:38:c6:82:c4","signal":-46,"wifi_ssid":"then-Network"},"online":true,"state":{"last_activity":"1954-08-11T13:07:23Z","locked":true},"telemetry":[[1769183388838,58.475744278738304],[1769183328838,92.01379367358314],[1769183268838,36.95768002328757],[1769183208838,83.88229579985462],[1769183148838,44.59853617783153],[1769183088838,18.136676623344364],[1769183028838,37.71327024069922],[1769182968838,87.21271544163226],[1769182908838,39.751526311520664],[1769182848838,39.48134545083622],[1769182788838,64.24115298191936],[1769182728838,92.89619463377397],[1769182668838,31.909039776301597],[1769182608838,11.008873013801955],[1769182548838,18.224262398354277],[1769182488838,6.329266542749303],[1769182428838,62.202478822147235],[1769182368838,11.762835019136633],[1769182308838,4.0781464026526475],[1769182248838,91.65861179810933],[1769182188838,12.188452399043035],[1769182128838,66.45425926466177],[1769182068838,92.42255337266894],[1769182008838,12.286181956653007],[1769181948838,7.524014012786262],[1769181888838,16.899148636140986],[1769181828838,80.76274999315314],[1769181768838,64.94481109169105],[1769181708838,56.39570305414259],[1769181648838,22.474191334699643],[1769181588838,51.65151688161731],[1769181528838,35.761970503691956],[1769181468838,36.41293557102608],[1769181408838,44.047302795133064],[1769181348838,48.267089827597836],[1769181288838,77.70195667350234],[1769181228838,15.512928475683404],[1769181168838,48.60575188805692],[1769181108838,33.213471870870514],[1769181048838,36.26125635552896],[1769180988838,6.9254627122117105],[1769180928838,2.5433546964764444],[1769180868838,15.740823332193965],[1769180808838,77.83361644017997],[1769180748838,77.84043679779028],[1769180688838,9.241854529849173],[1769180628838,23.471441551323146],[1769180568838,72.34066098714736],[1769180508838,33.76479127361505],[1769180448838,93.59926613294225],[1769180388838,47.879962521520625],[1769180328838,63.31478677528277],[1769180268838,3.900579959657797],[1769180208838,32.73788321660099],[1769180148838,31.665066482573433],[1769180088838,81.11487309668206],[1769180028838,38.33528896482374],[1769179968838,82.07374649676888],[1769179908838,66.05856754981643],[1769179848838,40.51347750606887],[1769179788838,52.02930564527399],[1769179728838,71.21279979328934],[1769179668838,82.9498246672469],[1769179608838,67.59023457994815],[1769179548838,39.651641957503614],[1769179488838,50.226616833017545],[1769179428838,90.71397898049915],[1769179368838,13.23963706711365],[1769179308838,99.88574866024926],[1769179248838,15.792671291198593],[1769179188838,48.52619651311177],[1769179128838,27.028097461318108],[1769179068838,64.87572278685818],[1769179008838,29.175503254354073],[1769178948838,14.049561331347643],[1769178888838,4.101890717402815],[1769178828838,25.80095610731962],[1769178768838,8.601649644015138],[1769178708838,53.223557002165634],[1769178648838,38.828670131692895],[1769178588838,64.07585332220852],[1769178528838,82.74074376554697],[1769178468838,53.756146284542524],[1769178408838,59.5369315108278],[1769178348838,59.82584915178529],[1769178288838,89.11316127893268],[1769178228838,83.8496868004811],[1769178168838,13.651262478286764],[1769178108838,7.005581524306281],[1769178048838,95.03508993195737],[1769177988838,13.213935034929355],[1769177928838,60.78193458153004],[1769177868838,55.844878690148256]],"type":"door_lock"} + ], + "logs": [] +} diff --git a/packages/sync/bench/payloads.ts b/packages/sync/bench/payloads.ts new file mode 100644 index 0000000..c300c2b --- /dev/null +++ b/packages/sync/bench/payloads.ts @@ -0,0 +1,56 @@ +/** + * Payloads shared by the wire benchmarks and the wire-size report. + * + * The three sizes mirror the ones circulated in the community comparison of `mcbe-ipc` and + * `@mcbe-mods/ipc`, so numbers measured here sit next to theirs without re-deriving a scale: + * a bare string, a small record, and a real deeply-nested document. + * + * The 16KB fixture is a trimmed npm registry document (7 versions of `@mcbe-mods/utils`). It is + * vendored rather than fetched so a benchmark run needs no network and cannot drift between runs. + */ +import registry from './fixtures/registry-16kb.json'; + +export interface Payload { + + /** Label used in bench names and report rows. */ + label: string; + + /** The value handed to `Envelope.data`. */ + value: unknown; +} + +/** A small record of the shape addons actually replicate: an identity plus a few scalars. */ +const SMALL_RECORD = { + player: 'Steve', + position: { x: 128.5, y: 64, z: -512.25 }, + inventory: ['diamond_sword', 'golden_apple', 'ender_pearl'], + balance: 12500, + rank: 'veteran', + lastSeen: 1747670460000, +}; + +export const TINY: Payload = { label: '5B', value: 'hello' }; +export const SMALL: Payload = { label: '200B', value: SMALL_RECORD }; +export const LARGE: Payload = { label: '16KB', value: registry }; + +export const PAYLOADS: readonly Payload[] = [TINY, SMALL, LARGE]; + +/** + * The burst packing actually sees: one node writing many keys in a single tick. + * + * Every `State.set` sends its own delta, and `broadcastOwnedSnapshots` and the reply to a + * `state-req` both send one message per namespace from inside a loop — so these pile into one + * node's outbound queue and leave together on the next flush. Heartbeats do not: each addon runs + * in its own script realm with its own queue, so a world's announces are one message apiece from + * places that can never share a batch. + */ +export function stateDeltaBurst(count: number): unknown[] { + return Array.from({ length: count }, (_, i) => ({ + ns: 'economy', + key: `price:${MATERIALS[i % MATERIALS.length]}`, + value: 16 + i, + ver: 1200 + i, + })); +} + +const MATERIALS = ['diamond', 'iron_ingot', 'gold_ingot', 'emerald', 'copper_ingot', 'netherite_scrap']; diff --git a/packages/sync/bench/pipeline.ts b/packages/sync/bench/pipeline.ts new file mode 100644 index 0000000..4d53dcd --- /dev/null +++ b/packages/sync/bench/pipeline.ts @@ -0,0 +1,108 @@ +/** + * The send/receive path, lifted out of `Bus` so it can run off-engine. + * + * `Bus` and `OutboundQueue` import `@minecraft/server` for the script-event channel and the tick + * loops, but every byte-level decision — envelope encoding, tagging, packing, framing, reassembly — + * lives in modules that import nothing. These helpers stitch those together in exactly the order + * `Bus.send`, `OutboundQueue.takeNext` and `Bus.handleScriptEvent` do, so what is measured here is + * what crosses the wire. + */ +import { Reassembler, splitIntoFrames } from '../src/chunk'; +import { PROTOCOL_MAX } from '../src/constants'; +import { type Envelope, decodeEnvelope, encodeEnvelope } from '../src/envelope'; +import { batchLength, decodeWire, encodeBatch, tagChunk } from '../src/wire'; + +/** A stable stand-in for a real node's instance id (`-<8 random chars>`). */ +export const INSTANCE_ID = 'ya-a1b2c3d4'; + +/** Message id in the shape `Bus.nextMid` produces: `/`. */ +export const MESSAGE_ID = `${INSTANCE_ID}/1`; + +/** Build the envelope `Bus.send` would build for a broadcast of `data`. */ +export function envelopeFor(data: unknown, mid = MESSAGE_ID): Envelope { + return { + v: PROTOCOL_MAX, + src: 'benchmark', + iid: INSTANCE_ID, + type: 'state-delta', + mid, + data, + }; +} + +/** + * Send side for one envelope: whole if it fits under the cap, split into tagged frames if not. + * Batching is deliberately excluded here — one envelope alone is the shape a latency-sensitive + * message takes, and {@link packEnvelopes} covers the other case. + */ +export function toWire(envelope: Envelope, maxMessage: number): string[] { + const encoded = encodeEnvelope(envelope); + + if (encoded.length + 1 <= maxMessage) { return [encodeBatch([encoded])]; } + + return splitIntoFrames(encoded, envelope.mid, maxMessage - 1).map(tagChunk); +} + +/** Pack a run of envelopes into as few messages as the cap allows, the way the queue does. */ +export function packEnvelopes(envelopes: readonly Envelope[], maxMessage: number): string[] { + const messages: string[] = []; + let parts: string[] = []; + let lengths: number[] = []; + + for (const envelope of envelopes) { + const encoded = encodeEnvelope(envelope); + + lengths.push(encoded.length); + + if (batchLength(lengths) > maxMessage && parts.length > 0) { + messages.push(encodeBatch(parts)); + parts = []; + lengths = [encoded.length]; + } + + parts.push(encoded); + } + + if (parts.length > 0) { messages.push(encodeBatch(parts)); } + + return messages; +} + +/** + * Receive side: parse each message and either dispatch its envelopes or feed its frame to the + * reassembler. `reassembler` is passed in so a benchmark can reuse one across iterations, the way + * a live `Bus` does. + */ +export function fromWire(messages: readonly string[], reassembler: Reassembler, tick: number): Envelope[] { + const received: Envelope[] = []; + + for (const message of messages) { + const wire = decodeWire(message); + + if (!wire) { continue; } + + if (wire.kind === 'envelopes') { + received.push(...wire.envelopes); + continue; + } + + const payload = reassembler.accept(wire.frame, tick); + + if (payload === undefined) { continue; } + + const envelope = decodeEnvelope(payload); + + if (envelope) { received.push(envelope); } + } + + return received; +} + +/** Total characters a group of messages puts on the bus. */ +export function wireSize(messages: readonly string[]): number { + let total = 0; + + for (const message of messages) { total += message.length; } + + return total; +} diff --git a/packages/sync/bench/wire-size.spec.ts b/packages/sync/bench/wire-size.spec.ts new file mode 100644 index 0000000..b3c5b79 --- /dev/null +++ b/packages/sync/bench/wire-size.spec.ts @@ -0,0 +1,107 @@ +/** + * What a message actually costs on the bus, and the invariants that cost has to respect. + * + * Script events are capped in size and bounded in count per tick, so both bytes and *slots* are + * scarce: a payload that frames badly occupies more of the send budget, and a small message sent + * alone spends a whole slot on a mostly empty one. This spec pins the properties the wire must + * never lose — every message fits, every group round trips — and prints the two size tables. + */ +import { describe, expect, it } from 'vitest'; +import { Reassembler } from '../src/chunk'; +import { MAX_FLUSH_PER_TICK, MAX_MESSAGE } from '../src/constants'; +import { encodeEnvelope } from '../src/envelope'; +import { PAYLOADS, type Payload, stateDeltaBurst } from './payloads'; +import { envelopeFor, fromWire, packEnvelopes, toWire, wireSize } from './pipeline'; + +interface Measurement { + payload: Payload; + messages: string[]; + envelopeChars: number; + wireChars: number; +} + +function measure(payload: Payload): Measurement { + const envelope = envelopeFor(payload.value); + const messages = toWire(envelope, MAX_MESSAGE); + + return { + payload, + messages, + envelopeChars: encodeEnvelope(envelope).length, + wireChars: wireSize(messages), + }; +} + +const MEASUREMENTS = PAYLOADS.map(measure); + +describe.each(MEASUREMENTS)('$payload.label', ({ payload, messages }) => { + it('keeps every message within the cap', () => { + for (const message of messages) { + expect(message.length).toBeLessThanOrEqual(MAX_MESSAGE); + } + }); + + it('round trips to the original data', () => { + const received = fromWire(messages, new Reassembler(), 0); + + expect(received).toHaveLength(1); + expect(received[0].data).toEqual(payload.value); + }); +}); + +describe('packing', () => { + // One node writing a page of prices in a single tick: 40 `set` calls, 40 deltas, one queue. + const burst = stateDeltaBurst(40).map((data, i) => envelopeFor(data, `iid-1/${i}`)); + + it('packs a burst of deltas into far fewer messages', () => { + const packed = packEnvelopes(burst, MAX_MESSAGE); + + for (const message of packed) { + expect(message.length).toBeLessThanOrEqual(MAX_MESSAGE); + } + + expect(packed.length).toBeLessThan(burst.length); + expect(fromWire(packed, new Reassembler(), 0)).toHaveLength(burst.length); + }); + + it('prints the packing table', () => { + const rows = [4, 12, 40].map((count) => { + const packed = packEnvelopes(burst.slice(0, count), MAX_MESSAGE); + + return { + deltas: count, + unpackedMessages: count, + packedMessages: packed.length, + wireChars: wireSize(packed), + // The engine's per-tick bound is on messages, not bytes, so this is the number that + // decides whether a burst clears in one flush. + slotsSaved: count - packed.length, + }; + }); + + // eslint-disable-next-line no-console + console.table(rows); + + expect(rows).toHaveLength(3); + }); +}); + +describe('size report', () => { + it('prints the table', () => { + // eslint-disable-next-line no-console + console.table(MEASUREMENTS.map(({ payload, messages, envelopeChars, wireChars }) => ({ + payload: payload.label, + dataChars: JSON.stringify(payload.value).length, + envelopeChars, + messages: messages.length, + wireChars, + // What the wire adds on top of the envelope: a tag character, and for a chunked envelope the + // per-frame header plus whatever JSON spends escaping the envelope into a frame's `p` field. + wireOverhead: `${(wireChars / envelopeChars).toFixed(2)}x`, + budgetUsed: `${((wireChars / (messages.length * MAX_MESSAGE)) * 100).toFixed(0)}%`, + ticksToFlush: Math.ceil(messages.length / MAX_FLUSH_PER_TICK), + }))); + + expect(MEASUREMENTS).toHaveLength(PAYLOADS.length); + }); +}); diff --git a/packages/sync/bench/wire.bench.ts b/packages/sync/bench/wire.bench.ts new file mode 100644 index 0000000..5941593 --- /dev/null +++ b/packages/sync/bench/wire.bench.ts @@ -0,0 +1,58 @@ +/** + * Wire-path microbenchmarks: what one message costs in CPU on the send and receive sides. + * + * Run with `yarn workspace @bedrock-core/sync run bench`. + * + * These are off-engine numbers on a desktop JIT, not QuickJS on a console — read them as ratios + * between payload sizes and between pipeline stages, never as a tick budget. The in-game suite is + * what answers "does this fit in a tick". + */ +import { bench, describe } from 'vitest'; +import { Reassembler } from '../src/chunk'; +import { MAX_MESSAGE } from '../src/constants'; +import { decodeEnvelope, encodeEnvelope } from '../src/envelope'; +import { PAYLOADS, stateDeltaBurst } from './payloads'; +import { envelopeFor, fromWire, packEnvelopes, toWire } from './pipeline'; + +for (const { label, value } of PAYLOADS) { + const envelope = envelopeFor(value); + const encoded = encodeEnvelope(envelope); + const messages = toWire(envelope, MAX_MESSAGE); + + describe(label, () => { + bench('encode envelope', () => { + encodeEnvelope(envelope); + }); + + bench('decode envelope', () => { + decodeEnvelope(encoded); + }); + + bench('encode to wire messages', () => { + toWire(envelope, MAX_MESSAGE); + }); + + bench('decode from wire messages', () => { + fromWire(messages, new Reassembler(), 0); + }); + + // The whole hop minus the engine: what a sender spends plus what a receiver spends. + bench('round trip (send path → receive path)', () => { + fromWire(toWire(envelope, MAX_MESSAGE), new Reassembler(), 0); + }); + }); +} + +// Packing is the queue’s work, not the bus’s, and runs once per flush over whatever has piled up — +// so its cost scales with the burst, not with one message. +describe('packing', () => { + const burst = stateDeltaBurst(40).map((data, i) => envelopeFor(data, `iid-1/${i}`)); + + bench('pack a 40-delta burst', () => { + packEnvelopes(burst, MAX_MESSAGE); + }); + + bench('unpack a 40-delta burst', () => { + fromWire(packEnvelopes(burst, MAX_MESSAGE), new Reassembler(), 0); + }); +}); diff --git a/packages/sync/package.json b/packages/sync/package.json index bff1792..8e64c59 100644 --- a/packages/sync/package.json +++ b/packages/sync/package.json @@ -36,16 +36,23 @@ ], "scripts": { "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.bench.json", + "test": "vitest run", + "bench": "vitest bench --run", "lint": "eslint ." }, + "dependencies": { + "@bedrock-core/observable": "workspace:^" + }, "devDependencies": { - "@minecraft/server": "2.8.0", - "@stylistic/eslint-plugin": "^5.10.0", - "eslint": "^10.5.0", - "typescript": "^6.0.3", - "typescript-eslint": "^8.62.0" + "@minecraft/server": "*", + "@stylistic/eslint-plugin": "*", + "eslint": "*", + "typescript": "*", + "typescript-eslint": "*", + "vitest": "*" }, "peerDependencies": { - "@minecraft/server": ">=2.8.0" + "@minecraft/server": ">=2.9.0 || >=2.10.0-0" } } diff --git a/packages/sync/src/bus.ts b/packages/sync/src/bus.ts index 335a51f..d946745 100644 --- a/packages/sync/src/bus.ts +++ b/packages/sync/src/bus.ts @@ -2,24 +2,50 @@ * The message bus: the one transport every higher layer builds on. * * Responsibilities: - * - encode/decode {@link Envelope}s and split/reassemble them into wire {@link Frame}s; - * - route all outbound traffic through the {@link OutboundQueue} (rate limiting); + * - encode/decode {@link Envelope}s, sending one whole where it fits and splitting it into wire + * {@link Frame}s where it does not; + * - encode each message at a protocol its recipient can read (see below); + * - route all outbound traffic through the {@link OutboundQueue}, which rate-limits it and packs + * small messages together; * - on receive, drop the node's own echoes (matched by instance id, not src, so a colliding * twin is still heard) and anything addressed elsewhere, then dispatch by message type. + * + * ## Speaking each peer's protocol + * + * Addons update on their own schedules, so one world routinely holds nodes built against + * different releases. The bus therefore has no single output format: `Discovery` negotiates a + * version per peer and pushes it here with {@link Bus.setPeerProtocol}, and every send takes its + * encoding from that table — the peer's own version for a directed message, the lowest any live + * peer can read for a broadcast. + * + * Two defaults keep an unheard-of reader from being cut off. A peer not in the table has yet to + * announce, so it gets {@link PROTOCOL_MIN}, which every supported build reads; so does a + * broadcast sent before any peer is known. Framing too old costs a few characters. Framing too + * new costs the whole message, for everyone behind. */ import { system, type ScriptEventCommandMessageAfterEvent } from '@minecraft/server'; -import { Reassembler, decodeFrame, splitIntoFrames } from './chunk'; -import { BUS_CHANNEL, BUS_NAMESPACE, MAX_MESSAGE, PROTOCOL_VERSION } from './constants'; +import { Reassembler, splitIntoFrames } from './chunk'; +import { BUS_CHANNEL, BUS_NAMESPACE, Cap, MAX_MESSAGE, PROTOCOL_MAX, PROTOCOL_MIN, TAGGED_WIRE_PROTOCOL } from './constants'; import { type Envelope, decodeEnvelope, encodeEnvelope } from './envelope'; import { OutboundQueue } from './queue'; +import { decodeWire, encodeLegacy, tagChunk } from './wire'; +/** What every `on*` / `subscribe` returns: call it to stop listening. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any export type Unsubscribe = (...args: any[]) => void; const EVICT_INTERVAL_TICKS = 20; +/** Handed every envelope of the type it was registered for. */ export type EnvelopeHandler = (envelope: Envelope) => void; +/** What a peer negotiated to, as pushed in by discovery. */ +interface PeerProtocol { + protocol: number; + caps: readonly string[]; +} + +/** What `send()` takes beside the type and the data. */ export interface SendOptions { /** Target addon id; omit to broadcast. */ @@ -29,8 +55,16 @@ export interface SendOptions { /** Reuse a specific message id (e.g. a chunk-group); otherwise one is generated. */ mid?: string; data?: unknown; + + /** + * Force an encoding rather than taking the one negotiated for `dst`. Discovery holds announces + * at {@link PROTOCOL_MIN} with it: the message that tells peers what we speak cannot itself + * assume an answer. + */ + protocol?: number; } +/** What `new Bus()` takes beside the ids. */ export interface BusOptions { maxMessage?: number; @@ -38,6 +72,7 @@ export interface BusOptions { instanceId?: string; } +/** The transport: envelopes in and out over one script-event channel, framed, packed and rate-limited. */ export class Bus { private readonly _selfId: string; private readonly _instanceId: string; @@ -45,6 +80,8 @@ export class Bus { private readonly _queue: OutboundQueue; private readonly _reassembler = new Reassembler(); private readonly _handlers = new Map>(); + private readonly _peerProtocols = new Map(); + private _broadcastProtocol: number = PROTOCOL_MIN; private _unsubscribe: Unsubscribe | undefined; private _evictHandle: number | undefined; private _counter = 0; @@ -53,7 +90,8 @@ export class Bus { this._selfId = selfId; this._instanceId = options.instanceId ?? `${system.currentTick.toString(36)}-${Math.random().toString(36).slice(2, 10)}`; this._maxMessage = options.maxMessage ?? MAX_MESSAGE; - this._queue = new OutboundQueue({ channel: BUS_CHANNEL }); + this._queue = new OutboundQueue({ channel: BUS_CHANNEL, maxMessage: this._maxMessage }); + this.recomputeBroadcast(); } get selfId(): string { @@ -64,11 +102,30 @@ export class Bus { return this._instanceId; } - /** Pending outbound message count (inspection helper). */ + /** Pending outbound entries, before any packing (inspection helper). */ get queueSize(): number { return this._queue.size; } + /** The encoding a broadcast currently goes out in: the lowest any live peer can read. */ + get broadcastProtocol(): number { + return this._broadcastProtocol; + } + + /** + * Record what a peer negotiated to. Discovery owns the negotiation — the announce is what + * carries a node's supported range — and pushes the result here so the bus can address it. + */ + setPeerProtocol(id: string, protocol: number, caps: readonly string[]): void { + this._peerProtocols.set(id, { protocol, caps }); + this.recomputeBroadcast(); + } + + /** Drop a peer that has gone quiet, letting the world's encoding rise if it was holding it down. */ + forgetPeer(id: string): void { + if (this._peerProtocols.delete(id)) { this.recomputeBroadcast(); } + } + /** Subscribe to the bus channel and start the flush + chunk-eviction loops. */ start(): void { if (this._unsubscribe) { return; } @@ -98,8 +155,9 @@ export class Bus { /** Build, frame and queue an envelope. Returns the message id. */ send(options: SendOptions): string { const mid = options.mid ?? this.nextMid(); + const protocol = options.protocol ?? this.protocolFor(options.dst); const envelope: Envelope = { - v: PROTOCOL_VERSION, + v: protocol, src: this._selfId, iid: this._instanceId, type: options.type, @@ -121,9 +179,30 @@ export class Bus { return mid; } - const frames = splitIntoFrames(encodeEnvelope(envelope), mid, this._maxMessage); + const encoded = encodeEnvelope(envelope); + + // A reader from before the wire tag takes bare frames and nothing else, so there is no + // whole-envelope shortcut here and nothing this may be packed with. + if (protocol < TAGGED_WIRE_PROTOCOL) { + for (const frame of encodeLegacy(encoded, mid, this._maxMessage)) { + this._queue.enqueueStandalone(frame); + } + + return mid; + } + + // The wire tag is part of the message, so both branches get one character less than the cap. + // An envelope that fits goes whole and may be packed with its neighbours; only one that does + // not is split, and its frames are each a message of their own. + if (encoded.length + 1 <= this._maxMessage) { + this._queue.enqueueEnvelope(encoded); + + return mid; + } - for (const frame of frames) { this._queue.enqueue(frame); } + for (const frame of splitIntoFrames(encoded, mid, this._maxMessage - 1)) { + this._queue.enqueueStandalone(tagChunk(frame)); + } return mid; } @@ -151,6 +230,39 @@ export class Bus { }; } + /** The encoding to use for one destination; `undefined` means a broadcast. */ + private protocolFor(dst: string | undefined): number { + if (dst === undefined) { return this._broadcastProtocol; } + + return this._peerProtocols.get(dst)?.protocol ?? PROTOCOL_MIN; + } + + /** + * Recompute what a broadcast may assume of its audience: the lowest protocol among live peers, + * and whether every one of them can read a packed message. Both rise on their own as the peers + * holding them down expire, so a world speeds back up once its last old addon is gone. + */ + private recomputeBroadcast(): void { + if (this._peerProtocols.size === 0) { + this._broadcastProtocol = PROTOCOL_MIN; + this._queue.setPacking(false); + + return; + } + + let lowest = PROTOCOL_MAX; + let packable = true; + + for (const peer of this._peerProtocols.values()) { + if (peer.protocol < lowest) { lowest = peer.protocol; } + + if (!peer.caps.includes(Cap.Batch)) { packable = false; } + } + + this._broadcastProtocol = lowest; + this._queue.setPacking(packable); + } + private nextMid(): string { return `${this._instanceId}/${++this._counter}`; } @@ -158,20 +270,31 @@ export class Bus { private handleScriptEvent(event: ScriptEventCommandMessageAfterEvent): void { if (event.id !== BUS_CHANNEL) { return; } - const frame = decodeFrame(event.message); + const wire = decodeWire(event.message); - if (!frame) { return; } + if (!wire) { return; } - const payload = this._reassembler.accept(frame, system.currentTick); + if (wire.kind === 'envelopes') { + for (const envelope of wire.envelopes) { this.receive(envelope); } + + return; + } + + const payload = this._reassembler.accept(wire.frame, system.currentTick); if (payload === undefined) { return; } const envelope = decodeEnvelope(payload); - if (!envelope) { return; } + if (envelope) { this.receive(envelope); } + } - // Drop our own echoes (matched by instance id, so a same-src twin is still delivered). - // Self-addressed messages never reach here — `send` loops them back locally. + /** + * Deliver an envelope that arrived over the wire, dropping our own echoes — matched by + * instance id, so a same-src twin is still heard. Self-addressed messages never reach here; + * `send` loops those back locally. + */ + private receive(envelope: Envelope): void { if (envelope.iid === this._instanceId) { return; } this.dispatch(envelope); diff --git a/packages/sync/src/chunk.ts b/packages/sync/src/chunk.ts index 93f7a29..29f2e3f 100644 --- a/packages/sync/src/chunk.ts +++ b/packages/sync/src/chunk.ts @@ -24,6 +24,7 @@ export interface Frame { p: string; } +/** One frame as it travels: the JSON of the frame object. */ export function encodeFrame(frame: Frame): string { return JSON.stringify(frame); } @@ -46,6 +47,7 @@ function isFrame(value: unknown): value is Frame { ); } +/** A frame back from the wire, or `undefined` when the text is not one. */ export function decodeFrame(json: string): Frame | undefined { let parsed: unknown; @@ -59,24 +61,79 @@ export function decodeFrame(json: string): Frame | undefined { } /** - * Split an encoded envelope into frames whose individual encoded size stays within - * `maxMessage`. The part budget is halved to absorb worst-case JSON string escaping (every - * character of `p` could become two), guaranteeing each `encodeFrame` result fits. + * Width of one character once JSON escapes it inside a string literal. `"` and `\` gain a + * backslash; anything below U+0020 becomes a six-character `\uXXXX`; everything else, printable + * non-ASCII included, is copied verbatim. */ -export function splitIntoFrames(payload: string, cid: string, maxMessage: number): string[] { - const overhead = encodeFrame({ c: cid, s: 999999, t: 999999, p: '' }).length; - const partBudget = Math.max(1, Math.floor((maxMessage - overhead) / 2)); - const total = Math.max(1, Math.ceil(payload.length / partBudget)); +function escapedWidth(code: number): number { + if (code === 0x22 || code === 0x5c) { return 2; } + + if (code < 0x20) { return 6; } + + return 1; +} + +/** + * Cut `payload` into the longest slices whose *escaped* length still fits `budget`. + * + * The slicing is exact rather than pessimistic: each character is charged what JSON will actually + * spend on it, so ordinary JSON — which escapes roughly one character in eight — fills a frame + * instead of leaving half of it reserved against an all-quotes payload that never arrives. A + * genuinely hostile payload simply yields more slices; no slice can ever exceed the budget. + */ +function sliceToEscapedBudget(payload: string, budget: number): string[] { + const parts: string[] = []; + let start = 0; + + while (start < payload.length) { + let cost = 0; + let end = start; + + while (end < payload.length) { + const code = payload.charCodeAt(end); + let width = escapedWidth(code); + let advance = 1; + + // A surrogate pair is one character to JSON. Splitting it would leave a lone high surrogate + // at the end of one frame and a lone low surrogate at the start of the next, so the pair + // moves as a unit or not at all. + if (code >= 0xd800 && code <= 0xdbff && end + 1 < payload.length) { + const low = payload.charCodeAt(end + 1); + + if (low >= 0xdc00 && low <= 0xdfff) { + width += 1; + advance = 2; + } + } - const frames: string[] = []; + if (cost + width > budget) { break; } - for (let seq = 0; seq < total; seq++) { - const part = payload.slice(seq * partBudget, (seq + 1) * partBudget); + cost += width; + end += advance; + } + + // Progress guard: reachable only if `maxMessage` cannot hold one escaped character, which + // would otherwise spin forever. Such a frame overruns the cap; every real cap is far above it. + if (end === start) { end = start + 1; } - frames.push(encodeFrame({ c: cid, s: seq, t: total, p: part })); + parts.push(payload.slice(start, end)); + start = end; } - return frames; + return parts.length > 0 ? parts : ['']; +} + +/** + * Split an encoded envelope into frames whose individual encoded size stays within `maxMessage`. + * + * `s` and `t` are costed at their widest, because the frame count is not known until the split has + * been made — reserving six digits for each is cheaper than splitting twice. + */ +export function splitIntoFrames(payload: string, cid: string, maxMessage: number): string[] { + const overhead = encodeFrame({ c: cid, s: 999999, t: 999999, p: '' }).length; + const parts = sliceToEscapedBudget(payload, Math.max(1, maxMessage - overhead)); + + return parts.map((part, seq) => encodeFrame({ c: cid, s: seq, t: parts.length, p: part })); } interface PendingGroup { diff --git a/packages/sync/src/constants.ts b/packages/sync/src/constants.ts index a484c63..519bee4 100644 --- a/packages/sync/src/constants.ts +++ b/packages/sync/src/constants.ts @@ -1,7 +1,70 @@ /** Protocol-wide constants shared by every layer. */ -/** Bumped on any breaking change to the envelope or frame wire format. */ -export const PROTOCOL_VERSION = 1; +/** + * Protocol support window. + * + * A node advertises the range it can speak and talks to each peer at the highest version they + * both support, so a world may hold addons built years apart without partitioning. Gating on a + * single version instead would make every bump a silent split: two meshes on one channel, each + * listing only its own half. + * + * `PROTOCOL_MIN` is the oldest wire format this build still reads and writes; `PROTOCOL_MAX` the + * newest it knows. Raising `MIN` drops support for everything below it, which is a breaking + * change — the window is two versions wide, so a version is readable for two releases after it + * stops being written. + */ +export const PROTOCOL_MIN = 1; + +/** Newest protocol this build speaks. See {@link PROTOCOL_MIN}. */ +export const PROTOCOL_MAX = 2; + +/** + * First protocol that reads the wire tag. Below it a message must be a bare frame, which is why + * this is the line {@link encodeLegacy} is chosen on rather than a bare `2` in the bus. + */ +export const TAGGED_WIRE_PROTOCOL = 2; + +/** + * Optional behaviours a node advertises alongside its protocol range. + * + * A capability is what a peer can *read*, so a sender consults the receiver's set before using + * one. Versions move in lockstep for everyone; capabilities let a single behaviour appear, + * degrade, or disappear on its own, which is what keeps the next addition from needing a bump. + */ +export const Cap = { + + /** Reads a {@link WireTag.Batch} message: several envelopes packed into one. */ + Batch: 'batch', +} as const; + +/** A capability a node can advertise. */ +export type Cap = typeof Cap[keyof typeof Cap]; + +/** Everything this build can read. Broadcast in every announce. */ +export const SELF_CAPS: readonly Cap[] = [Cap.Batch]; + +/** + * Leading character of a script-event message, saying which shape follows. See `wire.ts` for what + * each one carries; a one-piece message travels verbatim. + * + * The tag is frozen: a shape added later takes a new character, and a reader that does not know a + * character drops that one message rather than the peer that sent it. Protocol 1 predates the tag + * and opens with `{`, which `decodeWire` recognises as the bare frame it is. + */ +export const WireTag = { + + /** The rest of the message is one JSON envelope. */ + Envelope: '0', + + /** The rest is one frame of an envelope too large to send whole. */ + Chunk: '1', + + /** The rest is a JSON array of envelopes packed into a single message. */ + Batch: '2', +} as const; + +/** The leading character of a message. */ +export type WireTag = typeof WireTag[keyof typeof WireTag]; /** The single script-event namespace all bedrock-core traffic flows through. */ export const BUS_NAMESPACE = 'bedrock-core'; @@ -55,6 +118,10 @@ export const MessageType = { /** A full namespace dump used to (re)build a mirror. */ StateSnapshot: 'state-snapshot', + + /** A happening the sender announces to every realm; delivered once, kept by nobody. */ + Event: 'event', } as const; +/** What an envelope carries, by name. */ export type MessageType = typeof MessageType[keyof typeof MessageType]; diff --git a/packages/sync/src/discovery.ts b/packages/sync/src/discovery.ts index cb7b349..2cf923d 100644 --- a/packages/sync/src/discovery.ts +++ b/packages/sync/src/discovery.ts @@ -6,12 +6,31 @@ * heartbeat, and a freshly started node broadcasts a `whois` that prompts existing peers to * announce straight back. A TTL sweep drops peers that go quiet. * + * Who is present is a value, not a stream: {@link Discovery.peers} is an observable list that can + * be read, watched, or `computed` over. It republishes only when the world actually changes — + * a heartbeat that says nothing new refreshes `lastSeen` in place and notifies nobody, which is + * what lets a listener sit on the list without waking every five seconds per peer. + * * Because the bus filters echoes by instance id (not src), an announce whose `src` equals our * own id but comes from a different instance reaches us — that's a namespace collision, which * we surface via {@link Discovery.onCollision} rather than storing as a peer. + * + * ## Negotiation + * + * Discovery is also where protocol versions are agreed. Every announce carries the range its + * sender speaks, and hearing one settles the pair on the newest version both know, which is + * pushed to the bus so traffic to that peer is encoded for it. Nothing is exchanged to arrive at + * this: each side applies the same rule to the same advertised ranges, so both reach the same + * answer from one message — the same reasoning the runtime's host election uses. + * + * A node whose range does not overlap ours at all cannot be addressed. It is surfaced through + * {@link Discovery.onIncompatible} rather than dropped, so a world holding one can name the addon + * that needs updating instead of showing a list quietly missing a row. */ import { system } from '@minecraft/server'; -import { ANNOUNCE_INTERVAL_TICKS, MessageType, PEER_TTL_TICKS } from './constants'; +import { ANNOUNCE_INTERVAL_TICKS, MessageType, PEER_TTL_TICKS, PROTOCOL_MAX, PROTOCOL_MIN, SELF_CAPS } from './constants'; +import { capsFor, negotiateProtocol } from './negotiate'; +import { observable, type Observable, type ReadonlyObservable } from '@bedrock-core/observable'; import type { Bus, Unsubscribe } from './bus'; import type { Envelope } from './envelope'; @@ -23,11 +42,17 @@ export interface PeerInfo { version: string; schemaVersion: number; + /** + * The protocol this node and that peer settled on: the newest version both support. Traffic + * addressed to the peer is encoded at it. + */ + protocol: number; + + /** Optional behaviours the peer can read, narrowed to what {@link PeerInfo.protocol} allows. */ + caps: readonly string[]; + /** Opaque metadata the peer attached to its announce (e.g. a higher-layer manifest). */ meta?: Record; - - /** Tick this peer was last heard from. */ - lastSeen: number; } /** A detected namespace collision: another instance is announcing our own id. */ @@ -36,12 +61,67 @@ export interface CollisionInfo { instanceId: string; } +/** + * A node heard on the bus whose supported range does not overlap this build's, so nothing can be + * said to it. It is reported rather than stored as a peer: a world holding one is misconfigured, + * and the addon that cannot be talked to should be named instead of quietly missing. + */ +export interface IncompatiblePeer { + id: string; + + /** The range the peer advertised. */ + pmin: number; + pmax: number; +} + +/** + * The announce payload — the one message shape that must stay readable forever. + * + * Every other message can assume a negotiated protocol because the announce is what establishes + * it; the announce itself can assume nothing, so it goes out at {@link PROTOCOL_MIN} and only ever + * gains optional fields. A node that predates a field ignores it, which is why `pmin`/`pmax` are + * optional here: their absence identifies a protocol-1 node exactly, since they ship with 2. + */ interface AnnounceData { version: string; schemaVersion: number; meta?: Record; + + /** Oldest protocol the sender still speaks. Absent on protocol-1 nodes. */ + pmin?: number; + + /** Newest protocol the sender speaks. Absent on protocol-1 nodes. */ + pmax?: number; + + /** Optional behaviours the sender can read. Absent on nodes that predate capabilities. */ + caps?: readonly string[]; +} + +function sameCaps(a: readonly string[], b: readonly string[]): boolean { + return a.length === b.length && a.every((cap, i) => cap === b[i]); +} + +function sameMeta(a: Record | undefined, b: Record | undefined): boolean { + if (a === b) { return true; } + + if (a === undefined || b === undefined) { return false; } + + // Meta arrives re-parsed from the wire on every announce, so there is no identity to compare and + // its shape is the sender's to choose. Serializing it is the only honest equality, and a manifest + // is small enough to pay for once per peer per heartbeat. + return JSON.stringify(a) === JSON.stringify(b); +} + +/** Whether two readings of the same peer say the same thing. */ +function sameAdvertisement(a: PeerInfo, b: PeerInfo): boolean { + return a.version === b.version + && a.schemaVersion === b.schemaVersion + && a.protocol === b.protocol + && sameCaps(a.caps, b.caps) + && sameMeta(a.meta, b.meta); } +/** What `new Discovery()` takes beside the bus and the id. */ export interface DiscoveryOptions { version?: string; schemaVersion?: number; @@ -52,18 +132,36 @@ export interface DiscoveryOptions { peerTtlTicks?: number; } +/** Told a peer that came up or went down. */ export type PeerListener = (peer: PeerInfo) => void; + +/** Told two live nodes claim the same id. */ export type CollisionListener = (info: CollisionInfo) => void; +/** Told a peer whose protocol range does not overlap this build's. */ +export type IncompatibleListener = (peer: IncompatiblePeer) => void; + +/** Who is in the world: announces, `whois`, TTL eviction, collisions and protocol negotiation. */ export class Discovery { private readonly _bus: Bus; private readonly _self: AnnounceData; private readonly _announceIntervalTicks: number; private readonly _peerTtlTicks: number; private readonly _peers = new Map(); + private readonly _incompatible = new Map(); + // Liveness is kept beside the lists rather than inside them: it moves on every heartbeat, and a + // field that changes every five seconds per peer would make an observable list of peers useless. + private readonly _lastSeen = new Map(); + private readonly _peerList: Observable + = observable([], { label: 'discovery.peers' }); + + private readonly _incompatibleList: Observable + = observable([], { label: 'discovery.incompatiblePeers' }); + private readonly _onUp = new Set(); private readonly _onDown = new Set(); private readonly _onCollision = new Set(); + private readonly _onIncompatible = new Set(); private readonly _disposers: Unsubscribe[] = []; private readonly _handles: number[] = []; @@ -73,14 +171,29 @@ export class Discovery { version: options.version ?? '0.0.0', schemaVersion: options.schemaVersion ?? 0, meta: options.meta, + pmin: PROTOCOL_MIN, + pmax: PROTOCOL_MAX, + caps: SELF_CAPS, }; this._announceIntervalTicks = options.announceIntervalTicks ?? ANNOUNCE_INTERVAL_TICKS; this._peerTtlTicks = options.peerTtlTicks ?? PEER_TTL_TICKS; } - /** Known live peers (excludes self). */ - get peers(): PeerInfo[] { - return Array.from(this._peers.values()); + /** + * Known live peers (excludes self), as an observable list. Read it with `.get()`, watch it with + * `.subscribe()`, derive from it with `computed()`. Each notification carries a fresh array; + * the `PeerInfo` objects inside it are never mutated. + */ + get peers(): ReadonlyObservable { + return this._peerList; + } + + /** + * Live nodes whose protocol range does not overlap this build's, so they cannot be talked to. + * An observable list on the same terms as {@link Discovery.peers}. + */ + get incompatiblePeers(): ReadonlyObservable { + return this._incompatibleList; } /** Wire up handlers, announce + whois immediately, then start the heartbeat/sweep loops. */ @@ -105,20 +218,32 @@ export class Discovery { for (const handle of this._handles.splice(0)) { system.clearRun(handle); } } - /** Broadcast this node's presence. */ + /** + * Broadcast this node's presence, including the protocol range it speaks. + * + * Pinned to {@link PROTOCOL_MIN} because it is the message that establishes what everything else + * may assume: encoding it at anything newer would make it unreadable to exactly the peers it + * exists to reach. That costs it the packing a negotiated message gets, which a heartbeat every + * five seconds can afford. + */ announce(): void { - this._bus.send({ type: MessageType.Announce, data: this._self }); + this._bus.send({ type: MessageType.Announce, data: this._self, protocol: PROTOCOL_MIN }); } /** Ask every peer to announce itself (used at startup to discover existing nodes). */ whois(): void { - this._bus.send({ type: MessageType.Whois }); + this._bus.send({ type: MessageType.Whois, protocol: PROTOCOL_MIN }); } getPeer(id: string): PeerInfo | undefined { return this._peers.get(id); } + /** The tick a node was last heard from, or `undefined` if it has never been heard. */ + lastSeen(id: string): number | undefined { + return this._lastSeen.get(id); + } + /** Notified when a peer is first seen. Returns an unsubscribe function. */ onPeerUp(listener: PeerListener): Unsubscribe { this._onUp.add(listener); @@ -146,6 +271,18 @@ export class Discovery { }; } + /** + * Notified the first time a node with no overlapping protocol range is heard. Returns an + * unsubscribe function. + */ + onIncompatible(listener: IncompatibleListener): Unsubscribe { + this._onIncompatible.add(listener); + + return (): void => { + this._onIncompatible.delete(listener); + }; + } + private handleAnnounce(envelope: Envelope): void { // An announce carrying our own id (from a different instance — the bus already dropped // our own echoes) is a namespace collision, not a peer. @@ -159,37 +296,119 @@ export class Discovery { if (!data) { return; } + const protocol = negotiateProtocol(data.pmin, data.pmax); + + if (protocol === undefined) { + this.recordIncompatible(envelope.src, data); + + return; + } + const existing = this._peers.get(envelope.src); const peer: PeerInfo = { id: envelope.src, version: data.version, schemaVersion: data.schemaVersion, + protocol, + caps: capsFor(protocol, data.caps), meta: data.meta, - lastSeen: system.currentTick, }; - this._peers.set(envelope.src, peer); + this._lastSeen.set(envelope.src, system.currentTick); + + if (this._incompatible.delete(envelope.src)) { this.publishIncompatible(); } + + this._bus.setPeerProtocol(peer.id, peer.protocol, peer.caps); + + if (existing === undefined) { + this._peers.set(envelope.src, peer); + this.publishPeers(); - if (!existing) { for (const listener of this._onUp) { listener(peer); } + } else if (!sameAdvertisement(existing, peer)) { + // The same node saying something new — a version bump, a re-announced manifest. The list + // changes; nobody came up. + this._peers.set(envelope.src, peer); + this.publishPeers(); + } + + // A heartbeat that repeats what the peer already said stores nothing: the record already on + // hand says exactly this, and keeping it is what makes the map and the published list one set + // of objects rather than two equal ones. + } + + private publishPeers(): void { + this._peerList.set(Array.from(this._peers.values())); + } + + private publishIncompatible(): void { + this._incompatibleList.set(Array.from(this._incompatible.values())); + } + + private recordIncompatible(id: string, data: AnnounceData): void { + const entry: IncompatiblePeer = { + id, + pmin: typeof data.pmin === 'number' ? data.pmin : PROTOCOL_MIN, + pmax: typeof data.pmax === 'number' ? data.pmax : PROTOCOL_MIN, + }; + + this._lastSeen.set(id, system.currentTick); + + const existing = this._incompatible.get(id); + + this._incompatible.set(id, entry); + + if (existing === undefined) { + this.publishIncompatible(); + + for (const listener of this._onIncompatible) { listener(entry); } + } else if (existing.pmin !== entry.pmin || existing.pmax !== entry.pmax) { + this.publishIncompatible(); } } private handleWhois(envelope: Envelope): void { - // Reply directly to the asker so the rest of the world isn't spammed. + // Reply directly to the asker so the rest of the world isn't spammed. Unlike the broadcast + // announce this one is not pinned: it is addressed, so the bus already knows what the asker + // reads — and before its own announce lands, an unknown destination falls back to the oldest + // supported encoding anyway, which is exactly what a node this old needs. this._bus.send({ dst: envelope.src, type: MessageType.Announce, data: this._self }); } private sweep(): void { const cutoff = system.currentTick - this._peerTtlTicks; + const dropped: PeerInfo[] = []; for (const [id, peer] of this._peers) { - if (peer.lastSeen < cutoff) { + if ((this._lastSeen.get(id) ?? 0) < cutoff) { this._peers.delete(id); + this._lastSeen.delete(id); + this._bus.forgetPeer(id); + dropped.push(peer); + } + } + // The list is republished once, before any listener runs, so a handler reading `peers` during + // a multi-peer eviction never sees a half-swept world. + if (dropped.length > 0) { + this.publishPeers(); + + for (const peer of dropped) { for (const listener of this._onDown) { listener(peer); } } } + + let evicted = false; + + for (const id of this._incompatible.keys()) { + if ((this._lastSeen.get(id) ?? 0) < cutoff) { + this._incompatible.delete(id); + this._lastSeen.delete(id); + evicted = true; + } + } + + if (evicted) { this.publishIncompatible(); } } private parseAnnounce(data: unknown): AnnounceData | undefined { @@ -203,6 +422,9 @@ export class Discovery { version: candidate.version, schemaVersion: typeof candidate.schemaVersion === 'number' ? candidate.schemaVersion : 0, meta: typeof candidate.meta === 'object' && candidate.meta !== null ? candidate.meta : undefined, + pmin: typeof candidate.pmin === 'number' ? candidate.pmin : undefined, + pmax: typeof candidate.pmax === 'number' ? candidate.pmax : undefined, + caps: Array.isArray(candidate.caps) ? candidate.caps.filter((c): c is string => typeof c === 'string') : undefined, }; } } diff --git a/packages/sync/src/envelope.ts b/packages/sync/src/envelope.ts index e7b077a..1fe313d 100644 --- a/packages/sync/src/envelope.ts +++ b/packages/sync/src/envelope.ts @@ -1,12 +1,18 @@ /** - * The logical message exchanged between addons. Envelopes are JSON-serialized and then - * split into one or more wire {@link Frame}s by the chunker before they hit the bus. + * The logical message exchanged between addons. Envelopes are JSON-serialized and then, depending + * on the protocol the sender picked for the recipient, sent whole behind a wire tag or nested in + * one or more {@link Frame}s by the chunker before they hit the bus. */ -import { PROTOCOL_VERSION } from './constants'; +import { PROTOCOL_MAX, PROTOCOL_MIN } from './constants'; +/** One message between nodes: who sent it, what it is, and its data. */ export interface Envelope { - /** Protocol version (see {@link PROTOCOL_VERSION}). */ + /** + * Protocol version this envelope was written at — somewhere in + * [{@link PROTOCOL_MIN}, {@link PROTOCOL_MAX}]. The sender picks it per recipient, so the same + * node emits different versions to different peers. + */ v: number; /** Sender addon id. */ @@ -37,11 +43,15 @@ export function encodeEnvelope(envelope: Envelope): string { } /** - * Parse an envelope from its wire string. Returns `undefined` for malformed JSON, a - * structurally invalid envelope, or a mismatched protocol version — callers ignore those - * rather than throwing, so one bad sender can never crash a listener. + * Structural check for a parsed envelope, including that its protocol version falls inside the + * window this build supports. Exported because a batched message arrives as an array of + * already-parsed objects rather than as JSON text. + * + * The check is a range rather than an equality: a peer one version behind is understood, not + * ignored. Only a version outside the window — too old to still be supported, or newer than + * anything this build knows — is refused. */ -function isEnvelope(value: unknown): value is Envelope { +export function isEnvelope(value: unknown): value is Envelope { if (typeof value !== 'object' || value === null) { return false; } if (!('v' in value && 'src' in value && 'iid' in value && 'type' in value && 'mid' in value)) { return false; } @@ -50,7 +60,9 @@ function isEnvelope(value: unknown): value is Envelope { const dst = 'dst' in value ? value.dst : undefined; return ( - v === PROTOCOL_VERSION + typeof v === 'number' + && v >= PROTOCOL_MIN + && v <= PROTOCOL_MAX && typeof src === 'string' && typeof iid === 'string' && typeof type === 'string' @@ -59,6 +71,11 @@ function isEnvelope(value: unknown): value is Envelope { ); } +/** + * Parse an envelope from its wire string. Returns `undefined` for malformed JSON, a structurally + * invalid envelope, or a protocol version outside the supported window — callers ignore those + * rather than throwing, so one bad sender can never crash a listener. + */ export function decodeEnvelope(json: string): Envelope | undefined { let parsed: unknown; diff --git a/packages/sync/src/events.ts b/packages/sync/src/events.ts new file mode 100644 index 0000000..023bf12 --- /dev/null +++ b/packages/sync/src/events.ts @@ -0,0 +1,112 @@ +/** + * Events — a broadcast that is delivered and forgotten. + * + * The third thing a bus can do, beside pushing state ({@link State}) and answering a question + * ({@link Rpc}): the sender says something happened, every listener hears it in the same tick, and + * nobody keeps it. A listener attached after the fact hears nothing, which is the difference from + * state: an event is not a value, and a peer that wants the latest value wants the mirror. + * + * An event's namespace is the sending node's id, taken from the envelope rather than the payload, + * so a message cannot claim to come from an addon that did not send it. That is the same owner + * rule the mirror applies, for the same reason: robustness against a buggy pack, not security + * against a hostile one. + * + * Subscribing is a filter on namespace and name, so a listener can be attached before the sender + * is in the world — which matters here in a way it does not for state, because an event missed is + * missed for good. + */ +import { MessageType } from './constants'; +import type { Bus, Unsubscribe } from './bus'; +import type { Envelope } from './envelope'; + +/** What travels: the event name and its payload. The namespace is the envelope's `src`. */ +interface EventData { + n: string; + p?: unknown; +} + +/** Handed the payload and the namespace that announced it. */ +export type EventHandler = (payload: unknown, from: string) => void; + +function isEventData(value: unknown): value is EventData { + return typeof value === 'object' && value !== null && typeof (value as { n?: unknown }).n === 'string'; +} + +/** A happening: `emit` broadcasts one message, `on` listens for one sender's name, nothing is kept. */ +export class Events { + private readonly _bus: Bus; + private readonly _selfId: string; + private readonly _handlers = new Map>(); + private readonly _disposers: Unsubscribe[] = []; + + constructor(bus: Bus, selfId: string) { + this._bus = bus; + this._selfId = selfId; + } + + start(): void { + this._disposers.push(this._bus.on(MessageType.Event, (envelope) => { this._deliver(envelope); })); + } + + stop(): void { + for (const dispose of this._disposers.splice(0)) { dispose(); } + + this._handlers.clear(); + } + + /** + * Announce that something happened, under this node's own namespace. Every realm's listeners for + * it fire, this one's first and synchronously. + */ + emit(name: string, payload?: unknown): void { + this._dispatch(this._selfId, name, payload); + this._bus.send({ type: MessageType.Event, data: { n: name, p: payload } }); + } + + /** Listen for one event of one namespace. Attaching before that addon exists is fine. */ + on(namespace: string, name: string, handler: EventHandler): Unsubscribe { + const key = `${namespace}/${name}`; + let set = this._handlers.get(key); + + if (set === undefined) { + set = new Set(); + this._handlers.set(key, set); + } + + set.add(handler); + + let released = false; + + return (): void => { + if (released) { return; } + + released = true; + set.delete(handler); + + if (set.size === 0) { + this._handlers.delete(key); + } + }; + } + + private _deliver(envelope: Envelope): void { + if (!isEventData(envelope.data)) { return; } + + this._dispatch(envelope.src, envelope.data.n, envelope.data.p); + } + + /** One handler that throws must not stop the others, and must not escape into the engine. */ + private _dispatch(namespace: string, name: string, payload: unknown): void { + const set = this._handlers.get(`${namespace}/${name}`); + + if (set === undefined) { return; } + + for (const handler of [...set]) { + try { + handler(payload, namespace); + } catch (error) { + console.warn(`[sync] a listener for '${namespace}/${name}' threw: ${String(error)}`); + } + } + } +} diff --git a/packages/sync/src/index.ts b/packages/sync/src/index.ts index 2651766..857aba4 100644 --- a/packages/sync/src/index.ts +++ b/packages/sync/src/index.ts @@ -12,6 +12,7 @@ * const sync = createSync({ id: 'myaddon', version: '1.0.0' }); * sync.start(); * + * sync.discovery.peers.subscribe(peers => console.warn(peers.length, 'peers')); * sync.discovery.onPeerUp(peer => console.warn('peer up', peer.id)); * sync.rpc.onRequest('ping', () => 'pong'); * sync.state.set('myaddon', 'volume', 5); @@ -28,6 +29,8 @@ export type { CollisionInfo, CollisionListener, DiscoveryOptions, + IncompatibleListener, + IncompatiblePeer, PeerInfo, PeerListener, } from './discovery'; @@ -35,9 +38,16 @@ export type { export { Rpc } from './rpc'; export type { RequestHandler, RequestOptions, RpcOptions, TypedClient, RPCHandlerMap } from './rpc'; +export { Events } from './events'; +export type { EventHandler } from './events'; + export { State, stateKey } from './state'; -export type { SnapshotEntry, StateChange, StateChangeListener, StateKey, StateOptions } from './state'; +export type { SnapshotEntry, StateChange, StateChangeListener, StateKey } from './state'; export type { Unsubscribe } from './bus'; + +/** Re-exported so a consumer can type a `discovery.peers` subscription without depending on observable itself. */ +export type { Listener, ReadonlyObservable } from '@bedrock-core/observable'; export type { Envelope } from './envelope'; -export { MessageType, PROTOCOL_VERSION } from './constants'; +export { Cap, MAX_MESSAGE, MessageType, PROTOCOL_MAX, PROTOCOL_MIN, SELF_CAPS } from './constants'; +export { capsFor, negotiateProtocol } from './negotiate'; diff --git a/packages/sync/src/negotiate.ts b/packages/sync/src/negotiate.ts new file mode 100644 index 0000000..b19bbb4 --- /dev/null +++ b/packages/sync/src/negotiate.ts @@ -0,0 +1,37 @@ +/** + * Protocol negotiation — the rule two nodes apply to decide what to speak. + * + * Kept apart from `discovery.ts` because it is the part with no engine in it: given what a peer + * advertised, these answer what may be sent to it. That makes the rule testable off a running + * server, which matters more here than in most places — an error in it does not throw, it makes + * two addons quietly unable to hear each other. + */ +import { Cap, PROTOCOL_MAX, PROTOCOL_MIN, TAGGED_WIRE_PROTOCOL } from './constants'; + +/** + * The version to speak with a peer: the newest both sides know. + * + * `undefined` when the ranges do not overlap — one side has moved on past what the other still + * supports. Absent bounds mean a node from before the range was advertised, which can only be + * {@link PROTOCOL_MIN}, since the fields ship with the version above it. + */ +export function negotiateProtocol(theirMin: number | undefined, theirMax: number | undefined): number | undefined { + const low = typeof theirMin === 'number' ? theirMin : PROTOCOL_MIN; + const high = typeof theirMax === 'number' ? theirMax : PROTOCOL_MIN; + const agreed = Math.min(PROTOCOL_MAX, high); + + return agreed >= Math.max(PROTOCOL_MIN, low) ? agreed : undefined; +} + +/** + * What a peer can actually be sent, narrowed to the negotiated version: a capability advertised by + * a node that also speaks something newer is still out of reach at the version in use. + * + * A node that advertised no capabilities at all but negotiated {@link TAGGED_WIRE_PROTOCOL} still + * gets {@link Cap.Batch} — reading a batch is part of what that version means, not an extra. + */ +export function capsFor(protocol: number, advertised: readonly string[] | undefined): readonly string[] { + if (protocol < TAGGED_WIRE_PROTOCOL) { return []; } + + return advertised ?? [Cap.Batch]; +} diff --git a/packages/sync/src/node.ts b/packages/sync/src/node.ts index 6ae7d5a..8fc0f95 100644 --- a/packages/sync/src/node.ts +++ b/packages/sync/src/node.ts @@ -1,7 +1,7 @@ /** * A SyncNode is one addon's handle to the cross-addon layer. It owns and wires together the - * four subsystems — {@link Bus}, {@link Discovery}, {@link Rpc} and {@link State} — and - * drives their shared lifecycle. + * five subsystems — {@link Bus}, {@link Discovery}, {@link Rpc}, {@link State} and + * {@link Events} — and drives their shared lifecycle. * * sync is the first layer on `@minecraft/server`: a node needs no engine, just an id. Several * nodes can live in one script realm (they talk over the real `system` script-event bus), @@ -9,9 +9,11 @@ */ import { Bus } from './bus'; import { Discovery } from './discovery'; +import { Events } from './events'; import { Rpc } from './rpc'; import { State } from './state'; +/** What `new SyncNode()` and `createSync()` take. */ export interface SyncNodeOptions { /** Unique addon id; also the default namespace this node owns. Used as the envelope src. */ @@ -23,15 +25,6 @@ export interface SyncNodeOptions { /** Opaque metadata broadcast with every announce; surfaced on peers as `PeerInfo.meta`. */ meta?: Record; - /** Namespaces this node is authoritative for. Defaults to `[id]`. */ - ownedNamespaces?: string[]; - - /** - * When `true`, `state.set()` and `state.delete()` are restricted to owned namespaces. - * Attempts to write an unowned namespace throw. Defaults to `false` (shared-mutable). - */ - strictOwnership?: boolean; - /** Override the per-message size budget (mainly for tests). */ maxMessage?: number; @@ -39,6 +32,7 @@ export interface SyncNodeOptions { instanceId?: string; } +/** One addon's handle to the transport: the bus, discovery, rpc, state and events, with one lifecycle. */ export class SyncNode { private _started = false; readonly id: string; @@ -46,10 +40,9 @@ export class SyncNode { readonly discovery: Discovery; readonly rpc: Rpc; readonly state: State; + readonly events: Events; constructor(options: SyncNodeOptions) { - const owned = options.ownedNamespaces ?? [options.id]; - this.id = options.id; this.bus = new Bus(options.id, { maxMessage: options.maxMessage, instanceId: options.instanceId }); this.discovery = new Discovery(this.bus, { @@ -58,7 +51,8 @@ export class SyncNode { meta: options.meta, }); this.rpc = new Rpc(this.bus); - this.state = new State(this.bus, options.id, { ownedNamespaces: owned, strictOwnership: options.strictOwnership }); + this.state = new State(this.bus, options.id); + this.events = new Events(this.bus, options.id); } /** Start every subsystem. Idempotent. Order matters — see inline notes. */ @@ -71,6 +65,7 @@ export class SyncNode { this.bus.start(); // 2. Responders before announcers, so an incoming whois/state-req is already answerable. this.rpc.start(); + this.events.start(); this.discovery.start(); // 3. State announces a sync request + broadcasts its owned snapshots. this.state.start(); @@ -83,6 +78,7 @@ export class SyncNode { this.state.stop(); this.discovery.stop(); + this.events.stop(); this.rpc.stop(); this.bus.stop(); } diff --git a/packages/sync/src/queue.ts b/packages/sync/src/queue.ts index d05e6fe..a35614a 100644 --- a/packages/sync/src/queue.ts +++ b/packages/sync/src/queue.ts @@ -1,25 +1,53 @@ /** * Outbound queue. * - * The engine processes only a bounded number of script events per tick, so we never send - * inline. Messages are buffered and drained at most {@link MAX_FLUSH_PER_TICK} per flush - * tick. If a send throws (e.g. an unexpectedly oversized message slipped through), the - * message is dropped and counted rather than allowed to crash the flush loop. + * The engine processes only a bounded number of script events per tick, so we never send inline. + * Messages are buffered and drained at most {@link MAX_FLUSH_PER_TICK} per flush tick. If a send + * throws (e.g. an unexpectedly oversized message slipped through), the message is dropped and + * counted rather than allowed to crash the flush loop. + * + * The bound is on *messages*, not bytes, which is why the queue packs rather than simply drains: + * consecutive envelopes small enough to share a message are sent as one batch. A world with a + * dozen nodes heartbeating spends one slot per flush instead of a dozen, and a burst of state + * deltas costs slots proportional to its size rather than to its count. + * + * Packing is conditional: it produces a shape only a reader that knows the batch tag can parse, so + * the bus switches it off for as long as a peer that predates the tag is live (see `setPacking`). + * + * Chunks are never packed. An envelope is only split when it fills a message on its own, so there + * is nothing left over to pack it with, and frames of one group must stay in the queue's order. */ import { system } from '@minecraft/server'; -import { FLUSH_INTERVAL_TICKS, MAX_FLUSH_PER_TICK } from './constants'; +import { FLUSH_INTERVAL_TICKS, MAX_FLUSH_PER_TICK, MAX_MESSAGE } from './constants'; +import { batchLength, encodeBatch } from './wire'; + +interface Pending { + /** `true` for a complete script-event message that must be sent alone (a chunk). */ + standalone: boolean; + + /** An encoded envelope awaiting packing, or the finished message when `standalone`. */ + text: string; +} + +/** What `new OutboundQueue()` takes. */ export interface OutboundQueueOptions { channel: string; maxFlushPerTick?: number; flushIntervalTicks?: number; + + /** Character budget for one script-event message; bounds how much a batch may hold. */ + maxMessage?: number; } +/** Buffers outbound messages and drains a bounded number per tick, packing small ones together. */ export class OutboundQueue { private readonly _channel: string; private readonly _maxFlushPerTick: number; private readonly _flushIntervalTicks: number; - private readonly _pending: string[] = []; + private readonly _maxMessage: number; + private readonly _pending: Pending[] = []; + private _packing = true; private _handle: number | undefined; private _dropped = 0; @@ -27,9 +55,10 @@ export class OutboundQueue { this._channel = options.channel; this._maxFlushPerTick = options.maxFlushPerTick ?? MAX_FLUSH_PER_TICK; this._flushIntervalTicks = options.flushIntervalTicks ?? FLUSH_INTERVAL_TICKS; + this._maxMessage = options.maxMessage ?? MAX_MESSAGE; } - /** Number of messages still waiting to be sent. */ + /** Entries still waiting to be sent. Packing means this is an upper bound on messages. */ get size(): number { return this._pending.length; } @@ -39,6 +68,17 @@ export class OutboundQueue { return this._dropped; } + /** + * Allow or forbid packing several envelopes into one message. + * + * A packed message is a shape only a reader that knows the batch tag can parse, so the bus turns + * this off while any live peer is too old to read one. A lone envelope is still tagged and sent; + * only the packing stops. + */ + setPacking(enabled: boolean): void { + this._packing = enabled; + } + /** Begin the periodic flush loop. Idempotent. */ start(): void { if (this._handle !== undefined) { return; } @@ -54,18 +94,62 @@ export class OutboundQueue { this._handle = undefined; } - /** Queue a fully encoded wire message for delivery. */ - enqueue(message: string): void { - this._pending.push(message); + /** Queue an encoded envelope. It may travel packed with its neighbours. */ + enqueueEnvelope(encoded: string): void { + this._pending.push({ standalone: false, text: encoded }); } - private flushPending(): void { - const count = Math.min(this._maxFlushPerTick, this._pending.length); + /** Queue a finished script-event message that must be sent on its own. */ + enqueueStandalone(message: string): void { + this._pending.push({ standalone: true, text: message }); + } + + /** + * Take the next message off the queue, packing as many leading envelopes into it as fit. + * Returns `undefined` once the queue is empty. + */ + private takeNext(): string | undefined { + const head = this._pending[0]; + + if (head === undefined) { return undefined; } + + if (head.standalone) { + this._pending.shift(); - for (let i = 0; i < count; i++) { - const message = this._pending.shift(); + return head.text; + } + + const parts: string[] = []; + const lengths: number[] = []; + + while (this._pending.length > 0) { + const next = this._pending[0]; + + if (next.standalone) { break; } + + lengths.push(next.text.length); + + // An envelope that cannot fit even alone is taken anyway: the send below will throw and + // count it, which is a visible drop rather than a queue that never advances. + if (batchLength(lengths) > this._maxMessage && parts.length > 0) { + lengths.pop(); + break; + } + + parts.push(next.text); + this._pending.shift(); + + if (!this._packing) { break; } + } + + return encodeBatch(parts); + } + + private flushPending(): void { + for (let sent = 0; sent < this._maxFlushPerTick; sent++) { + const message = this.takeNext(); - if (message === undefined) { break; } + if (message === undefined) { return; } try { system.sendScriptEvent(this._channel, message); diff --git a/packages/sync/src/rpc.ts b/packages/sync/src/rpc.ts index 7743850..8b3e28a 100644 --- a/packages/sync/src/rpc.ts +++ b/packages/sync/src/rpc.ts @@ -50,8 +50,10 @@ function isResponseData(value: unknown): value is ResponseData { /** Handles an inbound request. `from` is the requester's addon id. */ export type RequestHandler = (params: unknown, from: string) => unknown | Promise; +/** What one `request()` takes beside the target, method and params. */ export interface RequestOptions { timeoutTicks?: number } +/** What `new Rpc()` takes beside the bus. */ export interface RpcOptions { defaultTimeoutTicks?: number } /** A Proxy-backed client whose methods dispatch to `rpc.request(targetId, methodName, params)`. */ @@ -68,6 +70,7 @@ export type RPCHandlerMap = { : never; }; +/** Request and reply over the bus, with a timeout on every request and typed clients and handler maps. */ export class Rpc { private readonly _bus: Bus; private readonly _defaultTimeoutTicks: number; diff --git a/packages/sync/src/state.ts b/packages/sync/src/state.ts index 4757c0f..46c32db 100644 --- a/packages/sync/src/state.ts +++ b/packages/sync/src/state.ts @@ -1,9 +1,9 @@ /** * Replicated key/value state — the shared channel. * - * Model (chosen with the user): **shared-mutable runtime.** Any node may read or write any - * `namespace:key`. Writes broadcast a `state-delta` and every node applies it to its - * in-memory mirror, so reads are always local — no round-trip. Conflicts resolve + * Model: **owner-written, replicated.** Any node may read any `namespace:key`; only the + * namespace's owner, the node whose id it is, writes it. Writes broadcast a `state-delta` and + * every node applies it to its in-memory mirror, so reads are always local — no round-trip. Conflicts resolve * last-write-wins on a Lamport-style logical clock, tie-broken by the writer's id, so all * mirrors converge regardless of delivery order. * @@ -89,6 +89,7 @@ function isSnapshotData(value: unknown): value is SnapshotData { return typeof ns === 'string' && Array.isArray(entries); } +/** One change to the mirror, as listeners see it. */ export interface StateChange { ns: string; key: string; @@ -98,35 +99,22 @@ export interface StateChange { deleted: boolean; } +/** Told every change to the mirror, local or from the wire. */ export type StateChangeListener = (change: StateChange) => void; -export interface StateOptions { - - /** Namespaces this node is authoritative for (answers snapshot requests for them). */ - ownedNamespaces?: string[]; - - /** - * When `true`, `set()` and `delete()` are restricted to owned namespaces. Any attempt to - * write a namespace not in `ownedNamespaces` throws. Defaults to `false` (shared-mutable). - */ - strictOwnership?: boolean; -} - +/** The replicated key/value mirror: local reads, broadcast deltas, owner-only apply, snapshots for late joiners. */ export class State { private readonly _bus: Bus; private readonly _selfId: string; - private readonly _owned: Set; - private readonly _strictOwnership: boolean; private readonly _store = new Map>(); private readonly _changeListeners = new Set(); private readonly _disposers: Unsubscribe[] = []; private _clock = 0; + private _droppedForeign = 0; - constructor(bus: Bus, selfId: string, options: StateOptions = {}) { + constructor(bus: Bus, selfId: string) { this._bus = bus; this._selfId = selfId; - this._owned = new Set(options.ownedNamespaces ?? [selfId]); - this._strictOwnership = options.strictOwnership ?? false; } /** Register handlers and pull existing state from peers (late-join sync). */ @@ -172,22 +160,30 @@ export class State { return Array.from(this._store.keys()); } - /** Write a key. Broadcasts a delta. Throws if `strictOwnership` is enabled and `ns` is not owned. */ + /** How many writes from a node other than the namespace's owner this mirror has refused. */ + get droppedForeign(): number { + return this._droppedForeign; + } + + /** + * Write a key. Broadcasts a delta. + * Only the owner of a namespace — the node whose id it is — may write it; every mirror applies + * that rule, this node's own included, so a write is visible here exactly when it is visible + * everywhere. + */ set(ns: string, key: StateKey, value: NoInfer): void; set(ns: string, key: string, value: unknown): void { - this.assertWritable(ns); const entry: Entry = { value, ver: ++this._clock, src: this._selfId }; - this.applyEntry(ns, key, entry); + this.applyEntry(ns, key, entry, ns === this._selfId); this._bus.send({ type: MessageType.StateDelta, data: { ns, key, value, ver: entry.ver } }); } - /** Delete a key (tombstone). Broadcasts a delta. Throws if `strictOwnership` is enabled and `ns` is not owned. */ + /** Delete a key (tombstone). Broadcasts a delta. Owner-only, like {@link State.set}. */ delete(ns: string, key: string): void { - this.assertWritable(ns); const entry: Entry = { ver: ++this._clock, src: this._selfId, del: true }; - this.applyEntry(ns, key, entry); + this.applyEntry(ns, key, entry, ns === this._selfId); this._bus.send({ type: MessageType.StateDelta, data: { ns, key, ver: entry.ver, del: true } }); } @@ -220,20 +216,13 @@ export class State { })); } - /** Broadcast a full snapshot of every owned namespace (used at startup). */ + /** Broadcast a full snapshot of this node's own namespace (used at startup). */ broadcastOwnedSnapshots(): void { - for (const ns of this._owned) { - const entries = this.snapshot(ns); + const ns = this._selfId; + const entries = this.snapshot(ns); - if (entries.length > 0) { - this._bus.send({ type: MessageType.StateSnapshot, data: { ns, entries } }); - } - } - } - - private assertWritable(ns: string): void { - if (this._strictOwnership && !this._owned.has(ns)) { - throw new Error(`[sync] cannot write to namespace '${ns}': not owned by this node (strictOwnership is enabled)`); + if (entries.length > 0) { + this._bus.send({ type: MessageType.StateSnapshot, data: { ns, entries } }); } } @@ -243,20 +232,19 @@ export class State { const { ns, key, ver, value, del } = envelope.data; this._clock = Math.max(this._clock, ver); - this.applyEntry(ns, key, { value, ver, src: envelope.src, del }); + this.applyEntry(ns, key, { value, ver, src: envelope.src, del }, envelope.src === ns); } private handleStateRequest(envelope: Envelope): void { const requested = requestedNamespace(envelope.data); + const ns = this._selfId; - for (const ns of this._owned) { - if (requested !== undefined && requested !== ns) { continue; } + if (requested !== undefined && requested !== ns) { return; } - const entries = this.snapshot(ns); + const entries = this.snapshot(ns); - if (entries.length > 0) { - this._bus.send({ dst: envelope.src, type: MessageType.StateSnapshot, data: { ns, entries } }); - } + if (entries.length > 0) { + this._bus.send({ dst: envelope.src, type: MessageType.StateSnapshot, data: { ns, entries } }); } } @@ -267,12 +255,18 @@ export class State { for (const entry of entries) { this._clock = Math.max(this._clock, entry.ver); - this.applyEntry(ns, entry.k, { value: entry.v, ver: entry.ver, src: entry.src, del: entry.del }); + this.applyEntry(ns, entry.k, { value: entry.v, ver: entry.ver, src: entry.src, del: entry.del }, entry.src === ns); } } - /** Apply an entry under last-write-wins. Returns whether it won. */ - private applyEntry(ns: string, key: string, incoming: Entry): boolean { + /** + * Apply an entry under last-write-wins. The owner of a namespace — the node whose id it is — is + * the only writer a mirror trusts; a write from anyone else is dropped and counted. This is + * robustness against a buggy pack, not security against a hostile one: a pack can write the + * underlying dynamic properties directly, and no message carries a sender identity worth + * trusting. Returns whether the entry won. + */ + private applyEntry(ns: string, key: string, incoming: Entry, fromOwner: boolean): boolean { let map = this._store.get(ns); if (!map) { @@ -280,6 +274,12 @@ export class State { this._store.set(ns, map); } + if (!fromOwner) { + this._droppedForeign++; + + return false; + } + const current = map.get(key); if (current && !this.isNewer(incoming, current)) { return false; } diff --git a/packages/sync/src/wire.ts b/packages/sync/src/wire.ts new file mode 100644 index 0000000..717e3d6 --- /dev/null +++ b/packages/sync/src/wire.ts @@ -0,0 +1,128 @@ +/** + * The wire layer: what one script-event message actually contains. + * + * A message opens with a tag character saying which of three shapes follows: + * + * ```text + * 0{"v":2,"src":"shop",…} one envelope, verbatim — nothing is nested, nothing is escaped + * 2[{"v":2,…},{"v":2,…}] several envelopes packed into one message + * 1{"c":"…","s":0,"t":9,"p":"…"} one frame of an envelope too large to send whole + * ``` + * + * Batching is what keeps the tag from being a rounding error: the engine takes a bounded number of + * script events per tick, not a bounded number of bytes, so a 100-character heartbeat sent alone + * spends a whole slot. Packing consecutive small messages trades unused bytes for slots. + * + * ## Reading protocol 1 + * + * Protocol 1 predates the tag: every message was a bare {@link Frame}, so it opens with `{`. The + * frame shape never changed, only what wraps it, so such a message decodes through the same + * reassembly path once recognised — which is the whole of what {@link decodeWire} needs to + * understand a node built against an older release. {@link encodeLegacy} produces that shape for + * peers that can only read it. + * + * A shape added in some later protocol takes a tag character of its own; a reader that does not + * know the character drops that one message rather than the peer that sent it. + */ +import { type Frame, decodeFrame, splitIntoFrames } from './chunk'; +import { WireTag } from './constants'; +import { type Envelope, isEnvelope } from './envelope'; + +/** One decoded script-event message: either envelopes to dispatch, or a frame to reassemble. */ +export type WireMessage + = { kind: 'envelopes'; envelopes: Envelope[] } + | { kind: 'chunk'; frame: Frame }; + +/** + * Pack already-encoded envelopes into one message. The parts are spliced as text rather than + * re-serialized, since the queue holds them encoded precisely so it can measure them. + */ +export function encodeBatch(encodedEnvelopes: readonly string[]): string { + if (encodedEnvelopes.length === 1) { return WireTag.Envelope + encodedEnvelopes[0]; } + + return `${WireTag.Batch}[${encodedEnvelopes.join(',')}]`; +} + +/** + * Length of the message {@link encodeBatch} would produce: the tag, the brackets, the parts and the + * commas between them. Used by the queue to decide what still fits. + */ +export function batchLength(partLengths: readonly number[]): number { + if (partLengths.length === 0) { return 0; } + + let total = 0; + + for (const length of partLengths) { total += length; } + + // One envelope needs no brackets: tag + part. Otherwise tag + '[' + parts + separators + ']'. + return partLengths.length === 1 ? total + 1 : total + partLengths.length + 2; +} + +/** Tag an already-encoded frame as the chunk it is. */ +export function tagChunk(encodedFrame: string): string { + return WireTag.Chunk + encodedFrame; +} + +/** + * Encode an envelope the way protocol 1 did: bare frames, no tag, the envelope nested in `p` even + * when it fits in one message. Each returned string is a finished message that must be sent on its + * own — there is no shape a protocol-1 reader would accept two envelopes in. + * + * Every supported protocol can read this, which is what makes it the form used for announces and + * for any broadcast heard by a peer that cannot read the tag. + */ +export function encodeLegacy(encodedEnvelope: string, mid: string, maxMessage: number): string[] { + return splitIntoFrames(encodedEnvelope, mid, maxMessage); +} + +/** + * Parse a script-event message. Returns `undefined` for an unknown tag, malformed JSON or a + * structurally invalid body — callers ignore those rather than throwing, so one bad sender can + * never crash a listener. A batch keeps whichever of its envelopes are valid. + */ +export function decodeWire(message: string): WireMessage | undefined { + const body = message.slice(1); + + switch (message[0]) { + case WireTag.Envelope: { + const envelope = parseJson(body); + + return isEnvelope(envelope) ? { kind: 'envelopes', envelopes: [envelope] } : undefined; + } + + case WireTag.Batch: { + const parsed = parseJson(body); + + if (!Array.isArray(parsed)) { return undefined; } + + const envelopes = parsed.filter(isEnvelope); + + return envelopes.length > 0 ? { kind: 'envelopes', envelopes } : undefined; + } + + case WireTag.Chunk: { + const frame = decodeFrame(body); + + return frame ? { kind: 'chunk', frame } : undefined; + } + + // A protocol-1 message is a bare frame, so it opens with the JSON it is rather than with a + // tag. The whole message is the frame — nothing was sliced off the front of it. + case '{': { + const frame = decodeFrame(message); + + return frame ? { kind: 'chunk', frame } : undefined; + } + + default: + return undefined; + } +} + +function parseJson(json: string): unknown { + try { + return JSON.parse(json); + } catch { + return undefined; + } +} diff --git a/packages/sync/test/chunk.spec.ts b/packages/sync/test/chunk.spec.ts new file mode 100644 index 0000000..2bdf284 --- /dev/null +++ b/packages/sync/test/chunk.spec.ts @@ -0,0 +1,100 @@ +/** + * Framing invariants. + * + * `splitIntoFrames` charges each character what JSON will actually spend escaping it, which is what + * lets a frame be filled rather than half-reserved. The risk that buys is arithmetic: get the cost + * of one character class wrong and a frame silently overruns the engine's cap, where it is dropped + * at send time rather than rejected here. These cases pin every class JSON widens, at the boundary + * where an off-by-one would show. + */ +import { describe, expect, it } from 'vitest'; +import { Reassembler, decodeFrame, splitIntoFrames } from '../src/chunk'; +import { MAX_MESSAGE } from '../src/constants'; + +const CID = 'ya-a1b2c3d4/1'; + +// Written by code point so no reader has to unpick a source-level escape from a wire-level one. +// JSON widens both: a backslash gains a second one, a control character becomes six characters. +const BACKSLASH = String.fromCharCode(0x5c); +const CONTROL = String.fromCharCode(0x02); + +/** Reassemble a group the way `Bus.handleScriptEvent` does, and return the payload. */ +function reassemble(frames: readonly string[]): string | undefined { + const reassembler = new Reassembler(); + let payload: string | undefined; + + for (const wire of frames) { + const frame = decodeFrame(wire); + + expect(frame).toBeDefined(); + payload = reassembler.accept(frame!, 0); + } + + return payload; +} + +function expectFramesFit(frames: readonly string[], maxMessage = MAX_MESSAGE): void { + for (const frame of frames) { + expect(frame.length).toBeLessThanOrEqual(maxMessage); + } +} + +describe('splitIntoFrames', () => { + it.each([ + ['plain ASCII', 'a'.repeat(20_000)], + ['quotes, which JSON widens to two characters', '"'.repeat(20_000)], + ['backslashes, likewise two characters', BACKSLASH.repeat(20_000)], + ['control characters, six characters each', CONTROL.repeat(20_000)], + ['printable non-ASCII, which JSON copies verbatim', 'é'.repeat(20_000)], + ['a mix at no particular alignment', `{"a":"${CONTROL}é${BACKSLASH}"}`.repeat(2_000)], + ])('fits every frame within the cap: %s', (_label, payload) => { + const frames = splitIntoFrames(payload, CID, MAX_MESSAGE); + + expectFramesFit(frames); + expect(reassemble(frames)).toBe(payload); + }); + + it('never splits a surrogate pair', () => { + // One astral code point per pair, so a frame boundary landing between the halves would emit + // two lone surrogates and corrupt the reassembled payload. + const payload = '🧱'.repeat(10_000); + const frames = splitIntoFrames(payload, CID, MAX_MESSAGE); + + expectFramesFit(frames); + + for (const wire of frames) { + const part = decodeFrame(wire)!.p; + + expect(part.charCodeAt(0)).toBeLessThan(0xdc00); + expect(part.charCodeAt(part.length - 1)).toBeGreaterThan(0xdbff); + } + + expect(reassemble(frames)).toBe(payload); + }); + + it('fills a frame rather than reserving against escaping that did not happen', () => { + // Real JSON escapes roughly one character in eight, so a packed frame lands near the cap. + // The previous halved-budget split could not exceed about half of it whatever the content. + const payload = JSON.stringify({ rows: Array.from({ length: 400 }, (_, i) => ({ id: i, name: `row-${i}` })) }); + const frames = splitIntoFrames(payload, CID, MAX_MESSAGE); + + expectFramesFit(frames); + expect(frames.length).toBeGreaterThan(1); + // Every frame but the last is packed; the tail carries whatever is left over. + expect(frames[0].length).toBeGreaterThan(MAX_MESSAGE * 0.9); + }); + + it('emits one frame for an empty payload', () => { + const frames = splitIntoFrames('', CID, MAX_MESSAGE); + + expect(frames).toHaveLength(1); + expect(reassemble(frames)).toBe(''); + }); + + it('makes progress even when the cap cannot hold one escaped character', () => { + const frames = splitIntoFrames(CONTROL.repeat(3), CID, 1); + + expect(frames).toHaveLength(3); + expect(reassemble(frames)).toBe(CONTROL.repeat(3)); + }); +}); diff --git a/packages/sync/test/discovery.spec.ts b/packages/sync/test/discovery.spec.ts new file mode 100644 index 0000000..6de8b52 --- /dev/null +++ b/packages/sync/test/discovery.spec.ts @@ -0,0 +1,222 @@ +/** + * Discovery as a value. The peer list is an observable, and the assertions here are about when it + * republishes: a node arriving or leaving is news, a heartbeat that repeats what the peer already + * said is not. That distinction is what lets a listener sit on `peers` without waking every five + * seconds per peer, so it is the part worth pinning down. The engine is faked down to the three + * members discovery uses — the tick, the interval, and cancelling one. + */ +import { describe, expect, it, vi } from 'vitest'; +import type { Bus, EnvelopeHandler, Unsubscribe } from '../src/bus'; +import { MessageType, PROTOCOL_MAX, PROTOCOL_MIN, SELF_CAPS } from '../src/constants'; +import type { Envelope } from '../src/envelope'; + +const engine = vi.hoisted(() => ({ tick: 0, intervals: [] as { fn: () => void; period: number }[] })); + +vi.mock('@minecraft/server', () => ({ + system: { + get currentTick(): number { + return engine.tick; + }, + runInterval: (fn: () => void, period: number): number => engine.intervals.push({ fn, period }), + clearRun: (): void => { /* nothing to cancel in the fake */ }, + }, +})); + +const { Discovery } = await import('../src/discovery'); +const { PEER_TTL_TICKS } = await import('../src/constants'); + +/** A `Bus` double with only the members `Discovery` reaches for. */ +function fakeBus(selfId: string): { bus: Bus; announce: (src: string, data: unknown) => void } { + const handlers = new Map>(); + + const bus = { + selfId, + on: (type: string, handler: EnvelopeHandler): Unsubscribe => { + let set = handlers.get(type); + + if (set === undefined) { + set = new Set(); + handlers.set(type, set); + } + + set.add(handler); + + return (): void => { + set.delete(handler); + }; + }, + send: (): string => 'mid', + setPeerProtocol: (): void => { /* the bus's encoding table is not under test */ }, + forgetPeer: (): void => { /* likewise */ }, + }; + + const announce = (src: string, data: unknown): void => { + const envelope: Envelope = { + v: PROTOCOL_MIN, + src, + iid: `${src}-iid`, + type: MessageType.Announce, + mid: `${src}/1`, + data, + }; + + for (const handler of handlers.get(MessageType.Announce) ?? []) { handler(envelope); } + }; + + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + return { bus: bus as unknown as Bus, announce }; +} + +function payload(overrides: Record = {}): Record { + return { version: '1.0.0', schemaVersion: 1, pmin: PROTOCOL_MIN, pmax: PROTOCOL_MAX, caps: SELF_CAPS, ...overrides }; +} + +function setup(): { + discovery: InstanceType; + announce: (src: string, data: unknown) => void; + sweep: () => void; +} { + engine.tick = 0; + engine.intervals = []; + + const { bus, announce } = fakeBus('self'); + const discovery = new Discovery(bus); + + discovery.start(); + + const sweep = engine.intervals.find(entry => entry.period === 40); + + if (sweep === undefined) { throw new Error('discovery did not register its sweep'); } + + return { discovery, announce, sweep: sweep.fn }; +} + +describe('Discovery.peers', () => { + it('publishes a new peer and reports it as up', () => { + const { discovery, announce } = setup(); + const seen: number[] = []; + + discovery.peers.subscribe(peers => seen.push(peers.length)); + + const up = vi.fn(); + + discovery.onPeerUp(up); + announce('a', payload()); + + expect(seen).toEqual([1]); + expect(up).toHaveBeenCalledTimes(1); + expect(discovery.peers.get().map(peer => peer.id)).toEqual(['a']); + }); + + it('stays quiet when a heartbeat repeats what the peer already said', () => { + const { discovery, announce } = setup(); + + announce('a', payload()); + + const listener = vi.fn(); + + discovery.peers.subscribe(listener); + + engine.tick = 100; + announce('a', payload()); + engine.tick = 200; + announce('a', payload()); + + expect(listener).not.toHaveBeenCalled(); + // The refresh still landed — liveness moved without the list moving. + expect(discovery.lastSeen('a')).toBe(200); + // And the record was not rebuilt behind the list: one set of objects, not two equal ones. + expect(discovery.getPeer('a')).toBe(discovery.peers.get()[0]); + }); + + it('republishes when a peer says something new, without reporting it up again', () => { + const { discovery, announce } = setup(); + + announce('a', payload()); + + const listener = vi.fn(); + const up = vi.fn(); + + discovery.peers.subscribe(listener); + discovery.onPeerUp(up); + + announce('a', payload({ version: '2.0.0' })); + + expect(listener).toHaveBeenCalledTimes(1); + expect(up).not.toHaveBeenCalled(); + expect(discovery.peers.get()[0]?.version).toBe('2.0.0'); + }); + + it('republishes when a peer changes its meta', () => { + const { discovery, announce } = setup(); + + announce('a', payload({ meta: { displayName: 'Shop' } })); + + const listener = vi.fn(); + + discovery.peers.subscribe(listener); + + announce('a', payload({ meta: { displayName: 'Shop' } })); + expect(listener).not.toHaveBeenCalled(); + + announce('a', payload({ meta: { displayName: 'Market' } })); + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('publishes once for a sweep that evicts several peers, before any listener runs', () => { + const { discovery, announce, sweep } = setup(); + + announce('a', payload()); + announce('b', payload()); + + const listener = vi.fn(); + const duringDown: number[] = []; + + discovery.peers.subscribe(listener); + discovery.onPeerDown(() => duringDown.push(discovery.peers.get().length)); + + engine.tick = PEER_TTL_TICKS + 1; + sweep(); + + expect(listener).toHaveBeenCalledTimes(1); + expect(discovery.peers.get()).toEqual([]); + // Both handlers saw the swept world, not a half-swept one. + expect(duringDown).toEqual([0, 0]); + }); +}); + +describe('Discovery.incompatiblePeers', () => { + it('publishes an unreachable node once and stays quiet on its heartbeats', () => { + const { discovery, announce } = setup(); + const listener = vi.fn(); + + discovery.incompatiblePeers.subscribe(listener); + + announce('old', payload({ pmin: PROTOCOL_MAX + 5, pmax: PROTOCOL_MAX + 9 })); + expect(listener).toHaveBeenCalledTimes(1); + expect(discovery.incompatiblePeers.get().map(peer => peer.id)).toEqual(['old']); + + engine.tick = 100; + announce('old', payload({ pmin: PROTOCOL_MAX + 5, pmax: PROTOCOL_MAX + 9 })); + expect(listener).toHaveBeenCalledTimes(1); + + // It is not a peer either. + expect(discovery.peers.get()).toEqual([]); + }); + + it('drops a node from the incompatible list once it announces a range this build can reach', () => { + const { discovery, announce } = setup(); + + announce('old', payload({ pmin: PROTOCOL_MAX + 5, pmax: PROTOCOL_MAX + 9 })); + + const listener = vi.fn(); + + discovery.incompatiblePeers.subscribe(listener); + + announce('old', payload()); + + expect(listener).toHaveBeenCalledTimes(1); + expect(discovery.incompatiblePeers.get()).toEqual([]); + expect(discovery.peers.get().map(peer => peer.id)).toEqual(['old']); + }); +}); diff --git a/packages/sync/test/events.spec.ts b/packages/sync/test/events.spec.ts new file mode 100644 index 0000000..af94217 --- /dev/null +++ b/packages/sync/test/events.spec.ts @@ -0,0 +1,180 @@ +/** + * Events on the bus: a broadcast delivered once and kept by nobody. Two nodes share a fake bus + * that delivers synchronously, so what the assertions see is what a realm sees in the same tick — + * the sender hears its own event, a listener attached before the sender exists still fires, the + * namespace comes from the envelope rather than the payload, and one listener that throws does + * not stop the rest. + */ +import { describe, expect, it, vi } from 'vitest'; +import type { Bus, EnvelopeHandler, Unsubscribe } from '../src/bus'; +import { MessageType, PROTOCOL_MAX } from '../src/constants'; +import type { Envelope } from '../src/envelope'; +import { Events } from '../src/events'; + +interface Wire { + attach(id: string, handlers: Map>): void; + deliver(from: string, type: string, data: unknown): void; + /** Deliver as if some other node had sent it — the forged-sender case. */ + forge(src: string, type: string, data: unknown): void; +} + +function wire(): Wire { + const nodes = new Map>>(); + + const send = (src: string, skip: string | undefined, type: string, data: unknown): void => { + const envelope: Envelope = { v: PROTOCOL_MAX, src, iid: `${src}-iid`, type, mid: `${src}/1`, data }; + + for (const [id, handlers] of nodes) { + if (id === skip) { continue; } + + for (const handler of handlers.get(type) ?? []) { handler(envelope); } + } + }; + + return { + attach: (id, handlers): void => { nodes.set(id, handlers); }, + deliver: (from, type, data): void => { send(from, from, type, data); }, + forge: (src, type, data): void => { send(src, undefined, type, data); }, + }; +} + +function fakeBus(w: Wire, id: string): Bus { + const handlers = new Map>(); + + w.attach(id, handlers); + + const bus = { + on: (type: string, handler: EnvelopeHandler): Unsubscribe => { + let set = handlers.get(type); + + if (set === undefined) { + set = new Set(); + handlers.set(type, set); + } + + set.add(handler); + + return (): void => { set.delete(handler); }; + }, + send: (options: { type: string; data?: unknown }): string => { + w.deliver(id, options.type, options.data); + + return 'mid'; + }, + }; + + return bus as unknown as Bus; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion +} + +function pair(): { w: Wire; a: Events; b: Events } { + const w = wire(); + const a = new Events(fakeBus(w, 'a'), 'a'); + const b = new Events(fakeBus(w, 'b'), 'b'); + + a.start(); + b.start(); + + return { w, a, b }; +} + +describe('events', () => { + it('reaches every listener in the same tick, the sender included', () => { + const { a, b } = pair(); + const here: unknown[] = []; + const there: unknown[] = []; + + a.on('a', 'purchase', payload => here.push(payload)); + b.on('a', 'purchase', payload => there.push(payload)); + + a.emit('purchase', { gold: 5 }); + + expect(here).toEqual([{ gold: 5 }]); + expect(there).toEqual([{ gold: 5 }]); + }); + + it('names the sender from the envelope, so a listener hears only the namespace it asked for', () => { + const { a, b } = pair(); + const fromA: string[] = []; + const fromB: string[] = []; + + b.on('a', 'ping', (_payload, from) => fromA.push(from)); + b.on('b', 'ping', (_payload, from) => fromB.push(from)); + + a.emit('ping'); + b.emit('ping'); + + expect(fromA).toEqual(['a']); + expect(fromB).toEqual(['b']); + }); + + it('keeps nothing: a listener attached after the fact hears nothing', () => { + const { a, b } = pair(); + const seen: unknown[] = []; + + a.emit('purchase', { gold: 1 }); + b.on('a', 'purchase', payload => seen.push(payload)); + + expect(seen).toEqual([]); + + a.emit('purchase', { gold: 2 }); + expect(seen).toEqual([{ gold: 2 }]); + }); + + it('lets a listener attach before the sender has ever emitted, and releases cleanly', () => { + const { a, b } = pair(); + const seen: unknown[] = []; + const release = b.on('nobody_yet', 'hello', payload => seen.push(payload)); + + a.emit('hello', 1); + expect(seen).toEqual([]); + + release(); + release(); + expect(seen).toEqual([]); + }); + + it('stops delivering once released, and once stopped', () => { + const { a, b } = pair(); + const seen: unknown[] = []; + const release = b.on('a', 'tick', payload => seen.push(payload)); + + a.emit('tick', 1); + release(); + a.emit('tick', 2); + + b.on('a', 'tick', payload => seen.push(payload)); + a.emit('tick', 3); + + b.stop(); + a.emit('tick', 4); + + expect(seen).toEqual([1, 3]); + }); + + it('isolates a listener that throws', () => { + const { a } = pair(); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const seen: unknown[] = []; + + a.on('a', 'boom', () => { throw new Error('nope'); }); + a.on('a', 'boom', payload => seen.push(payload)); + + expect(() => { a.emit('boom', 7); }).not.toThrow(); + expect(seen).toEqual([7]); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('a/boom')); + + warn.mockRestore(); + }); + + it('ignores a message that is not an event payload', () => { + const { w, b } = pair(); + const seen: unknown[] = []; + + b.on('a', 'purchase', payload => seen.push(payload)); + + w.forge('a', MessageType.Event, 'not an object'); + w.forge('a', MessageType.Event, { name: 'purchase' }); + + expect(seen).toEqual([]); + }); +}); diff --git a/packages/sync/test/negotiate.spec.ts b/packages/sync/test/negotiate.spec.ts new file mode 100644 index 0000000..9ef45e6 --- /dev/null +++ b/packages/sync/test/negotiate.spec.ts @@ -0,0 +1,75 @@ +/** + * Negotiation invariants. + * + * This is the rule that decides whether two addons in one world can hear each other at all, and it + * fails silently when wrong: no throw, no dropped-message counter, just a list with a row missing. + * The matrix below is therefore about the edges — the version one past the window in each + * direction, and the node that advertises no range because it predates the field. + */ +import { describe, expect, it } from 'vitest'; +import { Cap, PROTOCOL_MAX, PROTOCOL_MIN } from '../src/constants'; +import { capsFor, negotiateProtocol } from '../src/negotiate'; + +describe('negotiateProtocol', () => { + it('settles on the newest version both sides know', () => { + expect(negotiateProtocol(1, 2)).toBe(2); + expect(negotiateProtocol(2, 2)).toBe(2); + }); + + it('drops to the peer’s ceiling when it is behind', () => { + expect(negotiateProtocol(1, 1)).toBe(1); + }); + + it('reads a node that advertises no range as the oldest supported one', () => { + expect(negotiateProtocol(undefined, undefined)).toBe(PROTOCOL_MIN); + }); + + it('caps at this build’s ceiling when the peer is ahead', () => { + expect(negotiateProtocol(1, PROTOCOL_MAX + 5)).toBe(PROTOCOL_MAX); + }); + + it('refuses a peer that has dropped everything this build speaks', () => { + expect(negotiateProtocol(PROTOCOL_MAX + 1, PROTOCOL_MAX + 2)).toBeUndefined(); + }); + + it('refuses a peer too old for this build’s floor', () => { + expect(negotiateProtocol(PROTOCOL_MIN - 2, PROTOCOL_MIN - 1)).toBeUndefined(); + }); + + it('never returns a version outside the supported window', () => { + for (let min = -1; min <= PROTOCOL_MAX + 2; min++) { + for (let max = min; max <= PROTOCOL_MAX + 2; max++) { + const agreed = negotiateProtocol(min, max); + + if (agreed === undefined) { continue; } + + expect(agreed).toBeGreaterThanOrEqual(PROTOCOL_MIN); + expect(agreed).toBeLessThanOrEqual(PROTOCOL_MAX); + expect(agreed).toBeLessThanOrEqual(max); + } + } + }); + + it('agrees with itself from both sides', () => { + // Both nodes run this same rule over the same two ranges, which is what lets a pair settle + // without exchanging anything: swap the arguments and the answer has to be identical. + for (let max = PROTOCOL_MIN; max <= PROTOCOL_MAX; max++) { + expect(negotiateProtocol(PROTOCOL_MIN, max)).toBe(Math.min(PROTOCOL_MAX, max)); + } + }); +}); + +describe('capsFor', () => { + it('grants batching to a version that reads batches, even with nothing advertised', () => { + expect(capsFor(2, undefined)).toContain(Cap.Batch); + }); + + it('withholds every capability below the tagged wire', () => { + expect(capsFor(1, [Cap.Batch])).toEqual([]); + }); + + it('passes an advertised set through once the version allows it', () => { + expect(capsFor(2, [])).toEqual([]); + expect(capsFor(2, [Cap.Batch])).toEqual([Cap.Batch]); + }); +}); diff --git a/packages/sync/test/state.spec.ts b/packages/sync/test/state.spec.ts new file mode 100644 index 0000000..fcac212 --- /dev/null +++ b/packages/sync/test/state.spec.ts @@ -0,0 +1,137 @@ +/** + * The mirror's apply rule. Two nodes share a fake bus that delivers synchronously: the owner of a + * namespace is the only writer a mirror trusts, whatever the key, and a late joiner gets the + * owner's snapshot without inheriting a right to write it. + */ +import { describe, expect, it } from 'vitest'; +import type { Bus, EnvelopeHandler, Unsubscribe } from '../src/bus'; +import { MessageType, PROTOCOL_MAX } from '../src/constants'; +import type { Envelope } from '../src/envelope'; +import { State } from '../src/state'; + +interface Wire { + attach(id: string, handlers: Map>): void; + deliver(from: string, dst: string | undefined, type: string, data: unknown): void; +} + +function wire(): Wire { + const nodes = new Map>>(); + + return { + attach: (id, handlers): void => { + nodes.set(id, handlers); + }, + deliver: (from, dst, type, data): void => { + const envelope: Envelope = { v: PROTOCOL_MAX, src: from, iid: `${from}-iid`, type, mid: `${from}/${Math.random()}`, data }; + + for (const [id, handlers] of nodes) { + if (id === from || (dst !== undefined && dst !== id)) { + continue; + } + + for (const handler of handlers.get(type) ?? []) { + handler(envelope); + } + } + }, + }; +} + +/** A `Bus` double with only the two members `State` uses; the class's private fields are not part of that contract. */ +function fakeBus(w: Wire, id: string): Bus { + const handlers = new Map>(); + + w.attach(id, handlers); + + const bus = { + on: (type: string, handler: EnvelopeHandler): Unsubscribe => { + let set = handlers.get(type); + + if (set === undefined) { + set = new Set(); + handlers.set(type, set); + } + + set.add(handler); + + return (): void => { + set.delete(handler); + }; + }, + send: (options: { dst?: string; type: string; data?: unknown }): string => { + w.deliver(id, options.dst, options.type, options.data); + + return 'mid'; + }, + }; + + return bus as unknown as Bus; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion +} + +function pair(): { w: Wire; a: State; b: State } { + const w = wire(); + const a = new State(fakeBus(w, 'a'), 'a'); + const b = new State(fakeBus(w, 'b'), 'b'); + + a.start(); + b.start(); + + return { w, a, b }; +} + +describe('owner-only apply', () => { + it('mirrors the owner and drops a foreign write', () => { + const { a, b } = pair(); + + a.set('a', 'price', 10); + expect(b.get('a', 'price')).toBe(10); + + b.set('a', 'price', 99); + expect(a.get('a', 'price')).toBe(10); + expect(b.get('a', 'price')).toBe(10); + expect(a.droppedForeign).toBe(1); + expect(b.droppedForeign).toBe(1); + }); + + it('drops a foreign write whatever the key, and counts every one', () => { + const { a, b } = pair(); + + a.set('a', 'votes', 0); + b.set('a', 'votes', 1); + b.set('a', 'anything', 2); + + expect(a.get('a', 'votes')).toBe(0); + expect(a.get('a', 'anything')).toBeUndefined(); + expect(a.droppedForeign).toBe(2); + }); + + it('gives a late joiner the owner snapshot, and still refuses its writes', () => { + const w = wire(); + const a = new State(fakeBus(w, 'a'), 'a'); + + a.start(); + a.set('a', 'votes', 0); + a.set('a', 'price', 10); + + const late = new State(fakeBus(w, 'late'), 'late'); + + late.start(); + + expect(late.get('a', 'votes')).toBe(0); + expect(late.get('a', 'price')).toBe(10); + + late.set('a', 'votes', 3); + expect(a.get('a', 'votes')).toBe(0); + }); + + it('refuses a snapshot relayed from a node that is not the namespace owner', () => { + const w = wire(); + const a = new State(fakeBus(w, 'a'), 'a'); + + a.start(); + w.deliver('b', undefined, MessageType.StateSnapshot, { ns: 'a', entries: [{ k: 'price', v: 1, ver: 1, src: 'b' }] }); + + expect(a.get('a', 'price')).toBeUndefined(); + expect(a.droppedForeign).toBe(1); + }); +}); diff --git a/packages/sync/test/wire.spec.ts b/packages/sync/test/wire.spec.ts new file mode 100644 index 0000000..3055ab7 --- /dev/null +++ b/packages/sync/test/wire.spec.ts @@ -0,0 +1,146 @@ +/** + * Wire-shape invariants. + * + * Two things here are load-bearing beyond their size. `batchLength` must agree with `encodeBatch` + * exactly, because the queue decides what still fits by asking the former and then sends the + * latter — a disagreement of one character is a message over the engine's cap, dropped at send + * time with nothing but a counter to show for it. And `decodeWire` has to place every shape the + * supported window still contains — the same channel carries traffic from nodes built against + * older releases, and a shape it fails to place is an addon that silently drops out of the world. + */ +import { describe, expect, it } from 'vitest'; +import { Reassembler, encodeFrame } from '../src/chunk'; +import { PROTOCOL_MAX, PROTOCOL_MIN, WireTag } from '../src/constants'; +import { type Envelope, decodeEnvelope, encodeEnvelope } from '../src/envelope'; +import { batchLength, decodeWire, encodeBatch, encodeLegacy, tagChunk } from '../src/wire'; + +function envelope(mid: string, data: unknown = 'x'): Envelope { + return { v: PROTOCOL_MAX, src: 'test', iid: 'iid-1', type: 'state-delta', mid, data }; +} + +const ONE = envelope('a/1'); +const TWO = envelope('b/2', { some: 'payload', n: 42 }); + +describe('encodeBatch', () => { + it('sends a lone envelope in the direct shape, without the array brackets', () => { + const message = encodeBatch([encodeEnvelope(ONE)]); + + expect(message[0]).toBe(WireTag.Envelope); + expect(message.slice(1)).toBe(encodeEnvelope(ONE)); + }); + + it('agrees with batchLength for every batch size', () => { + const encoded = Array.from({ length: 12 }, (_, i) => encodeEnvelope(envelope(`m/${i}`, 'y'.repeat(i * 7)))); + + for (let count = 1; count <= encoded.length; count++) { + const parts = encoded.slice(0, count); + + expect(batchLength(parts.map(p => p.length))).toBe(encodeBatch(parts).length); + } + }); + + it('measures nothing for an empty batch', () => { + expect(batchLength([])).toBe(0); + }); +}); + +describe('decodeWire', () => { + it('reads back a direct envelope', () => { + const wire = decodeWire(encodeBatch([encodeEnvelope(ONE)])); + + expect(wire).toEqual({ kind: 'envelopes', envelopes: [ONE] }); + }); + + it('reads back every envelope in a batch', () => { + const wire = decodeWire(encodeBatch([encodeEnvelope(ONE), encodeEnvelope(TWO)])); + + expect(wire).toEqual({ kind: 'envelopes', envelopes: [ONE, TWO] }); + }); + + it('reads back a chunk', () => { + const frame = { c: 'a/1', s: 0, t: 4, p: 'part' }; + const wire = decodeWire(tagChunk(encodeFrame(frame))); + + expect(wire).toEqual({ kind: 'chunk', frame }); + }); + + it('keeps the sound envelopes in a batch that also carries a bad one', () => { + const message = `${WireTag.Batch}[${encodeEnvelope(ONE)},{"not":"an envelope"}]`; + + expect(decodeWire(message)).toEqual({ kind: 'envelopes', envelopes: [ONE] }); + }); + + it.each([ + ['a bare envelope, which is no shape any protocol sends', encodeEnvelope(ONE)], + ['an unknown tag', `9${encodeEnvelope(ONE)}`], + ['an empty message', ''], + ['a truncated body', `${WireTag.Envelope}{"v":2,"src":`], + ['a batch that is not an array', `${WireTag.Batch}${encodeEnvelope(ONE)}`], + ['a batch of nothing usable', `${WireTag.Batch}[{"nope":1}]`], + ['a chunk that is not a frame', `${WireTag.Chunk}{"c":"a/1"}`], + ['an envelope from beyond the supported window', `${WireTag.Envelope}{"v":99,"src":"x","iid":"i","type":"t","mid":"m"}`], + ['an envelope from below the supported window', `${WireTag.Envelope}{"v":0,"src":"x","iid":"i","type":"t","mid":"m"}`], + ])('ignores %s', (_label, message) => { + expect(decodeWire(message)).toBeUndefined(); + }); +}); + +/** + * Cross-version traffic, which is the whole reason the tag exists rather than a version field + * alone. A protocol-1 node emits a bare frame and can read nothing else, so these assert the two + * directions separately: that this build places what such a node sends, and that what it produces + * for one still has the shape that node parses. + */ +describe('protocol 1', () => { + const LEGACY: Envelope = { v: PROTOCOL_MIN, src: 'graves', iid: 'iid-old', type: 'announce', mid: 'g/1', data: { version: '1.2.0' } }; + + function reassemble(messages: readonly string[]): Envelope | undefined { + const reassembler = new Reassembler(); + let payload: string | undefined; + + for (const message of messages) { + const wire = decodeWire(message); + + if (wire?.kind !== 'chunk') { return undefined; } + + payload = reassembler.accept(wire.frame, 0) ?? payload; + } + + return payload === undefined ? undefined : decodeEnvelope(payload); + } + + it('reads a bare frame, the shape that predates the tag', () => { + const messages = encodeLegacy(encodeEnvelope(LEGACY), LEGACY.mid, 2000); + + expect(messages).toHaveLength(1); + expect(messages[0][0]).toBe('{'); + expect(reassemble(messages)).toEqual(LEGACY); + }); + + it('reassembles a bare-frame envelope split across messages', () => { + const big: Envelope = { ...LEGACY, data: { blob: 'z'.repeat(6000) } }; + const messages = encodeLegacy(encodeEnvelope(big), big.mid, 500); + + expect(messages.length).toBeGreaterThan(1); + expect(reassemble(messages)).toEqual(big); + }); + + it('emits frames a protocol-1 reader can parse, tag and all', () => { + // That reader knows one shape: the message is the frame, JSON straight through, no tag to + // strip. Asserting the shape here is what stands in for running the old decoder. + for (const message of encodeLegacy(encodeEnvelope(LEGACY), LEGACY.mid, 200)) { + expect(message.length).toBeLessThanOrEqual(200); + + const frame: unknown = JSON.parse(message); + + expect(frame).toMatchObject({ c: LEGACY.mid, s: expect.any(Number), t: expect.any(Number), p: expect.any(String) }); + } + }); + + it('accepts an envelope written at the older version', () => { + const wire = decodeWire(encodeLegacy(encodeEnvelope(LEGACY), LEGACY.mid, 2000)[0]); + + expect(wire?.kind).toBe('chunk'); + expect(reassemble(encodeLegacy(encodeEnvelope(LEGACY), LEGACY.mid, 2000))?.v).toBe(PROTOCOL_MIN); + }); +}); diff --git a/packages/sync/tsconfig.bench.json b/packages/sync/tsconfig.bench.json new file mode 100644 index 0000000..9301c49 --- /dev/null +++ b/packages/sync/tsconfig.bench.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "." + }, + "include": [ + "src/**/*", + "bench/**/*", + "test/**/*", + "vitest.config.ts", + "../../types/globals.d.ts" + ] +} diff --git a/packages/sync/vitest.config.ts b/packages/sync/vitest.config.ts new file mode 100644 index 0000000..a342f83 --- /dev/null +++ b/packages/sync/vitest.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['test/**/*.spec.ts', 'bench/**/*.spec.ts'], + benchmark: { + include: ['bench/**/*.bench.ts'], + }, + }, +}); diff --git a/packages/test-addon-2/CHANGELOG.md b/packages/test-addon-2/CHANGELOG.md deleted file mode 100644 index 3ac57ea..0000000 --- a/packages/test-addon-2/CHANGELOG.md +++ /dev/null @@ -1,9 +0,0 @@ -# @bedrock-core/server-test-addon-2 - -## 1.0.1 - -### Patch Changes - -- Updated dependencies [[`6b7f519`](https://github.com/bedrock-core/server/commit/6b7f519dc142a305de07516c34814fb972a875cb), [`36322bb`](https://github.com/bedrock-core/server/commit/36322bb53d4da391170686f124b78c4144d49bf9), [`b69851f`](https://github.com/bedrock-core/server/commit/b69851fe6ba452c899e2869adec03655c2bb404f), [`f45e781`](https://github.com/bedrock-core/server/commit/f45e7812d01bf48d0a8e8bece077f2bb44de9f31), [`b69851f`](https://github.com/bedrock-core/server/commit/b69851fe6ba452c899e2869adec03655c2bb404f)]: - - @bedrock-core/server-runtime@0.1.0 - - @bedrock-core/sync@0.1.0 diff --git a/packages/test-addon-2/config.json b/packages/test-addon-2/config.json deleted file mode 100644 index 452c2a6..0000000 --- a/packages/test-addon-2/config.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "$schema": "https://raw.githubusercontent.com/Bedrock-OSS/regolith-schemas/main/config/v1.4.json", - "author": "DrAv0011", - "description": "Second test addon (Shop) — cross-addon example with the Economy test addon", - "name": "core-server-test-2-public", - "packs": { - "behaviorPack": "./packs/BP", - "resourcePack": "./packs/RP" - }, - "regolith": { - "dataPath": "./packs/data", - "filterDefinitions": { - "bundler": { - "url": "github.com/bedrock-core/regolith-filters", - "version": "1.1.1" - }, - "guides": { - "runWith": "nodejs", - "script": "../../../regolith-filters/guides/main.js" - }, - "i18n": { - "runWith": "nodejs", - "script": "../../../regolith-filters/i18n/main.js" - } - }, - "formatVersion": "1.4.0", - "profiles": { - "default": { - "export": { - "build": "standard", - "readOnly": false, - "target": "development" - }, - "filters": [ - { - "filter": "guides", - "settings": { - "namespace": "drav0011_shop" - } - }, - { - "filter": "i18n" - }, - { - "filter": "bundler", - "settings": { - "debug": true - } - } - ] - } - } - } -} diff --git a/packages/test-addon-2/package.json b/packages/test-addon-2/package.json deleted file mode 100644 index afc2960..0000000 --- a/packages/test-addon-2/package.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "name": "@bedrock-core/server-test-addon-2", - "version": "1.0.1", - "private": true, - "description": "Second test addon (Shop) — cross-addon example with @bedrock-core/server-test-addon", - "main": "main.js", - "scripts": { - "regolith-install": "regolith install-all", - "build": "regolith run build", - "watch": "regolith watch", - "lint": "eslint ." - }, - "dependencies": { - "@bedrock-core/config": "*", - "@bedrock-core/i18n": "*", - "@bedrock-core/server-runtime": "workspace:^", - "@bedrock-core/sync": "workspace:^", - "@bedrock-core/ui": "^0.9.1", - "@minecraft/server": "2.8.0", - "@minecraft/server-ui": "2.1.0", - "typescript": "^6.0.3" - }, - "devDependencies": { - "@eslint/js": "^10.0.1", - "@eslint/json": "^2.0.0", - "@stylistic/eslint-plugin": "^5.10.0", - "eslint": "^10.5.0", - "eslint-plugin-minecraft-linting": "^2.0.12", - "globals": "^17.7.0", - "typescript": "^6.0.3", - "typescript-eslint": "^8.62.0" - }, - "packageManager": "yarn@4.9.4" -} diff --git a/packages/test-addon-2/packs/BP/manifest.json b/packages/test-addon-2/packs/BP/manifest.json deleted file mode 100644 index d4aa00f..0000000 --- a/packages/test-addon-2/packs/BP/manifest.json +++ /dev/null @@ -1,62 +0,0 @@ -{ - "format_version": 2, - "header": { - "name": "drav0011_shop.meta.name", - "description": "drav0011_shop.meta.description", - "uuid": "2c9add46-ecbf-4dd7-8c7c-71c244e928da", - "pack_scope": "world", - "version": [ - 1, - 0, - 0 - ], - "min_engine_version": [ - 1, - 26, - 30 - ] - }, - "modules": [ - { - "type": "data", - "uuid": "556f0e6c-8c8e-40a1-8afc-e750cc0438f8", - "version": [ - 1, - 0, - 0 - ] - }, - { - "type": "script", - "language": "javascript", - "uuid": "8e584cea-7544-483e-a092-625e90c9990c", - "entry": "scripts/main.js", - "version": [ - 1, - 0, - 0 - ] - } - ], - "dependencies": [ - { - "uuid": "b00d863f-bbac-4904-a46e-5417bf53451d", - "version": [ - 1, - 0, - 0 - ] - }, - { - "module_name": "@minecraft/server", - "version": "2.8.0" - }, - { - "module_name": "@minecraft/server-ui", - "version": "2.1.0" - } - ], - "metadata": { - "product_type": "addon" - } -} diff --git a/packages/test-addon-2/packs/BP/scripts/example.ts b/packages/test-addon-2/packs/BP/scripts/example.ts deleted file mode 100644 index 1384649..0000000 --- a/packages/test-addon-2/packs/BP/scripts/example.ts +++ /dev/null @@ -1,51 +0,0 @@ -/** - * The "Shop" example behavior: react to the Economy addon, call it over RPC, and toggle an - * optional feature based on whether a `leaderboard` addon is installed. - * - * The config schema (server-scope pricing and player-scope preferences) is declared via the - * `config` field of `core.register()` in main.ts. - */ -import { core } from '@bedrock-core/server-runtime'; - -// In a real project this interface lives in the economy addon's published types package -// (e.g. `@drav0011/economy-types`) and you install it as a devDependency. -interface EconomyRPC { getBalance(params: { player: string }): number } - -export const configDef = { - server: { - // Named, so the button screen this root now renders shows "Pricing" rather than the key. - // Its sibling `bannedItems` is a list, which no longer forces this level into a form — - // both get a row, and the list gets its own editor. - pricing: { - $label: 'Pricing', - $description: 'What a purchase costs and whether the shop is open at all.', - taxRate: { type: 'number', default: 0.05, min: 0, max: 1, step: 0.01, label: 'Tax Rate', description: 'Tax applied to all purchases' }, - currency: { type: 'enum', default: 'emerald', options: ['emerald', 'gold', 'diamond'] as const, label: 'Currency' }, - shopEnabled: { type: 'boolean', default: true, label: 'Shop Enabled' }, - }, - bannedItems: { type: 'list' as const, itemType: 'string' as const, maxItems: 50, default: [] as const, label: 'Banned Items', description: 'Item IDs that cannot be sold' }, - }, - player: { - allowGifts: { type: 'boolean', default: true, label: 'Allow Gifts' }, - displayCurrency: { type: 'enum', default: 'symbol', options: ['symbol', 'name', 'both'] as const, label: 'Currency Display' }, - }, -} as const; - -/** Published in a types package (e.g. `@drav0011/shop-types`) so consumers get typed access. */ -export type ShopConfigDef = typeof configDef; - -export function setupShop(): void { - // Once our required dependency (economy) is present, ask it for a balance. - core.registry.onDependenciesSatisfied(() => { - const economy = core.registry.get('drav0011_economy'); - - if (!economy) { - return; - } - - const economyRpc = core.rpc.typed(economy.id); - - economyRpc.getBalance({ player: 'Steve' }) - .catch((error: unknown) => console.warn(`[shop] balance request failed: ${String(error)}`)); - }); -} diff --git a/packages/test-addon-2/packs/BP/scripts/main.ts b/packages/test-addon-2/packs/BP/scripts/main.ts deleted file mode 100644 index bbf6582..0000000 --- a/packages/test-addon-2/packs/BP/scripts/main.ts +++ /dev/null @@ -1,41 +0,0 @@ -/** - * Test addon "Shop" — the second half of the cross-addon example. It shares a creator but a - * different namespace from "Economy"; it depends on the `economy` namespace, calls Economy - * over RPC, and lights up an optional feature when a `leaderboard` addon is present. - */ -import { core } from '@bedrock-core/server-runtime'; -import { ui } from '@bedrock-core/config'; -import bundle from '@bedrock-core/generated/i18n'; -import { createI18n } from '@bedrock-core/i18n'; -import guides from '@bedrock-core/generated/guides'; -import { configDef, setupShop } from './example'; - -// The addon's typed verbs over its resources (packs/data/i18n). Creating the instance -// also registers it as the default translation source for any UI this addon renders. -const i18n = createI18n(bundle); - -// register() declares everything in one call and brings the addon online — no separate -// start(). Display fields are translation keys — typed through key(), generated into -// this addon's .lang by the i18n filter; UIs localize them per player language. The -// i18n bundle and guide manifest ride along as optional fields; the typed config accessors register() returns are unused here — Shop only -// exposes its config to the UI and to cross-addon `core.config.of()` readers. -core.register({ - creator: 'drav0011', - pack: 'shop', - packName: i18n.key($ => $.meta.name), - creatorName: i18n.key($ => $.meta.creator), - version: '1.0.0', - description: i18n.key($ => $.meta.description), - icon: 'textures/ui/shop/icon', - thumbnail: 'textures/ui/shop/thumbnail', - dependencies: ['drav0011_economy'], - optionalDependencies: ['drav0011_leaderboard'], - translations: bundle, - guide: guides, - config: configDef, -}); - -setupShop(); -// Mount the shared config UI — command registration is first-wins across addons, so with -// several bedrock-core addons installed exactly one realm serves the UI for all of them. -ui(core); diff --git a/packages/test-addon-2/packs/RP/manifest.json b/packages/test-addon-2/packs/RP/manifest.json deleted file mode 100644 index 821280d..0000000 --- a/packages/test-addon-2/packs/RP/manifest.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "format_version": 2, - "header": { - "name": "@bedrock-core/server-test-2", - "description": "Shop reference addon (cross-addon example with Economy)", - "uuid": "b00d863f-bbac-4904-a46e-5417bf53451d", - "pack_scope": "world", - "version": [ - 1, - 0, - 0 - ], - "min_engine_version": [ - 1, - 26, - 30 - ] - }, - "modules": [ - { - "type": "resources", - "uuid": "faca2e5f-c820-49dc-9363-f8421412c728", - "version": [ - 1, - 0, - 0 - ] - } - ], - "dependencies": [ - { - "uuid": "2c9add46-ecbf-4dd7-8c7c-71c244e928da", - "version": [ - 1, - 0, - 0 - ] - } - ], - "metadata": { - "product_type": "addon" - } -} diff --git a/packages/test-addon-2/packs/RP/textures/ui/shop/icon.png b/packages/test-addon-2/packs/RP/textures/ui/shop/icon.png deleted file mode 100644 index 4d49850..0000000 Binary files a/packages/test-addon-2/packs/RP/textures/ui/shop/icon.png and /dev/null differ diff --git a/packages/test-addon-2/packs/RP/textures/ui/shop/missing_icon.png b/packages/test-addon-2/packs/RP/textures/ui/shop/missing_icon.png deleted file mode 100644 index dd3dc82..0000000 Binary files a/packages/test-addon-2/packs/RP/textures/ui/shop/missing_icon.png and /dev/null differ diff --git a/packages/test-addon-2/packs/RP/textures/ui/shop/thumbnail.png b/packages/test-addon-2/packs/RP/textures/ui/shop/thumbnail.png deleted file mode 100644 index 3345c66..0000000 Binary files a/packages/test-addon-2/packs/RP/textures/ui/shop/thumbnail.png and /dev/null differ diff --git a/packages/test-addon-2/packs/data/guides/en_US/getting-started/_category_.json b/packages/test-addon-2/packs/data/guides/en_US/getting-started/_category_.json deleted file mode 100644 index 62073ed..0000000 --- a/packages/test-addon-2/packs/data/guides/en_US/getting-started/_category_.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "label": "Getting Started", - "position": 2 -} diff --git a/packages/test-addon-2/packs/data/guides/en_US/getting-started/buying.mdx b/packages/test-addon-2/packs/data/guides/en_US/getting-started/buying.mdx deleted file mode 100644 index 09fcf9e..0000000 --- a/packages/test-addon-2/packs/data/guides/en_US/getting-started/buying.mdx +++ /dev/null @@ -1,12 +0,0 @@ ---- -title: Buying Items -sidebar_position: 1 ---- - -Buying from the Shop uses the coins tracked by the Economy addon. - -- Open the shop to see what is for sale -- Select an item to purchase it -- Your balance updates right away - -Back to the [Shop Guide](../intro.mdx). diff --git a/packages/test-addon-2/packs/data/guides/en_US/intro.mdx b/packages/test-addon-2/packs/data/guides/en_US/intro.mdx deleted file mode 100644 index 2a06cc9..0000000 --- a/packages/test-addon-2/packs/data/guides/en_US/intro.mdx +++ /dev/null @@ -1,13 +0,0 @@ ---- -title: Shop Guide -sidebar_position: 1 ---- - -Welcome to the Shop addon. This guide explains how to buy and sell items. - -- Browse the shop catalog -- Buy items with your coins -- Sell items you no longer need - -See [Buying Items](./getting-started/buying.mdx) to begin, or check the -[Markdown Showcase](./markdown-showcase.mdx) page for everything the guide format supports. diff --git a/packages/test-addon-2/packs/data/guides/en_US/markdown-showcase.mdx b/packages/test-addon-2/packs/data/guides/en_US/markdown-showcase.mdx deleted file mode 100644 index 3b7b526..0000000 --- a/packages/test-addon-2/packs/data/guides/en_US/markdown-showcase.mdx +++ /dev/null @@ -1,81 +0,0 @@ ---- -title: Markdown Showcase -sidebar_position: 3 ---- - -This page demonstrates every markdown feature the guides compiler understands: **bold**, -*italic*, ***bold italic***, ~~strikethrough~~, and `inline code`, all inside one paragraph. - -## Text and links - -A paragraph can mix styling freely, and it can also link elsewhere — to another page in -this guide, like [Buying Items](./getting-started/buying.mdx), or out to the web, like the [bedrock.dev wiki](https://wiki.bedrock.dev). Internal links become pressable buttons woven -into the sentence; external links stay as styled, non-pressable text. - -## Lists - -### Unordered, with nesting - -- Browse the shop catalog -- Buy an item - - Confirm the purchase - - Watch your balance update -- Sell an item you no longer need - -### Ordered, including a custom start - -1. Open the shop -2. Pick an item -3. Confirm the purchase - -The next list picks up where step 4 would have been, skipped for brevity: - -5. Step five -6. Step six - -## Code blocks - -```js -function greet(name) { - console.log(`Hello, ${name}!`); -} -``` - -## Images - -![Shop icon](textures/ui/shop/icon.png) - -## Admonitions - -:::note -A plain note, for supplementary context. -::: - -:::tip -A tip can contain **styled** text and `inline code` too. -::: - -:::info -An info box for neutral callouts. -::: - -:::warning[Careful] -Admonitions can override their title — this one says "Careful" instead of "Warning". -::: - -:::danger -A danger box, for anything destructive or hard to undo. -::: - -## Blockquotes - -> Blockquotes compile to the same note admonition as `:::note` — there is no separate -> "quote" block in the IR. - -## Horizontal rule - -Below is a thematic break: - ---- - -Back to the [Shop Guide](./intro.mdx). diff --git a/packages/test-addon/.gitignore b/packages/test-addon/.gitignore deleted file mode 100644 index f436931..0000000 --- a/packages/test-addon/.gitignore +++ /dev/null @@ -1,22 +0,0 @@ -/build -/.regolith - -.yarn/* -!.yarn/patches -!.yarn/plugins -!.yarn/releases -!.yarn/versions - -# Whether you use PnP or not, the node_modules folder is often used to store -# build artifacts that should be gitignored -node_modules - -# Swap the comments on the following lines if you wish to use zero-installs -# In that case, don't forget to run `yarn config set enableGlobalCache false`! -# Documentation here: https://yarnpkg.com/features/caching#zero-installs - -#!.yarn/cache -.pnp.* - -dist/ -coverage/ \ No newline at end of file diff --git a/packages/test-addon/.mcignore b/packages/test-addon/.mcignore deleted file mode 100644 index 704c8aa..0000000 --- a/packages/test-addon/.mcignore +++ /dev/null @@ -1,2 +0,0 @@ -/build -/.regolith diff --git a/packages/test-addon/.vscode/extensions.json b/packages/test-addon/.vscode/extensions.json deleted file mode 100644 index 25477be..0000000 --- a/packages/test-addon/.vscode/extensions.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "recommendations": [ - "aaron-bond.better-comments", - "blockceptionltd.blockceptionvscodeminecraftbedrockdevelopmentextension", - "dbaeumer.vscode-eslint", - "mojang-studios.minecraft-debugger", - "misodee.vscode-nbt", - "bedrockoss.regolith", - "jannisx11.snowstorm", - "netcorext.uuid-generator", - "christian-kohler.npm-intellisense" - ] -} \ No newline at end of file diff --git a/packages/test-addon/.vscode/launch.json b/packages/test-addon/.vscode/launch.json deleted file mode 100644 index 760eab4..0000000 --- a/packages/test-addon/.vscode/launch.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "version": "0.3.0", - "configurations": [ - { - "type": "minecraft-js", - "request": "attach", - "sourceMapRoot": "${env:APPDATA}/Minecraft Bedrock/Users/Shared/games/com.mojang/development_behavior_packs/core-server-test-public_bp/scripts/", - "generatedSourceRoot": "${env:APPDATA}/Minecraft Bedrock/Users/Shared/games/com.mojang/development_behavior_packs/core-server-test-public_bp/scripts/", - "localRoot": "${workspaceFolder}/packs/BP/scripts/", - "name": "(gametests) Debug with Minecraft", - "mode": "listen", - "port": 19144, - "targetModuleUuid": "12434de1-2661-41cb-b467-434accf35e90" - } - ] -} \ No newline at end of file diff --git a/packages/test-addon/.vscode/settings.json b/packages/test-addon/.vscode/settings.json deleted file mode 100644 index 519d06c..0000000 --- a/packages/test-addon/.vscode/settings.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "editor.formatOnSave": false, - "editor.codeActionsOnSave": { - "source.fixAll.eslint": "explicit" - }, - "editor.detectIndentation": false, - "editor.insertSpaces": true, - "editor.tabSize": 2, - "[typescript]": { - "editor.defaultFormatter": "dbaeumer.vscode-eslint", - "editor.insertSpaces": true, - "editor.tabSize": 2 - }, - "[javascript]": { - "editor.defaultFormatter": "dbaeumer.vscode-eslint", - "editor.insertSpaces": true, - "editor.tabSize": 2 - }, - "[json]": { - "editor.defaultFormatter": "vscode.json-language-features", - "editor.insertSpaces": false, - "editor.tabSize": 4 - }, - "[jsonc]": { - "editor.defaultFormatter": "vscode.json-language-features", - "editor.insertSpaces": false, - "editor.tabSize": 4 - }, - "js/ts.tsdk.path": "node_modules/typescript/lib", - "js/ts.tsdk.promptToUseWorkspaceVersion": true, - "eslint.validate": [ - "javascript", - "typescript", - "json", - "jsonc" - ], - "files.associations": { - "*.json": "jsonc" - }, - "eslint.enable": true, - "eslint.format.enable": true -} \ No newline at end of file diff --git a/packages/test-addon/.yarnrc.yml b/packages/test-addon/.yarnrc.yml deleted file mode 100644 index 59fcc97..0000000 --- a/packages/test-addon/.yarnrc.yml +++ /dev/null @@ -1,2 +0,0 @@ -# .yarnrc.yml -nodeLinker: node-modules \ No newline at end of file diff --git a/packages/test-addon/CHANGELOG.md b/packages/test-addon/CHANGELOG.md deleted file mode 100644 index 2adab6f..0000000 --- a/packages/test-addon/CHANGELOG.md +++ /dev/null @@ -1,9 +0,0 @@ -# @bedrock-core/server-test-addon - -## 1.0.1 - -### Patch Changes - -- Updated dependencies [[`6b7f519`](https://github.com/bedrock-core/server/commit/6b7f519dc142a305de07516c34814fb972a875cb), [`36322bb`](https://github.com/bedrock-core/server/commit/36322bb53d4da391170686f124b78c4144d49bf9), [`b69851f`](https://github.com/bedrock-core/server/commit/b69851fe6ba452c899e2869adec03655c2bb404f), [`f45e781`](https://github.com/bedrock-core/server/commit/f45e7812d01bf48d0a8e8bece077f2bb44de9f31), [`b69851f`](https://github.com/bedrock-core/server/commit/b69851fe6ba452c899e2869adec03655c2bb404f)]: - - @bedrock-core/server-runtime@0.1.0 - - @bedrock-core/sync@0.1.0 diff --git a/packages/test-addon/config.json b/packages/test-addon/config.json deleted file mode 100644 index 2a4a2d1..0000000 --- a/packages/test-addon/config.json +++ /dev/null @@ -1,57 +0,0 @@ -{ - "$schema": "https://raw.githubusercontent.com/Bedrock-OSS/regolith-schemas/main/config/v1.4.json", - "author": "DrAv0011", - "description": "Test addon and reference implementation for @bedrock-core/server library", - "name": "core-server-test-public", - "packs": { - "behaviorPack": "./packs/BP", - "resourcePack": "./packs/RP" - }, - "regolith": { - "dataPath": "./packs/data", - "filterDefinitions": { - "bundler": { - "url": "github.com/bedrock-core/regolith-filters", - "version": "1.1.1" - }, - "guides": { - "runWith": "nodejs", - "script": "../../../regolith-filters/guides/main.js" - }, - "i18n": { - "runWith": "nodejs", - "script": "../../../regolith-filters/i18n/main.js" - } - }, - "formatVersion": "1.4.0", - "profiles": { - "default": { - "export": { - "build": "standard", - "readOnly": false, - "target": "development" - }, - "filters": [ - { - "filter": "guides", - "settings": { - "namespace": "drav0011_economy" - } - }, - { - "filter": "i18n", - "settings": { - "namespace": "drav0011_economy" - } - }, - { - "filter": "bundler", - "settings": { - "debug": true - } - } - ] - } - } - } -} \ No newline at end of file diff --git a/packages/test-addon/eslint.config.mjs b/packages/test-addon/eslint.config.mjs deleted file mode 100644 index 354c5ad..0000000 --- a/packages/test-addon/eslint.config.mjs +++ /dev/null @@ -1,55 +0,0 @@ -import stylistic from "@stylistic/eslint-plugin"; -import minecraftLinting from "eslint-plugin-minecraft-linting"; -import { defineConfig } from "eslint/config"; -import { dirname } from "path"; -import tseslint from "typescript-eslint"; -import { fileURLToPath } from "url"; -import baseConfig from '../../eslint.config.mjs'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); - -export default defineConfig([ - ...baseConfig, - - { - files: ["**/*.ts", "**/*.tsx"], - ignores: ["**/*.d.ts"], - plugins: { - "@stylistic": stylistic, - "@typescript-eslint": tseslint.plugin, - "@minecraft": minecraftLinting - }, - - languageOptions: { - parser: tseslint.parser, - ecmaVersion: "latest", - sourceType: "module", - parserOptions: { - projectService: true, - tsconfigRootDir: __dirname, - }, - }, - - rules: { - ...baseConfig.rules, - "@minecraft/avoid-unnecessary-command": "error", - } - }, - - { - ignores: [ - ".*/**", // Any directory starting with dot (.yarn, .vscode, .regolith, etc.) - ".*", // Any file starting with dot - "node_modules/**", - "**/*.generated.*", // Filter-generated files (i18n bundle + declarations) - "**/*.*js", // Generated JS files - "filters/**", // The filters directory - "build/**", // Build output - "*.json", // Root level JSON files (config.json, package.json, tsconfig.json) - "*.md", // Root level markdown files - "*.mjs", // Root level mjs files (like this config) - "*.js", // Root level js files - ], - } -]); diff --git a/packages/test-addon/package.json b/packages/test-addon/package.json deleted file mode 100644 index 6016a00..0000000 --- a/packages/test-addon/package.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "name": "@bedrock-core/server-test-addon", - "version": "1.0.1", - "private": true, - "description": "Test addon and reference implementation for @bedrock-core/server framework", - "main": "main.js", - "scripts": { - "regolith-install": "regolith install-all", - "build": "regolith run build", - "watch": "regolith watch", - "lint": "eslint .", - "loopback": "CheckNetIsolation.exe LoopbackExempt -a -p=S-1-15-2-1958404141-86561845-1752920682-3514627264-368642714-62675701-733520436", - "loopback:preview": "CheckNetIsolation.exe LoopbackExempt -a -p=S-1-15-2-424268864-5579737-879501358-346833251-474568803-887069379-4040235476" - }, - "dependencies": { - "@bedrock-core/config": "*", - "@bedrock-core/i18n": "*", - "@bedrock-core/server-runtime": "workspace:^", - "@bedrock-core/sync": "workspace:^", - "@bedrock-core/ui": "^0.9.1", - "@minecraft/common": "1.3.0", - "@minecraft/math": "2.4.0", - "@minecraft/server": "2.8.0", - "@minecraft/server-gametest": "1.0.0-beta.1.21.111-stable", - "@minecraft/server-ui": "2.1.0", - "@minecraft/vanilla-data": "1.26.31", - "typescript": "^6.0.3", - "uuid": "^14.0.1" - }, - "devDependencies": { - "@eslint/js": "^10.0.1", - "@eslint/json": "^2.0.0", - "@stylistic/eslint-plugin": "^5.10.0", - "@types/uuid": "^11.0.0", - "eslint": "^10.5.0", - "eslint-plugin-minecraft-linting": "^2.0.12", - "globals": "^17.7.0", - "typescript": "^6.0.3", - "typescript-eslint": "^8.62.0" - }, - "packageManager": "yarn@4.9.4" -} diff --git a/packages/test-addon/packs/BP/manifest.json b/packages/test-addon/packs/BP/manifest.json deleted file mode 100644 index 10dc0bc..0000000 --- a/packages/test-addon/packs/BP/manifest.json +++ /dev/null @@ -1,66 +0,0 @@ -{ - "format_version": 2, - "header": { - "name": "drav0011_economy.meta.name", - "description": "drav0011_economy.meta.description", - "uuid": "53a3007c-d160-46d3-bba1-c05e8bd378d6", - "pack_scope": "world", - "version": [ - 1, - 0, - 0 - ], - "min_engine_version": [ - 1, - 26, - 30 - ] - }, - "modules": [ - { - "type": "data", - "uuid": "10005ff2-05a5-472e-8f0a-95d3f42eee38", - "version": [ - 1, - 0, - 0 - ] - }, - { - "type": "script", - "language": "javascript", - "uuid": "12434de1-2661-41cb-b467-434accf35e90", - "entry": "scripts/main.js", - "version": [ - 1, - 0, - 0 - ] - } - ], - "dependencies": [ - { - "uuid": "79e151e5-b5a9-46ba-8f6b-50dab618cbd2", - "version": [ - 1, - 0, - 0 - ] - }, - { - "module_name": "@minecraft/server", - "version": "2.8.0" - }, - { - "module_name": "@minecraft/server-ui", - "version": "2.1.0" - }, - { - "module_name": "@minecraft/server-gametest", - "version": "1.0.0-beta" - } - ], - "metadata": { - "product_type": "addon" - } -} \ No newline at end of file diff --git a/packages/test-addon/packs/BP/scripts/example.ts b/packages/test-addon/packs/BP/scripts/example.ts deleted file mode 100644 index 466ac0d..0000000 --- a/packages/test-addon/packs/BP/scripts/example.ts +++ /dev/null @@ -1,237 +0,0 @@ -/** - * Economy addon — exercises every config feature: - * accessor tree : config.server.economy.currency.kind.get() / .set() / .subscribe() at every depth - * server scope : get / patch / set / subscribe at root, group and leaf - * dimension scope: per-dim for(dim), get/patch/set - * player scope : per-player for(player), subscribe on join - * cross-addon : subscribe to Shop's config (test-addon-2) - * types : export EconomyConfigDef so consumers can type cross-addon reads - * - * The schema below is also the UI's reference case — see the note above `configDef`. - */ -import { core } from '@bedrock-core/server-runtime'; -import type { Config } from '@bedrock-core/server-runtime'; -import { system, world } from '@minecraft/server'; - -// ─── State persistence ──────────────────────────────────────────────────────── - -/** - * One dynamic property per state key, under this addon's OWN namespace — `core` belongs to the - * framework, an addon must not squat it. - * - * Per key, not one blob for the whole namespace: a dynamic property string caps at 32767 - * characters, and a namespace that grows a key per player crosses that on some later write, far - * from the code that added the key. Persisting per key also rewrites only what changed. - */ -const SAVE_PREFIX = 'drav0011:economy:'; - -/** Bedrock's ceiling for a string dynamic property. */ -const DP_STRING_MAX = 32767; - -// ─── RPC ───────────────────────────────────────────────────────────────────── - -export interface EconomyRPC { getBalance(params: { player: string }): number } - -// ─── Config schema ──────────────────────────────────────────────────────────── - -/** - * Declared via the `config` field of `core.register()` in main.ts. Export this type - * (e.g. from `@drav0011/economy-types`) so consumers can get fully-typed access - * via `core.config.of(...)`. - * - * It is deliberately shaped to hit every branch the config UI has, so opening - * `/drav0011_economy:config` walks through all of them: - * - * server every child is a group → SCREEN OF BUTTONS - * economy both children are groups → ANOTHER SCREEN OF BUTTONS - * balances holds settings → FORM (wide-range number → text input, - * narrow-range number → slider, boolean → toggle) - * currency FORM (3-option enum → inline toggle buttons) - * display FORM, with `advanced` drawn INLINE beneath it — a nested - * group on a form level has nowhere to navigate to - * (7-option enum → dropdown, past the inline cutoff) - * moderation nothing but lists → SCREEN OF BUTTONS, one row per list, - * each opening the list editor (string items, and enum - * items which offer only what is not already in) - * dimension FORM with a list stranded on it → the list falls back to - * showing its items and the command that edits them - * player FORM (multiselect → checkbox group; `notify` inline) - * - * `$label` / `$description` name a group; leave them off and the UI derives a title from - * the key (`display.advanced` below has no `$label`, so it reads as "Advanced"). - */ -export const configDef = { - server: { - economy: { - $label: 'Economy', - $description: 'Balances, currency and what players may go negative to.', - balances: { - $label: 'Balances', - $description: 'What players start with and how far they can go.', - startingBalance: { type: 'number' as const, default: 100, min: 0, max: 10000, step: 1, label: 'Starting Balance', description: 'Given to a player the first time they join.' }, - dailyBonus: { type: 'number' as const, default: 5, min: 0, max: 50, step: 1, label: 'Daily Bonus', description: 'Paid out once per day. Narrow range, so this one is a slider.' }, - maxBalance: { type: 'number' as const, default: 99999, min: 1, max: 999999, step: 1, label: 'Max Balance' }, - allowNegative: { type: 'boolean' as const, default: false, label: 'Allow Negative Balance' }, - }, - currency: { - $label: 'Currency', - kind: { type: 'enum' as const, default: 'emerald' as const, options: ['emerald', 'gold', 'diamond'] as const, label: 'Currency', description: 'Three options, so it draws as inline segments.' }, - symbol: { type: 'string' as const, default: 'em', maxLength: 4, label: 'Currency Symbol' }, - }, - }, - display: { - $label: 'Display', - $description: 'How balances are written wherever they appear.', - prefix: { type: 'string' as const, default: '', maxLength: 8, label: 'Balance Prefix' }, - suffix: { type: 'string' as const, default: ' em', maxLength: 8, label: 'Balance Suffix' }, - showInChat: { type: 'boolean' as const, default: true, label: 'Show In Chat' }, - advanced: { - // No `$label` on purpose — the UI falls back to the key. - $description: 'Nested under a level that has settings of its own, so it is drawn inline rather than behind a button.', - theme: { type: 'enum' as const, default: 'classic' as const, options: ['classic', 'compact', 'bold', 'mono', 'high_contrast', 'retro', 'minimal'] as const, label: 'Theme', description: 'Seven options, past the inline cutoff, so it draws as a dropdown.' }, - decimals: { type: 'number' as const, default: 2, min: 0, max: 4, step: 1, label: 'Decimal Places' }, - }, - }, - moderation: { - $label: 'Moderation', - $description: 'Nothing here is a form field, so this level is a list of buttons.', - blockedItems: { type: 'list' as const, itemType: 'string' as const, maxItems: 8, default: [] as const, label: 'Blocked Items', description: 'Item IDs that may never be traded. Typed in, so the editor asks for text.' }, - watchedDimensions: { type: 'list' as const, itemType: 'enum' as const, options: ['overworld', 'nether', 'the_end'] as const, default: ['nether'] as const, label: 'Watched Dimensions', description: 'Picked from a fixed set, so the editor offers only what is not already in.' }, - }, - }, - dimension: { - taxRate: { type: 'number' as const, default: 0.05, min: 0, max: 1, step: 0.01, label: 'Tax Rate' }, - tradingEnabled: { type: 'boolean' as const, default: true, label: 'Trading Enabled' }, - blockedTrades: { type: 'list' as const, itemType: 'string' as const, maxItems: 4, default: [] as const, label: 'Blocked Trades', description: 'On a level that HAS form fields, so this one has no button to offer — it shows its items and the command instead.' }, - }, - player: { - notify: { - $label: 'Notifications', - $description: 'Drawn inline: this level has settings of its own, so there is nothing to navigate to.', - onTransaction: { type: 'boolean' as const, default: true, label: 'Notify on Transaction' }, - onLogin: { type: 'boolean' as const, default: true, label: 'Notify on Login' }, - }, - displayFormat: { type: 'enum' as const, default: 'symbol' as const, options: ['symbol', 'full', 'short'] as const, label: 'Display Format' }, - features: { type: 'multiselect' as const, options: ['tips', 'sounds', 'popups'] as const, default: ['tips'] as const, label: 'Enabled Features', description: 'Any number of a fixed set — one checkbox per option.' }, - notes: { type: 'string' as const, default: '', maxLength: 100, label: 'Player Notes' }, - }, -} as const; - -export type EconomyConfigDef = typeof configDef; - -// ─── Setup ──────────────────────────────────────────────────────────────────── - -export function setupEconomy(config: Config): void { - // ─── RPC ────────────────────────────────────────────────────────────────── - - core.rpc.serve({ - getBalance: ({ player }) => { - const balance = core.state.get(`balance.${player}`); - - return typeof balance === 'number' ? balance : 0; - }, - }); - - // ─── Deferred setup (requires tick ≥ 1 for DP access) ──────────────────── - - system.run(() => { - // Restore state from dynamic properties, before subscribing — so replaying the saved keys - // does not write every one of them straight back out. - for (const dpKey of world.getDynamicPropertyIds()) { - if (!dpKey.startsWith(SAVE_PREFIX)) { continue; } - - const saved = world.getDynamicProperty(dpKey); - - if (typeof saved !== 'string') { continue; } - - try { - core.state.set(dpKey.slice(SAVE_PREFIX.length), JSON.parse(saved) as unknown); - } catch { - console.warn(`[economy] could not parse saved state '${dpKey}'`); - } - } - - // No namespace check: `core.state` is already scoped to this addon and hides the framework's - // own keys, so this fires only for what the addon itself wrote. - core.state.subscribe((change) => { - const dpKey = `${SAVE_PREFIX}${change.key}`; - - if (change.deleted) { - world.setDynamicProperty(dpKey, undefined); - - return; - } - - const encoded = JSON.stringify(change.value); - - if (encoded.length > DP_STRING_MAX) { - console.warn(`[economy] '${change.key}' is ${String(encoded.length)} chars, over the ${String(DP_STRING_MAX)} dynamic-property limit — not persisted`); - - return; - } - - world.setDynamicProperty(dpKey, encoded); - }); - core.state.set('currency', 'gold'); - - // Accessor tree — every node carries its own verbs, leaf or group, at any depth - config.server.economy.currency.kind.set('gold'); - config.server.economy.balances.startingBalance.set(120); - config.server.display.patch({ suffix: ' g' }); - - config.server.economy.currency.kind.subscribe((next, prev) => { - console.warn(`[economy] currency ${String(prev)} → ${next}`); - }); - // Subscribing at a GROUP fires for any leaf beneath it, however deep. - config.server.economy.subscribe((economy) => { - console.warn(`[economy] group changed, balance now ${String(economy.balances.startingBalance)}`); - }); - - // A list is patched like any other leaf — it is one flat key holding the whole array. - config.server.moderation.blockedItems.set(['minecraft:bedrock', 'minecraft:barrier']); - - // patch — deep merge, only touched keys change - config.server.patch({ economy: { balances: { startingBalance: 150 } } }); - - // set — full replace (all keys must be provided) - config.server.set({ - economy: { - balances: { startingBalance: 100, dailyBonus: 5, maxBalance: 99999, allowNegative: false }, - currency: { kind: 'emerald', symbol: 'em' }, - }, - display: { - prefix: '', suffix: ' em', showInChat: true, - advanced: { theme: 'classic', decimals: 2 }, - }, - moderation: { blockedItems: ['minecraft:bedrock'], watchedDimensions: ['nether'] }, - }); - - // Dimension scope: per-dimension override (unset keys fall back to the schema default) - const nether = world.getDimension('nether'); - - config.dimension.for(nether).taxRate.set(0.25); - config.dimension.patch(nether, { tradingEnabled: false, blockedTrades: ['minecraft:elytra'] }); - }); - - // ─── Player lifecycle: per-player config on join ────────────────────────── - - world.afterEvents.playerSpawn.subscribe(({ player, initialSpawn }) => { - if (!initialSpawn) { return; } - - // The player's own accessor tree — same shape as the server scope past `for()` - const playerCfg = config.player.for(player); - - // Per-player write, at the leaf - playerCfg.notes.set(`${player.name} joined`); - // A multiselect reads back as the array it is. - playerCfg.features.set(['tips', 'sounds']); - - playerCfg.notify.onLogin.subscribe((next) => { - player.sendMessage(`Login notifications ${next ? 'on' : 'off'}`); - }); - - if (playerCfg.notify.onLogin.get()) { - player.sendMessage(`Balance: 0${config.server.display.suffix.get()}`); - } - }); -} diff --git a/packages/test-addon/packs/BP/scripts/main.ts b/packages/test-addon/packs/BP/scripts/main.ts deleted file mode 100644 index bf0265f..0000000 --- a/packages/test-addon/packs/BP/scripts/main.ts +++ /dev/null @@ -1,38 +0,0 @@ -/** - * Test addon "Economy" — a reference bedrock-core addon and one half of the cross-addon - * example (the other half is `test-addon-2`, "Shop"). It registers with the runtime, serves - * balances over RPC, shares + persists its own state, and ships GameTests. - */ -import { core } from '@bedrock-core/server-runtime'; -import { ui } from '@bedrock-core/config'; -import bundle from '@bedrock-core/generated/i18n'; -import { createI18n } from '@bedrock-core/i18n'; -import guides from '@bedrock-core/generated/guides'; -import { configDef, setupEconomy } from './example'; -import './tests'; - -// The addon's typed verbs over its resources (packs/data/i18n). Creating the instance -// also registers it as the default translation source for any UI this addon renders. -const i18n = createI18n(bundle); - -// register() declares everything in one call and brings the addon online — no separate -// start(). Display fields are translation keys — typed through key(), generated into -// this addon's .lang by the i18n filter; UIs localize them per player language. The -// i18n bundle and guide manifest ride along as optional fields; register() returns the typed config accessors. -const config = core.register({ - creator: 'drav0011', - pack: 'economy', - packName: i18n.key($ => $.meta.name), - creatorName: i18n.key($ => $.meta.creator), - version: '1.0.0', - description: i18n.key($ => $.meta.description), - icon: 'textures/ui/economy/icon', - translations: bundle, - guide: guides, - config: configDef, -}); - -setupEconomy(config); -// Mount the shared config UI — command registration is first-wins across addons, so with -// several bedrock-core addons installed exactly one realm serves the UI for all of them. -ui(core); diff --git a/packages/test-addon/packs/BP/scripts/tests/index.ts b/packages/test-addon/packs/BP/scripts/tests/index.ts deleted file mode 100644 index c7c31f8..0000000 --- a/packages/test-addon/packs/BP/scripts/tests/index.ts +++ /dev/null @@ -1,160 +0,0 @@ -/** - * GameTests for the bedrock-core stack. Most run several runtimes inside this one script - * realm (they talk over the real `system` bus); the last asserts the separate "Shop" pack - * (test-addon-2) is present, so it only passes when both addons are installed. - * - * Run in-game: `/gametest runset core` (or `/gametest run core:`). - */ -import { type Test, register } from '@minecraft/server-gametest'; -import { Runtime, core } from '@bedrock-core/server-runtime'; - -const STRUCTURE = 'core:empty'; - -function gametest(name: string, fn: (test: Test) => void): void { - register('core', name, fn).structureName(STRUCTURE).tag('core').maxTicks(220); -} - -// Two runtimes discover each other and complete an RPC round-trip. register() auto-starts. -gametest('discovery_and_rpc', (test) => { - const a = new Runtime(); - - a.register({ creator: 'test', pack: 'demo_a', packName: 'A', version: '1.0.0' }); - const b = new Runtime(); - - b.register({ creator: 'test', pack: 'demo_b', packName: 'B', version: '1.0.0' }); - b.rpc.onRequest('ping', () => 'pong'); - - let reply: unknown; - - test.startSequence() - .thenIdle(20) - .thenExecute(() => void a.rpc.request(b.id, 'ping').then((r) => { reply = r; })) - .thenIdle(20) - .thenExecute(() => { - if (!a.registry.has(b.id)) { test.fail('A did not discover B'); } - - if (reply !== 'pong') { test.fail(`expected 'pong', got ${String(reply)}`); } - - a.stop(); - b.stop(); - }) - .thenSucceed(); -}); - -// A runtime can RPC itself. Self-addressed messages loop back locally (the bus can't -// hear its own echoes over the wire); the config UI relies on this to read the config of -// the very addon hosting it. -gametest('rpc_to_self', (test) => { - const a = new Runtime(); - - a.register({ creator: 'test', pack: 'self_rpc', packName: 'A', version: '1.0.0' }); - a.rpc.onRequest('echo', params => params); - - let reply: unknown; - - test.startSequence() - .thenIdle(10) - .thenExecute(() => void a.rpc.request(a.id, 'echo', 42).then((r) => { reply = r; })) - .thenIdle(20) - .thenExecute(() => { - if (reply !== 42) { test.fail(`self-RPC failed: expected 42, got ${String(reply)}`); } - - a.stop(); - }) - .thenSucceed(); -}); - -// Shared state replicates between runtimes (last-write-wins, local reads). -gametest('state_replication', (test) => { - const a = new Runtime(); - - a.register({ creator: 'test', pack: 'state_a', packName: 'A', version: '1.0.0' }); - const b = new Runtime(); - - b.register({ creator: 'test', pack: 'state_b', packName: 'B', version: '1.0.0' }); - - a.state.set('volume', 7); - test.startSequence() - .thenIdle(20) - .thenExecute(() => { - if (b.node.state.get(a.namespace, 'volume') !== 7) { test.fail('state did not replicate to B'); } - - a.stop(); - b.stop(); - }) - .thenSucceed(); -}); - -// Same creator, different addon → distinct namespaces → coexist. Identical namespace → collision. -gametest('distinct_vs_collision', (test) => { - const x = new Runtime(); - - x.register({ creator: 'test', pack: 'dup_a', packName: 'X', version: '1.0.0' }); - const y = new Runtime(); - - y.register({ creator: 'test', pack: 'dup_b', packName: 'Y', version: '1.0.0' }); - - const c1 = new Runtime(); - - c1.register({ creator: 'test', pack: 'clash_same', packName: 'First', version: '1.0.0' }); - let collided = false; - - c1.registry.onNamespaceCollision(() => { collided = true; }); - const c2 = new Runtime(); - - c2.register({ creator: 'test', pack: 'clash_same', packName: 'Second', version: '1.0.0' }); - - test.startSequence() - .thenIdle(30) - .thenExecute(() => { - if (x.id === y.id) { test.fail('distinct namespaces must yield distinct ids'); } - - if (!x.registry.has(y.id)) { test.fail('X should see Y as an ordinary peer'); } - - if (!collided) { test.fail('identical namespaces should report a collision'); } - - for (const r of [x, y, c1, c2]) { r.stop(); } - }) - .thenSucceed(); -}); - -// A feature enables only once its required namespace is present. -gametest('feature_toggle', (test) => { - const consumer = new Runtime(); - - consumer.register({ creator: 'test', pack: 'game_main', packName: 'Game', version: '1.0.0', optionalDependencies: ['test_lb_main'] }); - - let enabled = 0; - - consumer.features.add('lb-sync', { condition: r => r.registry.has('test_lb_main'), onEnable: () => enabled++, onDisable: () => { /* noop */ } }); - - const provider = new Runtime(); - - test.startSequence() - .thenIdle(20) - .thenExecute(() => { - if (enabled !== 0) { test.fail('feature enabled before its provider was present'); } - - provider.register({ creator: 'test', pack: 'lb_main', packName: 'Leaderboard', version: '1.0.0' }); - }) - .thenIdle(20) - .thenExecute(() => { - if (enabled !== 1) { test.fail('feature did not enable when provider appeared'); } - - consumer.stop(); - provider.stop(); - }) - .thenSucceed(); -}); - -// Cross-pack: the real "Shop" addon (test-addon-2) must be registered with our live core. -gametest('cross_pack_shop_present', (test) => { - test.startSequence() - .thenIdle(40) - .thenExecute(() => { - if (!core.registry.has('drav0011_shop')) { - test.fail('shop addon not present — is test-addon-2 installed and enabled?'); - } - }) - .thenSucceed(); -}); diff --git a/packages/test-addon/packs/BP/structures/core/empty.mcstructure b/packages/test-addon/packs/BP/structures/core/empty.mcstructure deleted file mode 100644 index f183c64..0000000 Binary files a/packages/test-addon/packs/BP/structures/core/empty.mcstructure and /dev/null differ diff --git a/packages/test-addon/packs/RP/manifest.json b/packages/test-addon/packs/RP/manifest.json deleted file mode 100644 index a0568a4..0000000 --- a/packages/test-addon/packs/RP/manifest.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "format_version": 2, - "header": { - "name": "@bedrock-core/server-test", - "description": "Economy reference addon (cross-addon example with Shop)", - "uuid": "79e151e5-b5a9-46ba-8f6b-50dab618cbd2", - "pack_scope": "world", - "version": [ - 1, - 0, - 0 - ], - "min_engine_version": [ - 1, - 26, - 30 - ] - }, - "modules": [ - { - "type": "resources", - "uuid": "b72c5dbb-9bcd-4245-b5f5-ef2753875055", - "version": [ - 1, - 0, - 0 - ] - } - ], - "dependencies": [ - { - "uuid": "53a3007c-d160-46d3-bba1-c05e8bd378d6", - "version": [ - 1, - 0, - 0 - ] - } - ], - "metadata": { - "product_type": "addon" - } -} \ No newline at end of file diff --git a/packages/test-addon/packs/RP/textures/ui/economy/icon.png b/packages/test-addon/packs/RP/textures/ui/economy/icon.png deleted file mode 100644 index 140c0e7..0000000 Binary files a/packages/test-addon/packs/RP/textures/ui/economy/icon.png and /dev/null differ diff --git a/packages/test-addon/packs/data/guides/en_US/getting-started/_category_.json b/packages/test-addon/packs/data/guides/en_US/getting-started/_category_.json deleted file mode 100644 index 62073ed..0000000 --- a/packages/test-addon/packs/data/guides/en_US/getting-started/_category_.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "label": "Getting Started", - "position": 2 -} diff --git a/packages/test-addon/packs/data/guides/en_US/getting-started/first-steps.mdx b/packages/test-addon/packs/data/guides/en_US/getting-started/first-steps.mdx deleted file mode 100644 index a82bb36..0000000 --- a/packages/test-addon/packs/data/guides/en_US/getting-started/first-steps.mdx +++ /dev/null @@ -1,12 +0,0 @@ ---- -title: First Steps -sidebar_position: 1 ---- - -Get up and running with the Economy addon in a few steps. - -- Run the balance command to see your coins -- Use the pay command to send coins to a friend -- Complete a job to earn more - -Back to the [Economy Guide](../intro.mdx). diff --git a/packages/test-addon/packs/data/guides/en_US/intro.mdx b/packages/test-addon/packs/data/guides/en_US/intro.mdx deleted file mode 100644 index 0a0a507..0000000 --- a/packages/test-addon/packs/data/guides/en_US/intro.mdx +++ /dev/null @@ -1,12 +0,0 @@ ---- -title: Economy Guide -sidebar_position: 1 ---- - -Welcome to the Economy addon. This guide explains balances and payments. - -- Check your balance -- Pay other players -- Earn coins from jobs - -See [First Steps](./getting-started/first-steps.mdx) to begin. diff --git a/packages/test-addon-2/.gitignore b/packages/test-fixtures/.gitignore similarity index 96% rename from packages/test-addon-2/.gitignore rename to packages/test-fixtures/.gitignore index f436931..c3408db 100644 --- a/packages/test-addon-2/.gitignore +++ b/packages/test-fixtures/.gitignore @@ -1,5 +1,5 @@ -/build -/.regolith +build/ +.regolith/ .yarn/* !.yarn/patches diff --git a/packages/test-addon-2/.mcignore b/packages/test-fixtures/.mcignore similarity index 100% rename from packages/test-addon-2/.mcignore rename to packages/test-fixtures/.mcignore diff --git a/packages/test-addon-2/.yarnrc.yml b/packages/test-fixtures/.yarnrc.yml similarity index 100% rename from packages/test-addon-2/.yarnrc.yml rename to packages/test-fixtures/.yarnrc.yml diff --git a/packages/test-addon-2/eslint.config.mjs b/packages/test-fixtures/eslint.config.mjs similarity index 100% rename from packages/test-addon-2/eslint.config.mjs rename to packages/test-fixtures/eslint.config.mjs diff --git a/packages/test-fixtures/main/config.json b/packages/test-fixtures/main/config.json new file mode 100644 index 0000000..b57df93 --- /dev/null +++ b/packages/test-fixtures/main/config.json @@ -0,0 +1,152 @@ +{ + "$schema": "https://raw.githubusercontent.com/Bedrock-OSS/regolith-schemas/main/config/v1.4.json", + "author": "DrAv0011", + "description": "GameTest fixture for the Bedrock Core server runtime", + "name": "core-server-fixture", + "packs": { + "behaviorPack": "./packs/BP", + "resourcePack": "./packs/RP" + }, + "regolith": { + "dataPath": "./packs/data", + "filterDefinitions": { + "core": { + "url": "github.com/bedrock-core/regolith-filters", + "version": "1.0.0" + }, + "manifest": { + "url": "github.com/bedrock-core/regolith-filters", + "version": "2.0.0" + }, + "generator": { + "url": "github.com/bedrock-core/regolith-filters", + "version": "1.2.0" + }, + "guides": { + "url": "github.com/bedrock-core/regolith-filters", + "version": "1.2.0" + }, + "i18n": { + "url": "github.com/bedrock-core/regolith-filters", + "version": "1.2.0" + }, + "ui-compiler": { + "url": "github.com/bedrock-core/regolith-filters", + "version": "0.1.0" + }, + "bundler": { + "url": "github.com/bedrock-core/regolith-filters", + "version": "1.2.0" + } + }, + "formatVersion": "1.4.0", + "profiles": { + "default": { + "export": { + "build": "standard", + "readOnly": false, + "target": "development" + }, + "filters": [ + { + "filter": "core", + "settings": { + "shared": { + "namespace": "core_fixture", + "pretty": { + "indent": "tab" + } + }, + "guides": false, + "ui-compiler": false, + "bundler": { + "debug": true + } + } + } + ] + }, + "build": { + "export": { + "build": "standard", + "readOnly": false, + "target": "local" + }, + "filters": [ + { + "filter": "core", + "settings": { + "shared": { + "namespace": "core_fixture", + "pretty": false + }, + "guides": false, + "ui-compiler": false, + "bundler": { + "debug": true + } + } + } + ] + }, + "test": { + "export": { + "build": "standard", + "readOnly": false, + "target": "development" + }, + "filters": [ + { + "filter": "core", + "settings": { + "shared": { + "namespace": "core_fixture", + "pretty": { + "indent": "tab" + } + }, + "guides": false, + "ui-compiler": false, + "bundler": { + "debug": true, + "tsConfigPath": "tsconfig.test.json" + }, + "manifest": { + "manifestPath": "BP/manifest.test.json" + } + } + } + ] + }, + "build-test": { + "export": { + "build": "standard", + "bpPath": "./build/test/BP", + "readOnly": false, + "rpPath": "./build/test/RP", + "target": "exact" + }, + "filters": [ + { + "filter": "core", + "settings": { + "shared": { + "namespace": "core_fixture", + "pretty": false + }, + "guides": false, + "ui-compiler": false, + "bundler": { + "debug": true, + "tsConfigPath": "tsconfig.test.json" + }, + "manifest": { + "manifestPath": "BP/manifest.test.json" + } + } + } + ] + } + } + } +} diff --git a/packages/test-fixtures/main/packs/BP/blocks/db_probe.json b/packages/test-fixtures/main/packs/BP/blocks/db_probe.json new file mode 100644 index 0000000..1da4ed4 --- /dev/null +++ b/packages/test-fixtures/main/packs/BP/blocks/db_probe.json @@ -0,0 +1,21 @@ +{ + "format_version": "1.26.50", + "minecraft:block": { + "description": { + "identifier": "core_fixture:db_probe", + "menu_category": { + "category": "none" + } + }, + "components": { + "minecraft:block_entity": { + "dynamic_properties": true + }, + "core_fixture:db_cleanup": {}, + "minecraft:destructible_by_mining": { + "seconds_to_destroy": 0.1 + }, + "minecraft:map_color": "#FF00FF" + } + } +} diff --git a/packages/test-fixtures/main/packs/BP/manifest.json b/packages/test-fixtures/main/packs/BP/manifest.json new file mode 100644 index 0000000..58406a5 --- /dev/null +++ b/packages/test-fixtures/main/packs/BP/manifest.json @@ -0,0 +1,45 @@ +{ + "format_version": 3, + "header": { + "name": "Bedrock Core fixture", + "description": "GameTest fixture for the Bedrock Core server runtime", + "uuid": "bf9fb8cc-414b-4b92-aae6-9365d1916edb", + "pack_scope": "world", + "version": "1.0.0", + "min_engine_version": "1.26.50" + }, + "modules": [ + { + "type": "data", + "uuid": "3a23e007-6b0d-4bd3-b76c-f53a74092ba6", + "version": "1.0.0" + }, + { + "type": "script", + "language": "javascript", + "uuid": "70ce27d2-f39d-4c02-8bf0-b3529002f32b", + "entry": "scripts/main.js", + "version": "1.0.0" + } + ], + "dependencies": [ + { + "uuid": "c8ab693a-6740-4f01-a832-c340370c088b", + "version": "1.0.0" + }, + { + "module_name": "@minecraft/server", + "version": "2.10.0" + }, + { + "module_name": "@minecraft/server-ui", + "version": "2.2.0" + } + ], + "metadata": { + "product_type": "addon", + "authors": [ + "DrAv0011" + ] + } +} diff --git a/packages/test-fixtures/main/packs/BP/manifest.test.json b/packages/test-fixtures/main/packs/BP/manifest.test.json new file mode 100644 index 0000000..ef8c5ec --- /dev/null +++ b/packages/test-fixtures/main/packs/BP/manifest.test.json @@ -0,0 +1,21 @@ +{ + "extends": "./manifest.json", + "dependencies": [ + { + "uuid": "c8ab693a-6740-4f01-a832-c340370c088b", + "version": "1.0.0" + }, + { + "module_name": "@minecraft/server", + "version": "2.10.0" + }, + { + "module_name": "@minecraft/server-ui", + "version": "2.2.0" + }, + { + "module_name": "@minecraft/server-gametest", + "version": "1.0.0-beta" + } + ] +} diff --git a/packages/test-fixtures/main/packs/BP/scripts/addon.ts b/packages/test-fixtures/main/packs/BP/scripts/addon.ts new file mode 100644 index 0000000..2f6deea --- /dev/null +++ b/packages/test-fixtures/main/packs/BP/scripts/addon.ts @@ -0,0 +1,13 @@ +/** + * What the fixture says about itself. Display fields are plain text rather than + * translation keys: the fixture ships no .lang, and Bedrock falls back to the + * literal string when no key matches. + */ +export const manifest = { + creator: 'core', + pack: 'fixture', + packName: 'Bedrock Core fixture', + creatorName: 'Bedrock Core', + version: '1.0.0', + description: 'GameTest fixture for the Bedrock Core server runtime', +} as const; diff --git a/packages/test-fixtures/main/packs/BP/scripts/gametest.ts b/packages/test-fixtures/main/packs/BP/scripts/gametest.ts new file mode 100644 index 0000000..5329ad8 --- /dev/null +++ b/packages/test-fixtures/main/packs/BP/scripts/gametest.ts @@ -0,0 +1,12 @@ +/** + * GameTest entry point — bundled only by the `test` and `build-test` profiles. + * + * Those profiles point the bundler at tsconfig.test.json, which names THIS file as the entry + * instead of main.ts, and pair it with manifest.test.json, the only manifest that declares + * @minecraft/server-gametest. + * + * Everything the release ships comes in through `./main`; the tests live in `./tests`. main.ts must + * never import from here — that one rule is what keeps the beta module out of a release build. + */ +import './main'; +import './tests'; diff --git a/packages/test-fixtures/main/packs/BP/scripts/main.ts b/packages/test-fixtures/main/packs/BP/scripts/main.ts new file mode 100644 index 0000000..1ad269c --- /dev/null +++ b/packages/test-fixtures/main/packs/BP/scripts/main.ts @@ -0,0 +1,18 @@ +/** + * The fixture registers with the runtime and registers the one block component its block type + * names. The GameTests in `./tests` build their own runtimes; this registration exists so `core` + * is live for the tests that read it, and so the peer pack has a realm to be discovered from. + */ +import { system } from '@minecraft/server'; +import { blockCleanup } from '@bedrock-core/db/minecraft'; +import { core } from '@bedrock-core/server-runtime'; +import { manifest } from './addon'; + +core.register({ manifest }); + +// `blocks/db_probe.json` names this component, and the engine removes a block type whose custom +// component nobody registered — so this ships with the pack rather than with the tests. It keeps +// `core.db`'s block indexes honest: every removal drops the block's index entry. +system.beforeEvents.startup.subscribe(({ blockComponentRegistry }) => { + blockComponentRegistry.registerCustomComponent('core_fixture:db_cleanup', blockCleanup(core.db)); +}); diff --git a/packages/test-fixtures/main/packs/BP/scripts/tests/bench-shared.ts b/packages/test-fixtures/main/packs/BP/scripts/tests/bench-shared.ts new file mode 100644 index 0000000..9ecfc0b --- /dev/null +++ b/packages/test-fixtures/main/packs/BP/scripts/tests/bench-shared.ts @@ -0,0 +1,230 @@ +/** + * S6 — what a shared delta costs on the bus. + * + * Two things hinge on the numbers: whether a value belongs on a `shared` key at all, and whether a + * query's warm read from the mirror ([04-query](../../../../../../docs/04-query.md)) is worth + * claiming. Both come down to whether a peer's copy of a realistic value arrives soon enough to + * read, and to what the owner pays to publish it. + * + * Peers are separate `State` instances over their own `Bus`, all inside this script realm. They + * talk through the engine's real `scriptEvent` transport, so serialization, the per-tick send + * budget and delivery order are the engine's. What this does *not* separate is the QuickJS heap: + * four packs would each parse their own copy, and that parse is counted once here. The apply and + * convergence figures hold; treat the memory question as unmeasured. + * + * Every test prints `BENCH {...}` lines, which `scripts/bench-report.mjs` lifts out of the + * transcript. Nothing is asserted except what would make a measurement meaningless. + */ +import { system } from '@minecraft/server'; +import { type Test, register } from '@minecraft/server-gametest'; +import { Bus, State } from '@bedrock-core/sync'; + +const STRUCTURE = 'core:empty'; + +function benchmark(name: string, fn: (test: Test) => void): void { + register('bench', name, fn).structureName(STRUCTURE).tag('bench').maxTicks(1200); +} + +function report(name: string, data: Record): void { + console.warn(`BENCH ${JSON.stringify({ name, ...data })}`); +} + +/** + * A value of roughly `chars` characters, shaped like the nested JSON an addon would actually + * share — a table of rows rather than one long string, so the parse cost is representative. + */ +function payload(chars: number): unknown { + const rows: { id: number; key: string; value: number }[] = []; + let length = 2; + + while (length < chars) { + const row = { id: rows.length, key: `entry:${rows.length}:material`, value: rows.length * 7 }; + + length += JSON.stringify(row).length + 1; + rows.push(row); + } + + return { ns: 'economy', rows }; +} + +const SIZES = [ + { label: '1KB', value: payload(1_000) }, + { label: '10KB', value: payload(10_000) }, +]; + +interface Realm { + id: string; + bus: Bus; + state: State; +} + +/** + * One realm: its own bus and state, started and ready to receive. + * + * An owner's id must BE the namespace it owns. A peer only applies a delta whose sender id equals + * the namespace, which is how owner-only replication is enforced — a mismatch is silently treated + * as a foreign write and dropped. + */ +function realm(id: string): Realm { + const bus = new Bus(id); + const state = new State(bus, id); + + bus.start(); + state.start(); + + return { id, bus, state }; +} + +function stopAll(realms: Realm[]): void { + for (const r of realms) { + r.state.stop(); + r.bus.stop(); + } +} + +/** + * What the owner pays to publish, measured on its own tick. + * + * `set()` is synchronous: it stamps a version, applies locally and hands an envelope to the queue, + * which serializes it. That whole cost lands on the caller's tick regardless of how many peers + * are listening, so it is measured once per size rather than once per fan-out. + */ +benchmark('shared_publish', (test) => { + const owner = realm('s6pub'); + const RUNS = 50; + + for (const { label, value } of SIZES) { + const start = Date.now(); + + for (let i = 0; i < RUNS; i++) { owner.state.set('s6pub', `k${i}`, value); } + + const ms = Date.now() - start; + + report('shared_publish', { + payload: label, + runs: RUNS, + totalMs: ms, + usPerSet: Math.round((ms * 1000) / RUNS), + }); + } + + stopAll([owner]); + test.succeed(); +}); + +/** + * How long a peer waits before it can read what the owner wrote, at two fan-outs. + * + * Convergence is reported as the worst peer, not the average: a warm read is only safe if *every* + * mirror has the value, and a query that reads the slowest one is the one that returns stale data. + */ +function convergence(test: Test, peerCount: number): void { + const ns = `s6c${peerCount}`; + const owner = realm(ns); + const peers = Array.from({ length: peerCount }, (_, i) => realm(`s6_conv_peer_${peerCount}_${i}`)); + const all = [owner, ...peers]; + + const seen = new Map(); + let sentTick = 0; + let sentMs = 0; + + for (const peer of peers) { + peer.state.subscribe((change) => { + if (change.ns !== ns || typeof change.key !== 'string') { return; } + + const arrivals = seen.get(change.key) ?? []; + + arrivals.push({ ticks: system.currentTick - sentTick, ms: Date.now() - sentMs }); + seen.set(change.key, arrivals); + }); + } + + const sequence = test.startSequence().thenIdle(20); + + for (const { label, value } of SIZES) { + sequence + .thenExecute(() => { + sentTick = system.currentTick; + sentMs = Date.now(); + owner.state.set(ns, label, value); + }) + .thenIdle(60) + .thenExecute(() => { + const arrivals = seen.get(label) ?? []; + const converged = arrivals.length === peerCount; + + report('shared_converge', { + peers: peerCount, + payload: label, + arrived: arrivals.length, + // The worst peer is the one a warm read has to wait for. + ticks: converged ? Math.max(...arrivals.map(a => a.ticks)) : null, + ms: converged ? Math.max(...arrivals.map(a => a.ms)) : null, + }); + + if (!converged) { + test.fail(`${label} reached ${arrivals.length} of ${peerCount} peers — the transport dropped it`); + } + }); + } + + sequence + .thenExecute(() => { stopAll(all); }) + .thenSucceed(); +} + +benchmark('shared_converge_2', test => convergence(test, 2)); +benchmark('shared_converge_4', test => convergence(test, 4)); + +/** + * Boot cost: an addon whose shared tree is `persisted` republishes every key a tick after + * registration, so a hundred of them land together. That burst is the worst case the transport + * sees in normal use, and it happens while the world is still loading. + */ +benchmark('shared_persist_boot', (test) => { + const ns = 's6boot'; + const KEYS = 100; + const owner = realm(ns); + const peer = realm('s6_boot_peer'); + const value = payload(200); + + let applied = 0; + let startTick = 0; + let startMs = 0; + let lastTick = 0; + let lastMs = 0; + + peer.state.subscribe((change) => { + if (change.ns !== ns) { return; } + + applied++; + lastTick = system.currentTick; + lastMs = Date.now(); + }); + + test.startSequence() + .thenIdle(20) + .thenExecute(() => { + startTick = system.currentTick; + startMs = Date.now(); + + for (let i = 0; i < KEYS; i++) { owner.state.set(ns, `key:${i}`, value); } + }) + .thenIdle(200) + .thenExecute(() => { + report('shared_persist_boot', { + keys: KEYS, + payload: '200B', + applied, + ticks: applied > 0 ? lastTick - startTick : null, + ms: applied > 0 ? lastMs - startMs : null, + // What a late joiner would have to replay to catch up. + snapshotEntries: owner.state.snapshot(ns).length, + }); + + if (applied !== KEYS) { test.fail(`peer applied ${applied} of ${KEYS} keys`); } + + stopAll([owner, peer]); + }) + .thenSucceed(); +}); diff --git a/packages/test-fixtures/main/packs/BP/scripts/tests/bench.ts b/packages/test-fixtures/main/packs/BP/scripts/tests/bench.ts new file mode 100644 index 0000000..26d08e6 --- /dev/null +++ b/packages/test-fixtures/main/packs/BP/scripts/tests/bench.ts @@ -0,0 +1,301 @@ +/** + * In-game benchmarks for the sync transport, run under the `bench` tag rather than `core`. + * + * These are kept out of the correctness suite for two reasons: they are slow, and a number that + * moves is not a failure. `yarn test:mc` has to stay a red/green signal, so nothing here is + * asserted except the properties that would make a measurement meaningless. Each test prints one + * `BENCH {...}` line, which `scripts/bench-report.mjs` lifts out of the server transcript. + * + * Why measure in the engine when `packages/sync/bench` already measures off it: QuickJS on a + * server is not V8 on a desktop, the tick loop is real, and the engine's own limits — the per-tick + * script-event budget, the size cap on one message — exist nowhere else. The off-engine numbers + * say which approach is cheaper; these say whether it fits. + */ +import { system } from '@minecraft/server'; +import { type Test, register } from '@minecraft/server-gametest'; +import { Bus, MAX_MESSAGE } from '@bedrock-core/sync'; + +const STRUCTURE = 'core:empty'; + +/** Channel used by the raw probes, so they never disturb — or get disturbed by — the real bus. */ +const PROBE_CHANNEL = 'bedrock-core:benchprobe'; + +function benchmark(name: string, fn: (test: Test) => void): void { + register('bench', name, fn).structureName(STRUCTURE).tag('bench').maxTicks(1200); +} + +/** One machine-readable result line. `scripts/bench-report.mjs` parses these out of the log. */ +function report(name: string, data: Record): void { + console.warn(`BENCH ${JSON.stringify({ name, ...data })}`); +} + +/** + * A payload of roughly `chars` characters, shaped like the nested JSON addons actually exchange. + * + * The running length is accumulated per row rather than re-measured from the whole array, since + * this builds at module load — on the engine, where a quadratic loop is a hitch on the tick that + * loads the pack. + */ +function payload(chars: number): unknown { + const rows: { id: number; key: string; value: number }[] = []; + let length = 2; + + while (length < chars) { + const row = { id: rows.length, key: `entry:${rows.length}:material`, value: rows.length * 7 }; + + length += JSON.stringify(row).length + 1; + rows.push(row); + } + + return { ns: 'economy', rows }; +} + +/** Read the size label back off a received payload without asserting a shape onto unknown data. */ +function labelOf(data: unknown): string | undefined { + if (typeof data !== 'object' || data === null || !('label' in data)) { return undefined; } + + const { label } = data; + + return typeof label === 'string' ? label : undefined; +} + +const LARGE = payload(16_000); + +const SIZES = [ + { label: '5B', value: 'hello' }, + { label: '200B', value: payload(200) }, + { label: '16KB', value: LARGE }, +]; + +// Serialization runs on the calling tick, so a payload whose encode does not fit in a tick cannot +// be sent at all, whatever the transport does with it afterwards. +benchmark('serialize', (test) => { + for (const { label, value } of SIZES) { + const envelope = { v: 2, src: 'bench', iid: 'bench-1', type: 'bench', mid: 'bench-1/1', data: value }; + const runs = label === '16KB' ? 10 : 100; + + const encodeStart = Date.now(); + let encoded = ''; + + for (let i = 0; i < runs; i++) { encoded = JSON.stringify(envelope); } + + const encodeMs = Date.now() - encodeStart; + const decodeStart = Date.now(); + + for (let i = 0; i < runs; i++) { JSON.parse(encoded); } + + report('serialize', { + payload: label, + runs, + chars: encoded.length, + encodeMs, + decodeMs: Date.now() - decodeStart, + }); + } + + test.succeed(); +}); + +// End-to-end latency in ticks. The queue never sends inline, so the floor is one flush plus the +// engine's delivery — the number worth knowing, since it bounds every RPC round trip. +benchmark('send_latency', (test) => { + const a = new Bus('bench_send_a'); + const b = new Bus('bench_send_b'); + + a.start(); + b.start(); + + const sentAt = new Map(); + const results: Record[] = []; + + b.on('bench-latency', (envelope) => { + const label = labelOf(envelope.data); + const sent = label === undefined ? undefined : sentAt.get(label); + + if (!sent || label === undefined) { return; } + + results.push({ + payload: label, + ticks: system.currentTick - sent.tick, + ms: Date.now() - sent.ms, + }); + }); + + const sequence = test.startSequence().thenIdle(20); + + for (const { label, value } of SIZES) { + sequence + .thenExecute(() => { + sentAt.set(label, { tick: system.currentTick, ms: Date.now() }); + a.send({ type: 'bench-latency', data: { label, value } }); + }) + .thenIdle(20); + } + + sequence + .thenExecute(() => { + for (const result of results) { report('send_latency', result); } + + if (results.length !== SIZES.length) { + test.fail(`only ${results.length} of ${SIZES.length} payloads arrived`); + } + + a.stop(); + b.stop(); + }) + .thenSucceed(); +}); + +// Three large payloads at once. Each one chunks, so this is really a question about the per-tick +// send budget: whether the queue drains them together or spreads them across flushes. +benchmark('backpressure', (test) => { + const a = new Bus('bench_bp_a'); + const b = new Bus('bench_bp_b'); + + a.start(); + b.start(); + + let startTick = 0; + let startMs = 0; + let received = 0; + let lastTick = 0; + let lastMs = 0; + + b.on('bench-bp', () => { + received++; + lastTick = system.currentTick; + lastMs = Date.now(); + }); + + test.startSequence() + .thenIdle(20) + .thenExecute(() => { + startTick = system.currentTick; + startMs = Date.now(); + + for (let i = 0; i < 3; i++) { a.send({ type: 'bench-bp', data: { i, value: LARGE } }); } + }) + .thenIdle(120) + .thenExecute(() => { + report('backpressure', { + concurrent: 3, + payload: '16KB', + received, + ticks: received > 0 ? lastTick - startTick : null, + ms: received > 0 ? lastMs - startMs : null, + }); + + if (received !== 3) { test.fail(`only ${received} of 3 large payloads arrived`); } + + a.stop(); + b.stop(); + }) + .thenSucceed(); +}); + +// Does the queue's packing survive contact with the engine? Counts the script events one node's +// burst of small messages actually puts on the wire. +benchmark('packing', (test) => { + const a = new Bus('bench_pack_a'); + const b = new Bus('bench_pack_b'); + + a.start(); + b.start(); + + const BURST = 40; + let messagesOnWire = 0; + let envelopesDelivered = 0; + + const probe = system.afterEvents.scriptEventReceive.subscribe( + (event) => { + if (event.message.includes('bench-pack')) { messagesOnWire++; } + }, + { namespaces: ['bedrock-core'] }, + ); + + b.on('bench-pack', () => { envelopesDelivered++; }); + + test.startSequence() + .thenIdle(20) + .thenExecute(() => { + for (let i = 0; i < BURST; i++) { + a.send({ type: 'bench-pack', data: { ns: 'economy', key: `price:${i}`, value: 16 + i, ver: 1200 + i } }); + } + }) + .thenIdle(40) + .thenExecute(() => { + report('packing', { + envelopesSent: BURST, + messagesOnWire, + envelopesDelivered, + envelopesPerMessage: messagesOnWire > 0 ? Number((envelopesDelivered / messagesOnWire).toFixed(2)) : null, + }); + + system.afterEvents.scriptEventReceive.unsubscribe(probe); + + if (envelopesDelivered !== BURST) { test.fail(`delivered ${envelopesDelivered} of ${BURST} envelopes`); } + + a.stop(); + b.stop(); + }) + .thenSucceed(); +}); + +// Mojang documents the cap as 2048 *characters*. sync counts JavaScript string length, which is +// UTF-16 code units — so if the engine is really counting UTF-8 bytes, a message of non-ASCII text +// passes every check in the library and is rejected at send, where the queue can do nothing but +// count it as dropped. That is a correctness question with a measurable answer, so it is measured. +benchmark('message_cap_units', (test) => { + const cases = [ + { label: 'ascii', text: 'a'.repeat(MAX_MESSAGE), bytesPerUnit: 1 }, + { label: 'latin1', text: 'é'.repeat(MAX_MESSAGE), bytesPerUnit: 2 }, + { label: 'cjk', text: '漢'.repeat(MAX_MESSAGE), bytesPerUnit: 3 }, + // An emoji is two UTF-16 units, so half as many of them reach the same string length. + { label: 'astral', text: '🧱'.repeat(MAX_MESSAGE / 2), bytesPerUnit: 2 }, + ]; + + const arrived = new Set(); + const threw = new Map(); + + const probe = system.afterEvents.scriptEventReceive.subscribe( + (event) => { + if (event.id !== PROBE_CHANNEL) { return; } + + for (const { label, text } of cases) { + if (event.message.length === text.length && event.message[0] === text[0]) { arrived.add(label); } + } + }, + { namespaces: ['bedrock-core'] }, + ); + + test.startSequence() + .thenIdle(10) + .thenExecute(() => { + for (const { label, text } of cases) { + try { + system.sendScriptEvent(PROBE_CHANNEL, text); + } catch (error) { + threw.set(label, String(error)); + } + } + }) + .thenIdle(40) + .thenExecute(() => { + for (const { label, text, bytesPerUnit } of cases) { + report('message_cap_units', { + encoding: label, + stringLength: text.length, + approxUtf8Bytes: text.length * bytesPerUnit, + sendThrew: threw.get(label) ?? null, + arrived: arrived.has(label), + }); + } + + system.afterEvents.scriptEventReceive.unsubscribe(probe); + + // The ASCII control has to survive, or the probe itself is broken and the other rows mean + // nothing. The non-ASCII rows are the measurement and are deliberately not asserted. + if (!arrived.has('ascii')) { test.fail('the ASCII control did not arrive — the probe is broken'); } + }) + .thenSucceed(); +}); diff --git a/packages/test-fixtures/main/packs/BP/scripts/tests/db.ts b/packages/test-fixtures/main/packs/BP/scripts/tests/db.ts new file mode 100644 index 0000000..711b15d --- /dev/null +++ b/packages/test-fixtures/main/packs/BP/scripts/tests/db.ts @@ -0,0 +1,411 @@ +/** + * GameTests for `@bedrock-core/db` — the half its unit tests cannot reach. + * + * `packages/db/test` runs against stubs shaped like the engine's classes, so every fact those + * stubs encode is an assumption until something asserts it on a real server: the two ABIs and + * what each can do, the 32 767-character property cap and the ~950 bytes a block entity holds, + * an `ItemStack` write landing on a copy, a stackable slot throwing, a dropped block taking its + * document and its index entry with it. Those are the measurements in `docs/spikes/S4`, `S5` and + * `S8`; here they are assertions, so a Bedrock release that moves one of them turns this suite + * red instead of silently breaking the library. + * + * Everything runs on `core.db` rather than a fresh runtime: the block cleanup component in + * `../main` is registered against that db, and only the db it heals can prove the healing. Each + * test therefore uses its own collection name and deletes what it wrote. + */ +import { BlockPermutation, ItemStack, world } from '@minecraft/server'; +import { type Test, register } from '@minecraft/server-gametest'; +import { COMPONENT_BUDGET, DIRECT_BUDGET, type Capabilities, type Where } from '@bedrock-core/db'; +import { DbBudgetError, blockTypes, core, entityTypes, schema, slots } from '@bedrock-core/server-runtime'; + +const STRUCTURE = 'core:empty'; + +/** The fixture's own block type: `minecraft:block_entity` with `dynamic_properties`. */ +const PROBE_BLOCK = 'core_fixture:db_probe'; + +function gametest(name: string, fn: (test: Test) => void): void { + register('core', name, fn).structureName(STRUCTURE).tag('core').maxTicks(220); +} + +/** The capabilities a collection reports for a target, or `undefined` after failing the test. */ +function capsOf(test: Test, where: Where, label: string): Capabilities | undefined { + if (!where.ok) { + test.fail(`${label}: refused — ${where.reason}`); + + return undefined; + } + + return where.caps; +} + +/** Why a collection refused a target, or `undefined` after failing the test because it did not. */ +function refusalOf(test: Test, where: Where, label: string): string | undefined { + if (where.ok) { + test.fail(`${label}: accepted, expected a refusal`); + + return undefined; + } + + return where.reason; +} + +// The direct ABI, on the three targets that have it, plus the one that has none. What the stubs +// claim per kind — own, enumerable, batch, the budget, and whether a write survives the handle it +// was made through — asked of the engine. +gametest('db_direct_abi', (test) => { + const notes = core.db.collection('t_abi', { schema: schema<{ text: string }>({ defaults: { text: '' } }) }); + const stand = test.spawn('minecraft:armor_stand', { x: 1, y: 1, z: 1 }); + const dimension = test.getDimension(); + + test.startSequence() + .thenIdle(5) + .thenExecute(() => { + const onWorld = capsOf(test, notes.where(world), 'world'); + const onEntity = capsOf(test, notes.where(stand), 'entity'); + const onDimension = capsOf(test, notes.where(dimension), 'dimension'); + + if (onWorld === undefined || onEntity === undefined || onDimension === undefined) { return; } + + for (const [label, caps] of [['world', onWorld], ['entity', onEntity]] as const) { + if (!caps.own || !caps.enumerable || !caps.batch || caps.budget !== DIRECT_BUDGET) { + test.fail(`${label} is no longer the direct ABI: ${JSON.stringify(caps)}`); + } + } + + // A dimension holds nothing of its own, so its document lives on the world under its identity. + if (onDimension.own || !onDimension.readableWhenUnloaded) { + test.fail(`a dimension should be proxied to the world: ${JSON.stringify(onDimension)}`); + } + + notes.for(stand).set({ text: 'kept' }); + notes.for(dimension).set({ text: 'proxied' }); + }) + .thenIdle(5) + .thenExecute(() => { + // The handle the write went through is gone; the engine is asked for the target again. + const again = world.getEntity(stand.id); + + if (again === undefined) { + test.fail('the armor stand vanished'); + + return; + } + + notes.forget(stand); + + if (notes.for(again).get()?.text !== 'kept') { + test.fail(`an entity write did not survive a fresh handle: ${JSON.stringify(notes.for(again).get())}`); + } + + notes.forget(dimension); + + const proxied = notes.for(world.getDimension(dimension.id)).get(); + + if (proxied?.text !== 'proxied') { test.fail(`a dimension document read back ${JSON.stringify(proxied)}`); } + + // The proxied document is the world's, so it outlives the test unless it is removed. + notes.for(dimension).delete(); + notes.for(again).delete(); + + if (notes.for(world.getDimension(dimension.id)).get() !== undefined) { test.fail('the dimension document survived its delete'); } + }) + .thenSucceed(); +}); + +// The property cap the whole direct family is sized from, and what the store does at it: a +// document past the cap is split across properties rather than refused, and its delete takes +// every piece with it. +gametest('db_direct_budget', (test) => { + const blobs = core.db.collection('t_budget', { + schema: schema<{ blob: string }>({ defaults: { blob: '' } }), + accept: entityTypes('minecraft:armor_stand'), + }); + const stand = test.spawn('minecraft:armor_stand', { x: 1, y: 1, z: 1 }); + const blob = 'x'.repeat(DIRECT_BUDGET + 5_000); + + test.startSequence() + .thenIdle(5) + .thenExecute(() => { + stand.setDynamicProperty('core_fixture:probe', 'a'.repeat(DIRECT_BUDGET)); + + let threw: string | undefined; + + try { + stand.setDynamicProperty('core_fixture:probe', 'a'.repeat(DIRECT_BUDGET + 1)); + } catch (error) { + threw = String(error); + } + + if (threw === undefined) { test.fail(`${DIRECT_BUDGET + 1} characters were accepted — DIRECT_BUDGET is no longer the cap`); } + + stand.setDynamicProperty('core_fixture:probe', undefined); + blobs.for(stand).set({ blob }); + blobs.forget(stand); + }) + .thenIdle(5) + .thenExecute(() => { + const read = blobs.for(stand).get(); + + if (read?.blob !== blob) { + test.fail(`the chunked document came back ${read === undefined ? 'undefined' : `${read.blob.length} characters`}, expected ${blob.length}`); + } + + blobs.for(stand).delete(); + + const left = stand.getDynamicPropertyIds().filter(id => id.includes('t_budget')); + + if (left.length !== 0) { test.fail(`delete left ${JSON.stringify(left)} behind`); } + }) + .thenSucceed(); +}); + +// The component ABI on a real block entity: its capabilities, its much smaller budget, and the +// removal path — `blockCleanup` in `../main` drops the index entry, and the block entity takes +// the document itself. Re-placing the type gives a fresh document, never the old one. +gametest('db_block_entity', (test) => { + const probes = core.db.collection('t_blocks', { + schema: schema<{ hits: number; note: string }>({ defaults: { hits: 0, note: '' } }), + accept: blockTypes(PROBE_BLOCK), + require: { own: true }, + }); + // Offered on a block and refused: the document could die with the block before the flush. + const counters = core.db.collection('t_block_coalesce', { + schema: schema<{ n: number }>({ defaults: { n: 0 } }), + accept: blockTypes(PROBE_BLOCK), + coalesce: true, + }); + const at = { x: 1, y: 1, z: 1 }; + const air = BlockPermutation.resolve('minecraft:air'); + // A failed sequence step still runs the ones after it, and a completed test refuses every + // method — so once something has failed, the rest of the sequence stands down. + let failed = false; + + const fail = (message: string): void => { + failed = true; + test.fail(message); + }; + + test.startSequence() + .thenExecute(() => { test.setBlockType(PROBE_BLOCK, at); }) + .thenIdle(4) + .thenExecute(() => { + const block = test.getBlock(at); + const caps = capsOf(test, probes.where(block), 'block'); + + if (caps === undefined) { + failed = true; + + return; + } + + if (!caps.own || caps.enumerable || caps.budget !== COMPONENT_BUDGET) { + fail(`a block entity is no longer the component ABI: ${JSON.stringify(caps)}`); + } + + const refusal = refusalOf(test, counters.where(block), 'coalesce on a block'); + + if (refusal === undefined) { + failed = true; + } else if (!refusal.startsWith('coalesce:')) { + fail(`coalesce on a block was refused for the wrong reason: ${refusal}`); + } + + // Just under the budget: what the engine still accepts on one block per pack. + probes.for(block).set({ hits: 1, note: 'n'.repeat(COMPONENT_BUDGET - 80) }); + + let budgetError: unknown; + + try { + probes.for(block).set({ hits: 2, note: 'n'.repeat(COMPONENT_BUDGET) }); + } catch (error) { + budgetError = error; + } + + if (!(budgetError instanceof DbBudgetError)) { fail(`an oversized block document threw ${String(budgetError)}, expected DbBudgetError`); } + }) + .thenIdle(4) + .thenExecute(() => { + if (failed) { return; } + + probes.forget(test.getBlock(at)); + + // A fresh handle from the engine reads what the last handle wrote, and nothing the refused + // write would have left behind. + const doc = probes.for(test.getBlock(at)).get(); + + if (doc?.hits !== 1 || doc.note.length !== COMPONENT_BUDGET - 80) { fail(`the block document read back ${JSON.stringify({ hits: doc?.hits, note: doc?.note.length })}`); } + + const indexed = [...probes.all()]; + const location = test.getBlock(at).location; + + if (indexed.length !== 1) { + fail(`the index holds ${indexed.length} entries, expected 1`); + + return; + } + + const entry = indexed[0]; + + if (entry?.kind !== 'block' || entry.identity !== `${test.getDimension().id}:${location.x},${location.y},${location.z}:${PROBE_BLOCK}`) { + fail(`the index entry is ${String(entry?.identity)}`); + } + + // `setPermutation` is one of the removals S4 measured `onBreak` firing for; the structure + // helpers place blocks without running block events at all. + test.getBlock(at).setPermutation(air); + }) + // `onBreak` runs a tick after the removal, so the index heals on the next tick, never in the + // call that removed the block. + .thenIdle(6) + .thenExecute(() => { + if (failed) { return; } + + if (probes.size !== 0) { fail(`the index kept ${probes.size} entries after the block was removed`); } + + const refusal = refusalOf(test, probes.where(test.getBlock(at)), 'air'); + + if (refusal === undefined) { + failed = true; + } else if (!refusal.includes('not accepted')) { + fail(`air was refused for the wrong reason: ${refusal}`); + } + + test.setBlockType(PROBE_BLOCK, at); + }) + .thenIdle(4) + .thenExecute(() => { + if (failed) { return; } + + probes.forget(test.getBlock(at)); + + const reborn = probes.for(test.getBlock(at)).get(); + + if (reborn !== undefined) { fail(`a re-placed block inherited a document: ${JSON.stringify(reborn)}`); } + + test.getBlock(at).setPermutation(air); + }) + .thenIdle(6) + .thenSucceed(); +}); + +// Why `ItemStack` is refused and a slot is offered instead: the stack a script holds is a copy, +// a stackable item throws on write, and only the live slot of a non-stackable item keeps bytes. +gametest('db_slots_and_stacks', (test) => { + const marks = core.db.collection('t_slots', { + schema: schema<{ owner: string }>({ defaults: { owner: '' } }), + accept: slots(), + }); + const at = { x: 1, y: 1, z: 1 }; + + test.startSequence() + .thenExecute(() => { test.setBlockType('minecraft:chest', at); }) + .thenIdle(4) + .thenExecute(() => { + const container = test.getBlock(at).getComponent('minecraft:inventory')?.container; + + if (container === undefined) { + test.fail('the chest has no container'); + + return; + } + + container.setItem(0, new ItemStack('minecraft:diamond_sword', 1)); + container.setItem(1, new ItemStack('minecraft:stone', 4)); + + // The resolver refuses an ItemStack by name, whatever its methods say. + const stack = core.db.resolver.resolve(new ItemStack('minecraft:stone', 1)); + + if (stack.ok || !stack.reason.includes('detached copy')) { test.fail(`an ItemStack resolved to ${JSON.stringify(stack)}`); } + + // And that is not pedantry: a write to a non-stackable copy is lost. + const copy = container.getItem(0); + + copy?.setDynamicProperty('core_fixture:probe', 'lost'); + + if (container.getItem(0)?.getDynamicProperty('core_fixture:probe') !== undefined) { + test.fail('a write to a detached ItemStack reached the world — the refusal is no longer needed'); + } + + // A stackable item cannot hold properties at all, so the slot holding one is refused. + const stackable = refusalOf(test, marks.where(container.getSlot(1)), 'a stackable slot'); + + if (stackable !== undefined && !stackable.includes('stackable')) { test.fail(`a stackable slot was refused for the wrong reason: ${stackable}`); } + + const empty = refusalOf(test, marks.where(container.getSlot(2)), 'an empty slot'); + + if (empty !== undefined && !empty.includes('empty')) { test.fail(`an empty slot was refused for the wrong reason: ${empty}`); } + + const caps = capsOf(test, marks.where(container.getSlot(0)), 'a non-stackable slot'); + + if (caps !== undefined && (!caps.own || caps.readableWhenUnloaded)) { test.fail(`a slot's capabilities are ${JSON.stringify(caps)}`); } + + marks.for(container.getSlot(0)).set({ owner: 'fixture' }); + }) + .thenIdle(4) + .thenExecute(() => { + const container = test.getBlock(at).getComponent('minecraft:inventory')?.container; + + if (container === undefined) { + test.fail('the chest lost its container'); + + return; + } + + // A slot has no identity, so nothing is cached: this read goes to the item's NBT. + const kept = marks.for(container.getSlot(0)).get(); + + if (kept?.owner !== 'fixture') { test.fail(`a slot document read back ${JSON.stringify(kept)}`); } + + container.setItem(0, undefined); + test.setBlockType('minecraft:air', at); + }) + .thenSucceed(); +}); + +// Write-behind: a coalesced document is in memory the moment it is patched and on the target +// after one flush, written once however many times it changed. +gametest('db_coalesce_flush', (test) => { + const counters = core.db.collection('t_coalesce', { + schema: schema<{ n: number }>({ defaults: { n: 0 } }), + accept: entityTypes('minecraft:armor_stand'), + coalesce: true, + }); + const stand = test.spawn('minecraft:armor_stand', { x: 1, y: 1, z: 1 }); + // What the collection writes under, once it writes: `resolve.ts` builds this key. + const key = `core-db:${core.namespace}:entity::t_coalesce:doc`; + + test.startSequence() + .thenIdle(5) + .thenExecute(() => { + const doc = counters.for(stand); + + for (let i = 1; i <= 50; i++) { doc.patch({ n: i }); } + + if (doc.get()?.n !== 50) { test.fail(`the in-memory document reads ${String(doc.get()?.n)} before the flush`); } + + if (stand.getDynamicProperty(key) !== undefined) { test.fail('a coalesced patch wrote through — nothing should reach the entity before the flush'); } + }) + .thenIdle(5) + .thenExecute(() => { + const raw = stand.getDynamicProperty(key); + + if (typeof raw !== 'string') { + test.fail(`the flush wrote ${String(raw)}`); + + return; + } + + const parsed: unknown = JSON.parse(raw); + const stored = typeof parsed === 'object' && parsed !== null && 'd' in parsed ? parsed.d : undefined; + + if (JSON.stringify(stored) !== JSON.stringify({ n: 50 })) { test.fail(`the flush stored ${JSON.stringify(stored)}`); } + + counters.for(stand).delete(); + core.db.flush(); + }) + .thenIdle(5) + .thenExecute(() => { + if (stand.getDynamicProperty(key) !== undefined) { test.fail('the delete did not reach the entity'); } + }) + .thenSucceed(); +}); diff --git a/packages/test-fixtures/main/packs/BP/scripts/tests/index.ts b/packages/test-fixtures/main/packs/BP/scripts/tests/index.ts new file mode 100644 index 0000000..9335f66 --- /dev/null +++ b/packages/test-fixtures/main/packs/BP/scripts/tests/index.ts @@ -0,0 +1,330 @@ +/** + * GameTests for the bedrock-core stack. Most run several runtimes inside this one script + * realm (they talk over the real `system` bus); the last asserts the separate peer pack + * is present, so it only passes when both fixture packs are installed. + * + * `./db` and `./sync` hold the tests that need the engine itself rather than several runtimes. + * + * Run in-game: `/gametest runset core` (or `/gametest run core:`). The `bench` tag in + * `./bench` is registered alongside them but runs only when asked for by name. + */ +import { Runtime, authorize, core, event, registerEvents, registerShared, schema } from '@bedrock-core/server-runtime'; +import { world } from '@minecraft/server'; +import { type Test, register } from '@minecraft/server-gametest'; +import './bench'; +import './bench-shared'; +import './db'; +import './sync'; + +const STRUCTURE = 'core:empty'; + +function gametest(name: string, fn: (test: Test) => void): void { + register('core', name, fn).structureName(STRUCTURE).tag('core').maxTicks(220); +} + +// Two runtimes discover each other and complete an RPC round-trip. register() auto-starts. +gametest('discovery_and_rpc', (test) => { + const a = new Runtime(); + + a.register({ manifest: { creator: 'test', pack: 'demo_a', packName: 'A', version: '1.0.0' } }); + const b = new Runtime(); + + b.register({ manifest: { creator: 'test', pack: 'demo_b', packName: 'B', version: '1.0.0' } }); + b.rpc.onRequest('ping', () => 'pong'); + + let reply: unknown; + + test.startSequence() + .thenIdle(20) + .thenExecute(() => void a.rpc.request(b.id, 'ping').then((r) => { reply = r; })) + .thenIdle(20) + .thenExecute(() => { + if (!a.registry.has(b.id)) { test.fail('A did not discover B'); } + + if (reply !== 'pong') { test.fail(`expected 'pong', got ${String(reply)}`); } + + a.stop(); + b.stop(); + }) + .thenSucceed(); +}); + +// A runtime can RPC itself. Self-addressed messages loop back locally (the bus can't +// hear its own echoes over the wire); the config UI relies on this to read the config of +// the very addon hosting it. +gametest('rpc_to_self', (test) => { + const a = new Runtime(); + + a.register({ manifest: { creator: 'test', pack: 'self_rpc', packName: 'A', version: '1.0.0' } }); + a.rpc.onRequest('echo', params => params); + + let reply: unknown; + + test.startSequence() + .thenIdle(10) + .thenExecute(() => void a.rpc.request(a.id, 'echo', 42).then((r) => { reply = r; })) + .thenIdle(20) + .thenExecute(() => { + if (reply !== 42) { test.fail(`self-RPC failed: expected 42, got ${String(reply)}`); } + + a.stop(); + }) + .thenSucceed(); +}); + +// A shared tree replicates between runtimes: the owner writes, a peer reads it typed and hears +// the change, and nothing a peer writes into the owner's namespace is applied anywhere. +gametest('shared_replication', (test) => { + const a = new Runtime(); + const { shared: own } = a.register({ + manifest: { creator: 'test', pack: 'shared_a', packName: 'A', version: '1.0.0' }, + shared: registerShared({ volume: 5, event: { name: 'none', active: false } }), + }); + const b = new Runtime(); + + b.register({ manifest: { creator: 'test', pack: 'shared_b', packName: 'B', version: '1.0.0' } }); + + const seen: number[] = []; + + own.volume.set(7); + test.startSequence() + .thenIdle(20) + .thenExecute(() => { + const mirror = b.shared.of<{ volume: number; event: { name: string; active: boolean } }>(a.namespace); + + if (mirror === undefined) { + test.fail('B never saw the shape'); + + return; + } + + if (mirror.volume.get() !== 7) { test.fail('volume did not replicate to B'); } + + // An object key travels whole. + if (mirror.event.get()?.name !== 'none') { test.fail(`an object key did not replicate: ${JSON.stringify(mirror.event.get())}`); } + + mirror.volume.subscribe((next) => { if (next !== undefined) { seen.push(next); } }); + + // A peer reaching past its read-only tree to the mirror itself changes nothing. + b.node.state.set(a.namespace, 'volume', 1); + own.event.set({ name: 'race', active: true }); + own.volume.set(9); + }) + .thenIdle(20) + .thenExecute(() => { + if (own.volume.get() !== 9) { test.fail(`a peer write reached the owner: ${String(own.volume.get())}`); } + + const mirror = b.shared.of<{ volume: number; event: { name: string; active: boolean } }>(a.namespace); + + if (mirror?.event.get()?.name !== 'race') { test.fail('the owner write did not reach the peer'); } + + if (seen.length !== 1 || seen[0] !== 9) { test.fail(`the peer's subscriber saw ${JSON.stringify(seen)}, expected [9]`); } + + a.stop(); + b.stop(); + }) + .thenSucceed(); +}); + +// Same creator, different addon → distinct namespaces → coexist. Identical namespace → collision. +gametest('distinct_vs_collision', (test) => { + const x = new Runtime(); + + x.register({ manifest: { creator: 'test', pack: 'dup_a', packName: 'X', version: '1.0.0' } }); + const y = new Runtime(); + + y.register({ manifest: { creator: 'test', pack: 'dup_b', packName: 'Y', version: '1.0.0' } }); + + const c1 = new Runtime(); + + c1.register({ manifest: { creator: 'test', pack: 'clash_same', packName: 'First', version: '1.0.0' } }); + let collided = false; + + c1.registry.onNamespaceCollision(() => { collided = true; }); + const c2 = new Runtime(); + + c2.register({ manifest: { creator: 'test', pack: 'clash_same', packName: 'Second', version: '1.0.0' } }); + + test.startSequence() + .thenIdle(30) + .thenExecute(() => { + if (x.id === y.id) { test.fail('distinct namespaces must yield distinct ids'); } + + if (!x.registry.has(y.id)) { test.fail('X should see Y as an ordinary peer'); } + + if (!collided) { test.fail('identical namespaces should report a collision'); } + + for (const r of [x, y, c1, c2]) { r.stop(); } + }) + .thenSucceed(); +}); + +// A feature enables only once its required namespace is present. +gametest('feature_toggle', (test) => { + const consumer = new Runtime(); + + consumer.register({ manifest: { creator: 'test', pack: 'game_main', packName: 'Game', version: '1.0.0', optionalDependencies: ['test_lb_main'] } }); + + let enabled = 0; + + consumer.features.add('lb-sync', { condition: r => r.registry.has('test_lb_main'), onEnable: () => enabled++, onDisable: () => { /* noop */ } }); + + const provider = new Runtime(); + + test.startSequence() + .thenIdle(20) + .thenExecute(() => { + if (enabled !== 0) { test.fail('feature enabled before its provider was present'); } + + provider.register({ manifest: { creator: 'test', pack: 'lb_main', packName: 'Leaderboard', version: '1.0.0' } }); + }) + .thenIdle(20) + .thenExecute(() => { + if (enabled !== 1) { test.fail('feature did not enable when provider appeared'); } + + consumer.stop(); + provider.stop(); + }) + .thenSucceed(); +}); + +// Cross-pack: the peer pack must be registered with our live core. This is the only test +// that needs two real packs installed; every other test builds its runtimes in this realm. +gametest('cross_pack_peer_present', (test) => { + test.startSequence() + .thenIdle(40) + .thenExecute(() => { + if (!core.registry.has('core_fixture_peer')) { + test.fail('peer pack not present — is test-fixture-peer installed and enabled?'); + } + }) + .thenSucceed(); +}); + +// db is local: a peer asking for a collection over rpc gets nothing, because nothing serves it. +// What crosses is what the owner registered itself — an rpc method over its own documents, with +// the player rule in front of it and its own event behind. +gametest('rpc_over_local_db', (test) => { + const owner = new Runtime(); + + owner.register({ manifest: { creator: 'test', pack: 'rpc_a', packName: 'A', version: '1.0.0' } }); + + const peer = new Runtime(); + + peer.register({ manifest: { creator: 'test', pack: 'rpc_b', packName: 'B', version: '1.0.0' } }); + + const notes = owner.db.collection('notes', { + schema: schema<{ text: string; seen: number }>({ defaults: { text: '', seen: 0 } }), + }); + const dimension = test.getDimension(); + + notes.for(dimension).set({ text: 'hello', seen: 1 }); + + interface NotesApi { + note(params: { dimId: string; actorId?: string }): { text: string; seen: number } | undefined; + markSeen(params: { dimId: string; actorId?: string }): { text: string; seen: number } | undefined; + } + + owner.rpc.serve({ + note: ({ dimId, actorId }) => { + authorize({ dimension: dimId }, actorId, 'read'); + + return notes.for(world.getDimension(dimId)).get(); + }, + markSeen: ({ dimId, actorId }) => { + authorize({ dimension: dimId }, actorId, 'write'); + + const doc = notes.for(world.getDimension(dimId)); + + doc.patch({ seen: (doc.get()?.seen ?? 0) + 1 }); + + return doc.get(); + }, + }); + + const client = peer.rpc.typed(owner.id); + let read: unknown; + let written: unknown; + let unserved: string | undefined; + + test.startSequence() + .thenIdle(20) + .thenExecute(() => { + void client.note({ dimId: dimension.id }).then((r) => { read = r; }); + void client.markSeen({ dimId: dimension.id }).then((r) => { written = r; }); + // The collection itself is not on the wire: db serves nobody. + void peer.rpc.request(owner.id, 'core:db.get', { collection: 'notes' }) + .catch((error: unknown) => { unserved = String(error); }); + }) + .thenIdle(30) + .thenExecute(() => { + if (JSON.stringify(read) !== JSON.stringify({ text: 'hello', seen: 1 })) { + test.fail(`peer read ${JSON.stringify(read)}`); + } + + // The reply carries the document after the write, so no second round trip is needed. + if (JSON.stringify(written) !== JSON.stringify({ text: 'hello', seen: 2 })) { + test.fail(`write replied ${JSON.stringify(written)}`); + } + + if (notes.for(dimension).get()?.seen !== 2) { test.fail('the owner document did not change'); } + + if (unserved === undefined || !unserved.includes('unknown method')) { + test.fail(`db answered a peer directly: ${String(unserved)}`); + } + + owner.stop(); + peer.stop(); + }) + .thenSucceed(); +}); + +// Events cross realms and are kept by nobody: the owner hears its own, a peer that subscribed +// before the owner existed hears it too, and a listener attached afterwards has missed it. +gametest('events_broadcast', (test) => { + const a = new Runtime(); + const b = new Runtime(); + const heard: string[] = []; + const late: string[] = []; + + b.register({ manifest: { creator: 'test', pack: 'events_b', packName: 'B', version: '1.0.0' } }); + + // Before A has registered at all: an event missed is missed for good, so this must attach now. + b.events.of<{ purchase: ReturnType> }>('test_events_a') + .purchase.subscribe(({ item }, from) => { heard.push(`${from}:${item}`); }); + + const { events: own } = a.register({ + manifest: { creator: 'test', pack: 'events_a', packName: 'A', version: '1.0.0' }, + events: registerEvents({ purchase: event<{ item: string }>() }), + }); + + own.purchase.subscribe(({ item }) => { heard.push(`self:${item}`); }); + + test.startSequence() + .thenIdle(20) + .thenExecute(() => { own.purchase.emit({ item: 'sword' }); }) + .thenIdle(10) + .thenExecute(() => { + // A listener attached now has missed the one already announced. + b.events.of<{ purchase: ReturnType> }>('test_events_a') + .purchase.subscribe(({ item }) => { late.push(item); }); + + if (heard.length !== 2) { test.fail(`heard ${JSON.stringify(heard)}, expected the owner and the peer`); } + + if (!heard.includes('self:sword')) { test.fail('the owner did not hear its own event'); } + + if (!heard.includes('test_events_a:sword')) { test.fail(`the peer did not hear it, or named the wrong sender: ${JSON.stringify(heard)}`); } + + if (late.length !== 0) { test.fail('an event was replayed to a late listener'); } + + own.purchase.emit({ item: 'shield' }); + }) + .thenIdle(10) + .thenExecute(() => { + if (late.length !== 1 || late[0] !== 'shield') { test.fail(`the late listener saw ${JSON.stringify(late)}`); } + + a.stop(); + b.stop(); + }) + .thenSucceed(); +}); diff --git a/packages/test-fixtures/main/packs/BP/scripts/tests/sync.ts b/packages/test-fixtures/main/packs/BP/scripts/tests/sync.ts new file mode 100644 index 0000000..93865bd --- /dev/null +++ b/packages/test-fixtures/main/packs/BP/scripts/tests/sync.ts @@ -0,0 +1,253 @@ +/** + * GameTests for `@bedrock-core/sync` — what only the engine's own transport can answer. + * + * `packages/sync/test` drives the bus through a fake script-event channel, so framing, + * reassembly and packing are proven against sync's idea of the engine. These tests put the same + * traffic on the real channel: a payload larger than one message, the same payload in characters + * the engine may count in bytes rather than UTF-16 units, a burst that has to pack, a mirror + * built from a snapshot rather than a delta, and one round trip that leaves this script realm + * for the peer pack and comes back. + * + * The single-realm tests in `./index` cover discovery, RPC and replication between runtimes that + * share a heap; nothing here duplicates them. + */ +import { system } from '@minecraft/server'; +import { type Test, register } from '@minecraft/server-gametest'; +import { Bus, MAX_MESSAGE } from '@bedrock-core/sync'; +import { Runtime, core, registerShared } from '@bedrock-core/server-runtime'; + +const STRUCTURE = 'core:empty'; + +function gametest(name: string, fn: (test: Test) => void): void { + register('core', name, fn).structureName(STRUCTURE).tag('core').maxTicks(400); +} + +/** + * A payload of roughly `chars` characters made of `unit`, shaped like the nested JSON addons + * exchange rather than one long string, so the encode and the split are representative. + */ +function payload(chars: number, unit: string): { ns: string; rows: { id: number; text: string }[] } { + const rows: { id: number; text: string }[] = []; + let length = 2; + + while (length < chars) { + const row = { id: rows.length, text: unit.repeat(8) }; + + length += JSON.stringify(row).length + 1; + rows.push(row); + } + + return { ns: 'fixture', rows }; +} + +/** A cheap checksum over a value's JSON, so a reassembly that lost or reordered a frame shows. */ +function checksum(value: unknown): number { + const json = JSON.stringify(value); + let sum = 0; + + for (let i = 0; i < json.length; i++) { sum = (sum * 31 + json.charCodeAt(i)) % 2_147_483_647; } + + return sum; +} + +/** Count the messages one node puts on the bus channel: every frame carries its instance id. */ +function wireProbe(instanceId: string, count: () => void): () => void { + const handler = system.afterEvents.scriptEventReceive.subscribe( + (event) => { if (event.message.includes(instanceId)) { count(); } }, + { namespaces: ['bedrock-core'] }, + ); + + return (): void => system.afterEvents.scriptEventReceive.unsubscribe(handler); +} + +/** + * A payload larger than one script-event message crosses whole. The frame count is asserted too: + * a cap that grew would make this a single message and quietly stop testing reassembly. + */ +gametest('sync_chunked_roundtrip', (test) => { + const sender = new Bus('sync_chunk_a', { instanceId: 'fixture-chunk-a' }); + const receiver = new Bus('sync_chunk_b', { instanceId: 'fixture-chunk-b' }); + const value = payload(16_000, 'a'); + let received: unknown; + let messages = 0; + + sender.start(); + receiver.start(); + receiver.on('fixture-chunked', (envelope) => { received = envelope.data; }); + + const stopProbe = wireProbe('fixture-chunk-a', () => { messages++; }); + + test.startSequence() + .thenIdle(10) + .thenExecute(() => { sender.send({ type: 'fixture-chunked', data: value }); }) + .thenIdle(40) + .thenExecute(() => { + stopProbe(); + sender.stop(); + receiver.stop(); + + if (received === undefined) { + test.fail(`a ${JSON.stringify(value).length}-character payload never arrived`); + + return; + } + + if (checksum(received) !== checksum(value)) { test.fail('the payload was reassembled wrong'); } + + if (messages < 2) { test.fail(`the payload went out in ${messages} message(s) — it is no longer being split`); } + }) + .thenSucceed(); +}); + +/** + * The same, in characters that are one UTF-16 unit and three UTF-8 bytes. sync budgets a message + * in string length; if the engine's cap is really bytes, every frame here is three times over it + * and nothing arrives — which is a bug in the library, not in the test. + */ +gametest('sync_unicode_roundtrip', (test) => { + const sender = new Bus('sync_uni_a', { instanceId: 'fixture-uni-a' }); + const receiver = new Bus('sync_uni_b', { instanceId: 'fixture-uni-b' }); + const value = payload(MAX_MESSAGE * 3, '漢'); + let received: unknown; + + sender.start(); + receiver.start(); + receiver.on('fixture-unicode', (envelope) => { received = envelope.data; }); + + test.startSequence() + .thenIdle(10) + .thenExecute(() => { sender.send({ type: 'fixture-unicode', data: value }); }) + .thenIdle(40) + .thenExecute(() => { + sender.stop(); + receiver.stop(); + + if (received === undefined) { + test.fail('a non-ASCII payload never arrived — the engine is not counting the same units sync is'); + + return; + } + + if (checksum(received) !== checksum(value)) { test.fail('a non-ASCII payload was reassembled wrong'); } + }) + .thenSucceed(); +}); + +/** + * A burst of small messages is packed into fewer script events than it has envelopes. Packing is + * negotiated — a sender only packs for readers that announced they can unpack — so this runs + * between two runtimes rather than two bare buses, which would never have met. + */ +gametest('sync_batch_packing', (test) => { + const a = new Runtime(); + const b = new Runtime(); + + a.register({ manifest: { creator: 'test', pack: 'pack_burst_a', packName: 'A', version: '1.0.0' } }); + b.register({ manifest: { creator: 'test', pack: 'pack_burst_b', packName: 'B', version: '1.0.0' } }); + const BURST = 40; + let delivered = 0; + let messages = 0; + + let stopProbe = (): void => {}; + + b.node.bus.on('fixture-packed', () => { delivered++; }); + + test.startSequence() + .thenIdle(30) + .thenExecute(() => { + if (!a.registry.has(b.id)) { test.fail('the two runtimes never met, so nothing could be negotiated'); } + + // Counted from here, so the announces that did the negotiating are not in the total. + stopProbe = wireProbe(a.node.bus.instanceId, () => { messages++; }); + + for (let i = 0; i < BURST; i++) { a.node.bus.send({ type: 'fixture-packed', data: { key: `price:${i}`, value: 16 + i } }); } + }) + .thenIdle(40) + .thenExecute(() => { + stopProbe(); + a.stop(); + b.stop(); + + if (delivered !== BURST) { test.fail(`${delivered} of ${BURST} envelopes arrived`); } + + if (messages >= BURST) { test.fail(`${BURST} envelopes went out in ${messages} messages — nothing was packed`); } + }) + .thenSucceed(); +}); + +/** + * A node that starts after everything was already said builds its mirror from a snapshot, not + * from deltas it never heard. This is the path every addon takes that is enabled mid-world. + */ +gametest('sync_late_joiner_snapshot', (test) => { + const owner = new Runtime(); + const { shared: own } = owner.register({ + manifest: { creator: 'test', pack: 'late_owner', packName: 'Owner', version: '1.0.0' }, + shared: registerShared({ volume: 1, motd: 'quiet' }), + }); + let late: Runtime | undefined; + + own.volume.set(11); + own.motd.set('loud'); + + test.startSequence() + .thenIdle(30) + .thenExecute(() => { + // Every delta is long gone by the time this node exists. + late = new Runtime(); + late.register({ manifest: { creator: 'test', pack: 'late_joiner', packName: 'Joiner', version: '1.0.0' } }); + }) + .thenIdle(40) + .thenExecute(() => { + const mirror = late?.shared.of<{ volume: number; motd: string }>(owner.namespace); + + if (mirror === undefined) { + test.fail('the late node never saw the shape'); + } else if (mirror.volume.get() !== 11 || mirror.motd.get() !== 'loud') { + test.fail(`the snapshot rebuilt ${JSON.stringify({ volume: mirror.volume.get(), motd: mirror.motd.get() })}`); + } + + owner.stop(); + late?.stop(); + }) + .thenSucceed(); +}); + +/** + * The only round trip that leaves this script realm: a chunked request to the peer pack, whose + * `echo` answers with what it reassembled. Two packs, two QuickJS heaps, one wire — the case an + * in-realm test cannot reach, since both ends there share a heap and a protocol by construction. + */ +gametest('cross_pack_rpc_chunked', (test) => { + const value = payload(8_000, 'b'); + const expected = { chars: JSON.stringify(value).length, sum: checksum(value) }; + let reply: unknown; + let failure: string | undefined; + + test.startSequence() + .thenIdle(40) + .thenExecute(() => { + if (!core.registry.has('core_fixture_peer')) { + test.fail('peer pack not present — is core-server-fixture-peer installed and enabled?'); + + return; + } + + void core.rpc.request('core_fixture_peer', 'echo', value) + .then((r) => { reply = r; }) + .catch((error: unknown) => { failure = String(error); }); + }) + .thenIdle(60) + .thenExecute(() => { + if (failure !== undefined) { + test.fail(`the peer pack refused the request: ${failure}`); + + return; + } + + if (JSON.stringify(reply) !== JSON.stringify(expected)) { + test.fail(`the peer pack answered ${JSON.stringify(reply)}, expected ${JSON.stringify(expected)}`); + } + }) + .thenSucceed(); +}); diff --git a/packages/test-addon-2/packs/BP/structures/bc/empty.mcstructure b/packages/test-fixtures/main/packs/BP/structures/core/empty.mcstructure similarity index 100% rename from packages/test-addon-2/packs/BP/structures/bc/empty.mcstructure rename to packages/test-fixtures/main/packs/BP/structures/core/empty.mcstructure diff --git a/packages/test-fixtures/main/packs/RP/manifest.json b/packages/test-fixtures/main/packs/RP/manifest.json new file mode 100644 index 0000000..120e499 --- /dev/null +++ b/packages/test-fixtures/main/packs/RP/manifest.json @@ -0,0 +1,30 @@ +{ + "format_version": 3, + "header": { + "name": "Bedrock Core fixture resources", + "description": "GameTest fixture for the Bedrock Core server runtime", + "uuid": "c8ab693a-6740-4f01-a832-c340370c088b", + "pack_scope": "world", + "version": "1.0.0", + "min_engine_version": "1.26.50" + }, + "modules": [ + { + "type": "resources", + "uuid": "c5d9048b-4c74-4f4d-af8a-7ab1e4ac2d60", + "version": "1.0.0" + } + ], + "dependencies": [ + { + "uuid": "bf9fb8cc-414b-4b92-aae6-9365d1916edb", + "version": "1.0.0" + } + ], + "metadata": { + "product_type": "addon", + "authors": [ + "DrAv0011" + ] + } +} diff --git a/packages/test-addon-2/packs/RP/texts/en_US.lang b/packages/test-fixtures/main/packs/RP/texts/en_US.lang similarity index 100% rename from packages/test-addon-2/packs/RP/texts/en_US.lang rename to packages/test-fixtures/main/packs/RP/texts/en_US.lang diff --git a/packages/test-addon-2/packs/RP/texts/languages.json b/packages/test-fixtures/main/packs/RP/texts/languages.json similarity index 100% rename from packages/test-addon-2/packs/RP/texts/languages.json rename to packages/test-fixtures/main/packs/RP/texts/languages.json diff --git a/packages/test-addon-2/packs/data/guides/guides.generated.d.ts b/packages/test-fixtures/main/packs/data/guides/guides.generated.d.ts similarity index 100% rename from packages/test-addon-2/packs/data/guides/guides.generated.d.ts rename to packages/test-fixtures/main/packs/data/guides/guides.generated.d.ts diff --git a/packages/test-addon/packs/data/i18n/en_US.ts b/packages/test-fixtures/main/packs/data/i18n/en_US.ts similarity index 100% rename from packages/test-addon/packs/data/i18n/en_US.ts rename to packages/test-fixtures/main/packs/data/i18n/en_US.ts diff --git a/packages/test-addon-2/packs/data/i18n/i18n.generated.d.ts b/packages/test-fixtures/main/packs/data/i18n/i18n.generated.d.ts similarity index 70% rename from packages/test-addon-2/packs/data/i18n/i18n.generated.d.ts rename to packages/test-fixtures/main/packs/data/i18n/i18n.generated.d.ts index 97a835e..b3a9731 100644 --- a/packages/test-addon-2/packs/data/i18n/i18n.generated.d.ts +++ b/packages/test-fixtures/main/packs/data/i18n/i18n.generated.d.ts @@ -1,12 +1,11 @@ // Auto-generated by the i18n Regolith filter. Do not edit — regenerated every build. declare module '@bedrock-core/generated/i18n' { type Own = typeof import('./en_US').default; - type Lib_core = typeof import('@bedrock-core/config/i18n/en_US').default & typeof import('@bedrock-core/guides/i18n/en_US').default; const bundle: { readonly namespace: string; readonly defaultLocale: 'en_US'; - readonly libs: readonly ['core']; + readonly libs: readonly []; /** locale → flat path → template ({{var}} form; vanilla entries only where referenced) */ readonly locales: Record>; /** flat path → interpolation argument order (default locale appearance order) */ @@ -14,10 +13,7 @@ declare module '@bedrock-core/generated/i18n' { /** locale → REAL key → value: .lang passthrough (guides, hand-written) for measurement */ readonly extra: Record>; /** Type-only: the tree the t()/key()/raw() selectors navigate. Absent at runtime. */ - readonly resources?: Omit & { - readonly core: Lib_core; - readonly vanilla: import('@bedrock-core/generated/i18n-vanilla').VanillaResources; - }; + readonly resources?: Own; }; export default bundle; } diff --git a/packages/test-addon-2/packs/data/i18n/vanilla.generated.d.ts b/packages/test-fixtures/main/packs/data/i18n/vanilla.generated.d.ts similarity index 100% rename from packages/test-addon-2/packs/data/i18n/vanilla.generated.d.ts rename to packages/test-fixtures/main/packs/data/i18n/vanilla.generated.d.ts diff --git a/packages/test-fixtures/main/packs/data/ui/ui.generated.ts b/packages/test-fixtures/main/packs/data/ui/ui.generated.ts new file mode 100644 index 0000000..ed2153f --- /dev/null +++ b/packages/test-fixtures/main/packs/data/ui/ui.generated.ts @@ -0,0 +1,9 @@ +// Placeholder. The `ui-compiler` filter replaces this in the build workspace +// with one `registerCompiledScreen` call per compiled screen — the guide pages +// the guides filter writes as screen modules; what is committed here is only +// what the editor and `tsc` read before Regolith has ever run, the same +// arrangement the i18n and guides bundles use. +// +// Importing it is what turns compiled screens on, and what makes +// `uiReference()` find the compiled screens to publish. +export {}; diff --git a/packages/test-addon/tsconfig.json b/packages/test-fixtures/main/tsconfig.json similarity index 69% rename from packages/test-addon/tsconfig.json rename to packages/test-fixtures/main/tsconfig.json index 6008976..be90cbd 100644 --- a/packages/test-addon/tsconfig.json +++ b/packages/test-fixtures/main/tsconfig.json @@ -6,7 +6,7 @@ "declarationMap": false, "sourceMap": true, "inlineSources": true, - "rootDir": "../../..", + "rootDir": "../../../..", "stripInternal": true, "strict": true, "moduleResolution": "bundler", @@ -20,20 +20,12 @@ "noImplicitReturns": true, "allowJs": true, "resolveJsonModule": true, - "jsx": "react-jsx", - "jsxImportSource": "@bedrock-core/ui", "paths": { - "@bedrock-core/ui/jsx-runtime": [ - "../../../ui/src/jsx/jsx-runtime.ts" - ], - "@bedrock-core/ui/jsx-dev-runtime": [ - "../../../ui/src/jsx/jsx-dev-runtime.ts" - ], "@bedrock-core/generated/i18n": [ "./packs/data/i18n/i18n.generated.json" ], - "@bedrock-core/generated/guides": [ - "./packs/data/guides/guides.generated.json" + "@bedrock-core/generated/ui": [ + "./packs/data/ui/ui.generated.ts" ] } }, @@ -43,7 +35,7 @@ ], "include": [ "packs/BP/scripts/**/*", - "packs/data/**/*.d.ts", + "packs/data/**/*.d.ts" ], "exclude": [ "node_modules", diff --git a/packages/test-fixtures/main/tsconfig.test.json b/packages/test-fixtures/main/tsconfig.test.json new file mode 100644 index 0000000..0ea7f7a --- /dev/null +++ b/packages/test-fixtures/main/tsconfig.test.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "files": [ + "packs/BP/scripts/gametest.ts" + ], + "exclude": [ + "node_modules", + "eslint.config.mjs" + ] +} diff --git a/packages/test-fixtures/package.json b/packages/test-fixtures/package.json new file mode 100644 index 0000000..0aa20a5 --- /dev/null +++ b/packages/test-fixtures/package.json @@ -0,0 +1,35 @@ +{ + "name": "@bedrock-core/server-test-fixtures", + "version": "1.0.1", + "private": true, + "description": "GameTest fixtures for the Bedrock Core server runtime: the test pack and the peer it discovers", + "scripts": { + "regolith-install": "cd main && regolith install-all && cd ../peer && regolith install-all", + "build": "cd main && regolith run build && cd ../peer && regolith run build", + "build:test": "cd main && regolith run build-test && cd ../peer && regolith run build-test", + "watch": "cd main && regolith watch", + "watch:test": "cd main && regolith watch test", + "lint": "eslint .", + "loopback": "CheckNetIsolation.exe LoopbackExempt -a -p=S-1-15-2-1958404141-86561845-1752920682-3514627264-368642714-62675701-733520436", + "loopback:preview": "CheckNetIsolation.exe LoopbackExempt -a -p=S-1-15-2-424268864-5579737-879501358-346833251-474568803-887069379-4040235476" + }, + "dependencies": { + "@bedrock-core/db": "workspace:^", + "@bedrock-core/server-runtime": "workspace:^", + "@bedrock-core/sync": "workspace:^", + "@minecraft/server": "*", + "@minecraft/server-gametest": "*", + "@minecraft/server-ui": "*" + }, + "devDependencies": { + "@eslint/js": "*", + "@eslint/json": "*", + "@stylistic/eslint-plugin": "*", + "eslint": "*", + "eslint-plugin-minecraft-linting": "*", + "globals": "*", + "typescript": "*", + "typescript-eslint": "*" + }, + "packageManager": "yarn@4.9.4" +} diff --git a/packages/test-fixtures/peer/config.json b/packages/test-fixtures/peer/config.json new file mode 100644 index 0000000..290342e --- /dev/null +++ b/packages/test-fixtures/peer/config.json @@ -0,0 +1,119 @@ +{ + "$schema": "https://raw.githubusercontent.com/Bedrock-OSS/regolith-schemas/main/config/v1.4.json", + "author": "DrAv0011", + "description": "Second pack for the cross-pack discovery test", + "name": "core-server-fixture-peer", + "packs": { + "behaviorPack": "./packs/BP", + "resourcePack": "./packs/RP" + }, + "regolith": { + "dataPath": "./packs/data", + "filterDefinitions": { + "core": { + "url": "github.com/bedrock-core/regolith-filters", + "version": "1.0.0" + }, + "manifest": { + "url": "github.com/bedrock-core/regolith-filters", + "version": "2.0.0" + }, + "generator": { + "url": "github.com/bedrock-core/regolith-filters", + "version": "1.2.0" + }, + "guides": { + "url": "github.com/bedrock-core/regolith-filters", + "version": "1.2.0" + }, + "i18n": { + "url": "github.com/bedrock-core/regolith-filters", + "version": "1.2.0" + }, + "ui-compiler": { + "url": "github.com/bedrock-core/regolith-filters", + "version": "0.1.0" + }, + "bundler": { + "url": "github.com/bedrock-core/regolith-filters", + "version": "1.2.0" + } + }, + "formatVersion": "1.4.0", + "profiles": { + "default": { + "export": { + "build": "standard", + "readOnly": false, + "target": "development" + }, + "filters": [ + { + "filter": "core", + "settings": { + "shared": { + "namespace": "core_fixture_peer", + "pretty": { + "indent": "tab" + } + }, + "guides": false, + "ui-compiler": false, + "bundler": { + "debug": true + } + } + } + ] + }, + "build": { + "export": { + "build": "standard", + "readOnly": false, + "target": "local" + }, + "filters": [ + { + "filter": "core", + "settings": { + "shared": { + "namespace": "core_fixture_peer", + "pretty": false + }, + "guides": false, + "ui-compiler": false, + "bundler": { + "debug": true + } + } + } + ] + }, + "build-test": { + "export": { + "build": "standard", + "bpPath": "./build/test/BP", + "readOnly": false, + "rpPath": "./build/test/RP", + "target": "exact" + }, + "filters": [ + { + "filter": "core", + "settings": { + "shared": { + "namespace": "core_fixture_peer", + "pretty": false + }, + "guides": false, + "ui-compiler": false, + "bundler": { + "debug": true + } + } + } + ] + } + } + } +} diff --git a/packages/test-fixtures/peer/packs/BP/manifest.json b/packages/test-fixtures/peer/packs/BP/manifest.json new file mode 100644 index 0000000..b6ccd0b --- /dev/null +++ b/packages/test-fixtures/peer/packs/BP/manifest.json @@ -0,0 +1,45 @@ +{ + "format_version": 3, + "header": { + "name": "Bedrock Core fixture peer", + "description": "Second pack for the cross-pack discovery test", + "uuid": "6b83adc1-ffae-4081-bc77-d0c7663cad33", + "pack_scope": "world", + "version": "1.0.0", + "min_engine_version": "1.26.50" + }, + "modules": [ + { + "type": "data", + "uuid": "9f3f0254-caad-409f-a01c-43162b8626b2", + "version": "1.0.0" + }, + { + "type": "script", + "language": "javascript", + "uuid": "0a401278-1cde-4b16-8f5e-52708bd21924", + "entry": "scripts/main.js", + "version": "1.0.0" + } + ], + "dependencies": [ + { + "uuid": "e78b0f04-7c7a-4d45-8470-4b60b382eeaf", + "version": "1.0.0" + }, + { + "module_name": "@minecraft/server", + "version": "2.10.0" + }, + { + "module_name": "@minecraft/server-ui", + "version": "2.2.0" + } + ], + "metadata": { + "product_type": "addon", + "authors": [ + "DrAv0011" + ] + } +} diff --git a/packages/test-fixtures/peer/packs/BP/scripts/addon.ts b/packages/test-fixtures/peer/packs/BP/scripts/addon.ts new file mode 100644 index 0000000..1fac089 --- /dev/null +++ b/packages/test-fixtures/peer/packs/BP/scripts/addon.ts @@ -0,0 +1,13 @@ +/** + * The peer pack's identity. It exists only so that `cross_pack_peer_present` can + * assert one real pack discovers another across pack boundaries — the one case the + * single-realm tests cannot cover. + */ +export const manifest = { + creator: 'core', + pack: 'fixture_peer', + packName: 'Bedrock Core fixture peer', + creatorName: 'Bedrock Core', + version: '1.0.0', + description: 'Second pack for the cross-pack discovery test', +} as const; diff --git a/packages/test-fixtures/peer/packs/BP/scripts/main.ts b/packages/test-fixtures/peer/packs/BP/scripts/main.ts new file mode 100644 index 0000000..06b4537 --- /dev/null +++ b/packages/test-fixtures/peer/packs/BP/scripts/main.ts @@ -0,0 +1,21 @@ +/** + * Registers, and serves the one method the other pack calls. Everything this pack is for happens + * in the other pack's tests: being discovered across a pack boundary, and answering a request + * large enough to have crossed the wire in pieces. + */ +import { core } from '@bedrock-core/server-runtime'; +import { manifest } from './addon'; + +core.register({ manifest }); + +/** The same checksum the caller computes, over what this realm reassembled. */ +function checksum(value: unknown): number { + const json = JSON.stringify(value); + let sum = 0; + + for (let i = 0; i < json.length; i++) { sum = (sum * 31 + json.charCodeAt(i)) % 2_147_483_647; } + + return sum; +} + +core.rpc.onRequest('echo', params => ({ chars: JSON.stringify(params).length, sum: checksum(params) })); diff --git a/packages/test-fixtures/peer/packs/RP/manifest.json b/packages/test-fixtures/peer/packs/RP/manifest.json new file mode 100644 index 0000000..52f07e5 --- /dev/null +++ b/packages/test-fixtures/peer/packs/RP/manifest.json @@ -0,0 +1,30 @@ +{ + "format_version": 3, + "header": { + "name": "Bedrock Core fixture peer resources", + "description": "Second pack for the cross-pack discovery test", + "uuid": "e78b0f04-7c7a-4d45-8470-4b60b382eeaf", + "pack_scope": "world", + "version": "1.0.0", + "min_engine_version": "1.26.50" + }, + "modules": [ + { + "type": "resources", + "uuid": "9d9432e9-19cd-4ada-bf30-046c305c6636", + "version": "1.0.0" + } + ], + "dependencies": [ + { + "uuid": "6b83adc1-ffae-4081-bc77-d0c7663cad33", + "version": "1.0.0" + } + ], + "metadata": { + "product_type": "addon", + "authors": [ + "DrAv0011" + ] + } +} diff --git a/packages/test-addon/packs/RP/texts/en_US.lang b/packages/test-fixtures/peer/packs/RP/texts/en_US.lang similarity index 100% rename from packages/test-addon/packs/RP/texts/en_US.lang rename to packages/test-fixtures/peer/packs/RP/texts/en_US.lang diff --git a/packages/test-addon/packs/RP/texts/languages.json b/packages/test-fixtures/peer/packs/RP/texts/languages.json similarity index 100% rename from packages/test-addon/packs/RP/texts/languages.json rename to packages/test-fixtures/peer/packs/RP/texts/languages.json diff --git a/packages/test-addon/packs/data/guides/guides.generated.d.ts b/packages/test-fixtures/peer/packs/data/guides/guides.generated.d.ts similarity index 100% rename from packages/test-addon/packs/data/guides/guides.generated.d.ts rename to packages/test-fixtures/peer/packs/data/guides/guides.generated.d.ts diff --git a/packages/test-addon-2/packs/data/i18n/en_US.ts b/packages/test-fixtures/peer/packs/data/i18n/en_US.ts similarity index 100% rename from packages/test-addon-2/packs/data/i18n/en_US.ts rename to packages/test-fixtures/peer/packs/data/i18n/en_US.ts diff --git a/packages/test-addon/packs/data/i18n/i18n.generated.d.ts b/packages/test-fixtures/peer/packs/data/i18n/i18n.generated.d.ts similarity index 70% rename from packages/test-addon/packs/data/i18n/i18n.generated.d.ts rename to packages/test-fixtures/peer/packs/data/i18n/i18n.generated.d.ts index 97a835e..b3a9731 100644 --- a/packages/test-addon/packs/data/i18n/i18n.generated.d.ts +++ b/packages/test-fixtures/peer/packs/data/i18n/i18n.generated.d.ts @@ -1,12 +1,11 @@ // Auto-generated by the i18n Regolith filter. Do not edit — regenerated every build. declare module '@bedrock-core/generated/i18n' { type Own = typeof import('./en_US').default; - type Lib_core = typeof import('@bedrock-core/config/i18n/en_US').default & typeof import('@bedrock-core/guides/i18n/en_US').default; const bundle: { readonly namespace: string; readonly defaultLocale: 'en_US'; - readonly libs: readonly ['core']; + readonly libs: readonly []; /** locale → flat path → template ({{var}} form; vanilla entries only where referenced) */ readonly locales: Record>; /** flat path → interpolation argument order (default locale appearance order) */ @@ -14,10 +13,7 @@ declare module '@bedrock-core/generated/i18n' { /** locale → REAL key → value: .lang passthrough (guides, hand-written) for measurement */ readonly extra: Record>; /** Type-only: the tree the t()/key()/raw() selectors navigate. Absent at runtime. */ - readonly resources?: Omit & { - readonly core: Lib_core; - readonly vanilla: import('@bedrock-core/generated/i18n-vanilla').VanillaResources; - }; + readonly resources?: Own; }; export default bundle; } diff --git a/packages/test-addon/packs/data/i18n/vanilla.generated.d.ts b/packages/test-fixtures/peer/packs/data/i18n/vanilla.generated.d.ts similarity index 100% rename from packages/test-addon/packs/data/i18n/vanilla.generated.d.ts rename to packages/test-fixtures/peer/packs/data/i18n/vanilla.generated.d.ts diff --git a/packages/test-fixtures/peer/packs/data/ui/ui.generated.ts b/packages/test-fixtures/peer/packs/data/ui/ui.generated.ts new file mode 100644 index 0000000..3faee40 --- /dev/null +++ b/packages/test-fixtures/peer/packs/data/ui/ui.generated.ts @@ -0,0 +1,7 @@ +// Placeholder. The `ui-compiler` filter replaces this in the build workspace +// with one `registerCompiledScreen` call per compiled screen; what is +// committed here is only what the editor and `tsc` read before Regolith has +// ever run, the same arrangement the i18n and guides bundles use. +// +// Importing it is what turns compiled screens on. +export {}; diff --git a/packages/test-addon-2/tsconfig.json b/packages/test-fixtures/peer/tsconfig.json similarity index 71% rename from packages/test-addon-2/tsconfig.json rename to packages/test-fixtures/peer/tsconfig.json index 698e45d..2ae0b40 100644 --- a/packages/test-addon-2/tsconfig.json +++ b/packages/test-fixtures/peer/tsconfig.json @@ -18,20 +18,12 @@ "skipLibCheck": true, "esModuleInterop": true, "forceConsistentCasingInFileNames": true, - "jsx": "react-jsx", - "jsxImportSource": "@bedrock-core/ui", "paths": { - "@bedrock-core/ui/jsx-runtime": [ - "../../../ui/src/jsx/jsx-runtime.ts" - ], - "@bedrock-core/ui/jsx-dev-runtime": [ - "../../../ui/src/jsx/jsx-dev-runtime.ts" - ], "@bedrock-core/generated/i18n": [ "./packs/data/i18n/i18n.generated.json" ], - "@bedrock-core/generated/guides": [ - "./packs/data/guides/guides.generated.json" + "@bedrock-core/generated/ui": [ + "./packs/data/ui/ui.generated.ts" ] } }, diff --git a/scripts/bench-report.mjs b/scripts/bench-report.mjs new file mode 100644 index 0000000..b36da62 --- /dev/null +++ b/scripts/bench-report.mjs @@ -0,0 +1,99 @@ +/** + * Turns a `bc-bds run --tag bench` transcript into tables. + * + * The runner's own report is a verdict per test, which is the right shape for a suite that is + * red or green and the wrong shape for one that produces numbers. The benchmarks print their + * results as `BENCH {json}` lines instead, and the server console carries script output verbatim, + * so the transcript the runner already writes is the data channel — no second mechanism, and the + * numbers stay next to the log lines that produced them. + * + * Usage: + * node scripts/bench-report.mjs [path/to/log] + * + * With no argument it reads the newest `bench` transcript under the runner's log directory. + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +/** Matches the marker anywhere in a line, since the engine prefixes script output with its own tags. */ +const BENCH_LINE = /BENCH (\{.*\})\s*$/; + +function logsDir() { + return process.env.BC_BDS_HOME + ? path.resolve(process.env.BC_BDS_HOME, 'logs') + : path.join(repoRoot, '.bds', 'logs'); +} + +function newestBenchLog() { + const dir = logsDir(); + + if (!fs.existsSync(dir)) { return undefined; } + + const candidates = fs.readdirSync(dir) + .filter(name => name.endsWith('-bench.log')) + .map(name => path.join(dir, name)) + .sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs); + + return candidates[0]; +} + +function parse(transcript) { + const results = []; + + for (const line of transcript.split(/\r?\n/)) { + const match = BENCH_LINE.exec(line); + + if (!match) { continue; } + + try { + results.push(JSON.parse(match[1])); + } catch { + process.stderr.write(`skipping unparseable BENCH line: ${line}\n`); + } + } + + return results; +} + +/** Group by the `name` each benchmark reports under, preserving first-seen order. */ +function groupByName(results) { + const groups = new Map(); + + for (const { name, ...row } of results) { + if (!groups.has(name)) { groups.set(name, []); } + + groups.get(name).push(row); + } + + return groups; +} + +const logFile = process.argv[2] ?? newestBenchLog(); + +if (!logFile) { + process.stderr.write(`no bench transcript found in ${logsDir()} — run \`yarn test:mc:bench\` first\n`); + process.exit(2); +} + +if (!fs.existsSync(logFile)) { + process.stderr.write(`no such transcript: ${logFile}\n`); + process.exit(2); +} + +const results = parse(fs.readFileSync(logFile, 'utf8')); + +if (results.length === 0) { + process.stderr.write(`no BENCH lines in ${logFile}\n`); + process.stderr.write('the suite may have failed before reporting — check the transcript\n'); + process.exit(1); +} + +process.stdout.write(`${path.relative(repoRoot, logFile)}\n`); + +for (const [name, rows] of groupByName(results)) { + process.stdout.write(`\n${name}\n`); + console.table(rows); +} diff --git a/scripts/clean.mjs b/scripts/clean.mjs new file mode 100644 index 0000000..d62e82e --- /dev/null +++ b/scripts/clean.mjs @@ -0,0 +1,65 @@ +/** + * Removes what an install or a build regenerates in this repository: dependency trees, addon + * builds, coverage reports, and the unpacked Bedrock server. + * + * yarn clean + * + * `.bds/cache` is kept: it holds the downloaded server archive that `.bds/server` is + * unpacked from, so removing the tree costs an unzip rather than a several-hundred-megabyte + * download. CI caches that directory for the same reason. + * + * Sources, configuration and anything git tracks are never touched. + */ +import { existsSync, readdirSync, rmSync } from 'node:fs'; +import { dirname, join, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +/** Directory names a tool here rewrites from scratch, wherever they appear. */ +const DISPOSABLE = new Set(['node_modules', 'dist', 'build', 'coverage', '.nyc_output', '.cache']); + +/** File suffixes that are incremental-build caches rather than sources. */ +const DISPOSABLE_SUFFIX = ['.tsbuildinfo']; + +/** Single paths, relative to the repository root, that the sweep would otherwise miss. */ +const PATHS = ['.bds/server', '.bds/logs']; + +/** Never descended into: it holds the repository itself, not anything regenerable. */ +const SKIP = new Set(['.git']); + +const root = join(dirname(fileURLToPath(import.meta.url)), '..'); + +let removed = 0; + +function drop(target) { + rmSync(target, { recursive: true, force: true }); + console.log(`removed ${relative(root, target)}`); + removed++; +} + +/** Depth-first, and a disposable directory is dropped whole rather than walked into. */ +function sweep(dir) { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const target = join(dir, entry.name); + + if (entry.isDirectory()) { + if (SKIP.has(entry.name)) continue; + + if (DISPOSABLE.has(entry.name)) { drop(target); continue; } + + sweep(target); + continue; + } + + if (DISPOSABLE_SUFFIX.some((suffix) => entry.name.endsWith(suffix))) drop(target); + } +} + +for (const rel of PATHS) { + const target = join(root, rel); + + if (existsSync(target)) drop(target); +} + +sweep(root); + +console.log(removed === 0 ? 'nothing to remove' : `${removed} ${removed === 1 ? 'path' : 'paths'} removed`); diff --git a/scripts/publish-tarballs.mjs b/scripts/publish-tarballs.mjs index 460cb69..ab4472f 100644 --- a/scripts/publish-tarballs.mjs +++ b/scripts/publish-tarballs.mjs @@ -13,6 +13,11 @@ // partial release (or after a manual bootstrap publish) is safe — the same job // `--tolerate-republish` did in the all-yarn flow. // +// `0.0.0` is the placeholder a package that has never shipped sits at, and it is +// skipped too: a package reaches its first release by having its version set by +// hand, because a changeset describes the delta between two released versions +// and there is no first one to diff against. +// // Usage: node scripts/publish-tarballs.mjs [excluded-package-name ...] // node scripts/publish-tarballs.mjs --only import { execFileSync } from 'node:child_process'; @@ -38,11 +43,11 @@ const rows = run('yarn', ['workspaces', 'list', '--json', '-v']) const byLocation = new Map(rows.map(row => [row.location, row])); const pending = rows.filter((row) => { - // `--only` may name the root workspace — in the ui repo the ROOT is the meta - // package. The default sweep still skips it: the meta is released separately, - // after its sub-packages, by the workflow step that owns the .mcpack. + // The root is included when it is a package in its own right — a repo whose root + // is the meta, or a single-package repo. The topological sort below puts it after + // everything it depends on, so one sweep releases the whole repo in order. A + // private root, as in the apps workspace, drops out on the `private` check. if (only !== undefined) return row.name === only; - if (row.location === '.') return false; const manifest = JSON.parse(readFileSync(join(row.location, 'package.json'), 'utf-8')); return manifest.private !== true && !exclude.has(row.name); }); @@ -68,6 +73,11 @@ for (const row of ordered) { const { name } = row; const { version } = JSON.parse(readFileSync(join(row.location, 'package.json'), 'utf-8')); + if (version === '0.0.0') { + console.log(`skip ${name}@${version} — unreleased placeholder; set its version to publish it`); + continue; + } + let exists = false; try { run('npm', ['view', `${name}@${version}`, 'version'], { stdio: ['ignore', 'pipe', 'ignore'] }); diff --git a/scripts/sync-meta-version.mjs b/scripts/sync-meta-version.mjs new file mode 100644 index 0000000..9718ed3 --- /dev/null +++ b/scripts/sync-meta-version.mjs @@ -0,0 +1,69 @@ +#!/usr/bin/env node +/** + * Version the root `@bedrock-core/server` package. + * + * Changesets only manages the `packages/*` workspaces — the repo root, which is the meta package + * itself, is invisible to it — so the meta's version is set here, immediately after + * `changeset version`. + * + * The rule: **the meta's version IS `@bedrock-core/server-runtime`'s**, character for character, + * prerelease tag included. The runtime is what the meta is; `db`, `i18n`, `observable` and `sync` are + * support around it. So `@bedrock-core/server@0.2.0` is `@bedrock-core/server-runtime@0.2.0`, and + * a consumer reading either number is reading the same one. + * + * A release the runtime does not move leaves the meta where it is: what shipped was a package the + * meta curates, and the curated set is republished with the runtime that next moves. + * + * The `workspace:*` dependency ranges are left untouched; `publish-tarballs.mjs` resolves them to + * concrete versions at pack time. + * + * Idempotent: re-running with the meta already on the runtime's version is a no-op. + */ +import { readFileSync, writeFileSync } from 'node:fs'; + +const META_PATH = 'package.json'; +const META_CHANGELOG = 'CHANGELOG.md'; + +/** The package whose version the meta's version *is*. */ +const RUNTIME_PATH = 'packages/server-runtime/package.json'; + +/** Everything the meta curates, in `packages/

` form — what its changelog entry lists. */ +const META_DEP_DIRS = ['db', 'i18n', 'observable', 'server-runtime', 'sync']; + +/** Matches the manifest's version field, capturing the quoted value so only it is replaced. */ +const VERSION_FIELD = /("version"\s*:\s*")([^"]*)(")/; + +const currentVersion = (path) => JSON.parse(readFileSync(path, 'utf8')).version; +const nameOf = (path) => JSON.parse(readFileSync(path, 'utf8')).name; + +const written = currentVersion(META_PATH); +const next = currentVersion(RUNTIME_PATH); + +if (next === written) { + console.log(`sync-meta-version: @bedrock-core/server already ${written} — no change.`); + process.exit(0); +} + +const manifest = readFileSync(META_PATH, 'utf8'); + +if (!VERSION_FIELD.test(manifest)) { + throw new Error(`sync-meta-version: could not find the version field in ${META_PATH}`); +} + +// Tabs — this manifest is tab-indented; a targeted replace preserves that. +writeFileSync(META_PATH, manifest.replace(VERSION_FIELD, `$1${next}$3`)); +console.log(`sync-meta-version: @bedrock-core/server ${written} → ${next}, matching @bedrock-core/server-runtime.`); + +// The meta's changelog is what it curates, so write the entry `changeset version` cannot. +const pinned = META_DEP_DIRS + .map(dir => ` - ${nameOf(`packages/${dir}/package.json`)}@${currentVersion(`packages/${dir}/package.json`)}`) + .join('\n'); +const entry = `## ${next}\n\n### Patch Changes\n\n- Curates:\n\n${pinned}\n\n`; +const changelog = readFileSync(META_CHANGELOG, 'utf8'); +const firstEntry = changelog.indexOf('## '); + +writeFileSync( + META_CHANGELOG, + firstEntry === -1 ? `${changelog.trimEnd()}\n\n${entry}` : changelog.slice(0, firstEntry) + entry + changelog.slice(firstEntry), +); +console.log(`sync-meta-version: ${META_CHANGELOG} entry for ${next}`); diff --git a/scripts/tag-packages.mjs b/scripts/tag-packages.mjs new file mode 100644 index 0000000..7d39039 --- /dev/null +++ b/scripts/tag-packages.mjs @@ -0,0 +1,61 @@ +#!/usr/bin/env node +/** + * Tag every public package at its current version. + * + * Changesets Action reads JSONL events from CHANGESETS_OUTPUT when a custom + * publish script is used. Reporting each new tag here lets the action push the + * tags and create the corresponding GitHub releases, including the root meta + * package that Changesets does not manage directly. + * + * Pass `--dry-run` to list missing tags without creating them. + */ +import { execFileSync } from 'node:child_process'; +import { appendFileSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +const shell = process.platform === 'win32'; +const dryRun = process.argv.includes('--dry-run'); + +function run(command, args, options = {}) { + return execFileSync(command, args, { encoding: 'utf8', shell, ...options }); +} + +const workspaces = run('yarn', ['workspaces', 'list', '--json']) + .trim() + .split('\n') + .filter(Boolean) + .map(line => JSON.parse(line)); + +for (const workspace of workspaces) { + const manifest = JSON.parse(readFileSync(join(workspace.location, 'package.json'), 'utf8')); + + if (manifest.private === true || manifest.version === '0.0.0') continue; + + const tag = `${manifest.name}@${manifest.version}`; + let exists = true; + + try { + run('git', ['rev-parse', '-q', '--verify', `refs/tags/${tag}`], { stdio: 'ignore' }); + } catch { + exists = false; + } + + if (exists) { + console.log(`tag ${tag} - already exists`); + continue; + } + if (dryRun) { + console.log(`tag ${tag} - would create`); + continue; + } + + run('git', ['tag', tag], { stdio: 'inherit' }); + console.log(`tag ${tag}`); + + if (process.env.CHANGESETS_OUTPUT) { + appendFileSync( + process.env.CHANGESETS_OUTPUT, + `${JSON.stringify({ type: 'git-tag', tag, packageName: manifest.name })}\n`, + ); + } +} diff --git a/src/db.ts b/src/db.ts new file mode 100644 index 0000000..3312fd4 --- /dev/null +++ b/src/db.ts @@ -0,0 +1,9 @@ +/** + * `@bedrock-core/server/db` — persisted documents on dynamic properties. + * + * Re-exports `@bedrock-core/db`. An addon rarely needs this subpath: `core.db` is already an + * instance keyed under the addon's namespace, and the runtime re-exports what a collection is + * declared with (`schema`, the acceptors and combinators, the errors). Reach here for the rest — + * the resolver, the host and capability types, the index. + */ +export * from '@bedrock-core/db'; diff --git a/src/i18n.ts b/src/i18n.ts new file mode 100644 index 0000000..a46467f --- /dev/null +++ b/src/i18n.ts @@ -0,0 +1,8 @@ +/** + * `@bedrock-core/server/i18n` — typed translations. + * + * Re-exports `@bedrock-core/i18n` (`createI18n`, the verbs and the display helpers) from the + * package every addon already depends on. It is the same module `core.register()` reads the + * translation bundle from, so the instance an addon creates is the one the runtime publishes. + */ +export * from '@bedrock-core/i18n'; diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..b586ee3 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,23 @@ +/** + * `@bedrock-core/server` — the meta package for the bedrock-core server stack. + * + * A single dependency that curates the matching versions of the server packages and + * re-exports the framework runtime. Most addons only need: + * + * ```ts + * import { core } from '@bedrock-core/server'; + * + * const config = core.register({ manifest: { creator: 'ms', pack: 'shop', packName: 'My Shop', version: '1.0.0' } }); + * ``` + * + * The packages the runtime is built on are each at their own subpath, for when you reach + * past `core` to the thing itself: + * + * - `@bedrock-core/server/sync` — the transport: bus, discovery, RPC, replicated state. + * - `@bedrock-core/server/db` — persisted documents. `core.db` is one of these already, and + * the runtime re-exports what a collection is *declared* with, so this subpath is for the + * rest of the surface. + * - `@bedrock-core/server/observable` — the reactive primitive every accessor in the stack is, + * `toNative` included: the bridge to a data-driven form's own observables. + */ +export * from '@bedrock-core/server-runtime'; diff --git a/src/observable.ts b/src/observable.ts new file mode 100644 index 0000000..3adb89c --- /dev/null +++ b/src/observable.ts @@ -0,0 +1,22 @@ +/** + * `@bedrock-core/server/observable` — the reactive primitive the stack notifies through. + * + * Re-exports `@bedrock-core/observable` and its Minecraft bridge. Every config leaf, shared key and + * db document already *is* one of these, so `observable`, `computed`, `effect`, `batch` and `last` + * compose with them directly, and `toNative` hands one to a data-driven form: + * + * ```ts + * import { computed, last, toNative } from '@bedrock-core/server/observable'; + * + * const total = computed(() => shared.price.get() * shared.stock.get(), [shared.price, shared.stock]); + * const lastJoin = last(world.afterEvents.playerSpawn); + * + * const { native: volume, dispose } = toNative(config.player.for(p).volume, { clientWritable: true }); + * new CustomForm(p, 'Settings').slider('Volume', volume, 0, 100).show().then(dispose); + * ``` + * + * The bridge is the only part that imports `@minecraft/server-ui`, so this subpath carries it as an + * optional peer dependency: a pack that reaches for `toNative` declares the module in its manifest. + */ +export * from '@bedrock-core/observable'; +export * from '@bedrock-core/observable/minecraft'; diff --git a/packages/server/src/sync.ts b/src/sync.ts similarity index 100% rename from packages/server/src/sync.ts rename to src/sync.ts diff --git a/tsconfig.eslint.json b/tsconfig.eslint.json index 968386f..c7fcc2d 100644 --- a/tsconfig.eslint.json +++ b/tsconfig.eslint.json @@ -4,6 +4,7 @@ "noEmit": true }, "include": [ + "src/**/*.ts", "packages/*/src/**/*.ts", "packages/*/src/**/*.spec.ts", "packages/*/src/**/*.test.ts", diff --git a/yarn.lock b/yarn.lock index 1508848..bb80515 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5,87 +5,107 @@ __metadata: version: 8 cacheKey: 10c0 -"@babel/runtime@npm:^7.5.5": +"@babel/helper-string-parser@npm:^7.29.7": version: 7.29.7 - resolution: "@babel/runtime@npm:7.29.7" - checksum: 10c0/ca11572f7146b21e0bde6a9ed4bb6a89eafbee5f0944c7eb54d0d8a2dac962c33638a1d611e14faa71dfbb92b4b5f9236232208568a6b7d5c6f3f39ddb91771e + resolution: "@babel/helper-string-parser@npm:7.29.7" + checksum: 10c0/194bc0f1716e396d5ffde56ad6119745fb9557662c98611590e5e454906783a4ccb21ce93056b8eb69a4909044834e45d96e50ac695bbe9e3221648fe033c06c languageName: node linkType: hard -"@bedrock-core/config@portal:../ui/packages/config::locator=%40bedrock-core%2Fserver-monorepo%40workspace%3A.": - version: 0.0.0-use.local - resolution: "@bedrock-core/config@portal:../ui/packages/config::locator=%40bedrock-core%2Fserver-monorepo%40workspace%3A." - peerDependencies: - "@bedrock-core/guides": "*" - "@bedrock-core/i18n": "*" - "@bedrock-core/navigation": "*" - "@bedrock-core/ore-styled": "*" - "@bedrock-core/server-runtime": "*" - "@bedrock-core/ui-runtime": "*" - "@minecraft/server": "*" +"@babel/helper-validator-identifier@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/helper-validator-identifier@npm:7.29.7" + checksum: 10c0/4795354e7ae0dcafa72de1cd04ec51252dc1498517170beaf019e03effc5b7bf13c6b21a3949a77e07b8125be7f106ed1131350d8ebd4566ae874094a726d62b languageName: node - linkType: soft + linkType: hard -"@bedrock-core/flexbox@portal:../ui/packages/flexbox::locator=%40bedrock-core%2Fserver-monorepo%40workspace%3A.": - version: 0.0.0-use.local - resolution: "@bedrock-core/flexbox@portal:../ui/packages/flexbox::locator=%40bedrock-core%2Fserver-monorepo%40workspace%3A." +"@babel/parser@npm:^7.29.7": + version: 7.29.8 + resolution: "@babel/parser@npm:7.29.8" + dependencies: + "@babel/types": "npm:^7.29.8" + bin: + parser: ./bin/babel-parser.js + checksum: 10c0/acc890c5e6a6dd40863a47b50bac111d7185ee6fbbe163ebe11d5214854ca2adb901462ad4d718a65090ef84bd2230e9e8ab45a2e0caccc685f1f57ab0bb1e28 languageName: node - linkType: soft + linkType: hard -"@bedrock-core/guides@portal:../ui/packages/guides::locator=%40bedrock-core%2Fserver-monorepo%40workspace%3A.": - version: 0.0.0-use.local - resolution: "@bedrock-core/guides@portal:../ui/packages/guides::locator=%40bedrock-core%2Fserver-monorepo%40workspace%3A." - peerDependencies: - "@bedrock-core/navigation": "*" - "@bedrock-core/ore-styled": "*" - "@bedrock-core/ui-runtime": "*" +"@babel/types@npm:^7.29.7, @babel/types@npm:^7.29.8": + version: 7.29.8 + resolution: "@babel/types@npm:7.29.8" + dependencies: + "@babel/helper-string-parser": "npm:^7.29.7" + "@babel/helper-validator-identifier": "npm:^7.29.7" + checksum: 10c0/be7c279f0abf2a086c633e21b49c7ca80275d05283cc5a268b67a708c9914bd0c944f1422b3eb3cb37682a2af5d560abf520ccf9b01b53ecbfe6b71fbc3fdde6 languageName: node - linkType: soft + linkType: hard -"@bedrock-core/i18n@npm:^0.1.0": +"@bcoe/v8-coverage@npm:^1.0.2": + version: 1.0.2 + resolution: "@bcoe/v8-coverage@npm:1.0.2" + checksum: 10c0/1eb1dc93cc17fb7abdcef21a6e7b867d6aa99a7ec88ec8207402b23d9083ab22a8011213f04b2cf26d535f1d22dc26139b7929e6c2134c254bd1e14ba5e678c3 + languageName: node + linkType: hard + +"@bedrock-core/bds-runner@npm:^0.1.0": version: 0.1.0 - resolution: "@bedrock-core/i18n@npm:0.1.0" - peerDependencies: - "@minecraft/server": ">=2.8.0" - checksum: 10c0/b79f6d71a001831d7fc481a3a080e52cbd3d826646dd497907714dcf03750fbbf8f175508a2704502a01bf0523103016c76fd34527584b680bb7ee2e4a99a6d4 + resolution: "@bedrock-core/bds-runner@npm:0.1.0" + dependencies: + jiti: "npm:^2.7.0" + prismarine-nbt: "npm:^2.7.0" + yauzl: "npm:^3.2.0" + bin: + bc-bds: bin/bc-bds.mjs + checksum: 10c0/963172c97f9b94aef827e5ec82f7db7ae7060aa2cb297edbee05b5391d5af871b311daae3b09d81aaa5fb600aea599e7c41c153e7f331af03aff7cec39b8c264 languageName: node linkType: hard -"@bedrock-core/navigation@portal:../ui/packages/navigation::locator=%40bedrock-core%2Fserver-monorepo%40workspace%3A.": +"@bedrock-core/db@workspace:*, @bedrock-core/db@workspace:^, @bedrock-core/db@workspace:packages/db": version: 0.0.0-use.local - resolution: "@bedrock-core/navigation@portal:../ui/packages/navigation::locator=%40bedrock-core%2Fserver-monorepo%40workspace%3A." + resolution: "@bedrock-core/db@workspace:packages/db" + dependencies: + "@bedrock-core/observable": "workspace:^" + "@minecraft/server": "npm:*" + "@stylistic/eslint-plugin": "npm:*" + eslint: "npm:*" + typescript: "npm:*" + typescript-eslint: "npm:*" + vitest: "npm:*" peerDependencies: - "@bedrock-core/ui-runtime": "*" - languageName: node + "@minecraft/server": ">=2.9.0 || >=2.10.0-0" + languageName: unknown linkType: soft -"@bedrock-core/ore-styled@portal:../ui/packages/ore-styled::locator=%40bedrock-core%2Fserver-monorepo%40workspace%3A.": +"@bedrock-core/i18n@workspace:*, @bedrock-core/i18n@workspace:^, @bedrock-core/i18n@workspace:packages/i18n": version: 0.0.0-use.local - resolution: "@bedrock-core/ore-styled@portal:../ui/packages/ore-styled::locator=%40bedrock-core%2Fserver-monorepo%40workspace%3A." - dependencies: - "@bedrock-core/i18n": "workspace:*" + resolution: "@bedrock-core/i18n@workspace:packages/i18n" + dependencies: + "@minecraft/server": "npm:*" + "@stylistic/eslint-plugin": "npm:*" + eslint: "npm:*" + typescript: "npm:*" + typescript-eslint: "npm:*" + vitest: "npm:*" peerDependencies: - "@bedrock-core/ui-runtime": "*" - languageName: node + "@minecraft/server": ">=2.9.0 || >=2.10.0-0" + languageName: unknown linkType: soft -"@bedrock-core/server-monorepo@workspace:.": +"@bedrock-core/observable@workspace:*, @bedrock-core/observable@workspace:^, @bedrock-core/observable@workspace:packages/observable": version: 0.0.0-use.local - resolution: "@bedrock-core/server-monorepo@workspace:." - dependencies: - "@changesets/changelog-github": "npm:^0.7.0" - "@changesets/cli": "npm:^2.31.0" - "@eslint/js": "npm:^10.0.1" - "@eslint/json": "npm:^2.0.0" - "@stylistic/eslint-plugin": "npm:^5.10.0" - "@types/node": "npm:^26.0.1" - concurrently: "npm:^10.0.3" - eslint: "npm:^10.5.0" - globals: "npm:^17.7.0" - jiti: "npm:^2.7.0" - nodemon: "npm:^3.1.14" - typescript: "npm:^6.0.3" - typescript-eslint: "npm:^8.62.0" + resolution: "@bedrock-core/observable@workspace:packages/observable" + dependencies: + "@minecraft/server-ui": "npm:*" + "@stylistic/eslint-plugin": "npm:*" + eslint: "npm:*" + typescript: "npm:*" + typescript-eslint: "npm:*" + vitest: "npm:*" + peerDependencies: + "@minecraft/server-ui": ">=2.1.0" + peerDependenciesMeta: + "@minecraft/server-ui": + optional: true languageName: unknown linkType: soft @@ -93,81 +113,75 @@ __metadata: version: 0.0.0-use.local resolution: "@bedrock-core/server-runtime@workspace:packages/server-runtime" dependencies: - "@bedrock-core/i18n": "npm:^0.1.0" + "@bedrock-core/db": "workspace:^" + "@bedrock-core/i18n": "workspace:^" + "@bedrock-core/observable": "workspace:^" "@bedrock-core/sync": "workspace:^" - "@minecraft/server": "npm:2.8.0" - "@stylistic/eslint-plugin": "npm:^5.10.0" - eslint: "npm:^10.5.0" - typescript: "npm:^6.0.3" - typescript-eslint: "npm:^8.62.0" + "@minecraft/server": "npm:*" + "@stylistic/eslint-plugin": "npm:*" + eslint: "npm:*" + typescript: "npm:*" + typescript-eslint: "npm:*" peerDependencies: - "@minecraft/server": ">=2.8.0" + "@minecraft/server": ">=2.9.0 || >=2.10.0-0" languageName: unknown linkType: soft -"@bedrock-core/server-test-addon-2@workspace:packages/test-addon-2": +"@bedrock-core/server-test-fixtures@workspace:packages/test-fixtures": version: 0.0.0-use.local - resolution: "@bedrock-core/server-test-addon-2@workspace:packages/test-addon-2" + resolution: "@bedrock-core/server-test-fixtures@workspace:packages/test-fixtures" dependencies: - "@bedrock-core/config": "npm:*" - "@bedrock-core/i18n": "npm:*" + "@bedrock-core/db": "workspace:^" "@bedrock-core/server-runtime": "workspace:^" "@bedrock-core/sync": "workspace:^" - "@bedrock-core/ui": "npm:^0.9.1" - "@eslint/js": "npm:^10.0.1" - "@eslint/json": "npm:^2.0.0" - "@minecraft/server": "npm:2.8.0" - "@minecraft/server-ui": "npm:2.1.0" - "@stylistic/eslint-plugin": "npm:^5.10.0" - eslint: "npm:^10.5.0" - eslint-plugin-minecraft-linting: "npm:^2.0.12" - globals: "npm:^17.7.0" - typescript: "npm:^6.0.3" - typescript-eslint: "npm:^8.62.0" + "@eslint/js": "npm:*" + "@eslint/json": "npm:*" + "@minecraft/server": "npm:*" + "@minecraft/server-gametest": "npm:*" + "@minecraft/server-ui": "npm:*" + "@stylistic/eslint-plugin": "npm:*" + eslint: "npm:*" + eslint-plugin-minecraft-linting: "npm:*" + globals: "npm:*" + typescript: "npm:*" + typescript-eslint: "npm:*" languageName: unknown linkType: soft -"@bedrock-core/server-test-addon@workspace:packages/test-addon": +"@bedrock-core/server@workspace:.": version: 0.0.0-use.local - resolution: "@bedrock-core/server-test-addon@workspace:packages/test-addon" + resolution: "@bedrock-core/server@workspace:." dependencies: - "@bedrock-core/config": "npm:*" - "@bedrock-core/i18n": "npm:*" - "@bedrock-core/server-runtime": "workspace:^" - "@bedrock-core/sync": "workspace:^" - "@bedrock-core/ui": "npm:^0.9.1" + "@bedrock-core/bds-runner": "npm:^0.1.0" + "@bedrock-core/db": "workspace:*" + "@bedrock-core/i18n": "workspace:*" + "@bedrock-core/observable": "workspace:*" + "@bedrock-core/server-runtime": "workspace:*" + "@bedrock-core/sync": "workspace:*" + "@changesets/changelog-github": "npm:^0.7.0" + "@changesets/cli": "npm:3.0.3" "@eslint/js": "npm:^10.0.1" "@eslint/json": "npm:^2.0.0" - "@minecraft/common": "npm:1.3.0" - "@minecraft/math": "npm:2.4.0" - "@minecraft/server": "npm:2.8.0" - "@minecraft/server-gametest": "npm:1.0.0-beta.1.21.111-stable" - "@minecraft/server-ui": "npm:2.1.0" - "@minecraft/vanilla-data": "npm:1.26.31" + "@minecraft/server": "npm:*" + "@minecraft/server-ui": "npm:*" + "@minecraft/vanilla-data": "npm:*" "@stylistic/eslint-plugin": "npm:^5.10.0" - "@types/uuid": "npm:^11.0.0" + "@types/node": "npm:^26.0.1" + "@vitest/coverage-v8": "npm:^4.1.10" + concurrently: "npm:^10.0.3" eslint: "npm:^10.5.0" - eslint-plugin-minecraft-linting: "npm:^2.0.12" globals: "npm:^17.7.0" + jiti: "npm:^2.7.0" + nodemon: "npm:^3.1.14" typescript: "npm:^6.0.3" typescript-eslint: "npm:^8.62.0" - uuid: "npm:^14.0.1" - languageName: unknown - linkType: soft - -"@bedrock-core/server@workspace:packages/server": - version: 0.0.0-use.local - resolution: "@bedrock-core/server@workspace:packages/server" - dependencies: - "@bedrock-core/server-runtime": "workspace:*" - "@bedrock-core/sync": "workspace:*" - "@minecraft/server": "npm:2.8.0" - "@stylistic/eslint-plugin": "npm:^5.10.0" - eslint: "npm:^10.5.0" - typescript: "npm:^6.0.3" - typescript-eslint: "npm:^8.62.0" + vitest: "npm:^4.1.10" peerDependencies: - "@minecraft/server": ">=2.8.0" + "@minecraft/server": ">=2.9.0 || >=2.10.0-0" + "@minecraft/server-ui": ">=2.1.0" + peerDependenciesMeta: + "@minecraft/server-ui": + optional: true languageName: unknown linkType: soft @@ -175,86 +189,53 @@ __metadata: version: 0.0.0-use.local resolution: "@bedrock-core/sync@workspace:packages/sync" dependencies: - "@minecraft/server": "npm:2.8.0" - "@stylistic/eslint-plugin": "npm:^5.10.0" - eslint: "npm:^10.5.0" - typescript: "npm:^6.0.3" - typescript-eslint: "npm:^8.62.0" + "@bedrock-core/observable": "workspace:^" + "@minecraft/server": "npm:*" + "@stylistic/eslint-plugin": "npm:*" + eslint: "npm:*" + typescript: "npm:*" + typescript-eslint: "npm:*" + vitest: "npm:*" peerDependencies: - "@minecraft/server": ">=2.8.0" + "@minecraft/server": ">=2.9.0 || >=2.10.0-0" languageName: unknown linkType: soft -"@bedrock-core/ui-runtime@portal:../ui/packages/ui-runtime::locator=%40bedrock-core%2Fserver-monorepo%40workspace%3A.": - version: 0.0.0-use.local - resolution: "@bedrock-core/ui-runtime@portal:../ui/packages/ui-runtime::locator=%40bedrock-core%2Fserver-monorepo%40workspace%3A." - dependencies: - "@bedrock-core/flexbox": "workspace:*" - "@bedrock-core/i18n": "workspace:*" - peerDependencies: - "@minecraft/server": "*" - "@minecraft/server-ui": "*" - languageName: node - linkType: soft - -"@bedrock-core/ui@portal:../ui::locator=%40bedrock-core%2Fserver-monorepo%40workspace%3A.": - version: 0.0.0-use.local - resolution: "@bedrock-core/ui@portal:../ui::locator=%40bedrock-core%2Fserver-monorepo%40workspace%3A." - dependencies: - "@bedrock-core/config": "workspace:*" - "@bedrock-core/flexbox": "workspace:*" - "@bedrock-core/guides": "workspace:*" - "@bedrock-core/i18n": "workspace:*" - "@bedrock-core/navigation": "workspace:*" - "@bedrock-core/ore-styled": "workspace:*" - "@bedrock-core/ui-runtime": "workspace:*" - peerDependencies: - "@minecraft/server": "*" - "@minecraft/server-ui": "*" - languageName: node - linkType: soft - -"@changesets/apply-release-plan@npm:^7.1.1": - version: 7.1.1 - resolution: "@changesets/apply-release-plan@npm:7.1.1" +"@changesets/apply-release-plan@npm:^8.1.1": + version: 8.1.1 + resolution: "@changesets/apply-release-plan@npm:8.1.1" dependencies: - "@changesets/config": "npm:^3.1.4" - "@changesets/get-version-range-type": "npm:^0.4.0" - "@changesets/git": "npm:^3.0.4" - "@changesets/should-skip-package": "npm:^0.1.2" - "@changesets/types": "npm:^6.1.0" - "@manypkg/get-packages": "npm:^1.1.3" - detect-indent: "npm:^6.0.0" - fs-extra: "npm:^7.0.1" - lodash.startcase: "npm:^4.4.0" - outdent: "npm:^0.5.0" - prettier: "npm:^2.7.1" - resolve-from: "npm:^5.0.0" - semver: "npm:^7.5.3" - checksum: 10c0/27de184e74e8e48b43fca1f73e7c7a2887b0cdacfe7ba9c09cdc4547dff0de1587bed5fe2d5ec0a3754fa422f6b8a528e0ac452c22ac7a6ae5211f5ac089bfb2 + "@changesets/config": "npm:^4.0.1" + "@changesets/format": "npm:^0.1.2" + "@changesets/git": "npm:^4.0.1" + "@changesets/should-skip-package": "npm:^1.0.0" + "@changesets/types": "npm:^7.0.0" + import-meta-resolve: "npm:^4.2.0" + jsonc-parser: "npm:^3.3.1" + semver: "npm:^7.8.1" + checksum: 10c0/2a1cfb9e4e3feb2c76bcec646e116ebebc81271fed21a3e77d5f170963f88347a34e5d1474f8b87258448182cfa651c047282c8b1a741613a7f7d3fefc2eb9a2 languageName: node linkType: hard -"@changesets/assemble-release-plan@npm:^6.0.10": - version: 6.0.10 - resolution: "@changesets/assemble-release-plan@npm:6.0.10" +"@changesets/assemble-release-plan@npm:^7.0.0": + version: 7.0.0 + resolution: "@changesets/assemble-release-plan@npm:7.0.0" dependencies: - "@changesets/errors": "npm:^0.2.0" - "@changesets/get-dependents-graph": "npm:^2.1.4" - "@changesets/should-skip-package": "npm:^0.1.2" - "@changesets/types": "npm:^6.1.0" - "@manypkg/get-packages": "npm:^1.1.3" - semver: "npm:^7.5.3" - checksum: 10c0/a0ea336a5f19f8d0a97b684983bcd9c3bb8d6881b7b6abd5b482b301795ae4600924c188982f5f98dc48ac88e94a063b66ab72659041eb2623ade3e35f05d555 + "@changesets/errors": "npm:^1.0.0" + "@changesets/get-dependents-graph": "npm:^3.0.0" + "@changesets/should-skip-package": "npm:^1.0.0" + "@changesets/types": "npm:^7.0.0" + semver: "npm:^7.8.1" + checksum: 10c0/2544759583889865487aec744e948c6d84206a42aa593430436ace83003fe00d3880ef623114c396cd5fd883eccb1e03fc4a719ba5c33038a8556df8fca4b9b7 languageName: node linkType: hard -"@changesets/changelog-git@npm:^0.2.1": - version: 0.2.1 - resolution: "@changesets/changelog-git@npm:0.2.1" +"@changesets/changelog-git@npm:^1.0.0": + version: 1.0.0 + resolution: "@changesets/changelog-git@npm:1.0.0" dependencies: - "@changesets/types": "npm:^6.1.0" - checksum: 10c0/6a6fb315ffb2266fcb8f32ae9a60ccdb5436e52350a2f53beacf9822d3355f9052aba5001a718e12af472b4a8fabd69b408d0b11c02ac909ba7a183d27a9f7fd + "@changesets/types": "npm:^7.0.0" + checksum: 10c0/f0f0ab31e6e6ac35ecdae46a521d4c84aab77bbb035898e364d8f9082391b2b8064ba33d9f97d4f1526195f8278f41910c72382b12fcc0092d5bb9ff3ae05310 languageName: node linkType: hard @@ -269,76 +250,74 @@ __metadata: languageName: node linkType: hard -"@changesets/cli@npm:^2.31.0": - version: 2.31.0 - resolution: "@changesets/cli@npm:2.31.0" - dependencies: - "@changesets/apply-release-plan": "npm:^7.1.1" - "@changesets/assemble-release-plan": "npm:^6.0.10" - "@changesets/changelog-git": "npm:^0.2.1" - "@changesets/config": "npm:^3.1.4" - "@changesets/errors": "npm:^0.2.0" - "@changesets/get-dependents-graph": "npm:^2.1.4" - "@changesets/get-release-plan": "npm:^4.0.16" - "@changesets/git": "npm:^3.0.4" - "@changesets/logger": "npm:^0.1.1" - "@changesets/pre": "npm:^2.0.2" - "@changesets/read": "npm:^0.6.7" - "@changesets/should-skip-package": "npm:^0.1.2" - "@changesets/types": "npm:^6.1.0" - "@changesets/write": "npm:^0.4.0" - "@inquirer/external-editor": "npm:^1.0.2" - "@manypkg/get-packages": "npm:^1.1.3" - ansi-colors: "npm:^4.1.3" - enquirer: "npm:^2.4.1" - fs-extra: "npm:^7.0.1" - mri: "npm:^1.2.0" - package-manager-detector: "npm:^0.2.0" - picocolors: "npm:^1.1.0" - resolve-from: "npm:^5.0.0" - semver: "npm:^7.5.3" - spawndamnit: "npm:^3.0.1" - term-size: "npm:^2.1.0" +"@changesets/cli@npm:3.0.3": + version: 3.0.3 + resolution: "@changesets/cli@npm:3.0.3" + dependencies: + "@changesets/apply-release-plan": "npm:^8.1.1" + "@changesets/assemble-release-plan": "npm:^7.0.0" + "@changesets/changelog-git": "npm:^1.0.0" + "@changesets/config": "npm:^4.0.1" + "@changesets/errors": "npm:^1.0.0" + "@changesets/get-dependents-graph": "npm:^3.0.0" + "@changesets/git": "npm:^4.0.1" + "@changesets/pre": "npm:^3.0.0" + "@changesets/read": "npm:^1.0.1" + "@changesets/should-skip-package": "npm:^1.0.0" + "@changesets/types": "npm:^7.0.0" + "@changesets/write": "npm:^1.0.1" + "@clack/prompts": "npm:^1.7.0" + "@manypkg/get-packages": "npm:^3.1.0" + "@pnpm/deps.graph-sequencer": "npm:^1100.0.1" + cac: "npm:^7.0.0" + import-meta-resolve: "npm:^4.2.0" + launch-editor: "npm:^2.14.1" + package-manager-detector: "npm:^1.6.0" + semver: "npm:^7.8.1" + tinyexec: "npm:^1.3.1" bin: changeset: bin.js - checksum: 10c0/3b15f4f5fc7ccaa0b82ca4f9803977ed141b6bed66f83cf8004c2f4ab8e3a00c3a813569b76e4c757d0a8ca5e778bcb6df6e4df91be6c98e0dfaa2cff87c9434 + checksum: 10c0/238b98c590bfe33e794cf5ab47e0fa6eb46fc587da9498af49037877f1043b3e91dac65c990ebd1675115d82f2a3a2b3e7aa1b5d385369b0322a24e858eb1313 languageName: node linkType: hard -"@changesets/config@npm:^3.1.4": - version: 3.1.4 - resolution: "@changesets/config@npm:3.1.4" +"@changesets/config@npm:^4.0.1": + version: 4.0.1 + resolution: "@changesets/config@npm:4.0.1" dependencies: - "@changesets/errors": "npm:^0.2.0" - "@changesets/get-dependents-graph": "npm:^2.1.4" - "@changesets/logger": "npm:^0.1.1" - "@changesets/should-skip-package": "npm:^0.1.2" - "@changesets/types": "npm:^6.1.0" - "@manypkg/get-packages": "npm:^1.1.3" - fs-extra: "npm:^7.0.1" - micromatch: "npm:^4.0.8" - checksum: 10c0/1c0e7975aa719e2c87dfda3f5a1eb81b9f4852cdfb5b5c9d181fa2f8f485e92370b3bdfdb6f666432207dd75a3f79fa8fffe7847d48fb11308acb5ecf327bc12 + "@changesets/get-dependents-graph": "npm:^3.0.0" + "@changesets/should-skip-package": "npm:^1.0.0" + "@changesets/types": "npm:^7.0.0" + "@manypkg/get-packages": "npm:^3.1.0" + picomatch: "npm:^4.0.7" + checksum: 10c0/959dbbb3ab9fab0427a8990cbe7d4465d9b67042c96108209c8110b7c5ccb2fc3303b75d5c024e911d3a51ca082d13d49a5091edf52014a2b8d69e89b58188ed languageName: node linkType: hard -"@changesets/errors@npm:^0.2.0": - version: 0.2.0 - resolution: "@changesets/errors@npm:0.2.0" +"@changesets/errors@npm:^1.0.0": + version: 1.0.0 + resolution: "@changesets/errors@npm:1.0.0" + checksum: 10c0/502439fb046b9916e8a0745374c75fa80883b0bdeea92aa2e85be07b8675dcdb50df5bd108ce66eddbda1f8863be750bf817301c9d4904c2168305df870e3f9f + languageName: node + linkType: hard + +"@changesets/format@npm:^0.1.1, @changesets/format@npm:^0.1.2": + version: 0.1.2 + resolution: "@changesets/format@npm:0.1.2" dependencies: - extendable-error: "npm:^0.1.5" - checksum: 10c0/f2757c752ab04e9733b0dfd7903f1caf873f9e603794c4d9ea2294af4f937c73d07273c24be864ad0c30b6a98424360d5b96a6eab14f97f3cf2cbfd3763b95c1 + package-manager-detector: "npm:^1.8.0" + tinyexec: "npm:^1.3.0" + checksum: 10c0/fdb35b885b0923c2fac48433f5d633cc1a091f10e589eb65e3cd33c6702864ea26ef41d7bb434f0c91715dafa609442b91a780e84089b9bd33b7906d65d13f39 languageName: node linkType: hard -"@changesets/get-dependents-graph@npm:^2.1.4": - version: 2.1.4 - resolution: "@changesets/get-dependents-graph@npm:2.1.4" +"@changesets/get-dependents-graph@npm:^3.0.0": + version: 3.0.0 + resolution: "@changesets/get-dependents-graph@npm:3.0.0" dependencies: - "@changesets/types": "npm:^6.1.0" - "@manypkg/get-packages": "npm:^1.1.3" - picocolors: "npm:^1.1.0" - semver: "npm:^7.5.3" - checksum: 10c0/37b12ba42f16c458d0b574bcafa0247ff2b9a218686a64c86fc75bccc9ba3982f9c27206542941cf3a0563d9b199f40a830682b45e9fd902536de91344cbd0a2 + "@changesets/types": "npm:^7.0.0" + semver: "npm:^7.8.1" + checksum: 10c0/ab675098db0b92f61115952fb793620bb5b76e502bad5f7f63bcc85a50e5515f70e08957be87381a58141ab2078bf94dd92df86efa55e73227e7604ba6a3968b languageName: node linkType: hard @@ -352,119 +331,104 @@ __metadata: languageName: node linkType: hard -"@changesets/get-release-plan@npm:^4.0.16": - version: 4.0.16 - resolution: "@changesets/get-release-plan@npm:4.0.16" +"@changesets/git@npm:^4.0.1": + version: 4.0.1 + resolution: "@changesets/git@npm:4.0.1" dependencies: - "@changesets/assemble-release-plan": "npm:^6.0.10" - "@changesets/config": "npm:^3.1.4" - "@changesets/pre": "npm:^2.0.2" - "@changesets/read": "npm:^0.6.7" - "@changesets/types": "npm:^6.1.0" - "@manypkg/get-packages": "npm:^1.1.3" - checksum: 10c0/4be4553e13fe331f6d5b2ed98fece21c8d2b38c04a0543f726a0398b7538ef8fd073d712c35ae4540ed4fc6f84f08de6335318bc09dd562b189fb6968d049e95 - languageName: node - linkType: hard - -"@changesets/get-version-range-type@npm:^0.4.0": - version: 0.4.0 - resolution: "@changesets/get-version-range-type@npm:0.4.0" - checksum: 10c0/e466208c8383489a383f37958d8b5b9aed38539f9287b47fe155a2e8855973f6960fb1724a1ee33b11580d65e1011059045ee654e8ef51e4783017d8989c9d3f + "@changesets/errors": "npm:^1.0.0" + "@changesets/types": "npm:^7.0.0" + "@manypkg/get-packages": "npm:^3.1.0" + picomatch: "npm:^4.0.4" + tinyexec: "npm:^1.3.0" + checksum: 10c0/b548ecfafb238ff477a793e4ee91c1fe818274b9ea5b6d5a9d619f75a915ad27831f2accf2edce77da57f918bf1009b59cca2c5aaa7288db4b266f87872c2c23 languageName: node linkType: hard -"@changesets/git@npm:^3.0.4": - version: 3.0.4 - resolution: "@changesets/git@npm:3.0.4" +"@changesets/parse@npm:^1.0.0": + version: 1.0.0 + resolution: "@changesets/parse@npm:1.0.0" dependencies: - "@changesets/errors": "npm:^0.2.0" - "@manypkg/get-packages": "npm:^1.1.3" - is-subdir: "npm:^1.1.1" - micromatch: "npm:^4.0.8" - spawndamnit: "npm:^3.0.1" - checksum: 10c0/4abbdc1dec6ddc50b6ad927d9eba4f23acd775fdff615415813099befb0cecd1b0f56ceea5e18a5a3cbbb919d68179366074b02a954fbf4016501e5fd125d2b5 + "@changesets/types": "npm:^7.0.0" + yaml: "npm:^2.9.0" + checksum: 10c0/76e93b099015420dfc08f3e3a9321ea2512659d8946d1ed86de64899b52d37faa87173cc80ead5d313ffce2f829ce380a0188c0a1cccaad48350e7c6a83720d0 languageName: node linkType: hard -"@changesets/logger@npm:^0.1.1": - version: 0.1.1 - resolution: "@changesets/logger@npm:0.1.1" +"@changesets/pre@npm:^3.0.0": + version: 3.0.0 + resolution: "@changesets/pre@npm:3.0.0" dependencies: - picocolors: "npm:^1.1.0" - checksum: 10c0/a0933b5bd4d99e10730b22612dc1bdfd25b8804c5b48f8cada050bf5c7a89b2ae9a61687f846a5e9e5d379a95b59fef795c8d5d91e49a251f8da2be76133f83f + "@changesets/errors": "npm:^1.0.0" + "@changesets/types": "npm:^7.0.0" + "@manypkg/get-packages": "npm:^3.1.0" + checksum: 10c0/56e49668f25cabf50dcab12b6ae20fd7dbeba628390149d38a9075b05c41b954d4fbcebe7535878087c5f0b707ce96b90b727286850c71d6d321477bd1a1c18e languageName: node linkType: hard -"@changesets/parse@npm:^0.4.3": - version: 0.4.3 - resolution: "@changesets/parse@npm:0.4.3" +"@changesets/read@npm:^1.0.1": + version: 1.0.1 + resolution: "@changesets/read@npm:1.0.1" dependencies: - "@changesets/types": "npm:^6.1.0" - js-yaml: "npm:^4.1.1" - checksum: 10c0/4d8488eaf224974ae335fec964dc1dc486abcfa9f96856cf4267c2765b02ed6af1778375ec03d38252ebab9e191aa4a11c5f37a6ad42e907e08290fed2b9690c + "@changesets/git": "npm:^4.0.1" + "@changesets/parse": "npm:^1.0.0" + "@changesets/types": "npm:^7.0.0" + checksum: 10c0/125de7132b71e9c184f35ec0ad4d23b9accb0aa04d178c835ef575fbf12afbed2fd2d975e987ebb70c007c9a08dc97a267123ba515a4c2f4f070279ad8193f46 languageName: node linkType: hard -"@changesets/pre@npm:^2.0.2": - version: 2.0.2 - resolution: "@changesets/pre@npm:2.0.2" +"@changesets/should-skip-package@npm:^1.0.0": + version: 1.0.0 + resolution: "@changesets/should-skip-package@npm:1.0.0" dependencies: - "@changesets/errors": "npm:^0.2.0" - "@changesets/types": "npm:^6.1.0" - "@manypkg/get-packages": "npm:^1.1.3" - fs-extra: "npm:^7.0.1" - checksum: 10c0/0af9396d84c47a88d79b757e9db4e3579b6620260f92c243b8349e7fcefca3c2652583f6d215c13115bed5d5cdc30c975f307fd6acbb89d205b1ba2ae403b918 + "@changesets/types": "npm:^7.0.0" + checksum: 10c0/9beb51e7a6a6d678fbfe60efca4646d7daf117ffb90d3dbe3e8b07806f87383cf2a49b0e15b5fe2b827b69f61c0cf7cb77c4bf27b403c2ce835c5ba7990ed0e5 languageName: node linkType: hard -"@changesets/read@npm:^0.6.7": - version: 0.6.7 - resolution: "@changesets/read@npm:0.6.7" - dependencies: - "@changesets/git": "npm:^3.0.4" - "@changesets/logger": "npm:^0.1.1" - "@changesets/parse": "npm:^0.4.3" - "@changesets/types": "npm:^6.1.0" - fs-extra: "npm:^7.0.1" - p-filter: "npm:^2.1.0" - picocolors: "npm:^1.1.0" - checksum: 10c0/eebda5f5cea8684b9cb470e74cd5e67043a62ca54452ac88bb1a998bebeee1a2e3a642dc76818155a145863551c65f10f9c4ff85378b0419179fc60049edbbc6 +"@changesets/types@npm:^6.1.0": + version: 6.1.0 + resolution: "@changesets/types@npm:6.1.0" + checksum: 10c0/b4cea3a4465d1eaf0bbd7be1e404aca5a055a61d4cc72aadcb73bbbda1670b4022736b8d3052616cbf1f451afa0637545d077697f4b923236539af9cd5abce6c languageName: node linkType: hard -"@changesets/should-skip-package@npm:^0.1.2": - version: 0.1.2 - resolution: "@changesets/should-skip-package@npm:0.1.2" - dependencies: - "@changesets/types": "npm:^6.1.0" - "@manypkg/get-packages": "npm:^1.1.3" - checksum: 10c0/484e339e7d6e6950e12bff4eda6e8eccb077c0fbb1f09dd95d2ae948b715226a838c71eaf50cd2d7e0e631ce3bfb1ca93ac752436e6feae5b87aece2e917b440 +"@changesets/types@npm:^7.0.0": + version: 7.0.0 + resolution: "@changesets/types@npm:7.0.0" + checksum: 10c0/494e09ff94968c1c81521f5ac8c6ab60a1e30d7d0cd9ea7f83c2cbac92efba38b03e008ee6fb4592d90b05d69bad864b6ae8155827d52a51c652f5c24d36ec83 languageName: node linkType: hard -"@changesets/types@npm:^4.0.1": - version: 4.1.0 - resolution: "@changesets/types@npm:4.1.0" - checksum: 10c0/a372ad21f6a1e0d4ce6c19573c1ca269eef1ad53c26751ad9515a24f003e7c49dcd859dbb1fedb6badaf7be956c1559e8798304039e0ec0da2d9a68583f13464 +"@changesets/write@npm:^1.0.1": + version: 1.0.1 + resolution: "@changesets/write@npm:1.0.1" + dependencies: + "@changesets/format": "npm:^0.1.1" + "@changesets/types": "npm:^7.0.0" + human-id: "npm:^4.2.0" + checksum: 10c0/c9cb074a8c524db1327e54dc1379ff4b50d3a4915409a3e5abe1edaf1ce45f6dafc6c8aced17a1f3eebfa162241b5fd8ea74fc751d2e24df56c2966e4169fcaf languageName: node linkType: hard -"@changesets/types@npm:^6.1.0": - version: 6.1.0 - resolution: "@changesets/types@npm:6.1.0" - checksum: 10c0/b4cea3a4465d1eaf0bbd7be1e404aca5a055a61d4cc72aadcb73bbbda1670b4022736b8d3052616cbf1f451afa0637545d077697f4b923236539af9cd5abce6c +"@clack/core@npm:1.5.1": + version: 1.5.1 + resolution: "@clack/core@npm:1.5.1" + dependencies: + fast-wrap-ansi: "npm:^0.2.0" + sisteransi: "npm:^1.0.5" + checksum: 10c0/6dca93ff5a34bfe8c150367519bc40a847e9172cb1a78b18c88da0507cb6ad899debe1ad5cfcb9796e439108f12f003cf06fd002e2293bc85ff500975e45ff13 languageName: node linkType: hard -"@changesets/write@npm:^0.4.0": - version: 0.4.0 - resolution: "@changesets/write@npm:0.4.0" +"@clack/prompts@npm:^1.7.0": + version: 1.8.1 + resolution: "@clack/prompts@npm:1.8.1" dependencies: - "@changesets/types": "npm:^6.1.0" - fs-extra: "npm:^7.0.1" - human-id: "npm:^4.1.1" - prettier: "npm:^2.7.1" - checksum: 10c0/311f4d0e536d1b5f2d3f9053537d62b2d4cdbd51e1d2767807ac9d1e0f380367f915d2ad370e5c73902d5a54bffd282d53fff5418c8ad31df51751d652bea826 + "@clack/core": "npm:1.5.1" + fast-string-width: "npm:^3.0.2" + fast-wrap-ansi: "npm:^0.2.0" + sisteransi: "npm:^1.0.5" + checksum: 10c0/ed38748acde0cb1e7ebdac1242470c78bd90380cbdde1e5560e0828881e3c74e2676de666086cf8183c8d4d3f1fc6e489f15a004f0d9f8ee294a3aa744de2093 languageName: node linkType: hard @@ -479,24 +443,13 @@ __metadata: languageName: node linkType: hard -"@eslint-community/regexpp@npm:^4.12.1, @eslint-community/regexpp@npm:^4.12.2": +"@eslint-community/regexpp@npm:^4.12.2": version: 4.12.2 resolution: "@eslint-community/regexpp@npm:4.12.2" checksum: 10c0/fddcbc66851b308478d04e302a4d771d6917a0b3740dc351513c0da9ca2eab8a1adf99f5e0aa7ab8b13fa0df005c81adeee7e63a92f3effd7d367a163b721c2d languageName: node linkType: hard -"@eslint/config-array@npm:^0.21.2": - version: 0.21.2 - resolution: "@eslint/config-array@npm:0.21.2" - dependencies: - "@eslint/object-schema": "npm:^2.1.7" - debug: "npm:^4.3.1" - minimatch: "npm:^3.1.5" - checksum: 10c0/89dfe815d18456177c0a1f238daf4593107fd20298b3598e0103054360d3b8d09d967defd8318f031185d68df1f95cfa68becf1390a9c5c6887665f1475142e3 - languageName: node - linkType: hard - "@eslint/config-array@npm:^0.23.5": version: 0.23.5 resolution: "@eslint/config-array@npm:0.23.5" @@ -508,15 +461,6 @@ __metadata: languageName: node linkType: hard -"@eslint/config-helpers@npm:^0.4.2": - version: 0.4.2 - resolution: "@eslint/config-helpers@npm:0.4.2" - dependencies: - "@eslint/core": "npm:^0.17.0" - checksum: 10c0/92efd7a527b2d17eb1a148409d71d80f9ac160b565ac73ee092252e8bf08ecd08670699f46b306b94f13d22e88ac88a612120e7847570dd7cdc72f234d50dcb4 - languageName: node - linkType: hard - "@eslint/config-helpers@npm:^0.6.0": version: 0.6.0 resolution: "@eslint/config-helpers@npm:0.6.0" @@ -526,15 +470,6 @@ __metadata: languageName: node linkType: hard -"@eslint/core@npm:^0.17.0": - version: 0.17.0 - resolution: "@eslint/core@npm:0.17.0" - dependencies: - "@types/json-schema": "npm:^7.0.15" - checksum: 10c0/9a580f2246633bc752298e7440dd942ec421860d1946d0801f0423830e67887e4aeba10ab9a23d281727a978eb93d053d1922a587d502942a713607f40ed704e - languageName: node - linkType: hard - "@eslint/core@npm:^1.2.1": version: 1.2.1 resolution: "@eslint/core@npm:1.2.1" @@ -544,30 +479,6 @@ __metadata: languageName: node linkType: hard -"@eslint/eslintrc@npm:^3.3.5": - version: 3.3.5 - resolution: "@eslint/eslintrc@npm:3.3.5" - dependencies: - ajv: "npm:^6.14.0" - debug: "npm:^4.3.2" - espree: "npm:^10.0.1" - globals: "npm:^14.0.0" - ignore: "npm:^5.2.0" - import-fresh: "npm:^3.2.1" - js-yaml: "npm:^4.1.1" - minimatch: "npm:^3.1.5" - strip-json-comments: "npm:^3.1.1" - checksum: 10c0/9fb9f1ca65e46d6173966e3aaa5bd353e3a65d7f1f582bebf77f578fab7d7960a399fac1ecfb1e7d52bd61f5cefd6531087ca52a3a3c388f2e1b4f1ebd3da8b7 - languageName: node - linkType: hard - -"@eslint/js@npm:9.39.4": - version: 9.39.4 - resolution: "@eslint/js@npm:9.39.4" - checksum: 10c0/5aa7dea2cbc5decf7f5e3b0c6f86a084ccee0f792d288ca8e839f8bc1b64e03e227068968e49b26096e6f71fd857ab6e42691d1b993826b9a3883f1bdd7a0e46 - languageName: node - linkType: hard - "@eslint/js@npm:^10.0.1": version: 10.0.1 resolution: "@eslint/js@npm:10.0.1" @@ -592,13 +503,6 @@ __metadata: languageName: node linkType: hard -"@eslint/object-schema@npm:^2.1.7": - version: 2.1.7 - resolution: "@eslint/object-schema@npm:2.1.7" - checksum: 10c0/936b6e499853d1335803f556d526c86f5fe2259ed241bc665000e1d6353828edd913feed43120d150adb75570cae162cf000b5b0dfc9596726761c36b82f4e87 - languageName: node - linkType: hard - "@eslint/object-schema@npm:^3.0.5": version: 3.0.5 resolution: "@eslint/object-schema@npm:3.0.5" @@ -606,16 +510,6 @@ __metadata: languageName: node linkType: hard -"@eslint/plugin-kit@npm:^0.4.1": - version: 0.4.1 - resolution: "@eslint/plugin-kit@npm:0.4.1" - dependencies: - "@eslint/core": "npm:^0.17.0" - levn: "npm:^0.4.1" - checksum: 10c0/51600f78b798f172a9915dffb295e2ffb44840d583427bc732baf12ecb963eb841b253300e657da91d890f4b323d10a1bd12934bf293e3018d8bb66fdce5217b - languageName: node - linkType: hard - "@eslint/plugin-kit@npm:^0.7.1, @eslint/plugin-kit@npm:^0.7.2": version: 0.7.2 resolution: "@eslint/plugin-kit@npm:0.7.2" @@ -674,21 +568,6 @@ __metadata: languageName: node linkType: hard -"@inquirer/external-editor@npm:^1.0.2": - version: 1.0.3 - resolution: "@inquirer/external-editor@npm:1.0.3" - dependencies: - chardet: "npm:^2.1.1" - iconv-lite: "npm:^0.7.0" - peerDependencies: - "@types/node": ">=18" - peerDependenciesMeta: - "@types/node": - optional: true - checksum: 10c0/82951cb7f3762dd78cca2ea291396841e3f4adfe26004b5badfed1cec4b6a04bb567dff94d0e41b35c61bdd7957317c64c22f58074d14b238d44e44d9e420019 - languageName: node - linkType: hard - "@isaacs/fs-minipass@npm:^4.0.0": version: 4.0.1 resolution: "@isaacs/fs-minipass@npm:4.0.1" @@ -698,109 +577,227 @@ __metadata: languageName: node linkType: hard -"@manypkg/find-root@npm:^1.1.0": - version: 1.1.0 - resolution: "@manypkg/find-root@npm:1.1.0" +"@jridgewell/resolve-uri@npm:^3.1.0": + version: 3.1.2 + resolution: "@jridgewell/resolve-uri@npm:3.1.2" + checksum: 10c0/d502e6fb516b35032331406d4e962c21fe77cdf1cbdb49c6142bcbd9e30507094b18972778a6e27cbad756209cfe34b1a27729e6fa08a2eb92b33943f680cf1e + languageName: node + linkType: hard + +"@jridgewell/sourcemap-codec@npm:^1.4.14, @jridgewell/sourcemap-codec@npm:^1.5.5": + version: 1.5.5 + resolution: "@jridgewell/sourcemap-codec@npm:1.5.5" + checksum: 10c0/f9e538f302b63c0ebc06eecb1dd9918dd4289ed36147a0ddce35d6ea4d7ebbda243cda7b2213b6a5e1d8087a298d5cf630fb2bd39329cdecb82017023f6081a0 + languageName: node + linkType: hard + +"@jridgewell/trace-mapping@npm:^0.3.31": + version: 0.3.31 + resolution: "@jridgewell/trace-mapping@npm:0.3.31" dependencies: - "@babel/runtime": "npm:^7.5.5" - "@types/node": "npm:^12.7.1" - find-up: "npm:^4.1.0" - fs-extra: "npm:^8.1.0" - checksum: 10c0/0ee907698e6c73d6f1821ff630f3fec6dcf38260817c8752fec8991ac38b95ba431ab11c2773ddf9beb33d0e057f1122b00e8ffc9b8411b3fd24151413626fa6 + "@jridgewell/resolve-uri": "npm:^3.1.0" + "@jridgewell/sourcemap-codec": "npm:^1.4.14" + checksum: 10c0/4b30ec8cd56c5fd9a661f088230af01e0c1a3888d11ffb6b47639700f71225be21d1f7e168048d6d4f9449207b978a235c07c8f15c07705685d16dc06280e9d9 languageName: node linkType: hard -"@manypkg/get-packages@npm:^1.1.3": - version: 1.1.3 - resolution: "@manypkg/get-packages@npm:1.1.3" +"@manypkg/find-root@npm:^3.1.0": + version: 3.1.0 + resolution: "@manypkg/find-root@npm:3.1.0" dependencies: - "@babel/runtime": "npm:^7.5.5" - "@changesets/types": "npm:^4.0.1" - "@manypkg/find-root": "npm:^1.1.0" - fs-extra: "npm:^8.1.0" - globby: "npm:^11.0.0" - read-yaml-file: "npm:^1.1.0" - checksum: 10c0/f05907d1174ae28861eaa06d0efdc144f773d9a4b8b65e1e7cdc01eb93361d335351b4a336e05c6aac02661be39e8809a3f7ad28bc67b6b338071434ab442130 + "@manypkg/tools": "npm:^2.1.0" + checksum: 10c0/15b3f5c5c66881af88c1c70dcec805237a2b7e1d541ca7687aade00978680024f8e77ca2f4bac1415e00377f2e78a998d842c345c288d3e69383d50280620b1c languageName: node linkType: hard -"@minecraft/common@npm:1.3.0": - version: 1.3.0 - resolution: "@minecraft/common@npm:1.3.0" - checksum: 10c0/6f335029426b6bab2b0c22562863c6a0681d855767ffa8d8e3f0e579ce940505ad2175bd4c96bb5c35b90344ee758757d1d7807908623b6fe00f5f73ea596911 +"@manypkg/get-packages@npm:^3.1.0": + version: 3.1.0 + resolution: "@manypkg/get-packages@npm:3.1.0" + dependencies: + "@manypkg/find-root": "npm:^3.1.0" + "@manypkg/tools": "npm:^2.1.0" + checksum: 10c0/e8baabd85a13f4537ae22124d5e53f1523ba0653f33006da45fc8bd642e0134786a6aad9d0a7ed99282ae20a80687ef5700bd72980441627b5c96658503ad83d languageName: node linkType: hard -"@minecraft/math@npm:2.4.0": - version: 2.4.0 - resolution: "@minecraft/math@npm:2.4.0" - peerDependencies: - "@minecraft/server": ^1.15.0 || ^2.0.0 - checksum: 10c0/b38640384285cef98d7757b1e2587e729dd2dd00cdd25716b0e594f12598d7dfca35ec9c0fc02631f246967d5276e0a8b2a312cc890a42fefa56c4e8429d87e8 +"@manypkg/tools@npm:^2.1.0": + version: 2.1.2 + resolution: "@manypkg/tools@npm:2.1.2" + dependencies: + jju: "npm:^1.4.0" + tinyglobby: "npm:^0.2.13" + yaml: "npm:^2.9.0" + checksum: 10c0/05c8ece3b3b3ff170765ecf230f0a5408543f1ad43f8b5dc97651adff0ffed3d860dabdd1e2d1068aa247832d473c4de583791675b8f3d07568da4cab2a0ebc3 languageName: node linkType: hard -"@minecraft/server-gametest@npm:1.0.0-beta.1.21.111-stable": - version: 1.0.0-beta.1.21.111-stable - resolution: "@minecraft/server-gametest@npm:1.0.0-beta.1.21.111-stable" +"@minecraft/server-gametest@npm:1.0.0-beta.1.26.50-stable": + version: 1.0.0-beta.1.26.50-stable + resolution: "@minecraft/server-gametest@npm:1.0.0-beta.1.26.50-stable" peerDependencies: "@minecraft/common": ^1.0.0 - "@minecraft/server": ^1.17.0 || ^2.0.0 - checksum: 10c0/29b4db7afecce6f6bc00f44f2b907f9523b2cd4c9d8a05640a88b271239d23b6f7ca6c44592843ed32b6d268bdecd27dbe74dfba9895f2d87c5076c98d6c39b6 + "@minecraft/server": ^1.17.0 || ^2.0.0 || ^2.11.0-beta.1.26.50-stable + checksum: 10c0/fe34487099a10f2e8727a22f45016e645d607ef8f3388446bd53dff6273a529dc4300081ab65d5e2f4a09026f8c4c44f1346d6b6776180bd8ddffa4c0e3ca05f languageName: node linkType: hard -"@minecraft/server-ui@npm:2.1.0": - version: 2.1.0 - resolution: "@minecraft/server-ui@npm:2.1.0" +"@minecraft/server-ui@npm:2.2.0": + version: 2.2.0 + resolution: "@minecraft/server-ui@npm:2.2.0" peerDependencies: "@minecraft/common": ^1.0.0 "@minecraft/server": ^2.0.0 - checksum: 10c0/b32fd20d0d384b227d6168860d43b0094069c5503a9982061cb324e85f8be432f2efb6daec9556c5c4a399f203a9c0fbea6d16d2d375249fdda0b676cffe1aa3 + checksum: 10c0/b1f681251c6be04b45780602f8b04c0c12a2f02c7f9c9a2de93be8816a847107f4fd3533c9529ee73e37acd4fea7455ba74d415e16e527b70507b88a76dc529f languageName: node linkType: hard -"@minecraft/server@npm:2.8.0": - version: 2.8.0 - resolution: "@minecraft/server@npm:2.8.0" +"@minecraft/server@npm:2.10.0": + version: 2.10.0 + resolution: "@minecraft/server@npm:2.10.0" peerDependencies: "@minecraft/common": ^1.2.0 "@minecraft/vanilla-data": ">=1.20.70" - checksum: 10c0/bb4f8a8a90b52831786f51ce320bbf2a8494f7d71d4d6329c22c9710a10a36ed7096acd4f1ff9d2c9971d95c391b8ba36ef674bda47629f9c671c9134994786d + checksum: 10c0/47c19918332d362c7ab051de035e90abc5ac2653a68a98d21de5fbb8c6145edf8b0c4ffaf541971a35016b912d12a4d991c3a4b825325565ea4fef6f6447fe6a languageName: node linkType: hard -"@minecraft/vanilla-data@npm:1.26.31": - version: 1.26.31 - resolution: "@minecraft/vanilla-data@npm:1.26.31" - checksum: 10c0/af2ea3b7035836ee5f08b8ed2abd921ee5164f519d1afaecb7a9131b637aec5316310921756b7a54a7e4579b96f123edbc7e9ff54a6f497be52fa53198884128 +"@minecraft/vanilla-data@npm:1.26.50": + version: 1.26.50 + resolution: "@minecraft/vanilla-data@npm:1.26.50" + checksum: 10c0/79aa52266213e2a39dc7c29c4293358ca8511979571fe19ab87b63606341b10825b8e19c93fcf7a907460bc9dc27faf1d9fa4a1877867ab81987c4b7bd76ce22 languageName: node linkType: hard -"@nodelib/fs.scandir@npm:2.1.5": - version: 2.1.5 - resolution: "@nodelib/fs.scandir@npm:2.1.5" - dependencies: - "@nodelib/fs.stat": "npm:2.0.5" - run-parallel: "npm:^1.1.9" - checksum: 10c0/732c3b6d1b1e967440e65f284bd06e5821fedf10a1bea9ed2bb75956ea1f30e08c44d3def9d6a230666574edbaf136f8cfd319c14fd1f87c66e6a44449afb2eb +"@oxc-project/types@npm:=0.146.0": + version: 0.146.0 + resolution: "@oxc-project/types@npm:0.146.0" + checksum: 10c0/15e99d1d4d9233244262779b6e3bbaf7f11a4e62b57c21ef3755acbec469cb90cd468548103fec282af649096e9c6389fd5221c28b268579061352c49b29f4c8 languageName: node linkType: hard -"@nodelib/fs.stat@npm:2.0.5, @nodelib/fs.stat@npm:^2.0.2": - version: 2.0.5 - resolution: "@nodelib/fs.stat@npm:2.0.5" - checksum: 10c0/88dafe5e3e29a388b07264680dc996c17f4bda48d163a9d4f5c1112979f0ce8ec72aa7116122c350b4e7976bc5566dc3ddb579be1ceaacc727872eb4ed93926d +"@pnpm/deps.graph-sequencer@npm:^1100.0.1": + version: 1100.0.1 + resolution: "@pnpm/deps.graph-sequencer@npm:1100.0.1" + checksum: 10c0/b38a152e1184055a358e7c522f068572e84759a88dfc56c98a349125b11d3cce72e452424c235af5cf18258a5034f88fe203c72ba57ffbe70b297afc50d08dea languageName: node linkType: hard -"@nodelib/fs.walk@npm:^1.2.3": - version: 1.2.8 - resolution: "@nodelib/fs.walk@npm:1.2.8" - dependencies: - "@nodelib/fs.scandir": "npm:2.1.5" - fastq: "npm:^1.6.0" - checksum: 10c0/db9de047c3bb9b51f9335a7bb46f4fcfb6829fb628318c12115fbaf7d369bfce71c15b103d1fc3b464812d936220ee9bc1c8f762d032c9f6be9acc99249095b1 +"@rolldown/binding-android-arm-eabi@npm:1.2.5": + version: 1.2.5 + resolution: "@rolldown/binding-android-arm-eabi@npm:1.2.5" + conditions: os=android & cpu=arm + languageName: node + linkType: hard + +"@rolldown/binding-android-arm64@npm:1.2.5": + version: 1.2.5 + resolution: "@rolldown/binding-android-arm64@npm:1.2.5" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + +"@rolldown/binding-darwin-arm64@npm:1.2.5": + version: 1.2.5 + resolution: "@rolldown/binding-darwin-arm64@npm:1.2.5" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@rolldown/binding-darwin-x64@npm:1.2.5": + version: 1.2.5 + resolution: "@rolldown/binding-darwin-x64@npm:1.2.5" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@rolldown/binding-freebsd-x64@npm:1.2.5": + version: 1.2.5 + resolution: "@rolldown/binding-freebsd-x64@npm:1.2.5" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + +"@rolldown/binding-linux-arm-gnueabihf@npm:1.2.5": + version: 1.2.5 + resolution: "@rolldown/binding-linux-arm-gnueabihf@npm:1.2.5" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + +"@rolldown/binding-linux-arm64-gnu@npm:1.2.5": + version: 1.2.5 + resolution: "@rolldown/binding-linux-arm64-gnu@npm:1.2.5" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"@rolldown/binding-linux-arm64-musl@npm:1.2.5": + version: 1.2.5 + resolution: "@rolldown/binding-linux-arm64-musl@npm:1.2.5" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"@rolldown/binding-linux-ppc64-gnu@npm:1.2.5": + version: 1.2.5 + resolution: "@rolldown/binding-linux-ppc64-gnu@npm:1.2.5" + conditions: os=linux & cpu=ppc64 & libc=glibc + languageName: node + linkType: hard + +"@rolldown/binding-linux-s390x-gnu@npm:1.2.5": + version: 1.2.5 + resolution: "@rolldown/binding-linux-s390x-gnu@npm:1.2.5" + conditions: os=linux & cpu=s390x & libc=glibc + languageName: node + linkType: hard + +"@rolldown/binding-linux-x64-gnu@npm:1.2.5": + version: 1.2.5 + resolution: "@rolldown/binding-linux-x64-gnu@npm:1.2.5" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"@rolldown/binding-linux-x64-musl@npm:1.2.5": + version: 1.2.5 + resolution: "@rolldown/binding-linux-x64-musl@npm:1.2.5" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"@rolldown/binding-openharmony-arm64@npm:1.2.5": + version: 1.2.5 + resolution: "@rolldown/binding-openharmony-arm64@npm:1.2.5" + conditions: os=openharmony & cpu=arm64 + languageName: node + linkType: hard + +"@rolldown/binding-win32-arm64-msvc@npm:1.2.5": + version: 1.2.5 + resolution: "@rolldown/binding-win32-arm64-msvc@npm:1.2.5" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@rolldown/binding-win32-x64-msvc@npm:1.2.5": + version: 1.2.5 + resolution: "@rolldown/binding-win32-x64-msvc@npm:1.2.5" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"@rolldown/pluginutils@npm:^1.0.0": + version: 1.0.1 + resolution: "@rolldown/pluginutils@npm:1.0.1" + checksum: 10c0/99d9b06d90196823e4d8c841f258db7a16e5dbba5824a2962b05d907b79f1ba929d56f22dd744fd530936e568c865ee56a719dc31e57e13bc0a8eb4764a8d8dd + languageName: node + linkType: hard + +"@standard-schema/spec@npm:^1.1.0": + version: 1.1.0 + resolution: "@standard-schema/spec@npm:1.1.0" + checksum: 10c0/d90f55acde4b2deb983529c87e8025fa693de1a5e8b49ecc6eb84d1fd96328add0e03d7d551442156c7432fd78165b2c26ff561b970a9a881f046abb78d6a526 languageName: node linkType: hard @@ -820,6 +817,23 @@ __metadata: languageName: node linkType: hard +"@types/chai@npm:^5.2.2": + version: 5.2.3 + resolution: "@types/chai@npm:5.2.3" + dependencies: + "@types/deep-eql": "npm:*" + assertion-error: "npm:^2.0.1" + checksum: 10c0/e0ef1de3b6f8045a5e473e867c8565788c444271409d155588504840ad1a53611011f85072188c2833941189400228c1745d78323dac13fcede9c2b28bacfb2f + languageName: node + linkType: hard + +"@types/deep-eql@npm:*": + version: 4.0.2 + resolution: "@types/deep-eql@npm:4.0.2" + checksum: 10c0/bf3f811843117900d7084b9d0c852da9a044d12eb40e6de73b552598a6843c21291a8a381b0532644574beecd5e3491c5ff3a0365ab86b15d59862c025384844 + languageName: node + linkType: hard + "@types/esrecurse@npm:^4.3.1": version: 4.3.1 resolution: "@types/esrecurse@npm:4.3.1" @@ -827,7 +841,7 @@ __metadata: languageName: node linkType: hard -"@types/estree@npm:^1.0.6, @types/estree@npm:^1.0.8": +"@types/estree@npm:^1.0.0, @types/estree@npm:^1.0.6, @types/estree@npm:^1.0.8": version: 1.0.9 resolution: "@types/estree@npm:1.0.9" checksum: 10c0/3ad3286ca2988cd550dafb8f2ad599c8474868e954fa601a36655bdfefd8039f7c714b8c1c7f2ae219ffbd58bd4660e66fa7479a0120fc02d4777057d4865387 @@ -841,13 +855,6 @@ __metadata: languageName: node linkType: hard -"@types/node@npm:^12.7.1": - version: 12.20.55 - resolution: "@types/node@npm:12.20.55" - checksum: 10c0/3b190bb0410047d489c49bbaab592d2e6630de6a50f00ba3d7d513d59401d279972a8f5a598b5bb8ddc1702f8a2f4ec57a65d93852f9c329639738e7053637d1 - languageName: node - linkType: hard - "@types/node@npm:^26.0.1": version: 26.0.1 resolution: "@types/node@npm:26.0.1" @@ -857,15 +864,6 @@ __metadata: languageName: node linkType: hard -"@types/uuid@npm:^11.0.0": - version: 11.0.0 - resolution: "@types/uuid@npm:11.0.0" - dependencies: - uuid: "npm:*" - checksum: 10c0/6ebf1448d8fdc78d348a8a84389b74083f2f58bed75a5a6cf3be8419d33dcf757735c8b2de746b066ff8ef07f4384d02549774dc84195ffa46b24745471e9d8e - languageName: node - linkType: hard - "@typescript-eslint/eslint-plugin@npm:8.62.0": version: 8.62.0 resolution: "@typescript-eslint/eslint-plugin@npm:8.62.0" @@ -1019,6 +1017,112 @@ __metadata: languageName: node linkType: hard +"@vitest/coverage-v8@npm:^4.1.10": + version: 4.1.11 + resolution: "@vitest/coverage-v8@npm:4.1.11" + dependencies: + "@bcoe/v8-coverage": "npm:^1.0.2" + "@vitest/utils": "npm:4.1.11" + ast-v8-to-istanbul: "npm:^1.0.0" + istanbul-lib-coverage: "npm:^3.2.2" + istanbul-lib-report: "npm:^3.0.1" + istanbul-reports: "npm:^3.2.0" + magicast: "npm:^0.5.2" + obug: "npm:^2.1.1" + std-env: "npm:^4.0.0-rc.1" + tinyrainbow: "npm:^3.1.0" + peerDependencies: + "@vitest/browser": 4.1.11 + vitest: 4.1.11 + peerDependenciesMeta: + "@vitest/browser": + optional: true + checksum: 10c0/91127fd40f445b506cc661c54e26defd75d970fa1983cb83442856dd7f295542f0378e0bca847036a7a5be871b3b17c27167d21f277e47368cf7409eafaa1b40 + languageName: node + linkType: hard + +"@vitest/expect@npm:4.1.11": + version: 4.1.11 + resolution: "@vitest/expect@npm:4.1.11" + dependencies: + "@standard-schema/spec": "npm:^1.1.0" + "@types/chai": "npm:^5.2.2" + "@vitest/spy": "npm:4.1.11" + "@vitest/utils": "npm:4.1.11" + chai: "npm:^6.2.2" + tinyrainbow: "npm:^3.1.0" + checksum: 10c0/0aa5e0973aca93a58cbdc3041c6bfed5897e976124965203b96a23b811f4ca403590c7eb15802c8d8366ec27a1db0682451e8a91c4d06617636de262baf86e4b + languageName: node + linkType: hard + +"@vitest/mocker@npm:4.1.11": + version: 4.1.11 + resolution: "@vitest/mocker@npm:4.1.11" + dependencies: + "@vitest/spy": "npm:4.1.11" + estree-walker: "npm:^3.0.3" + magic-string: "npm:^0.30.21" + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + checksum: 10c0/3111ea34bd5046f6c70bbd67cf8b89608ee8c41cb27ab2bd61d42f4b68810e9ea16a9a757a71bc254c105f73b407d00ebb6bab1ab0f7f5cdcc9d7d16602a3933 + languageName: node + linkType: hard + +"@vitest/pretty-format@npm:4.1.11": + version: 4.1.11 + resolution: "@vitest/pretty-format@npm:4.1.11" + dependencies: + tinyrainbow: "npm:^3.1.0" + checksum: 10c0/ad32525c73807c0b72f38dc29bc51fd5a17879dc650f37995a9c5adbb8526e15f787691f76aa8768448ec7ed5bf5ba2b12329eac8fda1428b4d6b8c036390f71 + languageName: node + linkType: hard + +"@vitest/runner@npm:4.1.11": + version: 4.1.11 + resolution: "@vitest/runner@npm:4.1.11" + dependencies: + "@vitest/utils": "npm:4.1.11" + pathe: "npm:^2.0.3" + checksum: 10c0/3c782b055e9e688e1785f7c8937bd1669bad1b0e5758cb8b844f918f30a324b1d21e174d46c941ce80ebf37f2b89b55b6d619a675025914a1ece6377b95909e2 + languageName: node + linkType: hard + +"@vitest/snapshot@npm:4.1.11": + version: 4.1.11 + resolution: "@vitest/snapshot@npm:4.1.11" + dependencies: + "@vitest/pretty-format": "npm:4.1.11" + "@vitest/utils": "npm:4.1.11" + magic-string: "npm:^0.30.21" + pathe: "npm:^2.0.3" + checksum: 10c0/35d82a7c2a3e4b57529c30387d568d9106b6bf960189214e5d8cb1f5288cb042305c2e5c7b0b2f8cb56a2c814973de0310bc56d72deb79427bea272b6224190c + languageName: node + linkType: hard + +"@vitest/spy@npm:4.1.11": + version: 4.1.11 + resolution: "@vitest/spy@npm:4.1.11" + checksum: 10c0/06c68247a8efd21006abe7532fee17f30ba83c8cda3e0b952ef89e278d58058f4b1de69b6bbaa2ed614c568bf4f5769fcc76be42802dedf2bcdd9c0aae601740 + languageName: node + linkType: hard + +"@vitest/utils@npm:4.1.11": + version: 4.1.11 + resolution: "@vitest/utils@npm:4.1.11" + dependencies: + "@vitest/pretty-format": "npm:4.1.11" + convert-source-map: "npm:^2.0.0" + tinyrainbow: "npm:^3.1.0" + checksum: 10c0/a2c1ddc64333458c3e031465c1ee0440a7660fd1b298c54ccbf865ef1e5cf3ebdd6c5c75a5ea235d8a672498b394e7121f992b096f021f45014ab25e9abd1b52 + languageName: node + linkType: hard + "abbrev@npm:^5.0.0": version: 5.0.0 resolution: "abbrev@npm:5.0.0" @@ -1026,6 +1130,15 @@ __metadata: languageName: node linkType: hard +"abort-controller@npm:^3.0.0": + version: 3.0.0 + resolution: "abort-controller@npm:3.0.0" + dependencies: + event-target-shim: "npm:^5.0.0" + checksum: 10c0/90ccc50f010250152509a344eb2e71977fbf8db0ab8f1061197e3275ddf6c61a41a6edfd7b9409c664513131dd96e962065415325ef23efa5db931b382d24ca5 + languageName: node + linkType: hard + "acorn-jsx@npm:^5.3.2": version: 5.3.2 resolution: "acorn-jsx@npm:5.3.2" @@ -1044,7 +1157,7 @@ __metadata: languageName: node linkType: hard -"ajv@npm:^6.12.6, ajv@npm:^6.14.0": +"ajv@npm:^6.12.6, ajv@npm:^6.14.0, ajv@npm:^6.5.4": version: 6.15.0 resolution: "ajv@npm:6.15.0" dependencies: @@ -1056,20 +1169,6 @@ __metadata: languageName: node linkType: hard -"ansi-colors@npm:^4.1.1, ansi-colors@npm:^4.1.3": - version: 4.1.3 - resolution: "ansi-colors@npm:4.1.3" - checksum: 10c0/ec87a2f59902f74e61eada7f6e6fe20094a628dab765cfdbd03c3477599368768cffccdb5d3bb19a1b6c99126783a143b1fee31aab729b31ffe5836c7e5e28b9 - languageName: node - linkType: hard - -"ansi-regex@npm:^5.0.1": - version: 5.0.1 - resolution: "ansi-regex@npm:5.0.1" - checksum: 10c0/9a64bb8627b434ba9327b60c027742e5d17ac69277960d041898596271d992d4d52ba7267a63ca10232e29f6107fc8a835f6ce8d719b88c5f8493f8254813737 - languageName: node - linkType: hard - "ansi-regex@npm:^6.2.2": version: 6.2.2 resolution: "ansi-regex@npm:6.2.2" @@ -1077,15 +1176,6 @@ __metadata: languageName: node linkType: hard -"ansi-styles@npm:^4.1.0": - version: 4.3.0 - resolution: "ansi-styles@npm:4.3.0" - dependencies: - color-convert: "npm:^2.0.1" - checksum: 10c0/895a23929da416f2bd3de7e9cb4eabd340949328ab85ddd6e484a637d8f6820d485f53933446f5291c3b760cbc488beb8e88573dd0f9c7daf83dccc8fe81b041 - languageName: node - linkType: hard - "ansi-styles@npm:^6.2.1": version: 6.2.3 resolution: "ansi-styles@npm:6.2.3" @@ -1097,39 +1187,27 @@ __metadata: version: 3.1.3 resolution: "anymatch@npm:3.1.3" dependencies: - normalize-path: "npm:^3.0.0" - picomatch: "npm:^2.0.4" - checksum: 10c0/57b06ae984bc32a0d22592c87384cd88fe4511b1dd7581497831c56d41939c8a001b28e7b853e1450f2bf61992dfcaa8ae2d0d161a0a90c4fb631ef07098fbac - languageName: node - linkType: hard - -"argparse@npm:^1.0.7": - version: 1.0.10 - resolution: "argparse@npm:1.0.10" - dependencies: - sprintf-js: "npm:~1.0.2" - checksum: 10c0/b2972c5c23c63df66bca144dbc65d180efa74f25f8fd9b7d9a0a6c88ae839db32df3d54770dcb6460cf840d232b60695d1a6b1053f599d84e73f7437087712de + normalize-path: "npm:^3.0.0" + picomatch: "npm:^2.0.4" + checksum: 10c0/57b06ae984bc32a0d22592c87384cd88fe4511b1dd7581497831c56d41939c8a001b28e7b853e1450f2bf61992dfcaa8ae2d0d161a0a90c4fb631ef07098fbac languageName: node linkType: hard -"argparse@npm:^2.0.1": +"assertion-error@npm:^2.0.1": version: 2.0.1 - resolution: "argparse@npm:2.0.1" - checksum: 10c0/c5640c2d89045371c7cedd6a70212a04e360fd34d6edeae32f6952c63949e3525ea77dbec0289d8213a99bbaeab5abfa860b5c12cf88a2e6cf8106e90dd27a7e - languageName: node - linkType: hard - -"array-union@npm:^2.1.0": - version: 2.1.0 - resolution: "array-union@npm:2.1.0" - checksum: 10c0/429897e68110374f39b771ec47a7161fc6a8fc33e196857c0a396dc75df0b5f65e4d046674db764330b6bb66b39ef48dd7c53b6a2ee75cfb0681e0c1a7033962 + resolution: "assertion-error@npm:2.0.1" + checksum: 10c0/bbbcb117ac6480138f8c93cf7f535614282dea9dc828f540cdece85e3c665e8f78958b96afac52f29ff883c72638e6a87d469ecc9fe5bc902df03ed24a55dba8 languageName: node linkType: hard -"balanced-match@npm:^1.0.0": - version: 1.0.2 - resolution: "balanced-match@npm:1.0.2" - checksum: 10c0/9308baf0a7e4838a82bbfd11e01b1cb0f0cf2893bc1676c27c2a8c0e70cbae1c59120c3268517a8ae7fb6376b4639ef81ca22582611dbee4ed28df945134aaee +"ast-v8-to-istanbul@npm:^1.0.0": + version: 1.0.5 + resolution: "ast-v8-to-istanbul@npm:1.0.5" + dependencies: + "@jridgewell/trace-mapping": "npm:^0.3.31" + estree-walker: "npm:^3.0.3" + js-tokens: "npm:^10.0.0" + checksum: 10c0/546db141f60913846ea2e74fc163ec5b9b1b5aa4863a564ccb15fefe12988191fe029b1d91541aa2f236900c1447de53876460c179efc3dd15b1b9281d6205a6 languageName: node linkType: hard @@ -1140,12 +1218,10 @@ __metadata: languageName: node linkType: hard -"better-path-resolve@npm:1.0.0": - version: 1.0.0 - resolution: "better-path-resolve@npm:1.0.0" - dependencies: - is-windows: "npm:^1.0.0" - checksum: 10c0/7335130729d59a14b8e4753fea180ca84e287cccc20cb5f2438a95667abc5810327c414eee7b3c79ed1b5a348a40284ea872958f50caba69432c40405eb0acce +"base64-js@npm:^1.3.1": + version: 1.5.1 + resolution: "base64-js@npm:1.5.1" + checksum: 10c0/f23823513b63173a001030fae4f2dabe283b99a9d324ade3ad3d148e218134676f1ee8568c877cd79ec1c53158dcf2d2ba527a97c606618928ba99dd930102bf languageName: node linkType: hard @@ -1156,16 +1232,6 @@ __metadata: languageName: node linkType: hard -"brace-expansion@npm:^1.1.7": - version: 1.1.15 - resolution: "brace-expansion@npm:1.1.15" - dependencies: - balanced-match: "npm:^1.0.0" - concat-map: "npm:0.0.1" - checksum: 10c0/648e273f57cfa9ed67d8a77bdb15b408205465d33da9331808ee3c188d8b55674c9cdbf1f320b65bc562e485e1263360ae62ad355e128e0435891f6430e795d7 - languageName: node - linkType: hard - "brace-expansion@npm:^5.0.5": version: 5.0.6 resolution: "brace-expansion@npm:5.0.6" @@ -1175,7 +1241,7 @@ __metadata: languageName: node linkType: hard -"braces@npm:^3.0.3, braces@npm:~3.0.2": +"braces@npm:~3.0.2": version: 3.0.3 resolution: "braces@npm:3.0.3" dependencies: @@ -1184,34 +1250,34 @@ __metadata: languageName: node linkType: hard -"callsites@npm:^3.0.0": - version: 3.1.0 - resolution: "callsites@npm:3.1.0" - checksum: 10c0/fff92277400eb06c3079f9e74f3af120db9f8ea03bad0e84d9aede54bbe2d44a56cccb5f6cf12211f93f52306df87077ecec5b712794c5a9b5dac6d615a3f301 +"buffer@npm:^6.0.3": + version: 6.0.3 + resolution: "buffer@npm:6.0.3" + dependencies: + base64-js: "npm:^1.3.1" + ieee754: "npm:^1.2.1" + checksum: 10c0/2a905fbbcde73cc5d8bd18d1caa23715d5f83a5935867c2329f0ac06104204ba7947be098fe1317fbd8830e26090ff8e764f08cd14fefc977bb248c3487bcbd0 languageName: node linkType: hard -"chalk@npm:5.6.2": - version: 5.6.2 - resolution: "chalk@npm:5.6.2" - checksum: 10c0/99a4b0f0e7991796b1e7e3f52dceb9137cae2a9dfc8fc0784a550dc4c558e15ab32ed70b14b21b52beb2679b4892b41a0aa44249bcb996f01e125d58477c6976 +"cac@npm:^7.0.0": + version: 7.0.0 + resolution: "cac@npm:7.0.0" + checksum: 10c0/e9da33cb9f0425546ae92a450d479276f9969a050fe64f5d6fedf058bdd87f22a370797fe1c158e07655fa9fc183749df7cb2037431e3772faa7bee9919fb763 languageName: node linkType: hard -"chalk@npm:^4.0.0": - version: 4.1.2 - resolution: "chalk@npm:4.1.2" - dependencies: - ansi-styles: "npm:^4.1.0" - supports-color: "npm:^7.1.0" - checksum: 10c0/4a3fef5cc34975c898ffe77141450f679721df9dde00f6c304353fa9c8b571929123b26a0e4617bde5018977eb655b31970c297b91b63ee83bb82aeb04666880 +"chai@npm:^6.2.2": + version: 6.2.2 + resolution: "chai@npm:6.2.2" + checksum: 10c0/e6c69e5f0c11dffe6ea13d0290936ebb68fcc1ad688b8e952e131df6a6d5797d5e860bc55cef1aca2e950c3e1f96daf79e9d5a70fb7dbaab4e46355e2635ed53 languageName: node linkType: hard -"chardet@npm:^2.1.1": - version: 2.2.0 - resolution: "chardet@npm:2.2.0" - checksum: 10c0/8d43a1dd3ce535aa070d04139fae62f879b4388394eb1d857364ce416675a1f0f63974fba8208e9cd11b49e1a6da3e65ea6393d0fc654a8c4217c0d74c16b1b4 +"chalk@npm:5.6.2": + version: 5.6.2 + resolution: "chalk@npm:5.6.2" + checksum: 10c0/99a4b0f0e7991796b1e7e3f52dceb9137cae2a9dfc8fc0784a550dc4c558e15ab32ed70b14b21b52beb2679b4892b41a0aa44249bcb996f01e125d58477c6976 languageName: node linkType: hard @@ -1252,29 +1318,6 @@ __metadata: languageName: node linkType: hard -"color-convert@npm:^2.0.1": - version: 2.0.1 - resolution: "color-convert@npm:2.0.1" - dependencies: - color-name: "npm:~1.1.4" - checksum: 10c0/37e1150172f2e311fe1b2df62c6293a342ee7380da7b9cfdba67ea539909afbd74da27033208d01d6d5cfc65ee7868a22e18d7e7648e004425441c0f8a15a7d7 - languageName: node - linkType: hard - -"color-name@npm:~1.1.4": - version: 1.1.4 - resolution: "color-name@npm:1.1.4" - checksum: 10c0/a1a3f914156960902f46f7f56bc62effc6c94e84b2cae157a526b1c1f74b677a47ec602bf68a61abfa2b42d15b7c5651c6dbe72a43af720bc588dff885b10f95 - languageName: node - linkType: hard - -"concat-map@npm:0.0.1": - version: 0.0.1 - resolution: "concat-map@npm:0.0.1" - checksum: 10c0/c996b1cfdf95b6c90fee4dae37e332c8b6eb7d106430c17d538034c0ad9a1630cb194d2ab37293b1bdd4d779494beee7786d586a50bd9376fd6f7bcc2bd4c98f - languageName: node - linkType: hard - "concurrently@npm:^10.0.3": version: 10.0.3 resolution: "concurrently@npm:10.0.3" @@ -1292,7 +1335,14 @@ __metadata: languageName: node linkType: hard -"cross-spawn@npm:^7.0.5, cross-spawn@npm:^7.0.6": +"convert-source-map@npm:^2.0.0": + version: 2.0.0 + resolution: "convert-source-map@npm:2.0.0" + checksum: 10c0/8f2f7a27a1a011cc6cc88cc4da2d7d0cfa5ee0369508baae3d98c260bb3ac520691464e5bbe4ae7cdf09860c1d69ecc6f70c63c6e7c7f7e3f18ec08484dc7d9b + languageName: node + linkType: hard + +"cross-spawn@npm:^7.0.6": version: 7.0.6 resolution: "cross-spawn@npm:7.0.6" dependencies: @@ -1329,19 +1379,10 @@ __metadata: languageName: node linkType: hard -"detect-indent@npm:^6.0.0": - version: 6.1.0 - resolution: "detect-indent@npm:6.1.0" - checksum: 10c0/dd83cdeda9af219cf77f5e9a0dc31d828c045337386cfb55ce04fad94ba872ee7957336834154f7647b89b899c3c7acc977c57a79b7c776b506240993f97acc7 - languageName: node - linkType: hard - -"dir-glob@npm:^3.0.1": - version: 3.0.1 - resolution: "dir-glob@npm:3.0.1" - dependencies: - path-type: "npm:^4.0.0" - checksum: 10c0/dcac00920a4d503e38bb64001acb19df4efc14536ada475725e12f52c16777afdee4db827f55f13a908ee7efc0cb282e2e3dbaeeb98c0993dd93d1802d3bf00c +"detect-libc@npm:^2.0.3": + version: 2.1.2 + resolution: "detect-libc@npm:2.1.2" + checksum: 10c0/acc675c29a5649fa1fb6e255f993b8ee829e510b6b56b0910666949c80c364738833417d0edb5f90e4e46be17228b0f2b66a010513984e18b15deeeac49369c4 languageName: node linkType: hard @@ -1359,16 +1400,6 @@ __metadata: languageName: node linkType: hard -"enquirer@npm:^2.4.1": - version: 2.4.1 - resolution: "enquirer@npm:2.4.1" - dependencies: - ansi-colors: "npm:^4.1.1" - strip-ansi: "npm:^6.0.1" - checksum: 10c0/43850479d7a51d36a9c924b518dcdc6373b5a8ae3401097d336b7b7e258324749d0ad37a1fcaa5706f04799baa05585cd7af19ebdf7667673e7694435fcea918 - languageName: node - linkType: hard - "env-paths@npm:^2.2.0": version: 2.2.1 resolution: "env-paths@npm:2.2.1" @@ -1376,6 +1407,13 @@ __metadata: languageName: node linkType: hard +"es-module-lexer@npm:^2.0.0": + version: 2.3.2 + resolution: "es-module-lexer@npm:2.3.2" + checksum: 10c0/5e7389424c43478439f12f9a6aca1750f6f99afa384fc3de329f4a45f152ab156671055008adaafc74960877abf8cc338aeebf6bed8c21297b146fd6eb7a22f8 + languageName: node + linkType: hard + "escalade@npm:^3.1.1": version: 3.2.0 resolution: "escalade@npm:3.2.0" @@ -1402,16 +1440,6 @@ __metadata: languageName: node linkType: hard -"eslint-scope@npm:^8.4.0": - version: 8.4.0 - resolution: "eslint-scope@npm:8.4.0" - dependencies: - esrecurse: "npm:^4.3.0" - estraverse: "npm:^5.2.0" - checksum: 10c0/407f6c600204d0f3705bd557f81bd0189e69cd7996f408f8971ab5779c0af733d1af2f1412066b40ee1588b085874fc37a2333986c6521669cdbdd36ca5058e0 - languageName: node - linkType: hard - "eslint-scope@npm:^9.1.2": version: 9.1.2 resolution: "eslint-scope@npm:9.1.2" @@ -1490,56 +1518,7 @@ __metadata: languageName: node linkType: hard -"eslint@npm:^9.17.0": - version: 9.39.4 - resolution: "eslint@npm:9.39.4" - dependencies: - "@eslint-community/eslint-utils": "npm:^4.8.0" - "@eslint-community/regexpp": "npm:^4.12.1" - "@eslint/config-array": "npm:^0.21.2" - "@eslint/config-helpers": "npm:^0.4.2" - "@eslint/core": "npm:^0.17.0" - "@eslint/eslintrc": "npm:^3.3.5" - "@eslint/js": "npm:9.39.4" - "@eslint/plugin-kit": "npm:^0.4.1" - "@humanfs/node": "npm:^0.16.6" - "@humanwhocodes/module-importer": "npm:^1.0.1" - "@humanwhocodes/retry": "npm:^0.4.2" - "@types/estree": "npm:^1.0.6" - ajv: "npm:^6.14.0" - chalk: "npm:^4.0.0" - cross-spawn: "npm:^7.0.6" - debug: "npm:^4.3.2" - escape-string-regexp: "npm:^4.0.0" - eslint-scope: "npm:^8.4.0" - eslint-visitor-keys: "npm:^4.2.1" - espree: "npm:^10.4.0" - esquery: "npm:^1.5.0" - esutils: "npm:^2.0.2" - fast-deep-equal: "npm:^3.1.3" - file-entry-cache: "npm:^8.0.0" - find-up: "npm:^5.0.0" - glob-parent: "npm:^6.0.2" - ignore: "npm:^5.2.0" - imurmurhash: "npm:^0.1.4" - is-glob: "npm:^4.0.0" - json-stable-stringify-without-jsonify: "npm:^1.0.1" - lodash.merge: "npm:^4.6.2" - minimatch: "npm:^3.1.5" - natural-compare: "npm:^1.4.0" - optionator: "npm:^0.9.3" - peerDependencies: - jiti: "*" - peerDependenciesMeta: - jiti: - optional: true - bin: - eslint: bin/eslint.js - checksum: 10c0/1955067c2d991f0c84f4c4abfafe31bb47fa3b717a7fd3e43fe1e511c6f859d7700cbca969f85661dc4c130f7aeced5e5444884314198a54428f5e5141db9337 - languageName: node - linkType: hard - -"espree@npm:^10.0.1, espree@npm:^10.4.0": +"espree@npm:^10.4.0": version: 10.4.0 resolution: "espree@npm:10.4.0" dependencies: @@ -1561,17 +1540,7 @@ __metadata: languageName: node linkType: hard -"esprima@npm:^4.0.0": - version: 4.0.1 - resolution: "esprima@npm:4.0.1" - bin: - esparse: ./bin/esparse.js - esvalidate: ./bin/esvalidate.js - checksum: 10c0/ad4bab9ead0808cf56501750fd9d3fb276f6b105f987707d059005d57e182d18a7c9ec7f3a01794ebddcca676773e42ca48a32d67a250c9d35e009ca613caba3 - languageName: node - linkType: hard - -"esquery@npm:^1.5.0, esquery@npm:^1.7.0": +"esquery@npm:^1.7.0": version: 1.7.0 resolution: "esquery@npm:1.7.0" dependencies: @@ -1596,6 +1565,15 @@ __metadata: languageName: node linkType: hard +"estree-walker@npm:^3.0.3": + version: 3.0.3 + resolution: "estree-walker@npm:3.0.3" + dependencies: + "@types/estree": "npm:^1.0.0" + checksum: 10c0/c12e3c2b2642d2bcae7d5aa495c60fa2f299160946535763969a1c83fc74518ffa9c2cd3a8b69ac56aea547df6a8aac25f729a342992ef0bbac5f1c73e78995d + languageName: node + linkType: hard + "esutils@npm:^2.0.2": version: 2.0.3 resolution: "esutils@npm:2.0.3" @@ -1603,6 +1581,27 @@ __metadata: languageName: node linkType: hard +"event-target-shim@npm:^5.0.0": + version: 5.0.1 + resolution: "event-target-shim@npm:5.0.1" + checksum: 10c0/0255d9f936215fd206156fd4caa9e8d35e62075d720dc7d847e89b417e5e62cf1ce6c9b4e0a1633a9256de0efefaf9f8d26924b1f3c8620cffb9db78e7d3076b + languageName: node + linkType: hard + +"events@npm:^3.3.0": + version: 3.3.0 + resolution: "events@npm:3.3.0" + checksum: 10c0/d6b6f2adbccbcda74ddbab52ed07db727ef52e31a61ed26db9feb7dc62af7fc8e060defa65e5f8af9449b86b52cc1a1f6a79f2eafcf4e62add2b7a1fa4a432f6 + languageName: node + linkType: hard + +"expect-type@npm:^1.3.0": + version: 1.4.0 + resolution: "expect-type@npm:1.4.0" + checksum: 10c0/d40d76b8570695d36587beb3cc28494da2ca3ec8f04e67f5622ed2d372d850e401a9adef19c6835e1a8173903f157c79540b34c7b3fbd7cd8ce726cc903c57b7 + languageName: node + linkType: hard + "exponential-backoff@npm:^3.1.1": version: 3.1.3 resolution: "exponential-backoff@npm:3.1.3" @@ -1610,13 +1609,6 @@ __metadata: languageName: node linkType: hard -"extendable-error@npm:^0.1.5": - version: 0.1.7 - resolution: "extendable-error@npm:0.1.7" - checksum: 10c0/c46648b7682448428f81b157cbfe480170fd96359c55db477a839ddeaa34905a18cba0b989bafe5e83f93c2491a3fcc7cc536063ea326ba9d72e9c6e2fe736a7 - languageName: node - linkType: hard - "fast-deep-equal@npm:^3.1.1, fast-deep-equal@npm:^3.1.3": version: 3.1.3 resolution: "fast-deep-equal@npm:3.1.3" @@ -1624,19 +1616,6 @@ __metadata: languageName: node linkType: hard -"fast-glob@npm:^3.2.9": - version: 3.3.3 - resolution: "fast-glob@npm:3.3.3" - dependencies: - "@nodelib/fs.stat": "npm:^2.0.2" - "@nodelib/fs.walk": "npm:^1.2.3" - glob-parent: "npm:^5.1.2" - merge2: "npm:^1.3.0" - micromatch: "npm:^4.0.8" - checksum: 10c0/f6aaa141d0d3384cf73cbcdfc52f475ed293f6d5b65bfc5def368b09163a9f7e5ec2b3014d80f733c405f58e470ee0cc451c2937685045cddcdeaa24199c43fe - languageName: node - linkType: hard - "fast-json-stable-stringify@npm:^2.0.0": version: 2.1.0 resolution: "fast-json-stable-stringify@npm:2.1.0" @@ -1651,12 +1630,28 @@ __metadata: languageName: node linkType: hard -"fastq@npm:^1.6.0": - version: 1.20.1 - resolution: "fastq@npm:1.20.1" +"fast-string-truncated-width@npm:^3.0.2": + version: 3.0.3 + resolution: "fast-string-truncated-width@npm:3.0.3" + checksum: 10c0/043b8663397d14a3880ce4f3407bcda60b40db9bbeafe62863a35d1f9c69ea17c8da3fcd72de235553e6c9cd053128cde9e24ca0d4a7463208f48db3cd23d981 + languageName: node + linkType: hard + +"fast-string-width@npm:^3.0.2": + version: 3.0.2 + resolution: "fast-string-width@npm:3.0.2" + dependencies: + fast-string-truncated-width: "npm:^3.0.2" + checksum: 10c0/c8822d175315bb353ebe782b65214ac53b13e3bf704e03b132ea7bdfa8de6a636375b3ab7a4097545393d109381c37c4f387c72a462c90b61412dbc4632f39a7 + languageName: node + linkType: hard + +"fast-wrap-ansi@npm:^0.2.0": + version: 0.2.2 + resolution: "fast-wrap-ansi@npm:0.2.2" dependencies: - reusify: "npm:^1.0.4" - checksum: 10c0/e5dd725884decb1f11e5c822221d76136f239d0236f176fab80b7b8f9e7619ae57e6b4e5b73defc21e6b9ef99437ee7b545cff8e6c2c337819633712fa9d352e + fast-string-width: "npm:^3.0.2" + checksum: 10c0/1aa7be4f7cb86f4bdb14691cb6bcc0b8df8b3b89df142ade3ae1602332dcf6f990cd750a923cd581ca0847808cb4ec1aa5afaafa7a72f849e87a2a62c98fa370 languageName: node linkType: hard @@ -1690,16 +1685,6 @@ __metadata: languageName: node linkType: hard -"find-up@npm:^4.1.0": - version: 4.1.0 - resolution: "find-up@npm:4.1.0" - dependencies: - locate-path: "npm:^5.0.0" - path-exists: "npm:^4.0.0" - checksum: 10c0/0406ee89ebeefa2d507feb07ec366bebd8a6167ae74aa4e34fb4c4abd06cf782a3ce26ae4194d70706f72182841733f00551c209fe575cb00bd92104056e78c1 - languageName: node - linkType: hard - "find-up@npm:^5.0.0": version: 5.0.0 resolution: "find-up@npm:5.0.0" @@ -1727,29 +1712,7 @@ __metadata: languageName: node linkType: hard -"fs-extra@npm:^7.0.1": - version: 7.0.1 - resolution: "fs-extra@npm:7.0.1" - dependencies: - graceful-fs: "npm:^4.1.2" - jsonfile: "npm:^4.0.0" - universalify: "npm:^0.1.0" - checksum: 10c0/1943bb2150007e3739921b8d13d4109abdc3cc481e53b97b7ea7f77eda1c3c642e27ae49eac3af074e3496ea02fde30f411ef410c760c70a38b92e656e5da784 - languageName: node - linkType: hard - -"fs-extra@npm:^8.1.0": - version: 8.1.0 - resolution: "fs-extra@npm:8.1.0" - dependencies: - graceful-fs: "npm:^4.2.0" - jsonfile: "npm:^4.0.0" - universalify: "npm:^0.1.0" - checksum: 10c0/259f7b814d9e50d686899550c4f9ded85c46c643f7fe19be69504888e007fcbc08f306fae8ec495b8b998635e997c9e3e175ff2eeed230524ef1c1684cc96423 - languageName: node - linkType: hard - -"fsevents@npm:~2.3.2": +"fsevents@npm:~2.3.2, fsevents@npm:~2.3.3": version: 2.3.3 resolution: "fsevents@npm:2.3.3" dependencies: @@ -1759,7 +1722,7 @@ __metadata: languageName: node linkType: hard -"fsevents@patch:fsevents@npm%3A~2.3.2#optional!builtin": +"fsevents@patch:fsevents@npm%3A~2.3.2#optional!builtin, fsevents@patch:fsevents@npm%3A~2.3.3#optional!builtin": version: 2.3.3 resolution: "fsevents@patch:fsevents@npm%3A2.3.3#optional!builtin::version=2.3.3&hash=df0bf1" dependencies: @@ -1782,15 +1745,6 @@ __metadata: languageName: node linkType: hard -"glob-parent@npm:^5.1.2, glob-parent@npm:~5.1.2": - version: 5.1.2 - resolution: "glob-parent@npm:5.1.2" - dependencies: - is-glob: "npm:^4.0.1" - checksum: 10c0/cab87638e2112bee3f839ef5f6e0765057163d39c66be8ec1602f3823da4692297ad4e972de876ea17c44d652978638d2fd583c6713d0eb6591706825020c9ee - languageName: node - linkType: hard - "glob-parent@npm:^6.0.2": version: 6.0.2 resolution: "glob-parent@npm:6.0.2" @@ -1800,10 +1754,12 @@ __metadata: languageName: node linkType: hard -"globals@npm:^14.0.0": - version: 14.0.0 - resolution: "globals@npm:14.0.0" - checksum: 10c0/b96ff42620c9231ad468d4c58ff42afee7777ee1c963013ff8aabe095a451d0ceeb8dcd8ef4cbd64d2538cef45f787a78ba3a9574f4a634438963e334471302d +"glob-parent@npm:~5.1.2": + version: 5.1.2 + resolution: "glob-parent@npm:5.1.2" + dependencies: + is-glob: "npm:^4.0.1" + checksum: 10c0/cab87638e2112bee3f839ef5f6e0765057163d39c66be8ec1602f3823da4692297ad4e972de876ea17c44d652978638d2fd583c6713d0eb6591706825020c9ee languageName: node linkType: hard @@ -1814,21 +1770,7 @@ __metadata: languageName: node linkType: hard -"globby@npm:^11.0.0": - version: 11.1.0 - resolution: "globby@npm:11.1.0" - dependencies: - array-union: "npm:^2.1.0" - dir-glob: "npm:^3.0.1" - fast-glob: "npm:^3.2.9" - ignore: "npm:^5.2.0" - merge2: "npm:^1.4.1" - slash: "npm:^3.0.0" - checksum: 10c0/b39511b4afe4bd8a7aead3a27c4ade2b9968649abab0a6c28b1a90141b96ca68ca5db1302f7c7bd29eab66bf51e13916b8e0a3d0ac08f75e1e84a39b35691189 - languageName: node - linkType: hard - -"graceful-fs@npm:^4.1.2, graceful-fs@npm:^4.1.5, graceful-fs@npm:^4.1.6, graceful-fs@npm:^4.2.0, graceful-fs@npm:^4.2.6": +"graceful-fs@npm:^4.2.6": version: 4.2.11 resolution: "graceful-fs@npm:4.2.11" checksum: 10c0/386d011a553e02bc594ac2ca0bd6d9e4c22d7fa8cfbfc448a6d148c59ea881b092db9dbe3547ae4b88e55f1b01f7c4a2ecc53b310c042793e63aa44cf6c257f2 @@ -1849,21 +1791,26 @@ __metadata: languageName: node linkType: hard -"human-id@npm:^4.1.1": - version: 4.2.0 - resolution: "human-id@npm:4.2.0" +"html-escaper@npm:^2.0.0": + version: 2.0.2 + resolution: "html-escaper@npm:2.0.2" + checksum: 10c0/208e8a12de1a6569edbb14544f4567e6ce8ecc30b9394fcaa4e7bb1e60c12a7c9a1ed27e31290817157e8626f3a4f29e76c8747030822eb84a6abb15c255f0a0 + languageName: node + linkType: hard + +"human-id@npm:^4.2.0": + version: 4.2.1 + resolution: "human-id@npm:4.2.1" bin: human-id: dist/cli.js - checksum: 10c0/80071f3b785b2e91080b5f9aa2d079c2e9a464e009ea12c035255b61d1b70beefe7eda42209dff9c1bb9a9fa58e50ada38c1b314ba3a23266a72cd5e9a453e1f + checksum: 10c0/e7a6f89843fc10c3827d856f862b6b65678de22d97336524ff61ad0ff9414e9c6c8414bbcf7ccb329bdb7651db65666fa84aeac4b48aa3891f3ed8f0178a70c6 languageName: node linkType: hard -"iconv-lite@npm:^0.7.0": - version: 0.7.3 - resolution: "iconv-lite@npm:0.7.3" - dependencies: - safer-buffer: "npm:>= 2.1.2 < 3.0.0" - checksum: 10c0/be2fd2414f7e94be3a63063fa0ad5919c16bc7b6e00c77eea0765ced6db405739f38dd51016583058fba25cc1d0106ba30b83fbb7a7e32f824d4db76f23d8d33 +"ieee754@npm:^1.2.1": + version: 1.2.1 + resolution: "ieee754@npm:1.2.1" + checksum: 10c0/b0782ef5e0935b9f12883a2e2aa37baa75da6e66ce6515c168697b42160807d9330de9a32ec1ed73149aea02e0d822e572bca6f1e22bdcbd2149e13b050b17bb languageName: node linkType: hard @@ -1888,13 +1835,10 @@ __metadata: languageName: node linkType: hard -"import-fresh@npm:^3.2.1": - version: 3.3.1 - resolution: "import-fresh@npm:3.3.1" - dependencies: - parent-module: "npm:^1.0.0" - resolve-from: "npm:^4.0.0" - checksum: 10c0/bf8cc494872fef783249709385ae883b447e3eb09db0ebd15dcead7d9afe7224dad7bd7591c6b73b0b19b3c0f9640eb8ee884f01cfaf2887ab995b0b36a0cbec +"import-meta-resolve@npm:^4.2.0": + version: 4.2.0 + resolution: "import-meta-resolve@npm:4.2.0" + checksum: 10c0/3ee8aeecb61d19b49d2703987f977e9d1c7d4ba47db615a570eaa02fe414f40dfa63f7b953e842cbe8470d26df6371332bfcf21b2fd92b0112f9fea80dde2c4c languageName: node linkType: hard @@ -1937,22 +1881,6 @@ __metadata: languageName: node linkType: hard -"is-subdir@npm:^1.1.1": - version: 1.2.0 - resolution: "is-subdir@npm:1.2.0" - dependencies: - better-path-resolve: "npm:1.0.0" - checksum: 10c0/03a03ee2ee6578ce589b1cfaf00e65c86b20fd1b82c1660625557c535439a7477cda77e20c62cda6d4c99e7fd908b4619355ae2d989f4a524a35350a44353032 - languageName: node - linkType: hard - -"is-windows@npm:^1.0.0": - version: 1.0.2 - resolution: "is-windows@npm:1.0.2" - checksum: 10c0/b32f418ab3385604a66f1b7a3ce39d25e8881dee0bd30816dc8344ef6ff9df473a732bcc1ec4e84fe99b2f229ae474f7133e8e93f9241686cfcf7eebe53ba7a5 - languageName: node - linkType: hard - "isexe@npm:^2.0.0": version: 2.0.0 resolution: "isexe@npm:2.0.0" @@ -1967,6 +1895,34 @@ __metadata: languageName: node linkType: hard +"istanbul-lib-coverage@npm:^3.0.0, istanbul-lib-coverage@npm:^3.2.2": + version: 3.2.2 + resolution: "istanbul-lib-coverage@npm:3.2.2" + checksum: 10c0/6c7ff2106769e5f592ded1fb418f9f73b4411fd5a084387a5410538332b6567cd1763ff6b6cadca9b9eb2c443cce2f7ea7d7f1b8d315f9ce58539793b1e0922b + languageName: node + linkType: hard + +"istanbul-lib-report@npm:^3.0.0, istanbul-lib-report@npm:^3.0.1": + version: 3.0.1 + resolution: "istanbul-lib-report@npm:3.0.1" + dependencies: + istanbul-lib-coverage: "npm:^3.0.0" + make-dir: "npm:^4.0.0" + supports-color: "npm:^7.1.0" + checksum: 10c0/84323afb14392de8b6a5714bd7e9af845cfbd56cfe71ed276cda2f5f1201aea673c7111901227ee33e68e4364e288d73861eb2ed48f6679d1e69a43b6d9b3ba7 + languageName: node + linkType: hard + +"istanbul-reports@npm:^3.2.0": + version: 3.2.0 + resolution: "istanbul-reports@npm:3.2.0" + dependencies: + html-escaper: "npm:^2.0.0" + istanbul-lib-report: "npm:^3.0.0" + checksum: 10c0/d596317cfd9c22e1394f22a8d8ba0303d2074fe2e971887b32d870e4b33f8464b10f8ccbe6847808f7db485f084eba09e6c2ed706b3a978e4b52f07085b8f9bc + languageName: node + linkType: hard + "jiti@npm:^2.7.0": version: 2.7.0 resolution: "jiti@npm:2.7.0" @@ -1976,26 +1932,17 @@ __metadata: languageName: node linkType: hard -"js-yaml@npm:^3.6.1": - version: 3.15.0 - resolution: "js-yaml@npm:3.15.0" - dependencies: - argparse: "npm:^1.0.7" - esprima: "npm:^4.0.0" - bin: - js-yaml: bin/js-yaml.js - checksum: 10c0/ca966bd354ac5b1b7a4694ebdba46526796aa3a6a99529fa540af2abf85918bd155a50ccc0166b413130a00622999973754458ec01e7095bc902177bfdbd5b64 +"jju@npm:^1.4.0": + version: 1.4.0 + resolution: "jju@npm:1.4.0" + checksum: 10c0/f3f444557e4364cfc06b1abf8331bf3778b26c0c8552ca54429bc0092652172fdea26cbffe33e1017b303d5aa506f7ede8571857400efe459cb7439180e2acad languageName: node linkType: hard -"js-yaml@npm:^4.1.1": - version: 4.2.0 - resolution: "js-yaml@npm:4.2.0" - dependencies: - argparse: "npm:^2.0.1" - bin: - js-yaml: bin/js-yaml.js - checksum: 10c0/1916456c118746603b067d74bbcbb0445d9a1d5e474ad4ae775e7b20525bed902e01d9d97dd0c81fcd8d4f596162309d0eb057f4aa38f3e9647f14075e9dea45 +"js-tokens@npm:^10.0.0": + version: 10.0.0 + resolution: "js-tokens@npm:10.0.0" + checksum: 10c0/a93498747812ba3e0c8626f95f75ab29319f2a13613a0de9e610700405760931624433a0de59eb7c27ff8836e526768fb20783861b86ef89be96676f2c996b64 languageName: node linkType: hard @@ -2020,43 +1967,159 @@ __metadata: languageName: node linkType: hard -"jsonfile@npm:^4.0.0": - version: 4.0.0 - resolution: "jsonfile@npm:4.0.0" - dependencies: - graceful-fs: "npm:^4.1.6" - dependenciesMeta: - graceful-fs: - optional: true - checksum: 10c0/7dc94b628d57a66b71fb1b79510d460d662eb975b5f876d723f81549c2e9cd316d58a2ddf742b2b93a4fa6b17b2accaf1a738a0e2ea114bdfb13a32e5377e480 +"jsonc-parser@npm:^3.3.1": + version: 3.3.1 + resolution: "jsonc-parser@npm:3.3.1" + checksum: 10c0/269c3ae0a0e4f907a914bf334306c384aabb9929bd8c99f909275ebd5c2d3bc70b9bcd119ad794f339dec9f24b6a4ee9cd5a8ab2e6435e730ad4075388fc2ab6 + languageName: node + linkType: hard + +"keyv@npm:^4.5.4": + version: 4.5.4 + resolution: "keyv@npm:4.5.4" + dependencies: + json-buffer: "npm:3.0.1" + checksum: 10c0/aa52f3c5e18e16bb6324876bb8b59dd02acf782a4b789c7b2ae21107fab95fab3890ed448d4f8dba80ce05391eeac4bfabb4f02a20221342982f806fa2cf271e + languageName: node + linkType: hard + +"launch-editor@npm:^2.14.1": + version: 2.14.1 + resolution: "launch-editor@npm:2.14.1" + dependencies: + picocolors: "npm:^1.1.1" + shell-quote: "npm:^1.8.4" + checksum: 10c0/bb0ab182086afaf1c391abd38d6fc9d5391cb43d0c2da1d96176f79e9995635cdf34dcfd8df3f756bb82d7bbbb7d37014d5eb312f337350889c0cff92db53dab + languageName: node + linkType: hard + +"levn@npm:^0.4.1": + version: 0.4.1 + resolution: "levn@npm:0.4.1" + dependencies: + prelude-ls: "npm:^1.2.1" + type-check: "npm:~0.4.0" + checksum: 10c0/effb03cad7c89dfa5bd4f6989364bfc79994c2042ec5966cb9b95990e2edee5cd8969ddf42616a0373ac49fac1403437deaf6e9050fbbaa3546093a59b9ac94e + languageName: node + linkType: hard + +"lightningcss-android-arm64@npm:1.33.0": + version: 1.33.0 + resolution: "lightningcss-android-arm64@npm:1.33.0" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + +"lightningcss-darwin-arm64@npm:1.33.0": + version: 1.33.0 + resolution: "lightningcss-darwin-arm64@npm:1.33.0" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"lightningcss-darwin-x64@npm:1.33.0": + version: 1.33.0 + resolution: "lightningcss-darwin-x64@npm:1.33.0" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"lightningcss-freebsd-x64@npm:1.33.0": + version: 1.33.0 + resolution: "lightningcss-freebsd-x64@npm:1.33.0" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + +"lightningcss-linux-arm-gnueabihf@npm:1.33.0": + version: 1.33.0 + resolution: "lightningcss-linux-arm-gnueabihf@npm:1.33.0" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + +"lightningcss-linux-arm64-gnu@npm:1.33.0": + version: 1.33.0 + resolution: "lightningcss-linux-arm64-gnu@npm:1.33.0" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"lightningcss-linux-arm64-musl@npm:1.33.0": + version: 1.33.0 + resolution: "lightningcss-linux-arm64-musl@npm:1.33.0" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"lightningcss-linux-x64-gnu@npm:1.33.0": + version: 1.33.0 + resolution: "lightningcss-linux-x64-gnu@npm:1.33.0" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"lightningcss-linux-x64-musl@npm:1.33.0": + version: 1.33.0 + resolution: "lightningcss-linux-x64-musl@npm:1.33.0" + conditions: os=linux & cpu=x64 & libc=musl languageName: node linkType: hard -"keyv@npm:^4.5.4": - version: 4.5.4 - resolution: "keyv@npm:4.5.4" - dependencies: - json-buffer: "npm:3.0.1" - checksum: 10c0/aa52f3c5e18e16bb6324876bb8b59dd02acf782a4b789c7b2ae21107fab95fab3890ed448d4f8dba80ce05391eeac4bfabb4f02a20221342982f806fa2cf271e +"lightningcss-win32-arm64-msvc@npm:1.33.0": + version: 1.33.0 + resolution: "lightningcss-win32-arm64-msvc@npm:1.33.0" + conditions: os=win32 & cpu=arm64 languageName: node linkType: hard -"levn@npm:^0.4.1": - version: 0.4.1 - resolution: "levn@npm:0.4.1" - dependencies: - prelude-ls: "npm:^1.2.1" - type-check: "npm:~0.4.0" - checksum: 10c0/effb03cad7c89dfa5bd4f6989364bfc79994c2042ec5966cb9b95990e2edee5cd8969ddf42616a0373ac49fac1403437deaf6e9050fbbaa3546093a59b9ac94e +"lightningcss-win32-x64-msvc@npm:1.33.0": + version: 1.33.0 + resolution: "lightningcss-win32-x64-msvc@npm:1.33.0" + conditions: os=win32 & cpu=x64 languageName: node linkType: hard -"locate-path@npm:^5.0.0": - version: 5.0.0 - resolution: "locate-path@npm:5.0.0" +"lightningcss@npm:^1.33.0": + version: 1.33.0 + resolution: "lightningcss@npm:1.33.0" dependencies: - p-locate: "npm:^4.1.0" - checksum: 10c0/33a1c5247e87e022f9713e6213a744557a3e9ec32c5d0b5efb10aa3a38177615bf90221a5592674857039c1a0fd2063b82f285702d37b792d973e9e72ace6c59 + detect-libc: "npm:^2.0.3" + lightningcss-android-arm64: "npm:1.33.0" + lightningcss-darwin-arm64: "npm:1.33.0" + lightningcss-darwin-x64: "npm:1.33.0" + lightningcss-freebsd-x64: "npm:1.33.0" + lightningcss-linux-arm-gnueabihf: "npm:1.33.0" + lightningcss-linux-arm64-gnu: "npm:1.33.0" + lightningcss-linux-arm64-musl: "npm:1.33.0" + lightningcss-linux-x64-gnu: "npm:1.33.0" + lightningcss-linux-x64-musl: "npm:1.33.0" + lightningcss-win32-arm64-msvc: "npm:1.33.0" + lightningcss-win32-x64-msvc: "npm:1.33.0" + dependenciesMeta: + lightningcss-android-arm64: + optional: true + lightningcss-darwin-arm64: + optional: true + lightningcss-darwin-x64: + optional: true + lightningcss-freebsd-x64: + optional: true + lightningcss-linux-arm-gnueabihf: + optional: true + lightningcss-linux-arm64-gnu: + optional: true + lightningcss-linux-arm64-musl: + optional: true + lightningcss-linux-x64-gnu: + optional: true + lightningcss-linux-x64-musl: + optional: true + lightningcss-win32-arm64-msvc: + optional: true + lightningcss-win32-x64-msvc: + optional: true + checksum: 10c0/ce1f8279fbae636dbf37fa6e7385d5f98ed881d72af3362f24afbd4685e19c1fcdfecf17e5dd77f2ebee3d0c23ade276230d85842d07292229a2cffba8ff20a3 languageName: node linkType: hard @@ -2069,34 +2132,46 @@ __metadata: languageName: node linkType: hard -"lodash.merge@npm:4.6.2, lodash.merge@npm:^4.6.2": +"lodash.merge@npm:4.6.2": version: 4.6.2 resolution: "lodash.merge@npm:4.6.2" checksum: 10c0/402fa16a1edd7538de5b5903a90228aa48eb5533986ba7fa26606a49db2572bf414ff73a2c9f5d5fd36b31c46a5d5c7e1527749c07cbcf965ccff5fbdf32c506 languageName: node linkType: hard -"lodash.startcase@npm:^4.4.0": - version: 4.4.0 - resolution: "lodash.startcase@npm:4.4.0" - checksum: 10c0/bd82aa87a45de8080e1c5ee61128c7aee77bf7f1d86f4ff94f4a6d7438fc9e15e5f03374b947be577a93804c8ad6241f0251beaf1452bf716064eeb657b3a9f0 +"lodash.reduce@npm:^4.6.0": + version: 4.6.0 + resolution: "lodash.reduce@npm:4.6.0" + checksum: 10c0/5d2dab823523a1a7f81eb5f4c1edcc03aab55504b1299a2385737389644ba6d2ad219169dfc5c16632a67a345d925ef6a5e8816b4e18a36f94ed66f8e7740b36 languageName: node linkType: hard -"merge2@npm:^1.3.0, merge2@npm:^1.4.1": - version: 1.4.1 - resolution: "merge2@npm:1.4.1" - checksum: 10c0/254a8a4605b58f450308fc474c82ac9a094848081bf4c06778200207820e5193726dc563a0d2c16468810516a5c97d9d3ea0ca6585d23c58ccfff2403e8dbbeb +"magic-string@npm:^0.30.21": + version: 0.30.21 + resolution: "magic-string@npm:0.30.21" + dependencies: + "@jridgewell/sourcemap-codec": "npm:^1.5.5" + checksum: 10c0/299378e38f9a270069fc62358522ddfb44e94244baa0d6a8980ab2a9b2490a1d03b236b447eee309e17eb3bddfa482c61259d47960eb018a904f0ded52780c4a + languageName: node + linkType: hard + +"magicast@npm:^0.5.2": + version: 0.5.4 + resolution: "magicast@npm:0.5.4" + dependencies: + "@babel/parser": "npm:^7.29.7" + "@babel/types": "npm:^7.29.7" + source-map-js: "npm:^1.2.1" + checksum: 10c0/f6a3b33d1c994cace3999fc96876a9fd06429deff8266563ccdc1d731c9edc4e1fb59d724128d7d4f3382eb457cd5a39ae1ec7b1e1864e068297ee2a30c12a3d languageName: node linkType: hard -"micromatch@npm:^4.0.8": - version: 4.0.8 - resolution: "micromatch@npm:4.0.8" +"make-dir@npm:^4.0.0": + version: 4.0.0 + resolution: "make-dir@npm:4.0.0" dependencies: - braces: "npm:^3.0.3" - picomatch: "npm:^2.3.1" - checksum: 10c0/166fa6eb926b9553f32ef81f5f531d27b4ce7da60e5baf8c021d043b27a388fb95e46a8038d5045877881e673f8134122b59624d5cecbd16eb50a42e7a6b5ca8 + semver: "npm:^7.5.3" + checksum: 10c0/69b98a6c0b8e5c4fe9acb61608a9fbcfca1756d910f51e5dbe7a9e5cfb74fca9b8a0c8a0ffdf1294a740826c1ab4871d5bf3f62f72a3049e5eac6541ddffed68 languageName: node linkType: hard @@ -2109,15 +2184,6 @@ __metadata: languageName: node linkType: hard -"minimatch@npm:^3.1.5": - version: 3.1.5 - resolution: "minimatch@npm:3.1.5" - dependencies: - brace-expansion: "npm:^1.1.7" - checksum: 10c0/2ecbdc0d33f07bddb0315a8b5afbcb761307a8778b48f0b312418ccbced99f104a2d17d8aca7573433c70e8ccd1c56823a441897a45e384ea76ef401a26ace70 - languageName: node - linkType: hard - "minipass@npm:^7.0.4, minipass@npm:^7.1.2": version: 7.1.3 resolution: "minipass@npm:7.1.3" @@ -2134,13 +2200,6 @@ __metadata: languageName: node linkType: hard -"mri@npm:^1.2.0": - version: 1.2.0 - resolution: "mri@npm:1.2.0" - checksum: 10c0/a3d32379c2554cf7351db6237ddc18dc9e54e4214953f3da105b97dc3babe0deb3ffe99cf409b38ea47cc29f9430561ba6b53b24ab8f9ce97a4b50409e4a50e7 - languageName: node - linkType: hard - "ms@npm:^2.1.3": version: 2.1.3 resolution: "ms@npm:2.1.3" @@ -2148,6 +2207,15 @@ __metadata: languageName: node linkType: hard +"nanoid@npm:^3.3.17": + version: 3.3.18 + resolution: "nanoid@npm:3.3.18" + bin: + nanoid: bin/nanoid.cjs + checksum: 10c0/b994b4e396730f8be2520923284e2040d61eaee55cc6d4935ef6d38d34bafdc46133eda4d3faea5073bda545aa6079d82b886caeac5c731cf9ac18bcc1301425 + languageName: node + linkType: hard + "natural-compare@npm:^1.4.0": version: 1.4.0 resolution: "natural-compare@npm:1.4.0" @@ -2227,6 +2295,13 @@ __metadata: languageName: node linkType: hard +"obug@npm:^2.1.1": + version: 2.1.4 + resolution: "obug@npm:2.1.4" + checksum: 10c0/34a0ee97cd88573cfd97d384c2a79f07118ae5680d7e45d1de6e99c74eddefe145e8ca27a2db02195a1ee5fded5aa22b924869c842728c201b9f109a27d0ef19 + languageName: node + linkType: hard + "optionator@npm:^0.9.3": version: 0.9.4 resolution: "optionator@npm:0.9.4" @@ -2241,31 +2316,6 @@ __metadata: languageName: node linkType: hard -"outdent@npm:^0.5.0": - version: 0.5.0 - resolution: "outdent@npm:0.5.0" - checksum: 10c0/e216a4498889ba1babae06af84cdc4091f7cac86da49d22d0163b3be202a5f52efcd2bcd3dfca60a361eb3a27b4299f185c5655061b6b402552d7fcd1d040cff - languageName: node - linkType: hard - -"p-filter@npm:^2.1.0": - version: 2.1.0 - resolution: "p-filter@npm:2.1.0" - dependencies: - p-map: "npm:^2.0.0" - checksum: 10c0/5ac34b74b3b691c04212d5dd2319ed484f591c557a850a3ffc93a08cb38c4f5540be059c6b10a185773c479ca583a91ea00c7d6c9958c815e6b74d052f356645 - languageName: node - linkType: hard - -"p-limit@npm:^2.2.0": - version: 2.3.0 - resolution: "p-limit@npm:2.3.0" - dependencies: - p-try: "npm:^2.0.0" - checksum: 10c0/8da01ac53efe6a627080fafc127c873da40c18d87b3f5d5492d465bb85ec7207e153948df6b9cbaeb130be70152f874229b8242ee2be84c0794082510af97f12 - languageName: node - linkType: hard - "p-limit@npm:^3.0.2": version: 3.1.0 resolution: "p-limit@npm:3.1.0" @@ -2275,15 +2325,6 @@ __metadata: languageName: node linkType: hard -"p-locate@npm:^4.1.0": - version: 4.1.0 - resolution: "p-locate@npm:4.1.0" - dependencies: - p-limit: "npm:^2.2.0" - checksum: 10c0/1b476ad69ad7f6059744f343b26d51ce091508935c1dbb80c4e0a2f397ffce0ca3a1f9f5cd3c7ce19d7929a09719d5c65fe70d8ee289c3f267cd36f2881813e9 - languageName: node - linkType: hard - "p-locate@npm:^5.0.0": version: 5.0.0 resolution: "p-locate@npm:5.0.0" @@ -2293,35 +2334,10 @@ __metadata: languageName: node linkType: hard -"p-map@npm:^2.0.0": - version: 2.1.0 - resolution: "p-map@npm:2.1.0" - checksum: 10c0/735dae87badd4737a2dd582b6d8f93e49a1b79eabbc9815a4d63a528d5e3523e978e127a21d784cccb637010e32103a40d2aaa3ab23ae60250b1a820ca752043 - languageName: node - linkType: hard - -"p-try@npm:^2.0.0": - version: 2.2.0 - resolution: "p-try@npm:2.2.0" - checksum: 10c0/c36c19907734c904b16994e6535b02c36c2224d433e01a2f1ab777237f4d86e6289fd5fd464850491e940379d4606ed850c03e0f9ab600b0ebddb511312e177f - languageName: node - linkType: hard - -"package-manager-detector@npm:^0.2.0": - version: 0.2.11 - resolution: "package-manager-detector@npm:0.2.11" - dependencies: - quansync: "npm:^0.2.7" - checksum: 10c0/247991de461b9e731f3463b7dae9ce187e53095b7b94d7d96eec039abf418b61ccf74464bec1d0c11d97311f33472e77baccd4c5898f77358da4b5b33395e0b1 - languageName: node - linkType: hard - -"parent-module@npm:^1.0.0": - version: 1.0.1 - resolution: "parent-module@npm:1.0.1" - dependencies: - callsites: "npm:^3.0.0" - checksum: 10c0/c63d6e80000d4babd11978e0d3fee386ca7752a02b035fd2435960ffaa7219dc42146f07069fb65e6e8bf1caef89daf9af7535a39bddf354d78bf50d8294f556 +"package-manager-detector@npm:^1.6.0, package-manager-detector@npm:^1.8.0": + version: 1.8.0 + resolution: "package-manager-detector@npm:1.8.0" + checksum: 10c0/4c8e2c47fdc874465d3b3b11934401db9d73470ccbdabeb092e46cb94abbc491d5befc8c271b5ea1ae9c6a881f44c2997e011873365bde9fd6c3dc001221ff7a languageName: node linkType: hard @@ -2339,21 +2355,28 @@ __metadata: languageName: node linkType: hard -"path-type@npm:^4.0.0": - version: 4.0.0 - resolution: "path-type@npm:4.0.0" - checksum: 10c0/666f6973f332f27581371efaf303fd6c272cc43c2057b37aa99e3643158c7e4b2626549555d88626e99ea9e046f82f32e41bbde5f1508547e9a11b149b52387c +"pathe@npm:^2.0.3": + version: 2.0.3 + resolution: "pathe@npm:2.0.3" + checksum: 10c0/c118dc5a8b5c4166011b2b70608762e260085180bb9e33e80a50dcdb1e78c010b1624f4280c492c92b05fc276715a4c357d1f9edc570f8f1b3d90b6839ebaca1 languageName: node linkType: hard -"picocolors@npm:^1.1.0": +"pend@npm:~1.2.0": + version: 1.2.0 + resolution: "pend@npm:1.2.0" + checksum: 10c0/8a87e63f7a4afcfb0f9f77b39bb92374afc723418b9cb716ee4257689224171002e07768eeade4ecd0e86f1fa3d8f022994219fb45634f2dbd78c6803e452458 + languageName: node + linkType: hard + +"picocolors@npm:^1.1.1": version: 1.1.1 resolution: "picocolors@npm:1.1.1" checksum: 10c0/e2e3e8170ab9d7c7421969adaa7e1b31434f789afb9b3f115f6b96d91945041ac3ceb02e9ec6fe6510ff036bcc0bf91e69a1772edc0b707e12b19c0f2d6bcf58 languageName: node linkType: hard -"picomatch@npm:^2.0.4, picomatch@npm:^2.2.1, picomatch@npm:^2.3.1": +"picomatch@npm:^2.0.4, picomatch@npm:^2.2.1": version: 2.3.2 resolution: "picomatch@npm:2.3.2" checksum: 10c0/a554d1709e59be97d1acb9eaedbbc700a5c03dbd4579807baed95100b00420bc729335440ef15004ae2378984e2487a7c1cebd743cfdb72b6fa9ab69223c0d61 @@ -2367,10 +2390,28 @@ __metadata: languageName: node linkType: hard -"pify@npm:^4.0.1": - version: 4.0.1 - resolution: "pify@npm:4.0.1" - checksum: 10c0/6f9d404b0d47a965437403c9b90eca8bb2536407f03de165940e62e72c8c8b75adda5516c6b9b23675a5877cc0bcac6bdfb0ef0e39414cd2476d5495da40e7cf +"picomatch@npm:^4.0.5": + version: 4.0.5 + resolution: "picomatch@npm:4.0.5" + checksum: 10c0/947bc6b6e1ff1e6c5aaf95b107a0839d12802f4f7b867663f67d47accba939ca1cb582cf99dfc30438efa1c4648ac5990967e783e8929c36b03e8440704ef1bd + languageName: node + linkType: hard + +"picomatch@npm:^4.0.7": + version: 4.0.7 + resolution: "picomatch@npm:4.0.7" + checksum: 10c0/beb6ae02c43ae44e84883b90830196d9046b1726ead292adcf7f57945e0bb0d992d68563d87e03b484b6f3c9a5c6defda7523477f047d7f0e663f126cc01787f + languageName: node + linkType: hard + +"postcss@npm:^8.5.26": + version: 8.5.26 + resolution: "postcss@npm:8.5.26" + dependencies: + nanoid: "npm:^3.3.17" + picocolors: "npm:^1.1.1" + source-map-js: "npm:^1.2.1" + checksum: 10c0/2bdafc00d96bd57b6649a52e458864a4bf58ee56cfdbe4aea1472b5cccc127e6c1ad653bd0bec50d211e650eb0b9270c80e1e72aff2e2fa40d9e7363234d6e43 languageName: node linkType: hard @@ -2381,12 +2422,12 @@ __metadata: languageName: node linkType: hard -"prettier@npm:^2.7.1": - version: 2.8.8 - resolution: "prettier@npm:2.8.8" - bin: - prettier: bin-prettier.js - checksum: 10c0/463ea8f9a0946cd5b828d8cf27bd8b567345cf02f56562d5ecde198b91f47a76b7ac9eae0facd247ace70e927143af6135e8cf411986b8cb8478784a4d6d724a +"prismarine-nbt@npm:^2.7.0": + version: 2.8.0 + resolution: "prismarine-nbt@npm:2.8.0" + dependencies: + protodef: "npm:^1.18.0" + checksum: 10c0/842c358415e27bd88dc180db6e60ec1a1886199deeab20bc847b2dd793424fff9a7b817bb22b374f936cda8567833f6ff0142406987835d13237a446209f05ab languageName: node linkType: hard @@ -2397,6 +2438,35 @@ __metadata: languageName: node linkType: hard +"process@npm:^0.11.10": + version: 0.11.10 + resolution: "process@npm:0.11.10" + checksum: 10c0/40c3ce4b7e6d4b8c3355479df77aeed46f81b279818ccdc500124e6a5ab882c0cc81ff7ea16384873a95a74c4570b01b120f287abbdd4c877931460eca6084b3 + languageName: node + linkType: hard + +"protodef-validator@npm:^1.3.0": + version: 1.4.0 + resolution: "protodef-validator@npm:1.4.0" + dependencies: + ajv: "npm:^6.5.4" + bin: + protodef-validator: cli.js + checksum: 10c0/6ab8666a58fd79c9a9cd46aaee17ff0a5b5d4275ebe14b80baa9cb943f82dde11fd2fcf51e21f5f935826136d22d10c4b92d9fa4a07135de14613b15275b04e5 + languageName: node + linkType: hard + +"protodef@npm:^1.18.0": + version: 1.19.0 + resolution: "protodef@npm:1.19.0" + dependencies: + lodash.reduce: "npm:^4.6.0" + protodef-validator: "npm:^1.3.0" + readable-stream: "npm:^4.4.0" + checksum: 10c0/5daf62c156b59a051a2e1cbdddf6b16f410234b231d56672d7f719b8b411d1983927fe3dc8c9b2e0125e61707a2c77b1ecdab132fde2175955bfb17ee9f22bd1 + languageName: node + linkType: hard + "pstree.remy@npm:^1.1.8": version: 1.1.8 resolution: "pstree.remy@npm:1.1.8" @@ -2411,29 +2481,16 @@ __metadata: languageName: node linkType: hard -"quansync@npm:^0.2.7": - version: 0.2.11 - resolution: "quansync@npm:0.2.11" - checksum: 10c0/cb9a1f8ebce074069f2f6a78578873ffedd9de9f6aa212039b44c0870955c04a71c3b1311b5d97f8ac2f2ec476de202d0a5c01160cb12bc0a11b7ef36d22ef56 - languageName: node - linkType: hard - -"queue-microtask@npm:^1.2.2": - version: 1.2.3 - resolution: "queue-microtask@npm:1.2.3" - checksum: 10c0/900a93d3cdae3acd7d16f642c29a642aea32c2026446151f0778c62ac089d4b8e6c986811076e1ae180a694cedf077d453a11b58ff0a865629a4f82ab558e102 - languageName: node - linkType: hard - -"read-yaml-file@npm:^1.1.0": - version: 1.1.0 - resolution: "read-yaml-file@npm:1.1.0" +"readable-stream@npm:^4.4.0": + version: 4.7.0 + resolution: "readable-stream@npm:4.7.0" dependencies: - graceful-fs: "npm:^4.1.5" - js-yaml: "npm:^3.6.1" - pify: "npm:^4.0.1" - strip-bom: "npm:^3.0.0" - checksum: 10c0/85a9ba08bb93f3c91089bab4f1603995ec7156ee595f8ce40ae9f49d841cbb586511508bd47b7cf78c97f678c679b2c6e2c0092e63f124214af41b6f8a25ca31 + abort-controller: "npm:^3.0.0" + buffer: "npm:^6.0.3" + events: "npm:^3.3.0" + process: "npm:^0.11.10" + string_decoder: "npm:^1.3.0" + checksum: 10c0/fd86d068da21cfdb10f7a4479f2e47d9c0a9b0c862fc0c840a7e5360201580a55ac399c764b12a4f6fa291f8cee74d9c4b7562e0d53b3c4b2769f2c98155d957 languageName: node linkType: hard @@ -2446,33 +2503,61 @@ __metadata: languageName: node linkType: hard -"resolve-from@npm:^4.0.0": - version: 4.0.0 - resolution: "resolve-from@npm:4.0.0" - checksum: 10c0/8408eec31a3112ef96e3746c37be7d64020cda07c03a920f5024e77290a218ea758b26ca9529fd7b1ad283947f34b2291c1c0f6aa0ed34acfdda9c6014c8d190 - languageName: node - linkType: hard - -"resolve-from@npm:^5.0.0": - version: 5.0.0 - resolution: "resolve-from@npm:5.0.0" - checksum: 10c0/b21cb7f1fb746de8107b9febab60095187781137fd803e6a59a76d421444b1531b641bba5857f5dc011974d8a5c635d61cec49e6bd3b7fc20e01f0fafc4efbf2 - languageName: node - linkType: hard - -"reusify@npm:^1.0.4": - version: 1.1.0 - resolution: "reusify@npm:1.1.0" - checksum: 10c0/4eff0d4a5f9383566c7d7ec437b671cc51b25963bd61bf127c3f3d3f68e44a026d99b8d2f1ad344afff8d278a8fe70a8ea092650a716d22287e8bef7126bb2fa - languageName: node - linkType: hard - -"run-parallel@npm:^1.1.9": - version: 1.2.0 - resolution: "run-parallel@npm:1.2.0" - dependencies: - queue-microtask: "npm:^1.2.2" - checksum: 10c0/200b5ab25b5b8b7113f9901bfe3afc347e19bb7475b267d55ad0eb86a62a46d77510cb0f232507c9e5d497ebda569a08a9867d0d14f57a82ad5564d991588b39 +"rolldown@npm:~1.2.4": + version: 1.2.5 + resolution: "rolldown@npm:1.2.5" + dependencies: + "@oxc-project/types": "npm:=0.146.0" + "@rolldown/binding-android-arm-eabi": "npm:1.2.5" + "@rolldown/binding-android-arm64": "npm:1.2.5" + "@rolldown/binding-darwin-arm64": "npm:1.2.5" + "@rolldown/binding-darwin-x64": "npm:1.2.5" + "@rolldown/binding-freebsd-x64": "npm:1.2.5" + "@rolldown/binding-linux-arm-gnueabihf": "npm:1.2.5" + "@rolldown/binding-linux-arm64-gnu": "npm:1.2.5" + "@rolldown/binding-linux-arm64-musl": "npm:1.2.5" + "@rolldown/binding-linux-ppc64-gnu": "npm:1.2.5" + "@rolldown/binding-linux-s390x-gnu": "npm:1.2.5" + "@rolldown/binding-linux-x64-gnu": "npm:1.2.5" + "@rolldown/binding-linux-x64-musl": "npm:1.2.5" + "@rolldown/binding-openharmony-arm64": "npm:1.2.5" + "@rolldown/binding-win32-arm64-msvc": "npm:1.2.5" + "@rolldown/binding-win32-x64-msvc": "npm:1.2.5" + "@rolldown/pluginutils": "npm:^1.0.0" + dependenciesMeta: + "@rolldown/binding-android-arm-eabi": + optional: true + "@rolldown/binding-android-arm64": + optional: true + "@rolldown/binding-darwin-arm64": + optional: true + "@rolldown/binding-darwin-x64": + optional: true + "@rolldown/binding-freebsd-x64": + optional: true + "@rolldown/binding-linux-arm-gnueabihf": + optional: true + "@rolldown/binding-linux-arm64-gnu": + optional: true + "@rolldown/binding-linux-arm64-musl": + optional: true + "@rolldown/binding-linux-ppc64-gnu": + optional: true + "@rolldown/binding-linux-s390x-gnu": + optional: true + "@rolldown/binding-linux-x64-gnu": + optional: true + "@rolldown/binding-linux-x64-musl": + optional: true + "@rolldown/binding-openharmony-arm64": + optional: true + "@rolldown/binding-win32-arm64-msvc": + optional: true + "@rolldown/binding-win32-x64-msvc": + optional: true + bin: + rolldown: ./bin/cli.mjs + checksum: 10c0/f6b4840300dcf4bb1b1f901fbc55d7642caefe63fd68e3a804f99eece8f4abae39dd3cd481d395e27f73fe55111563ea2511fef758f62f4c19af7ce2b42088e9 languageName: node linkType: hard @@ -2485,14 +2570,14 @@ __metadata: languageName: node linkType: hard -"safer-buffer@npm:>= 2.1.2 < 3.0.0": - version: 2.1.2 - resolution: "safer-buffer@npm:2.1.2" - checksum: 10c0/7e3c8b2e88a1841c9671094bbaeebd94448111dd90a81a1f606f3f67708a6ec57763b3b47f06da09fc6054193e0e6709e77325415dc8422b04497a8070fa02d4 +"safe-buffer@npm:~5.2.0": + version: 5.2.1 + resolution: "safe-buffer@npm:5.2.1" + checksum: 10c0/6501914237c0a86e9675d4e51d89ca3c21ffd6a31642efeba25ad65720bce6921c9e7e974e5be91a786b25aa058b5303285d3c15dbabf983a919f5f630d349f3 languageName: node linkType: hard -"semver@npm:^7.3.5, semver@npm:^7.5.3, semver@npm:^7.7.3": +"semver@npm:^7.3.5, semver@npm:^7.5.3, semver@npm:^7.7.3, semver@npm:^7.8.1": version: 7.8.5 resolution: "semver@npm:7.8.5" bin: @@ -2524,10 +2609,17 @@ __metadata: languageName: node linkType: hard -"signal-exit@npm:^4.0.1": - version: 4.1.0 - resolution: "signal-exit@npm:4.1.0" - checksum: 10c0/41602dce540e46d599edba9d9860193398d135f7ff72cab629db5171516cfae628d21e7bfccde1bbfdf11c48726bc2a6d1a8fb8701125852fbfda7cf19c6aa83 +"shell-quote@npm:^1.8.4": + version: 1.10.0 + resolution: "shell-quote@npm:1.10.0" + checksum: 10c0/46ee59bfd972ce6a45500c44ed130dff2d0a7d6fbac9841e59d548518cad8060a06393c9a5dcbc0cede294ad80b2a2cd8c904679e09265f53efc0a0879f30961 + languageName: node + linkType: hard + +"siginfo@npm:^2.0.0": + version: 2.0.0 + resolution: "siginfo@npm:2.0.0" + checksum: 10c0/3def8f8e516fbb34cb6ae415b07ccc5d9c018d85b4b8611e3dc6f8be6d1899f693a4382913c9ed51a06babb5201639d76453ab297d1c54a456544acf5c892e34 languageName: node linkType: hard @@ -2540,27 +2632,31 @@ __metadata: languageName: node linkType: hard -"slash@npm:^3.0.0": - version: 3.0.0 - resolution: "slash@npm:3.0.0" - checksum: 10c0/e18488c6a42bdfd4ac5be85b2ced3ccd0224773baae6ad42cfbb9ec74fc07f9fa8396bd35ee638084ead7a2a0818eb5e7151111544d4731ce843019dab4be47b +"sisteransi@npm:^1.0.5": + version: 1.0.5 + resolution: "sisteransi@npm:1.0.5" + checksum: 10c0/230ac975cca485b7f6fe2b96a711aa62a6a26ead3e6fb8ba17c5a00d61b8bed0d7adc21f5626b70d7c33c62ff4e63933017a6462942c719d1980bb0b1207ad46 languageName: node linkType: hard -"spawndamnit@npm:^3.0.1": - version: 3.0.1 - resolution: "spawndamnit@npm:3.0.1" - dependencies: - cross-spawn: "npm:^7.0.5" - signal-exit: "npm:^4.0.1" - checksum: 10c0/a9821a59bc78a665bd44718dea8f4f4010bb1a374972b0a6a1633b9186cda6d6fd93f22d1e49d9944d6bb175ba23ce29036a4bd624884fb157d981842c3682f3 +"source-map-js@npm:^1.2.1": + version: 1.2.1 + resolution: "source-map-js@npm:1.2.1" + checksum: 10c0/7bda1fc4c197e3c6ff17de1b8b2c20e60af81b63a52cb32ec5a5d67a20a7d42651e2cb34ebe93833c5a2a084377e17455854fee3e21e7925c64a51b6a52b0faf languageName: node linkType: hard -"sprintf-js@npm:~1.0.2": - version: 1.0.3 - resolution: "sprintf-js@npm:1.0.3" - checksum: 10c0/ecadcfe4c771890140da5023d43e190b7566d9cf8b2d238600f31bec0fc653f328da4450eb04bd59a431771a8e9cc0e118f0aa3974b683a4981b4e07abc2a5bb +"stackback@npm:0.0.2": + version: 0.0.2 + resolution: "stackback@npm:0.0.2" + checksum: 10c0/89a1416668f950236dd5ac9f9a6b2588e1b9b62b1b6ad8dff1bfc5d1a15dbf0aafc9b52d2226d00c28dffff212da464eaeebfc6b7578b9d180cef3e3782c5983 + languageName: node + linkType: hard + +"std-env@npm:^4.0.0-rc.1": + version: 4.2.0 + resolution: "std-env@npm:4.2.0" + checksum: 10c0/40ac525ce7b7c556abc332a7376f14356eeb1a7f17f6ff9a003eb9f52326ff1f3745d3e1b43452675b1ec6fcc319f1b1d6f3b0d386cf3f91058479ad883cff69 languageName: node linkType: hard @@ -2575,12 +2671,12 @@ __metadata: languageName: node linkType: hard -"strip-ansi@npm:^6.0.1": - version: 6.0.1 - resolution: "strip-ansi@npm:6.0.1" +"string_decoder@npm:^1.3.0": + version: 1.3.0 + resolution: "string_decoder@npm:1.3.0" dependencies: - ansi-regex: "npm:^5.0.1" - checksum: 10c0/1ae5f212a126fe5b167707f716942490e3933085a5ff6c008ab97ab2f272c8025d3aa218b7bd6ab25729ca20cc81cddb252102f8751e13482a5199e873680952 + safe-buffer: "npm:~5.2.0" + checksum: 10c0/810614ddb030e271cd591935dcd5956b2410dd079d64ff92a1844d6b7588bf992b3e1b69b0f4d34a3e06e0bd73046ac646b5264c1987b20d0601f81ef35d731d languageName: node linkType: hard @@ -2593,20 +2689,6 @@ __metadata: languageName: node linkType: hard -"strip-bom@npm:^3.0.0": - version: 3.0.0 - resolution: "strip-bom@npm:3.0.0" - checksum: 10c0/51201f50e021ef16672593d7434ca239441b7b760e905d9f33df6e4f3954ff54ec0e0a06f100d028af0982d6f25c35cd5cda2ce34eaebccd0250b8befb90d8f1 - languageName: node - linkType: hard - -"strip-json-comments@npm:^3.1.1": - version: 3.1.1 - resolution: "strip-json-comments@npm:3.1.1" - checksum: 10c0/9681a6257b925a7fa0f285851c0e613cc934a50661fa7bb41ca9cbbff89686bb4a0ee366e6ecedc4daafd01e83eee0720111ab294366fe7c185e935475ebcecd - languageName: node - linkType: hard - "supports-color@npm:10.2.2": version: 10.2.2 resolution: "supports-color@npm:10.2.2" @@ -2645,14 +2727,28 @@ __metadata: languageName: node linkType: hard -"term-size@npm:^2.1.0": - version: 2.2.1 - resolution: "term-size@npm:2.2.1" - checksum: 10c0/89f6bba1d05d425156c0910982f9344d9e4aebf12d64bfa1f460d93c24baa7bc4c4a21d355fbd7153c316433df0538f64d0ae6e336cc4a69fdda4f85d62bc79d +"tinybench@npm:^2.9.0": + version: 2.9.0 + resolution: "tinybench@npm:2.9.0" + checksum: 10c0/c3500b0f60d2eb8db65250afe750b66d51623057ee88720b7f064894a6cb7eb93360ca824a60a31ab16dab30c7b1f06efe0795b352e37914a9d4bad86386a20c + languageName: node + linkType: hard + +"tinyexec@npm:^1.0.2": + version: 1.3.0 + resolution: "tinyexec@npm:1.3.0" + checksum: 10c0/e9b89f97489d2aab2cef408da279e6b32547e738d1275032ccb8fd0028a006d93eb70fc51c6cffd9fc2f5aca6c2a273d8b6f73b52d46ee5116da6b94969ef958 + languageName: node + linkType: hard + +"tinyexec@npm:^1.3.0, tinyexec@npm:^1.3.1": + version: 1.3.1 + resolution: "tinyexec@npm:1.3.1" + checksum: 10c0/c590369cb3fa73cc18a3998caec8a00f9712d91309c881be3ac52b160fb2f80c7b1fbf3591858ab6abab47ad55385b0c86ec19982e32441a01f55298dab9ccd2 languageName: node linkType: hard -"tinyglobby@npm:^0.2.12, tinyglobby@npm:^0.2.15": +"tinyglobby@npm:^0.2.12, tinyglobby@npm:^0.2.13, tinyglobby@npm:^0.2.15, tinyglobby@npm:^0.2.17": version: 0.2.17 resolution: "tinyglobby@npm:0.2.17" dependencies: @@ -2662,6 +2758,13 @@ __metadata: languageName: node linkType: hard +"tinyrainbow@npm:^3.1.0": + version: 3.1.1 + resolution: "tinyrainbow@npm:3.1.1" + checksum: 10c0/f9d2743832c6191f753408f36224fe817620b8abcef572b2e570204c673a901d753ff84ca8e7b88f9c79e934295b3ffc6fcbc56a06f126e24e1ec6186dcad40d + languageName: node + linkType: hard + "to-regex-range@npm:^5.0.1": version: 5.0.1 resolution: "to-regex-range@npm:5.0.1" @@ -2736,16 +2839,6 @@ __metadata: languageName: node linkType: hard -"typescript@npm:^5.6.0": - version: 5.9.3 - resolution: "typescript@npm:5.9.3" - bin: - tsc: bin/tsc - tsserver: bin/tsserver - checksum: 10c0/6bd7552ce39f97e711db5aa048f6f9995b53f1c52f7d8667c1abdc1700c68a76a308f579cd309ce6b53646deb4e9a1be7c813a93baaf0a28ccd536a30270e1c5 - languageName: node - linkType: hard - "typescript@npm:^6.0.3": version: 6.0.3 resolution: "typescript@npm:6.0.3" @@ -2756,16 +2849,6 @@ __metadata: languageName: node linkType: hard -"typescript@patch:typescript@npm%3A^5.6.0#optional!builtin": - version: 5.9.3 - resolution: "typescript@patch:typescript@npm%3A5.9.3#optional!builtin::version=5.9.3&hash=5786d5" - bin: - tsc: bin/tsc - tsserver: bin/tsserver - checksum: 10c0/ad09fdf7a756814dce65bc60c1657b40d44451346858eea230e10f2e95a289d9183b6e32e5c11e95acc0ccc214b4f36289dcad4bf1886b0adb84d711d336a430 - languageName: node - linkType: hard - "typescript@patch:typescript@npm%3A^6.0.3#optional!builtin": version: 6.0.3 resolution: "typescript@patch:typescript@npm%3A6.0.3#optional!builtin::version=6.0.3&hash=5786d5" @@ -2797,13 +2880,6 @@ __metadata: languageName: node linkType: hard -"universalify@npm:^0.1.0": - version: 0.1.2 - resolution: "universalify@npm:0.1.2" - checksum: 10c0/e70e0339f6b36f34c9816f6bf9662372bd241714dc77508d231d08386d94f2c4aa1ba1318614f92015f40d45aae1b9075cd30bd490efbe39387b60a76ca3f045 - languageName: node - linkType: hard - "uri-js@npm:^4.2.2": version: 4.4.1 resolution: "uri-js@npm:4.4.1" @@ -2813,12 +2889,128 @@ __metadata: languageName: node linkType: hard -"uuid@npm:*, uuid@npm:^14.0.1": - version: 14.0.1 - resolution: "uuid@npm:14.0.1" +"vite@npm:^6.0.0 || ^7.0.0 || ^8.0.0": + version: 8.2.2 + resolution: "vite@npm:8.2.2" + dependencies: + fsevents: "npm:~2.3.3" + lightningcss: "npm:^1.33.0" + picomatch: "npm:^4.0.5" + postcss: "npm:^8.5.26" + rolldown: "npm:~1.2.4" + tinyglobby: "npm:^0.2.17" + peerDependencies: + "@types/node": ^20.19.0 || >=22.12.0 + "@vitejs/devtools": ^0.4.0 || ^0.5.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: ">=1.21.0" + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: ">=0.54.8" + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + dependenciesMeta: + fsevents: + optional: true + peerDependenciesMeta: + "@types/node": + optional: true + "@vitejs/devtools": + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + bin: + vite: bin/vite.js + checksum: 10c0/94cbbbdc38ad500dcb86b6202ddd14aa41d05c80739766cada9bbe250b410d1a27be433c9c491ed39744019471ac1e27a59908616a88c42f9787bdb6bdca49d2 + languageName: node + linkType: hard + +"vitest@npm:^4.1.10": + version: 4.1.11 + resolution: "vitest@npm:4.1.11" + dependencies: + "@vitest/expect": "npm:4.1.11" + "@vitest/mocker": "npm:4.1.11" + "@vitest/pretty-format": "npm:4.1.11" + "@vitest/runner": "npm:4.1.11" + "@vitest/snapshot": "npm:4.1.11" + "@vitest/spy": "npm:4.1.11" + "@vitest/utils": "npm:4.1.11" + es-module-lexer: "npm:^2.0.0" + expect-type: "npm:^1.3.0" + magic-string: "npm:^0.30.21" + obug: "npm:^2.1.1" + pathe: "npm:^2.0.3" + picomatch: "npm:^4.0.3" + std-env: "npm:^4.0.0-rc.1" + tinybench: "npm:^2.9.0" + tinyexec: "npm:^1.0.2" + tinyglobby: "npm:^0.2.15" + tinyrainbow: "npm:^3.1.0" + vite: "npm:^6.0.0 || ^7.0.0 || ^8.0.0" + why-is-node-running: "npm:^2.3.0" + peerDependencies: + "@edge-runtime/vm": "*" + "@opentelemetry/api": ^1.9.0 + "@types/node": ^20.0.0 || ^22.0.0 || >=24.0.0 + "@vitest/browser-playwright": 4.1.11 + "@vitest/browser-preview": 4.1.11 + "@vitest/browser-webdriverio": 4.1.11 + "@vitest/coverage-istanbul": 4.1.11 + "@vitest/coverage-v8": 4.1.11 + "@vitest/ui": 4.1.11 + happy-dom: "*" + jsdom: "*" + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + "@edge-runtime/vm": + optional: true + "@opentelemetry/api": + optional: true + "@types/node": + optional: true + "@vitest/browser-playwright": + optional: true + "@vitest/browser-preview": + optional: true + "@vitest/browser-webdriverio": + optional: true + "@vitest/coverage-istanbul": + optional: true + "@vitest/coverage-v8": + optional: true + "@vitest/ui": + optional: true + happy-dom: + optional: true + jsdom: + optional: true + vite: + optional: false bin: - uuid: dist-node/bin/uuid - checksum: 10c0/2d961289097cb68c37d93d1da0b5c3aafc1eb9948a455cca8d0ae53a099b2fe583b8933254a84097f700e6bac2e23033364904df0467c22551d5d9611febcaf6 + vitest: ./vitest.mjs + checksum: 10c0/3fa0948cf74adcccc8cbcdb4e6d30ada6933bdfb1816ff99200f3d3b689325b37dc483b22535b57b6d911f7a7b64eaa6a5f8da1606cfe7f2466f48000c21e296 languageName: node linkType: hard @@ -2861,6 +3053,18 @@ __metadata: languageName: node linkType: hard +"why-is-node-running@npm:^2.3.0": + version: 2.3.0 + resolution: "why-is-node-running@npm:2.3.0" + dependencies: + siginfo: "npm:^2.0.0" + stackback: "npm:0.0.2" + bin: + why-is-node-running: cli.js + checksum: 10c0/1cde0b01b827d2cf4cb11db962f3958b9175d5d9e7ac7361d1a7b0e2dc6069a263e69118bd974c4f6d0a890ef4eedfe34cf3d5167ec14203dbc9a18620537054 + languageName: node + linkType: hard + "word-wrap@npm:^1.2.5": version: 1.2.5 resolution: "word-wrap@npm:1.2.5" @@ -2893,6 +3097,15 @@ __metadata: languageName: node linkType: hard +"yaml@npm:^2.9.0": + version: 2.9.1 + resolution: "yaml@npm:2.9.1" + bin: + yaml: bin.mjs + checksum: 10c0/9d06676ef02b89559a8b10a71882bd2cdae81e8393dc8dbc871615c3259475d9609ad973d19cb3c9e71e72d6206e3d1e7aa241c7e55f3d16fd2e77db305827d6 + languageName: node + linkType: hard + "yargs-parser@npm:^22.0.0": version: 22.0.0 resolution: "yargs-parser@npm:22.0.0" @@ -2914,6 +3127,15 @@ __metadata: languageName: node linkType: hard +"yauzl@npm:^3.2.0": + version: 3.4.0 + resolution: "yauzl@npm:3.4.0" + dependencies: + pend: "npm:~1.2.0" + checksum: 10c0/17a98c42c0065e8af429eb8a61f7a0e4562181ed54080366b838f34f741b6829f167f804787c86b7646bb042707f35871739f053de0548285e405a2eae4da025 + languageName: node + linkType: hard + "yocto-queue@npm:^0.1.0": version: 0.1.0 resolution: "yocto-queue@npm:0.1.0"