From d75ab82d43e251e7f42fb668d37978e0e7fb60ce Mon Sep 17 00:00:00 2001 From: DrAv0011 Date: Sun, 16 Aug 2026 04:25:40 +0200 Subject: [PATCH 01/71] feat(i18n): add @bedrock-core/i18n package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TS-first localization runtime for the i18n Regolith filter's bundles: typed verbs (t/key/raw) with selector and dot-path autocompletion, interpolation- variable inference, CLDR plurals without Intl, the locale chain (persisted override → client language → sibling region → default), lazy real-key resolve() for measurement, createResourceBundle for libraries and filterless addons, and the RawMessage-native raw() with nested rawtext parameters. Co-Authored-By: Claude Fable 5 --- eslint.config.mjs | 19 +++++ package.json | 50 ++++++++++++ src/__tests__/contract.test.ts | 73 +++++++++++++++++ src/__tests__/engine.test.ts | 125 ++++++++++++++++++++++++++++++ src/__tests__/fixture.ts | 89 +++++++++++++++++++++ src/__tests__/interpolate.test.ts | 40 ++++++++++ src/__tests__/locale.test.ts | 59 ++++++++++++++ src/__tests__/plural.test.ts | 50 ++++++++++++ src/__tests__/types.test.ts | 61 +++++++++++++++ src/bundle.ts | 51 ++++++++++++ src/index.ts | 30 +++++++ src/interpolate.ts | 71 +++++++++++++++++ src/locale.ts | 33 ++++++++ src/plural.ts | 67 ++++++++++++++++ src/resources.ts | 72 +++++++++++++++++ src/types.ts | 107 +++++++++++++++++++++++++ tsconfig.json | 10 +++ vitest.config.ts | 8 ++ 18 files changed, 1015 insertions(+) create mode 100644 eslint.config.mjs create mode 100644 package.json create mode 100644 src/__tests__/contract.test.ts create mode 100644 src/__tests__/engine.test.ts create mode 100644 src/__tests__/fixture.ts create mode 100644 src/__tests__/interpolate.test.ts create mode 100644 src/__tests__/locale.test.ts create mode 100644 src/__tests__/plural.test.ts create mode 100644 src/__tests__/types.test.ts create mode 100644 src/bundle.ts create mode 100644 src/index.ts create mode 100644 src/interpolate.ts create mode 100644 src/locale.ts create mode 100644 src/plural.ts create mode 100644 src/resources.ts create mode 100644 src/types.ts create mode 100644 tsconfig.json create mode 100644 vitest.config.ts diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..8e68576 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,19 @@ +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, + { + files: ['**/*.ts', '**/*.tsx'], + languageOptions: { + parserOptions: { + tsconfigRootDir: __dirname, + }, + }, + }, +]); diff --git a/package.json b/package.json new file mode 100644 index 0000000..5a0f5aa --- /dev/null +++ b/package.json @@ -0,0 +1,50 @@ +{ + "name": "@bedrock-core/i18n", + "version": "0.0.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/ui", + "type": "module", + "main": "src/index.ts", + "types": "src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + } + }, + "files": [ + "src" + ], + "sideEffects": false, + "scripts": { + "build": "tsc -p tsconfig.json", + "lint": "eslint .", + "test": "vitest run", + "coverage": "vitest run --coverage" + }, + "devDependencies": { + "@minecraft/server": "2.8.0", + "typescript": "^6.0.3", + "vitest": "^4.1.5" + }, + "peerDependencies": { + "@minecraft/server": ">=2.8.0" + } +} diff --git a/src/__tests__/contract.test.ts b/src/__tests__/contract.test.ts new file mode 100644 index 0000000..2186cfa --- /dev/null +++ b/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/src/__tests__/engine.test.ts b/src/__tests__/engine.test.ts new file mode 100644 index 0000000..61a56f4 --- /dev/null +++ b/src/__tests__/engine.test.ts @@ -0,0 +1,125 @@ +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('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/src/__tests__/fixture.ts b/src/__tests__/fixture.ts new file mode 100644 index 0000000..ff43ea8 --- /dev/null +++ b/src/__tests__/fixture.ts @@ -0,0 +1,89 @@ +/** + * 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_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/src/__tests__/interpolate.test.ts b/src/__tests__/interpolate.test.ts new file mode 100644 index 0000000..34c22ba --- /dev/null +++ b/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/src/__tests__/locale.test.ts b/src/__tests__/locale.test.ts new file mode 100644 index 0000000..8ea0e7d --- /dev/null +++ b/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/src/__tests__/plural.test.ts b/src/__tests__/plural.test.ts new file mode 100644 index 0000000..b411a58 --- /dev/null +++ b/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/src/__tests__/types.test.ts b/src/__tests__/types.test.ts new file mode 100644 index 0000000..ce2f989 --- /dev/null +++ b/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/src/bundle.ts b/src/bundle.ts new file mode 100644 index 0000000..3547823 --- /dev/null +++ b/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/src/index.ts b/src/index.ts new file mode 100644 index 0000000..1e88159 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,30 @@ +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 { interpolate, templateVars, toPositional } from './interpolate'; +export { pickLocale } from './locale'; +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/src/interpolate.ts b/src/interpolate.ts new file mode 100644 index 0000000..4ffe186 --- /dev/null +++ b/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/src/locale.ts b/src/locale.ts new file mode 100644 index 0000000..55b0626 --- /dev/null +++ b/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/src/plural.ts b/src/plural.ts new file mode 100644 index 0000000..084f158 --- /dev/null +++ b/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/src/resources.ts b/src/resources.ts new file mode 100644 index 0000000..082e9cf --- /dev/null +++ b/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/src/types.ts b/src/types.ts new file mode 100644 index 0000000..4594fd3 --- /dev/null +++ b/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/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..5543346 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "noEmit": true + }, + "include": [ + "src/**/*", + "vitest.config.ts" + ] +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..7eeb3f8 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + include: ['src/**/*.test.ts'], + }, +}); From 922daf8e7498ed610e13bef58d53df5691a00f08 Mon Sep 17 00:00:00 2001 From: DrAv0011 Date: Sun, 16 Aug 2026 07:02:05 +0200 Subject: [PATCH 02/71] feat(i18n): typed verb engine with bound display() and the DisplayText union createI18n(bundle): t()/key()/raw() verbs over the generated bundle, lazy resolve() (no materialized tables), CLDR plurals without Intl, the locale chain (override -> client -> sibling region -> default), and display() bound on every verb set - any DisplayText (string | RawMessage) to a plain string for breadcrumbs, native modal text and chat. DisplayText is the one union every text channel shares; resolveDisplay stays as the resolver-level primitive. Creating the addon's instance registers it as the default translation source the UI runtime injects at every root. Co-Authored-By: Claude Fable 5 --- README.md | 141 ++++++++++++++++++++++ src/createI18n.ts | 295 ++++++++++++++++++++++++++++++++++++++++++++++ src/display.ts | 40 +++++++ src/index.ts | 2 + 4 files changed, 478 insertions(+) create mode 100644 README.md create mode 100644 src/createI18n.ts create mode 100644 src/display.ts diff --git a/README.md b/README.md new file mode 100644 index 0000000..323a8ed --- /dev/null +++ b/README.md @@ -0,0 +1,141 @@ +# @bedrock-core/i18n + +Localization for Minecraft Bedrock addons: typed keys, interpolation and plurals — resolved on +the **client** in each player's own language wherever possible, on the **server** whenever you +need the actual string. + +This package is the runtime half. The build half is the +[`i18n` Regolith filter](../../../regolith-filters/i18n/README.md), which turns your +`packs/data/i18n/.ts` modules into `.lang` files, a runtime bundle and the types this +package's API infers from. Read that README first for authoring, namespacing and what gets +generated. + +## Setup + +Once per addon: + +```ts +// BP/scripts/i18n.ts +import { createI18n } from '@bedrock-core/i18n'; +import bundle from '@bedrock-core/generated/i18n'; + +export const { t, key, raw, forPlayer, forLocale, setLocale, clearLocale } = createI18n(bundle); +``` + +Everything — key paths, interpolation variables, plural forms — is inferred from the bundle's +type. No module augmentation, no manual type imports: the filter's generated `.d.ts` roots the +tree at your own keys and grafts `core` (libraries) and `vanilla` on. + +## Three verbs + +The core idea: prefer the client, fall back to the server. The client resolves `.lang` keys per +player for free; the server only resolves when your code needs the string *now*. + +| Verb | Returns | Resolved by | Use for | +| --- | --- | --- | --- | +| `key()` | namespaced key string | client | `Text` children, registry display fields — no args | +| `raw()` | `RawMessage` (`translate` + `with`) | client | interpolated text the client should localize | +| `t()` | plain string | server | layout math, chat, composing strings | + +```ts +key($ => $.shop.title) // 'drav0011_shop.shop.title' +raw($ => $.shop.bought, { item, price }) // { rawtext: [{ translate: 'drav0011_shop.shop.bought', with: [...] }] } +t($ => $.shop.bought, { item, price }) // 'You bought Apple for 5 emeralds.' +``` + +Every verb takes a selector (`$ => $.shop.bought`) or the equivalent dot string +(`'shop.bought'`) — both autocomplete, both are checked. Interpolation is typed: the `{{var}}` +placeholders in the authored template become required properties, so a missing or misnamed +argument is a compile error. `raw()` orders its `with` array by the argument order the filter +recorded at build time; a shared contract test pins the two sides together. + +## Locale resolution + +`t()` needs a locale. The chain, first hit wins: + +1. Per-player override — `setLocale(player, 'es_ES')`, persisted in a dynamic property so it + survives rejoin; `clearLocale(player)` removes it. +2. The player's client language — `player.clientSystemInfo.locale`. +3. A sibling region of that language — a player on unauthored `es_MX` gets the Spanish written + for Spain rather than English. +4. The addon's `defaultLocale`, then any locale the bundle carries. + +```ts +t($ => $.shop.title) // defaultLocale — no player in sight +const { t: tp } = forPlayer(player); // bound to the chain above +const { t: es } = forLocale('es_ES'); // pinned, e.g. for logs +``` + +`forPlayer` / `forLocale` return the full bound verb set (`t`, `key`, `raw`) — binding matters +for plurals even on the client-resolved verbs, see below. + +## Plurals + +Author `_one` / `_other` (and `_zero`, `_two`, `_few`, `_many` where a language needs them) +variants; they collapse into a single leaf that takes `count`: + +```ts +t($ => $.shop.stock, { count: 3 }) // '3 left in stock' +``` + +Bedrock `.lang` has no plural mechanism, so the suffix is always chosen **server-side** — even +for `key()`/`raw()`, the chosen suffixed key is what travels to the client. That choice depends +on the target language's plural rules, so pluralized leaves require a bound verb set +(`forPlayer(player).raw(...)`). Rules come from a built-in CLDR category table — no +`Intl.PluralRules` required, because Bedrock's script engine does not guarantee it. + +## In UI components + +Binding the verbs to the viewing player is one line in your components: + +```tsx +const { t, key, raw } = i18n.forPlayer(usePlayer()); +``` + +Wrap it in your own `useTranslation()` hook if you like; `@bedrock-core/config` does exactly +that ([`src/i18n/index.ts`](../config/src/i18n/index.ts)), including preferring the +world-published table so addon overrides reach its breadcrumbs and modal text. + +Measurement needs **no wiring at all**: `createI18n(bundle)` registers itself as the addon's +default translation source, and localized `Text` children resolve through it lazily, per player — +`resolve(realKey)` inverse-maps the key into the bundle and converts the one template it needs; +no tables are ever materialized. `TranslationContext` exists to OVERRIDE that — hosts that +resolve beyond their own bundle (config provides `core.translations.forPlayer(player)`) or +subtrees pinned to custom data. Libraries creating internal instances pass +`{ asDefault: false }` so they never shadow the host addon's bundle. + +## Cross-addon sharing + +Publish the bundle itself through registration: + +```ts +core.register({ ..., translations: bundle }); +``` + +The server runtime replicates the bundle — objects, templates and argument order intact — and +serves two lazy views: `core.translations.of(addonId)` gives verbs over a peer's strings, and +`core.translations.forPlayer(player)` gives one resolver chaining every published bundle, later +registrations winning collisions the way Bedrock's own world-level `.lang` merge does. Registry +display fields (`packName`, `description`, `creatorName`) are translation keys for exactly this +reason. The per-template `{{var}}` → `%N$s` conversion at lookup time is pinned by the same +contract test as the filter's `.lang` output. + +## Without the filter — and inside libraries + +`createResourceBundle(namespace, { en_US, es_ES })` builds the same bundle shape from nested +resource modules at runtime: full typed verbs, no build step. Libraries use it over the +resources they ship (`config` gets `core.addons.title` from its own `src/i18n/en_US.ts`); +addons can use it standalone and adopt the filter later — what they give up until then is only +what a build can do: `.lang` emission, the vanilla branch, cross-locale checks. + +## Engine notes + +The engine is fully custom, a few KB, with **zero runtime dependencies** — no i18next in the +bundle. It keeps i18next's conventions (`{{var}}` interpolation, plural suffixes, the selector +call shape) so existing knowledge and the filter's docs transfer, but the type machinery and the +resolver are this package's own, sized for Bedrock's constraints: no `Intl`, no dynamic import, +every locale statically in one bundle. + +Compile-time guarantees end where dynamic strings begin: `t()` on a key the type system never +saw (a runtime-assembled string) returns the key itself, mirroring how Bedrock renders an +unknown `.lang` key literally. diff --git a/src/createI18n.ts b/src/createI18n.ts new file mode 100644 index 0000000..5e9cea1 --- /dev/null +++ b/src/createI18n.ts @@ -0,0 +1,295 @@ +/** + * 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 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 = bundle.args[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, bundle.args[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/src/display.ts b/src/display.ts new file mode 100644 index 0000000..4814989 --- /dev/null +++ b/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/src/index.ts b/src/index.ts index 1e88159..0204dbf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,6 +12,8 @@ export type { TranslateFn, TranslationResolver, } from './createI18n'; +export { resolveDisplay } from './display'; +export type { DisplayText } from './display'; export { interpolate, templateVars, toPositional } from './interpolate'; export { pickLocale } from './locale'; export { pluralCategory } from './plural'; From e7300cb93ba3c5531c3ec9d9491f424b6bf3bc25 Mon Sep 17 00:00:00 2001 From: DrAv0011 Date: Sun, 16 Aug 2026 07:44:28 +0200 Subject: [PATCH 03/71] fix(i18n): keep arguments for locale-only plural variants raw() and resolve() read bundle.args[variant]; a CLDR category the default locale never declares (Czech few) had no entry, so the count argument was dropped and players saw a literal %1$s. Variants now borrow their group's _other argument order when unrecorded, and the fixture records stock_few the way the fixed filter now emits it. Co-Authored-By: Claude Fable 5 --- src/__tests__/engine.test.ts | 12 ++++++++++++ src/__tests__/fixture.ts | 1 + src/createI18n.ts | 16 ++++++++++++++-- 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/__tests__/engine.test.ts b/src/__tests__/engine.test.ts index 61a56f4..1d37dff 100644 --- a/src/__tests__/engine.test.ts +++ b/src/__tests__/engine.test.ts @@ -83,6 +83,18 @@ describe('raw()', () => { .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({ diff --git a/src/__tests__/fixture.ts b/src/__tests__/fixture.ts index ff43ea8..de8b58c 100644 --- a/src/__tests__/fixture.ts +++ b/src/__tests__/fixture.ts @@ -38,6 +38,7 @@ export const bundle: I18nBundle & { readonly resources?: Resources } = { args: { 'core.addons.version': ['version'], 'shop.bought': ['item', 'price'], + 'shop.stock_few': ['count'], 'shop.stock_one': ['count'], 'shop.stock_other': ['count'], }, diff --git a/src/createI18n.ts b/src/createI18n.ts index 5e9cea1..7e23a57 100644 --- a/src/createI18n.ts +++ b/src/createI18n.ts @@ -166,6 +166,18 @@ export function createI18n(bundle: B, options: CreateI18nO 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> { @@ -210,7 +222,7 @@ export function createI18n(bundle: B, options: CreateI18nO return { translate, with: toWith(args) }; } - const order = bundle.args[variant]; + const order = argsFor(variant); if (args !== undefined && order !== undefined && order.length > 0) { return { translate, with: toWith(order.map(name => args[name])) }; @@ -242,7 +254,7 @@ export function createI18n(bundle: B, options: CreateI18nO for (const path of candidates) { const template = table[path] ?? defaultTable[path]; - if (template !== undefined) { return toPositional(template, bundle.args[path] ?? []); } + if (template !== undefined) { return toPositional(template, argsFor(path) ?? []); } } // Vanilla entries are stored under their branch, already client-form. From dcc0edbfeb7b3b1025ac188f743570702c72a54d Mon Sep 17 00:00:00 2001 From: DrAv0011 Date: Sun, 16 Aug 2026 08:05:49 +0200 Subject: [PATCH 04/71] fix(rehearsal): install both repos before either builds The portal: resolutions make each repo type-check the other's sources, so building ui while server-public was still uninstalled failed on missing @minecraft/server types. Split install/version/build into three passes across both clones. Co-Authored-By: Claude Fable 5 --- src/createI18n.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/createI18n.ts b/src/createI18n.ts index 7e23a57..11b770e 100644 --- a/src/createI18n.ts +++ b/src/createI18n.ts @@ -176,7 +176,7 @@ export function createI18n(bundle: B, options: CreateI18nO */ const argsFor = (path: string): readonly string[] | undefined => bundle.args[path] - ?? (PLURAL_SUFFIX_RE.test(path) ? bundle.args[path.replace(PLURAL_SUFFIX_RE, '_other')] : undefined); + ?? (PLURAL_SUFFIX_RE.test(path) ? bundle.args[path.replace(PLURAL_SUFFIX_RE, '_other')] : undefined); const bound = new Map>>(); From 443210ba3ec793f3fab4324d5637a7e47e8d02bd Mon Sep 17 00:00:00 2001 From: DrAv0011 Date: Mon, 17 Aug 2026 00:39:00 +0200 Subject: [PATCH 05/71] docs: correct the package READMEs against the current API and protocol MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ui-runtime protocol section was three revisions behind: it documented bcuiv0005, a 501-byte reserved block and no region field, and its own byte arithmetic summed to 1107 rather than 1024 — anyone writing a decoder from it landed on wrong offsets. Rewritten from control.ts/serializer.ts, including the common fontType field and why it exists, and the title-metadata section now describes the scroll/modal layout instead of a serializeTitleMetadata() that no longer exists. Removed APIs that never existed or no longer do: ore-styled advertised a whole Tabs family, navigation exported a Screen. Fixed examples that could not compile — navigation's Profile: {userId} builds an array child, which Text rejects, and bare-string Button children are dropped at serialize time. The resource-pack README now documents the pack as what it actually is: the only artifact published from that package is packs/RP, zipped into the .mcpack by the release workflow. The behavior pack and packs/data are the local test harness and never ship, so they no longer read as things a user receives. i18n is the release headline and was reachable from nothing — it now has a real install path (standalone and via the @bedrock-core/ui/i18n facade) and is linked from the root README. copilot-instructions carried the same stale protocol in five places, including a documented test regex that would now fail. Co-Authored-By: Claude Opus 5 --- README.md | 114 +++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 104 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 323a8ed..544e1fc 100644 --- a/README.md +++ b/README.md @@ -5,14 +5,71 @@ the **client** in each player's own language wherever possible, on the **server* need the actual string. This package is the runtime half. The build half is the -[`i18n` Regolith filter](../../../regolith-filters/i18n/README.md), which turns your -`packs/data/i18n/.ts` modules into `.lang` files, a runtime bundle and the types this -package's API infers from. Read that README first for authoring, namespacing and what gets +[`i18n` Regolith filter](https://github.com/bedrock-core/regolith-filters/tree/main/i18n), which +turns your `packs/data/i18n/.ts` modules into `.lang` files, a runtime bundle and the types +this package's API infers from. Read that README for authoring, namespacing and what gets generated. +## Install + +The package ships as a dependency of `@bedrock-core/ui` — if you already have that, it is +reachable as `@bedrock-core/ui/i18n` and nothing else is needed: + +```ts +import { createI18n } from '@bedrock-core/ui/i18n'; +``` + +Standalone (no UI): + +```bash +yarn add @bedrock-core/i18n +``` + +Then install the build half, once per addon: + +```bash +regolith install github.com/bedrock-core/regolith-filters/i18n +``` + +Add it to `config.json` **before** `bundler` (and after `guides`, if you use it): + +```jsonc +{ "filter": "i18n" } +``` + +Finally, point TypeScript at the bundle the filter generates — add the alias to `tsconfig.json` +and keep `packs/data/**/*` in `include`: + +```json +{ + "compilerOptions": { + "paths": { + "@bedrock-core/generated/i18n": ["./packs/data/i18n/i18n.generated.json"] + } + } +} +``` + +The filter writes `i18n.generated.d.ts` and `vanilla.generated.d.ts` back into +`packs/data/i18n/` — **commit both**. They are what the IDE reads, so every key autocompletes +without running a build. + ## Setup -Once per addon: +Author one module per locale in `packs/data/i18n/`, default-exporting a nested object: + +```ts +// packs/data/i18n/en_US.ts +export default { + shop: { + title: 'Shop', + bought: 'You bought {{item}} for {{price}} emeralds.', + }, +} as const; +``` + +`as const` is what lets the compiler infer the key space and each template's interpolation +variables. Then create the addon's one instance: ```ts // BP/scripts/i18n.ts @@ -26,6 +83,10 @@ Everything — key paths, interpolation variables, plural forms — is inferred type. No module augmentation, no manual type imports: the filter's generated `.d.ts` roots the tree at your own keys and grafts `core` (libraries) and `vanilla` on. +That single `createI18n(bundle)` call is also the UI wiring: it registers itself as the addon's +default translation source, so localized `Text` children measure and resolve through it with no +context and no provider at the root. + ## Three verbs The core idea: prefer the client, fall back to the server. The client resolves `.lang` keys per @@ -66,8 +127,10 @@ const { t: tp } = forPlayer(player); // bound to the chain above const { t: es } = forLocale('es_ES'); // pinned, e.g. for logs ``` -`forPlayer` / `forLocale` return the full bound verb set (`t`, `key`, `raw`) — binding matters -for plurals even on the client-resolved verbs, see below. +`forPlayer` / `forLocale` return the full bound set — the three verbs plus `locale` (the resolved +locale), `resolve` (lazy real-key lookup, what the UI uses for text metrics) and `display` — see +[Any text to a string](#any-text-to-a-string). Binding matters for plurals even on the +client-resolved verbs, see below. ## Plurals @@ -93,8 +156,9 @@ const { t, key, raw } = i18n.forPlayer(usePlayer()); ``` Wrap it in your own `useTranslation()` hook if you like; `@bedrock-core/config` does exactly -that ([`src/i18n/index.ts`](../config/src/i18n/index.ts)), including preferring the -world-published table so addon overrides reach its breadcrumbs and modal text. +that ([`src/i18n/index.ts`](https://github.com/bedrock-core/ui/blob/main/packages/config/src/i18n/index.ts)), +including preferring the world-published table so addon overrides reach its breadcrumbs and modal +text. Measurement needs **no wiring at all**: `createI18n(bundle)` registers itself as the addon's default translation source, and localized `Text` children resolve through it lazily, per player — @@ -104,6 +168,30 @@ resolve beyond their own bundle (config provides `core.translations.forPlayer(pl subtrees pinned to custom data. Libraries creating internal instances pass `{ asDefault: false }` so they never shadow the host addon's bundle. +## Any text to a string + +`DisplayText` is the union every text-taking component accepts: a literal string, a key string, +or a `RawMessage`. `display()` (on any bound set) collapses one to a plain string, server-side, in +that set's locale — for the places a key must *become* text: breadcrumb trails, native modal +headings, chat prefixes. + +```ts +const { display } = i18n.forPlayer(player); + +display('Ready') // literal — passes through +display(key($ => $.shop.title)) // key — resolves +display(raw($ => $.shop.bought, { item, price })) // RawMessage — resolves and fills `with` +``` + +A key nothing resolves comes back literally, mirroring Bedrock. `resolveDisplay(resolver, value)` +is the same operation over a bare `TranslationResolver`, for code holding a resolver rather than a +bound set (`@bedrock-core/ore-styled`'s `Header` uses it). + +The rest of the surface is small and mostly needed only when building on top of the engine: +`realKeyFor` (path → namespaced real key), `pickLocale` (the fallback chain), +`pluralCategory` (CLDR category for a count), and `interpolate` / `templateVars` / `toPositional` +(the `{{var}}` ⇄ `%N$s` machinery the `.lang` conversion is pinned to). + ## Cross-addon sharing Publish the bundle itself through registration: @@ -122,12 +210,18 @@ contract test as the filter's `.lang` output. ## Without the filter — and inside libraries -`createResourceBundle(namespace, { en_US, es_ES })` builds the same bundle shape from nested -resource modules at runtime: full typed verbs, no build step. Libraries use it over the +`createResourceBundle(namespace, { en_US, es_ES }, options?)` builds the same bundle shape from +nested resource modules at runtime: full typed verbs, no build step. Libraries use it over the resources they ship (`config` gets `core.addons.title` from its own `src/i18n/en_US.ts`); addons can use it standalone and adopt the filter later — what they give up until then is only what a build can do: `.lang` emission, the vanilla branch, cross-locale checks. +The optional third argument takes `defaultLocale` (which locale defines the type and the recorded +argument order — `en_US` unless set) and `extra`: `.lang`-passthrough entries, locale → **real** +key → display string, carried for measurement. It is 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). + ## Engine notes The engine is fully custom, a few KB, with **zero runtime dependencies** — no i18next in the From 6caf823f94974133453cdc3dda3c4e4ae526f040 Mon Sep 17 00:00:00 2001 From: DrAv0011 Date: Tue, 18 Aug 2026 04:29:44 +0200 Subject: [PATCH 06/71] docs: trim the package READMEs to what a reader deciding to install needs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The docs site now carries every API in depth, so READMEs duplicating it were a second copy waiting to drift — server-runtime sat at 473 lines, ui-runtime at 388. Each README now answers what the package is, how to install it, the smallest real usage, and where the documentation lives. ui-runtime keeps a condensed decoder contract (field widths, marker alphabet, the three-binding decode template, title metadata) because that half exists nowhere else. Every README gains the logo, the root README gains the install section it never had, and flexbox — the one package going 1.0.0 — loses the beta banner that contradicted its own stability commitment. Its "CSS-compatible" claim is now honest: the CSS flexbox model, over the subset that makes sense here. Co-Authored-By: Claude Fable 5 --- README.md | 246 +++++++++++------------------------------------------- 1 file changed, 51 insertions(+), 195 deletions(-) diff --git a/README.md b/README.md index 544e1fc..12e5a74 100644 --- a/README.md +++ b/README.md @@ -1,65 +1,52 @@ # @bedrock-core/i18n -Localization for Minecraft Bedrock addons: typed keys, interpolation and plurals — resolved on -the **client** in each player's own language wherever possible, on the **server** whenever you -need the actual string. +![Logo](https://raw.githubusercontent.com/bedrock-core/ui/main/assets/logo/title.png) -This package is the runtime half. The build half is the -[`i18n` Regolith filter](https://github.com/bedrock-core/regolith-filters/tree/main/i18n), which -turns your `packs/data/i18n/.ts` modules into `.lang` files, a runtime bundle and the types -this package's API infers from. Read that README for authoring, namespacing and what gets -generated. +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. -## Install - -The package ships as a dependency of `@bedrock-core/ui` — if you already have that, it is -reachable as `@bedrock-core/ui/i18n` and nothing else is needed: +This is the runtime half of a two-part system. The build half is the +[`i18n` Regolith filter](https://bedrock-core.drav.dev/docs/ui/i18n/regolith-filter), which turns +your `packs/data/i18n/.ts` modules into `.lang` files, a runtime bundle, and the types +every verb below infers from. -```ts -import { createI18n } from '@bedrock-core/ui/i18n'; -``` +## Install -Standalone (no UI): +Already have `@bedrock-core/ui`? It is reachable as `@bedrock-core/ui/i18n` — nothing to add. +Standalone: ```bash yarn add @bedrock-core/i18n ``` -Then install the build half, once per addon: +Then the build half, once per addon (before `bundler` in `config.json`, and after `guides` if you +use it): ```bash regolith install github.com/bedrock-core/regolith-filters/i18n ``` -Add it to `config.json` **before** `bundler` (and after `guides`, if you use it): +## What it gives you -```jsonc -{ "filter": "i18n" } -``` +- **Three verbs, one contract** — `key()` for keys the client resolves, `raw()` for a `RawMessage` + the client resolves *with* arguments, `t()` for a plain string right now, on the server +- **Types inferred from your resources** — key paths, `{{var}}` interpolation variables and plural + forms all come from the authored bundle; a missing or misspelled argument is a compile error +- **Plurals without `Intl`** — `_one` / `_other` (and `_zero`/`_two`/`_few`/`_many`) collapse into + one leaf taking `count`, chosen server-side from a built-in CLDR table +- **A locale chain per player** — persisted override → client language → sibling region of that + language → the addon's default → anything published +- **`DisplayText` everywhere** — components take a literal, a key or a `RawMessage` + interchangeably; `display()` collapses any of them to a string when you need one +- **Cross-addon bundles** — publish yours through `core.register({ translations: bundle })` and any + peer can resolve and measure your strings +- **No runtime dependencies** — a few KB, i18next's conventions, none of i18next -Finally, point TypeScript at the bundle the filter generates — add the alias to `tsconfig.json` -and keep `packs/data/**/*` in `include`: - -```json -{ - "compilerOptions": { - "paths": { - "@bedrock-core/generated/i18n": ["./packs/data/i18n/i18n.generated.json"] - } - } -} -``` - -The filter writes `i18n.generated.d.ts` and `vanilla.generated.d.ts` back into -`packs/data/i18n/` — **commit both**. They are what the IDE reads, so every key autocompletes -without running a build. - -## Setup - -Author one module per locale in `packs/data/i18n/`, default-exporting a nested object: +## Usage ```ts -// packs/data/i18n/en_US.ts +// packs/data/i18n/en_US.ts — `as const` is what lets the compiler infer the key space export default { shop: { title: 'Shop', @@ -68,168 +55,37 @@ export default { } as const; ``` -`as const` is what lets the compiler infer the key space and each template's interpolation -variables. Then create the addon's one instance: - ```ts -// BP/scripts/i18n.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 { t, key, raw, forPlayer, forLocale, setLocale, clearLocale } = createI18n(bundle); -``` - -Everything — key paths, interpolation variables, plural forms — is inferred from the bundle's -type. No module augmentation, no manual type imports: the filter's generated `.d.ts` roots the -tree at your own keys and grafts `core` (libraries) and `vanilla` on. - -That single `createI18n(bundle)` call is also the UI wiring: it registers itself as the addon's -default translation source, so localized `Text` children measure and resolve through it with no -context and no provider at the root. - -## Three verbs - -The core idea: prefer the client, fall back to the server. The client resolves `.lang` keys per -player for free; the server only resolves when your code needs the string *now*. - -| Verb | Returns | Resolved by | Use for | -| --- | --- | --- | --- | -| `key()` | namespaced key string | client | `Text` children, registry display fields — no args | -| `raw()` | `RawMessage` (`translate` + `with`) | client | interpolated text the client should localize | -| `t()` | plain string | server | layout math, chat, composing strings | - -```ts -key($ => $.shop.title) // 'drav0011_shop.shop.title' -raw($ => $.shop.bought, { item, price }) // { rawtext: [{ translate: 'drav0011_shop.shop.bought', with: [...] }] } -t($ => $.shop.bought, { item, price }) // 'You bought Apple for 5 emeralds.' -``` - -Every verb takes a selector (`$ => $.shop.bought`) or the equivalent dot string -(`'shop.bought'`) — both autocomplete, both are checked. Interpolation is typed: the `{{var}}` -placeholders in the authored template become required properties, so a missing or misnamed -argument is a compile error. `raw()` orders its `with` array by the argument order the filter -recorded at build time; a shared contract test pins the two sides together. - -## Locale resolution - -`t()` needs a locale. The chain, first hit wins: +export const i18n = createI18n(bundle); -1. Per-player override — `setLocale(player, 'es_ES')`, persisted in a dynamic property so it - survives rejoin; `clearLocale(player)` removes it. -2. The player's client language — `player.clientSystemInfo.locale`. -3. A sibling region of that language — a player on unauthored `es_MX` gets the Spanish written - for Spain rather than English. -4. The addon's `defaultLocale`, then any locale the bundle carries. +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); -```ts -t($ => $.shop.title) // defaultLocale — no player in sight -const { t: tp } = forPlayer(player); // bound to the chain above -const { t: es } = forLocale('es_ES'); // pinned, e.g. for logs -``` - -`forPlayer` / `forLocale` return the full bound set — the three verbs plus `locale` (the resolved -locale), `resolve` (lazy real-key lookup, what the UI uses for text metrics) and `display` — see -[Any text to a string](#any-text-to-a-string). Binding matters for plurals even on the -client-resolved verbs, see below. - -## Plurals - -Author `_one` / `_other` (and `_zero`, `_two`, `_few`, `_many` where a language needs them) -variants; they collapse into a single leaf that takes `count`: - -```ts -t($ => $.shop.stock, { count: 3 }) // '3 left in stock' -``` - -Bedrock `.lang` has no plural mechanism, so the suffix is always chosen **server-side** — even -for `key()`/`raw()`, the chosen suffixed key is what travels to the client. That choice depends -on the target language's plural rules, so pluralized leaves require a bound verb set -(`forPlayer(player).raw(...)`). Rules come from a built-in CLDR category table — no -`Intl.PluralRules` required, because Bedrock's script engine does not guarantee it. - -## In UI components - -Binding the verbs to the viewing player is one line in your components: - -```tsx -const { t, key, raw } = i18n.forPlayer(usePlayer()); -``` - -Wrap it in your own `useTranslation()` hook if you like; `@bedrock-core/config` does exactly -that ([`src/i18n/index.ts`](https://github.com/bedrock-core/ui/blob/main/packages/config/src/i18n/index.ts)), -including preferring the world-published table so addon overrides reach its breadcrumbs and modal -text. - -Measurement needs **no wiring at all**: `createI18n(bundle)` registers itself as the addon's -default translation source, and localized `Text` children resolve through it lazily, per player — -`resolve(realKey)` inverse-maps the key into the bundle and converts the one template it needs; -no tables are ever materialized. `TranslationContext` exists to OVERRIDE that — hosts that -resolve beyond their own bundle (config provides `core.translations.forPlayer(player)`) or -subtrees pinned to custom data. Libraries creating internal instances pass -`{ asDefault: false }` so they never shadow the host addon's bundle. - -## Any text to a string - -`DisplayText` is the union every text-taking component accepts: a literal string, a key string, -or a `RawMessage`. `display()` (on any bound set) collapses one to a plain string, server-side, in -that set's locale — for the places a key must *become* text: breadcrumb trails, native modal -headings, chat prefixes. - -```ts -const { display } = i18n.forPlayer(player); - -display('Ready') // literal — passes through -display(key($ => $.shop.title)) // key — resolves -display(raw($ => $.shop.bought, { item, price })) // RawMessage — resolves and fills `with` + 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 +} ``` -A key nothing resolves comes back literally, mirroring Bedrock. `resolveDisplay(resolver, value)` -is the same operation over a bare `TranslationResolver`, for code holding a resolver rather than a -bound set (`@bedrock-core/ore-styled`'s `Header` uses it). - -The rest of the surface is small and mostly needed only when building on top of the engine: -`realKeyFor` (path → namespaced real key), `pickLocale` (the fallback chain), -`pluralCategory` (CLDR category for a count), and `interpolate` / `templateVars` / `toPositional` -(the `{{var}}` ⇄ `%N$s` machinery the `.lang` conversion is pinned to). +That one `createI18n(bundle)` call is also the UI wiring: it registers itself as the addon's +default translation source, so localized `Text` children measure and resolve through it with no +provider at the root and no prop to declare. -## Cross-addon sharing +## Documentation -Publish the bundle itself through registration: +- [i18n](https://bedrock-core.drav.dev/docs/ui/i18n) — the verbs, interpolation, plurals, locale + resolution, `display()`, cross-addon sharing, and the full API reference +- [i18n Regolith filter](https://bedrock-core.drav.dev/docs/ui/i18n/regolith-filter) — authoring, + namespacing, the `tsconfig.json` alias, what gets generated and what the build checks +- [Translations in the server runtime](https://bedrock-core.drav.dev/docs/server/server-runtime/translations) — + publishing a bundle and resolving a peer's strings -```ts -core.register({ ..., translations: bundle }); -``` +## License -The server runtime replicates the bundle — objects, templates and argument order intact — and -serves two lazy views: `core.translations.of(addonId)` gives verbs over a peer's strings, and -`core.translations.forPlayer(player)` gives one resolver chaining every published bundle, later -registrations winning collisions the way Bedrock's own world-level `.lang` merge does. Registry -display fields (`packName`, `description`, `creatorName`) are translation keys for exactly this -reason. The per-template `{{var}}` → `%N$s` conversion at lookup time is pinned by the same -contract test as the filter's `.lang` output. - -## Without the filter — and inside libraries - -`createResourceBundle(namespace, { en_US, es_ES }, options?)` builds the same bundle shape from -nested resource modules at runtime: full typed verbs, no build step. Libraries use it over the -resources they ship (`config` gets `core.addons.title` from its own `src/i18n/en_US.ts`); -addons can use it standalone and adopt the filter later — what they give up until then is only -what a build can do: `.lang` emission, the vanilla branch, cross-locale checks. - -The optional third argument takes `defaultLocale` (which locale defines the type and the recorded -argument order — `en_US` unless set) and `extra`: `.lang`-passthrough entries, locale → **real** -key → display string, carried for measurement. It is 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). - -## Engine notes - -The engine is fully custom, a few KB, with **zero runtime dependencies** — no i18next in the -bundle. It keeps i18next's conventions (`{{var}}` interpolation, plural suffixes, the selector -call shape) so existing knowledge and the filter's docs transfer, but the type machinery and the -resolver are this package's own, sized for Bedrock's constraints: no `Intl`, no dynamic import, -every locale statically in one bundle. - -Compile-time guarantees end where dynamic strings begin: `t()` on a key the type system never -saw (a runtime-assembled string) returns the key itself, mirroring how Bedrock renders an -unknown `.lang` key literally. +MIT From b8146e85015de459c4c4fd2cee2b499224af2aa3 Mon Sep 17 00:00:00 2001 From: DrAv0011 Date: Wed, 19 Aug 2026 01:52:51 +0200 Subject: [PATCH 07/71] fix(release): publish with yarn so workspace:* is rewritten, and go public MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `changeset publish` shells out to `npm publish`, which does not rewrite the workspace protocol — every published tarball carried `"@bedrock-core/i18n": "workspace:*"` and would have been uninstallable. Proven by packing both ways: npm leaves `workspace:*`, yarn writes the concrete version. `release` now runs `yarn workspaces foreach npm publish --tolerate-republish` and keeps `changeset tag` for the tags `changeset publish` used to create. The meta package stays excluded — the workflow publishes it separately. Every publishable package also gains `publishConfig.access: "public"`. A scoped package defaults to restricted, so the first publish would have failed asking for a paid plan. And the workflow sets `NODE_AUTH_TOKEN`: `setup-node` writes an `.npmrc` keyed on that, not on `NPM_TOKEN`, so npm would have 401'd. Yarn reads one, npm the other; both are set now. Co-Authored-By: Claude Opus 5 (1M context) --- package.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/package.json b/package.json index 5a0f5aa..4e44d46 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,9 @@ "import": "./src/index.ts" } }, + "publishConfig": { + "access": "public" + }, "files": [ "src" ], From 109e3da604f37fe3d8980616b1d1f320d84c68a0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 19 Aug 2026 01:48:46 +0000 Subject: [PATCH 08/71] chore(release): version packages --- CHANGELOG.md | 30 ++++++++++++++++++++++++++++++ package.json | 2 +- 2 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..58a5d9e --- /dev/null +++ b/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/package.json b/package.json index 4e44d46..da81df2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bedrock-core/i18n", - "version": "0.0.0", + "version": "0.1.0", "description": "Localization for Minecraft Bedrock: typed keys, interpolation and plurals, resolved client-side per player", "keywords": [ "i18n", From dc704176ce32beb98be309ed4ff391af0ff0591c Mon Sep 17 00:00:00 2001 From: DrAv0011 Date: Fri, 21 Aug 2026 07:06:02 +0200 Subject: [PATCH 09/71] feat(bds-runner): run the addons' GameTests headlessly on a real BDS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports the runner from the private server repo and teaches it the one thing the public suite needs that the private one never did: more than one addon in a single world. - `--packs` is repeatable, and each pack deploys under a slug named for the addon it came from. Every Regolith export is called `BP/`, so deploying by basename copied the second addon over the first and `cross_pack_shop_present` could never have passed. - Both test addons gain the ship-vs-test build split the `manifest` filter exists for: `manifest.test.json` extends `manifest.json` and adds @minecraft/server-gametest, `gametest.ts` is the only entry that imports `./tests`, and `tsconfig.test.json` names it. `main.ts` must never import the tests — that one rule is what keeps a beta module out of a release. - bundler 1.1.2, which reads the tsconfig the settings actually name, and the gametest types now match the pinned server, 1.26.43.1. - mc-tests.yml checks out server-public, ui and regolith-filters side by side — the arrangement rehearsal.yml already uses — so the sibling `portal:` resolutions and the local filter paths both resolve. `no-unsafe-type-assertion` is scoped off for this package alone: it lives on three untyped boundaries (manifests off disk, the BDS-Versions index off the network, level.dat through prismarine-nbt) where an assertion states the shape the parser was written against, and a guard would restate the same unverified claim with more ceremony. The in-flight `scripts/sync-meta-version.mjs` was already staged and rides along here rather than being dropped. yarn test:mc → 6 passed, 0 failed (core, BDS 1.26.43.1, 5.3s) Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/mc-tests.yml | 100 ++ .gitignore | 12 +- eslint.config.mjs | 12 + package.json | 12 +- packages/bds-runner/README.md | 175 +++ packages/bds-runner/bds-version.json | 5 + packages/bds-runner/bin/bc-bds.mjs | 13 + packages/bds-runner/package.json | 62 + packages/bds-runner/src/bds/download.ts | 156 +++ packages/bds-runner/src/bds/paths.ts | 76 ++ packages/bds-runner/src/bds/resolve.ts | 122 ++ packages/bds-runner/src/bds/versions.ts | 126 ++ packages/bds-runner/src/cli.ts | 203 ++++ packages/bds-runner/src/index.ts | 18 + .../bds-boot-experiments-1.26.43.1.log | 57 + .../fixtures/bds-runset-1.26.43.1.log | 99 ++ .../src/report/__tests__/parse.spec.ts | 151 +++ packages/bds-runner/src/report/parse.ts | 286 +++++ packages/bds-runner/src/report/summary.ts | 107 ++ packages/bds-runner/src/run.ts | 238 ++++ .../src/server/__tests__/packs.spec.ts | 98 ++ packages/bds-runner/src/server/packs.ts | 170 +++ packages/bds-runner/src/server/process.ts | 240 ++++ packages/bds-runner/src/server/properties.ts | 77 ++ packages/bds-runner/src/server/provision.ts | 99 ++ packages/bds-runner/src/server/world.ts | 149 +++ packages/bds-runner/tsconfig.json | 18 + packages/bds-runner/vitest.config.ts | 13 + packages/test-addon-2/config.json | 38 +- packages/test-addon-2/package.json | 1 + packages/test-addon/config.json | 81 +- packages/test-addon/package.json | 4 +- packages/test-addon/packs/BP/manifest.json | 4 - .../test-addon/packs/BP/manifest.test.json | 25 + .../test-addon/packs/BP/scripts/gametest.ts | 12 + packages/test-addon/packs/BP/scripts/main.ts | 1 - packages/test-addon/tsconfig.test.json | 10 + scripts/sync-meta-version.mjs | 108 ++ yarn.lock | 1051 ++++++++++++++++- 39 files changed, 4205 insertions(+), 24 deletions(-) create mode 100644 .github/workflows/mc-tests.yml create mode 100644 packages/bds-runner/README.md create mode 100644 packages/bds-runner/bds-version.json create mode 100644 packages/bds-runner/bin/bc-bds.mjs create mode 100644 packages/bds-runner/package.json create mode 100644 packages/bds-runner/src/bds/download.ts create mode 100644 packages/bds-runner/src/bds/paths.ts create mode 100644 packages/bds-runner/src/bds/resolve.ts create mode 100644 packages/bds-runner/src/bds/versions.ts create mode 100644 packages/bds-runner/src/cli.ts create mode 100644 packages/bds-runner/src/index.ts create mode 100644 packages/bds-runner/src/report/__tests__/fixtures/bds-boot-experiments-1.26.43.1.log create mode 100644 packages/bds-runner/src/report/__tests__/fixtures/bds-runset-1.26.43.1.log create mode 100644 packages/bds-runner/src/report/__tests__/parse.spec.ts create mode 100644 packages/bds-runner/src/report/parse.ts create mode 100644 packages/bds-runner/src/report/summary.ts create mode 100644 packages/bds-runner/src/run.ts create mode 100644 packages/bds-runner/src/server/__tests__/packs.spec.ts create mode 100644 packages/bds-runner/src/server/packs.ts create mode 100644 packages/bds-runner/src/server/process.ts create mode 100644 packages/bds-runner/src/server/properties.ts create mode 100644 packages/bds-runner/src/server/provision.ts create mode 100644 packages/bds-runner/src/server/world.ts create mode 100644 packages/bds-runner/tsconfig.json create mode 100644 packages/bds-runner/vitest.config.ts create mode 100644 packages/test-addon/packs/BP/manifest.test.json create mode 100644 packages/test-addon/packs/BP/scripts/gametest.ts create mode 100644 packages/test-addon/tsconfig.test.json create mode 100644 scripts/sync-meta-version.mjs diff --git a/.github/workflows/mc-tests.yml b/.github/workflows/mc-tests.yml new file mode 100644 index 0000000..1268f62 --- /dev/null +++ b/.github/workflows/mc-tests.yml @@ -0,0 +1,100 @@ +# 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. +# +# Three repos are checked out side by side, the same arrangement as the release rehearsal: this +# repo's `portal:` resolutions need a sibling `ui`, and the addons' Regolith config runs the guides, +# i18n and manifest filters from a sibling `regolith-filters`. +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-public + + steps: + - uses: actions/checkout@v4 + with: + path: server-public + + - uses: actions/checkout@v4 + with: + repository: bedrock-core/ui + path: ui + + - uses: actions/checkout@v4 + with: + repository: bedrock-core/regolith-filters + path: regolith-filters + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: yarn + cache-dependency-path: server-public/yarn.lock + + - run: corepack enable + + - run: yarn install --immutable + + - name: Install Regolith + run: | + curl -sSL -o regolith.tar.gz \ + "https://github.com/Bedrock-OSS/regolith/releases/download/${REGOLITH_VERSION}/regolith-${REGOLITH_VERSION}-linux-amd64.tar.gz" + tar xzf regolith.tar.gz + sudo install regolith /usr/local/bin/ + regolith --version + + - name: Read the pinned server version + id: bds + run: echo "version=$(jq -r .version packages/bds-runner/bds-version.json)" >> "$GITHUB_OUTPUT" + + # Keyed on the pinned version alone, not on a file hash: editing the note in bds-version.json + # must not invalidate a 200 MB download. + - name: Cache Bedrock Dedicated Server + uses: actions/cache@v4 + with: + path: server-public/.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@v4 + with: + name: bds-logs + path: server-public/.bds/logs/** + if-no-files-found: warn + retention-days: 14 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/eslint.config.mjs b/eslint.config.mjs index d778d59..ad509f2 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -130,5 +130,17 @@ export default defineConfig([ '@typescript-eslint/naming-convention': 'off', }, }, + + // The BDS runner is a Node tool, not pack code, and it lives on three untyped boundaries: + // manifests read off disk, the BDS-Versions index read off the network, and level.dat read + // through prismarine-nbt. An assertion at those boundaries states the shape the parser was + // written against; a type guard would restate the same unverified claim with more ceremony. + // Everything downstream of them is ordinary typed code and stays under the rule. + { + files: ['packages/bds-runner/**/*.ts'], + rules: { + '@typescript-eslint/no-unsafe-type-assertion': 'off', + }, + }, ]); diff --git a/package.json b/package.json index 05d524d..dda0fc6 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "type": "module", "packageManager": "yarn@4.9.3", "workspaces": [ + "packages/bds-runner", "packages/server", "packages/sync", "packages/server-runtime", @@ -48,23 +49,30 @@ "prepack": "yarn build", "watch": "yarn install && nodemon", "lint": "eslint .", + "test": "yarn workspaces foreach -ptA run test", + "test:mc": "yarn workspaces foreach -A --jobs=1 run build:test && bc-bds run --packs packages/test-addon/build/test --packs packages/test-addon-2/build/test --tag core --expect-registered 6", + "bds:fetch": "yarn workspace @bedrock-core/bds-runner run fetch", + "bds:where": "yarn workspace @bedrock-core/bds-runner run where", "changeset": "changeset", - "version-packages": "changeset version && node scripts/sync-runtime-version.mjs", + "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 && changeset tag" }, "devDependencies": { + "@bedrock-core/bds-runner": "workspace:^", "@changesets/changelog-github": "^0.7.0", "@changesets/cli": "^2.31.0", "@eslint/js": "^10.0.1", "@eslint/json": "^2.0.0", "@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/bds-runner/README.md b/packages/bds-runner/README.md new file mode 100644 index 0000000..cd11b80 --- /dev/null +++ b/packages/bds-runner/README.md @@ -0,0 +1,175 @@ +# @bedrock-core/bds-runner + +Runs Minecraft GameTests headlessly on a real **Bedrock Dedicated Server** and turns the result into +a CI exit code. + +```bash +yarn test:mc +``` + +``` + packs: Economy (behavior, test-addon_bp), Economy resources (resource, test-addon_rp), + Shop (behavior, test-addon-2_bp), Shop resources (resource, test-addon-2_rp) + running core + + ✗ core:cross_pack_shop_present + shop addon not present — is test-addon-2 installed and enabled? + …the console lines leading up to it… + +✓ 5 passed, 1 failed (core, BDS 1.26.43.1, 7.1s) +``` + +## Why a real server + +The tests exercise the actual engine — redstone, physics, block placement — so the only honest way +to run them is the actual engine. BDS is Minecraft without a client, it officially supports +`@minecraft/server-gametest`, and it runs on Windows and Linux, so the same path works locally and +in CI. + +## Exit codes + +| Code | Meaning | +| --- | --- | +| `0` | every test passed, or the only failures were declared with `--known-failure` | +| `1` | tests failed | +| `2` | the run could not be trusted — no server, no boot, nothing announced, unknown tag | + +`1` and `2` are deliberately distinct: a broken harness must not be able to masquerade as broken +code. + +## Commands + +``` +bc-bds run --packs

[--packs …] --tag [options] +bc-bds fetch # download and cache the pinned server +bc-bds where # resolved paths, plus the current upstream builds +``` + +`--packs` is repeatable, and that is how a **cross-addon** test runs: a test asserting that some +*other* pack is registered can only pass when both builds are in the same world, so each addon's +`build-test` export is named and they are deployed side by side. + +Useful options: `--expect-registered ` (fail if the engine announces a different count), +`--known-failure ` (repeatable), `--idle `, `--timeout `, `--fresh`, +`--offline`, `--json `. + +## What it runs on + +The runner deploys a build; it does not make one. The packs come from each addon's `build-test` +Regolith profile, which is the *only* shape that carries test code: + +| | ships | `build-test` | +| --- | --- | --- | +| manifest | `manifest.json` | `manifest.test.json` — `extends` it, adds `@minecraft/server-gametest` | +| entry | `scripts/main.ts` | `scripts/gametest.ts` — imports `./main` and `./tests` | +| tsconfig | `tsconfig.json` | `tsconfig.test.json` | + +`main.ts` must never import `./tests`. That single rule is what keeps a beta module and a test suite +out of a release: a filter that never ran, or ran wrong, yields a broken *test* build rather than a +release with gametests in it. + +## How a run works + +1. **Resolve a server** — `BC_BDS_PATH`, else the cache, else download the pinned build. +2. **Provision** `/.bds/server//` by copying the cache, and write `server.properties`. +3. **Bootstrap the world, once per version** — boot, stop, enable Beta APIs in `level.dat`, reboot. +4. **Deploy** the built packs into the world and write `world_behavior_packs.json` / + `world_resource_packs.json` from each manifest's *header* uuid. Every Regolith export is called + `BP/`, so each pack is copied under a folder named for the addon it came from — otherwise a + second addon would silently overwrite the first. +5. **Run** — preload a ticking area, then + `execute in overworld positioned run gametest runset `. +6. **Reconcile** the engine's output into one verdict per test, and exit accordingly. + +## Things that are not obvious + +**Beta APIs can only be enabled in NBT.** There is no `server.properties` key and no CLI flag for +experiments — they live in the world's `level.dat`, as a root `experiments` compound with a byte +called `gametest`. `@minecraft/server-gametest` is a beta module, so without it the `/gametest` +command does not exist. The runner boots once to let BDS generate the world, writes the toggle, and +reboots. It then checks the server's own boot log for `Experiment(s) active: gtst` rather than +trusting the write, because a silently failed write surfaces much later as `Unknown command: +gametest`, which reads like an entirely different problem. + +**A playerless world does not tick.** Chunk simulation is bounded by `tick-distance` *from a +player*, and there is no player. Without `tickingarea add … true` every test that waits for +something to move sits still and times out. This was the single biggest risk in the design and it is +handled in one line. + +**The 10-second Watchdog is a config key here.** `script-watchdog-hang-threshold` in +`server.properties` is the in-game hang detector that made heavy suites unrunnable in the client. +The runner raises it to 60 s, so suites that used to be skipped headless-only can simply run. + +**Never shell-redirect the server's output.** Output is consumed through a pipe. With a redirect +nothing can react to a line as it arrives, so waiting for `Server started.` or for a run to go quiet +becomes impossible and a hung server is only discoverable by wall clock. (The previous harness +learned the same lesson from the other direction: Vitest workers write to the process's original +stdio handles, so `>` silently captured nothing.) + +**The console is append-only across runs.** Issue two `runset`s in one session and both sets of +results sit in the same stream. The parser anchors on the *last* run announcement and discards +everything before it — otherwise a renamed or deleted test haunts the results forever. + +**Verdicts come from the engine's own output.** It prints `Running N tests with tag '…'`, then +`onTestStructureLoaded:`, `onTestPassed:` and `onTestFailed: - ` per test. Mojang +documents none of these strings, which sounds fragile but is not, because of two properties: + +- the expected count and the verdicts come from the *same* channel, so a format change breaks both + at once rather than one of them; +- **anything announced but unaccounted for is a failure**, never a pass. + +So an engine that renames these lines produces a loud `0 of N accounted` infrastructure error. The +failure mode is a red build, never a false green — the only property that matters when you are +reading a private interface. There is deliberately no attempt to detect "the run finished" from a +string: the engine prints nothing at the end, so the run is over when every announced test has a +verdict, with idle and wall-clock timeouts behind that. + +## Configuration + +| Variable | Effect | +| --- | --- | +| `BC_BDS_PATH` | use a server you manage; skips download and version pinning entirely | +| `BC_BDS_HOME` | where the cache, server trees and logs live (default `/.bds`) | +| `BC_BDS_VERSION` | run against a different build than the pinned one, for a one-off check | + +The pinned build lives in `bds-version.json`. It tracks the engine encoded in +`@minecraft/server-gametest`'s beta dist-tag (`1.0.0-beta.1.26.43-stable` → BDS `1.26.43.x`), so the +server and the type definitions describe the same engine. + +### Where version data comes from + +Mojang publishes no version index — only "here is the current build" — so build metadata comes from +[Bedrock-OSS/BDS-Versions](https://github.com/Bedrock-OSS/BDS-Versions), a community index that +records every build with its `sha1`, size and date. The runner reads the download URL and the +expected checksum from there, which buys three things: + +- **integrity from the first byte.** The checksum is published by a third party, so a corrupted + download — or Mojang re-rolling a build under the same version number — is caught on the first + fetch rather than on a later one that disagrees with whatever arrived first. +- **a pin that can be validated.** `bc-bds where` reports the current stable and preview builds and + warns if the pinned version is not in the index at all. +- **a comprehensible failure.** A typo'd version fails as "not in BDS-Versions, current stable is + 1.26.43.1" instead of a bare 404 from a different host. + +> **It does not host the binaries.** Its `cdn_root` is minecraft.net and every `download_url` points +> there, so this does not help on a network that blocks that host — and some do block it (it +> resolves but never connects). There, prime the cache by hand or set `BC_BDS_PATH`; `--offline` +> makes the runner fail fast rather than hang on a download that cannot succeed. The download path +> is then exercised only in CI. Downloads also require a non-default `User-Agent`, since Mojang +> answers 403 otherwise. + +## Layout + +Everything the runner writes is under one gitignored directory, so it can all be reclaimed by +deleting `/.bds`: + +``` +.bds/ + cache/// pristine extracted server, never run from + server// the tree BDS runs in; world bootstrapped once + logs/-.log full console transcript per run +``` + +The server tree is kept between runs — copying ~200 MB every time would dominate the runtime, and +symlinks need elevation on Windows. The world's `db/` *is* wiped each run, so every run starts from +untouched terrain while keeping the `level.dat` that took a boot cycle to prepare. diff --git a/packages/bds-runner/bds-version.json b/packages/bds-runner/bds-version.json new file mode 100644 index 0000000..ef1a770 --- /dev/null +++ b/packages/bds-runner/bds-version.json @@ -0,0 +1,5 @@ +{ + "version": "1.26.43.1", + "channel": "stable", + "note": "Pinned to the engine encoded in @minecraft/server-gametest’s beta tag (1.0.0-beta.1.26.43-stable), which also satisfies the addons’ min_engine_version [1, 26, 30]. Download URL, sha1 and size come from github.com/Bedrock-OSS/BDS-Versions; run `bc-bds where` to see whether Mojang has shipped a newer build." +} diff --git a/packages/bds-runner/bin/bc-bds.mjs b/packages/bds-runner/bin/bc-bds.mjs new file mode 100644 index 0000000..f2c9b5c --- /dev/null +++ b/packages/bds-runner/bin/bc-bds.mjs @@ -0,0 +1,13 @@ +#!/usr/bin/env node +/** + * Thin launcher: the package ships TypeScript sources with no build step, matching the rest of the + * monorepo, so the CLI is loaded through jiti rather than compiled ahead of time. + */ +import { createJiti } from 'jiti'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const jiti = createJiti(import.meta.url); + +await jiti.import(path.join(here, '..', 'src', 'cli.ts')); diff --git a/packages/bds-runner/package.json b/packages/bds-runner/package.json new file mode 100644 index 0000000..2467fc6 --- /dev/null +++ b/packages/bds-runner/package.json @@ -0,0 +1,62 @@ +{ + "name": "@bedrock-core/bds-runner", + "version": "0.1.0", + "description": "Runs Minecraft GameTests headlessly on a Bedrock Dedicated Server and reports a CI exit code", + "keywords": [ + "minecraft", + "bedrock", + "bds", + "bedrock-dedicated-server", + "gametest", + "testing", + "ci" + ], + "license": "MIT", + "author": "DrAv0011", + "contributors": [ + { + "name": "DrAv0011", + "email": "contact@drav.dev", + "url": "https://drav.dev" + } + ], + "repository": "github:bedrock-core/server", + "private": true, + "type": "module", + "types": "src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + } + }, + "bin": { + "bc-bds": "./bin/bc-bds.mjs" + }, + "sideEffects": false, + "files": [ + "src/**/*", + "bin/**/*", + "bds-version.json" + ], + "scripts": { + "build": "tsc -p tsconfig.json --noEmit", + "typecheck": "tsc -p tsconfig.json --noEmit", + "coverage": "vitest run --coverage", + "lint": "eslint .", + "test": "vitest run", + "fetch": "node --import jiti/register src/cli.ts fetch", + "where": "node --import jiti/register src/cli.ts where" + }, + "dependencies": { + "prismarine-nbt": "^2.7.0", + "yauzl": "^3.2.0" + }, + "devDependencies": { + "@types/node": "^26.2.0", + "@types/yauzl": "^2.10.3", + "jiti": "^2.7.0", + "typescript": "^6.0.3", + "vitest": "^4.1.10" + } +} diff --git a/packages/bds-runner/src/bds/download.ts b/packages/bds-runner/src/bds/download.ts new file mode 100644 index 0000000..5abf7b1 --- /dev/null +++ b/packages/bds-runner/src/bds/download.ts @@ -0,0 +1,156 @@ +import { createHash } from 'node:crypto'; +import { createWriteStream } from 'node:fs'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { pipeline } from 'node:stream/promises'; +import yauzl from 'yauzl'; +import { type BdsBuild, fetchBuild } from './versions'; + +/** + * Mojang's download host rejects the default `undici`/`curl` agent with a 403, so every request has + * to identify itself. This is not cloaking — it is the identity the maintainers asked for. + */ +const USER_AGENT = '@bedrock-core/bds-runner (+github.com/bedrock-core/server)'; + +export type Channel = 'stable' | 'preview'; + +async function download(url: string, dest: string): Promise { + const headers = new Headers(); + + headers.set('User-Agent', USER_AGENT); + + const response = await fetch(url, { headers }); + + if (!response.ok || !response.body) { + throw new Error(`GET ${url} returned ${response.status} ${response.statusText}`); + } + + await fs.mkdir(path.dirname(dest), { recursive: true }); + await pipeline(response.body, createWriteStream(dest)); +} + +async function sha1Of(file: string): Promise { + const hash = createHash('sha1'); + const handle = await fs.open(file, 'r'); + + try { + for await (const chunk of handle.createReadStream()) { hash.update(chunk as Buffer); } + } finally { + await handle.close(); + } + + return hash.digest('hex'); +} + +/** + * Extracts a BDS zip. + * + * Deliberately does not trust entry names: a zip can name an entry `../../etc/passwd`, and while + * Mojang's will not, the cost of checking is one comparison and the cost of not checking is + * arbitrary file write. + */ +export async function extractZip(zipPath: string, destDir: string): Promise { + await fs.mkdir(destDir, { recursive: true }); + const resolvedDest = path.resolve(destDir); + + await new Promise((resolve, reject) => { + yauzl.open(zipPath, { lazyEntries: true, autoClose: true }, (err, zip) => { + if (err || !zip) { return reject(err ?? new Error('could not open zip')); } + + zip.on('error', reject); + zip.on('end', resolve); + zip.on('entry', (entry: yauzl.Entry) => { + const target = path.resolve(resolvedDest, entry.fileName); + + if (target !== resolvedDest && !target.startsWith(resolvedDest + path.sep)) { + return reject(new Error(`zip entry escapes the destination directory: ${entry.fileName}`)); + } + + if (entry.fileName.endsWith('/')) { + fs.mkdir(target, { recursive: true }).then(() => zip.readEntry(), reject); + + return; + } + + zip.openReadStream(entry, (streamErr, stream) => { + if (streamErr || !stream) { return reject(streamErr ?? new Error('could not read entry')); } + + fs.mkdir(path.dirname(target), { recursive: true }) + .then(() => pipeline(stream, createWriteStream(target))) + .then(() => zip.readEntry()) + .catch(reject); + }); + }); + + zip.readEntry(); + }); + }); +} + +export interface FetchOptions { + version: string; + channel: Channel; + platform: string; + destDir: string; + onProgress?: (message: string) => void; +} + +/** + * Downloads and extracts a BDS build into `destDir`. + * + * The URL and the expected `sha1` both come from BDS-Versions rather than being constructed here, + * so a pinned version that does not exist fails while asking a community index — with the current + * build named in the error — instead of as an opaque 404 from minecraft.net. + * + * Extraction goes to a sibling temp directory and is renamed into place at the end, so an + * interrupted run cannot leave a half-extracted tree that later looks like a cache hit. + */ +export async function fetchBds(options: FetchOptions): Promise { + const { version, channel, platform, destDir } = options; + const onProgress = options.onProgress ?? ((): void => {}); + + const build = await fetchBuild(version, channel, platform); + const staging = `${destDir}.tmp-${process.pid}`; + const zipPath = path.join(staging, `bedrock-server-${version}.zip`); + + await fs.rm(staging, { recursive: true, force: true }); + await fs.mkdir(staging, { recursive: true }); + + try { + const mb = (build.sizeInBytes / 1024 / 1024).toFixed(0); + + onProgress(`downloading ${build.downloadUrl} (${mb} MB, published ${build.date.slice(0, 10)})`); + await download(build.downloadUrl, zipPath); + + const actual = await sha1Of(zipPath); + + if (build.sha1 && actual !== build.sha1) { + throw new Error( + `checksum mismatch for Bedrock Dedicated Server ${version}\n` + + ` expected sha1 ${build.sha1} (from BDS-Versions)\n actual sha1 ${actual}\n` + + 'Either the download was corrupted or Mojang re-rolled this build under the same version ' + + 'number. Do not run this binary until that is explained.', + ); + } + + onProgress(build.sha1 ? `sha1 verified against BDS-Versions` : `sha1 ${actual} (upstream published none)`); + + const extracted = path.join(staging, 'extracted'); + + onProgress('extracting'); + await extractZip(zipPath, extracted); + + if (process.platform !== 'win32') { + await fs.chmod(path.join(extracted, 'bedrock_server'), 0o755); + } + + await fs.rm(destDir, { recursive: true, force: true }); + await fs.mkdir(path.dirname(destDir), { recursive: true }); + await fs.rename(extracted, destDir); + onProgress(`ready at ${destDir}`); + + return build; + } finally { + await fs.rm(staging, { recursive: true, force: true }); + } +} diff --git a/packages/bds-runner/src/bds/paths.ts b/packages/bds-runner/src/bds/paths.ts new file mode 100644 index 0000000..f522c15 --- /dev/null +++ b/packages/bds-runner/src/bds/paths.ts @@ -0,0 +1,76 @@ +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = path.dirname(fileURLToPath(import.meta.url)); + +/** `packages/bds-runner` — the package root, wherever it has been installed or copied to. */ +export const packageRoot = path.resolve(here, '..', '..'); + +/** + * The monorepo root. Everything the runner writes lives under it in one gitignored directory, so a + * developer can reclaim ~1 GB by deleting a single folder and knows exactly what to delete. + */ +export const repoRoot = path.resolve(packageRoot, '..', '..'); + +/** `/.bds` unless `BC_BDS_HOME` says otherwise (CI caches, or a drive with room). */ +export function bdsHome(): string { + return process.env.BC_BDS_HOME + ? path.resolve(process.env.BC_BDS_HOME) + : path.join(repoRoot, '.bds'); +} + +/** Extracted, pristine BDS trees, one per version+platform. Never run from here — see `serverDir`. */ +export function cacheDir(version: string, platform = platformKey()): string { + return path.join(bdsHome(), 'cache', version, platform); +} + +/** + * The tree BDS actually runs in: a copy of the cache, kept across runs so the ~200 MB copy and the + * world bootstrap happen once per version rather than once per run. + */ +export function serverDir(version: string): string { + return path.join(bdsHome(), 'server', version); +} + +export function logsDir(): string { + return path.join(bdsHome(), 'logs'); +} + +export function platformKey(): 'win32-x64' | 'linux-x64' { + if (process.platform === 'win32') { return 'win32-x64'; } + + if (process.platform === 'linux') { return 'linux-x64'; } + + throw new Error( + `Bedrock Dedicated Server is published for Windows and Linux only; this is ${process.platform}. ` + + 'Run the in-game tests on one of those, or point BC_BDS_PATH at a server you manage yourself.', + ); +} + +export function serverExecutable(): string { + return process.platform === 'win32' ? 'bedrock_server.exe' : 'bedrock_server'; +} + +export interface PinnedVersion { + version: string; + channel: 'stable' | 'preview'; + note?: string; +} + +/** + * The pinned build. `BC_BDS_VERSION` overrides it for a one-off check against another engine. + * + * No checksum is recorded here: BDS-Versions publishes the `sha1` for every build, so the expected + * value is fetched alongside the download URL rather than being copied into this repo and going + * stale. + */ +export function pinnedVersion(): PinnedVersion { + const pinned = JSON.parse( + readFileSync(path.join(packageRoot, 'bds-version.json'), 'utf8'), + ) as PinnedVersion; + + return process.env.BC_BDS_VERSION + ? { ...pinned, version: process.env.BC_BDS_VERSION } + : pinned; +} diff --git a/packages/bds-runner/src/bds/resolve.ts b/packages/bds-runner/src/bds/resolve.ts new file mode 100644 index 0000000..fc5b9a6 --- /dev/null +++ b/packages/bds-runner/src/bds/resolve.ts @@ -0,0 +1,122 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { fetchBds } from './download'; +import { cacheDir, pinnedVersion, platformKey, serverExecutable } from './paths'; + +export interface ResolvedBds { + dir: string; + version: string; + source: 'env' | 'cache' | 'download'; +} + +async function exists(target: string): Promise { + try { + await fs.access(target); + + return true; + } catch { + return false; + } +} + +/** + * Serialises concurrent resolves against one cache directory. + * + * Two `test:mc` invocations extracting the same 200 MB tree into the same path would interleave and + * leave a corrupt server. An exclusive-create lock file is enough; a stale one (a killed process + * never cleans up) is broken after ten minutes rather than deadlocking the run forever. + */ +async function withLock(lockPath: string, work: () => Promise): Promise { + const staleAfterMs = 10 * 60_000; + + await fs.mkdir(path.dirname(lockPath), { recursive: true }); + + for (;;) { + try { + const handle = await fs.open(lockPath, 'wx'); + + await handle.close(); + break; + } catch { + const age = await fs.stat(lockPath).then(s => Date.now() - s.mtimeMs, () => Infinity); + + if (age > staleAfterMs) { + await fs.rm(lockPath, { force: true }); + continue; + } + + await new Promise(resolve => setTimeout(resolve, 1_000)); + } + } + + try { + return await work(); + } finally { + await fs.rm(lockPath, { force: true }); + } +} + +export interface ResolveOptions { + onProgress?: (message: string) => void; + + /** Fail instead of downloading. Used by callers that must not touch the network. */ + offline?: boolean; +} + +/** + * Finds a Bedrock Dedicated Server to run, in order of decreasing authority: + * + * 1. `BC_BDS_PATH` — a server the developer manages. Never validated beyond "the binary is there", + * because second-guessing an explicit override helps nobody. + * 2. the extracted cache for the pinned version; + * 3. a fresh download. + * + * The override matters more than it looks: `www.minecraft.net` is unreachable from some networks + * (it resolves but never connects), so on those machines the cache has to be primed by hand and + * downloading is a CI-only path. + */ +export async function resolveBds(options: ResolveOptions = {}): Promise { + const { onProgress = (): void => {}, offline = false } = options; + const { version, channel } = pinnedVersion(); + const platform = platformKey(); + + if (process.env.BC_BDS_PATH) { + const dir = path.resolve(process.env.BC_BDS_PATH); + const exe = path.join(dir, serverExecutable()); + + if (!await exists(exe)) { + throw new Error( + `BC_BDS_PATH is set to ${dir} but ${serverExecutable()} is not there. ` + + 'Point it at the directory containing the server binary.', + ); + } + + onProgress(`using BC_BDS_PATH ${dir}`); + + return { dir, version: 'unknown (BC_BDS_PATH)', source: 'env' }; + } + + const dir = cacheDir(version, platform); + + if (await exists(path.join(dir, serverExecutable()))) { + return { dir, version, source: 'cache' }; + } + + if (offline) { + throw new Error( + `Bedrock Dedicated Server ${version} is not in the cache at ${dir} and downloading is disabled. ` + + 'Run `yarn bds:fetch`, or set BC_BDS_PATH to a server you already have.', + ); + } + + return withLock(`${dir}.lock`, async () => { + // Another process may have won the race while we waited for the lock. + if (await exists(path.join(dir, serverExecutable()))) { + return { dir, version, source: 'cache' as const }; + } + + await fetchBds({ version, channel, platform, destDir: dir, onProgress }); + + return { dir, version, source: 'download' as const }; + }); +} diff --git a/packages/bds-runner/src/bds/versions.ts b/packages/bds-runner/src/bds/versions.ts new file mode 100644 index 0000000..82146da --- /dev/null +++ b/packages/bds-runner/src/bds/versions.ts @@ -0,0 +1,126 @@ +/** + * Build metadata from [BDS-Versions](https://github.com/Bedrock-OSS/BDS-Versions). + * + * Mojang publishes no version index — only "here is the current build" — so knowing that a pinned + * version exists, or what a build's checksum should be, previously meant either trusting whatever + * downloaded first or scraping. BDS-Versions is a community-maintained index that records every + * build with its `sha1`, size and date. + * + * **It hosts no binaries.** Its `cdn_root` is minecraft.net and every `download_url` points there, + * so this does not make the server downloadable on a network that blocks minecraft.net — see + * `BC_BDS_PATH` and `--offline` for that. What it changes is trust: the checksum now comes from a + * third party *before* the first download, rather than being recorded from whatever arrived. + */ +import type { Channel } from './download'; + +const RAW_ROOT = 'https://raw.githubusercontent.com/Bedrock-OSS/BDS-Versions/main'; + +/** BDS-Versions names platforms differently from Node, and keeps preview builds in their own tree. */ +const PLATFORM_DIRS = new Map([ + ['stable:win32-x64', 'windows'], + ['stable:linux-x64', 'linux'], + ['preview:win32-x64', 'windows_preview'], + ['preview:linux-x64', 'linux_preview'], +]); + +/** The key into `versions.json`, which tracks stable and preview under one platform entry. */ +const INDEX_KEYS = new Map([ + ['win32-x64', 'windows'], + ['linux-x64', 'linux'], +]); + +export interface BdsBuild { + version: string; + downloadUrl: string; + + /** Published by BDS-Versions, so integrity is checkable on the very first download. */ + sha1: string; + sizeInBytes: number; + date: string; + releaseNotes?: string; +} + +export interface PlatformIndex { + stable: string; + preview: string; + versions: string[]; +} + +async function fetchJson(url: string): Promise> { + const response = await fetch(url, { headers: { accept: 'application/json' } }); + + if (!response.ok) { throw new Error(`GET ${url} returned ${response.status} ${response.statusText}`); } + + return await response.json() as Record; +} + +function platformDir(channel: Channel, platform: string): string { + const dir = PLATFORM_DIRS.get(`${channel}:${platform}`); + + if (!dir) { throw new Error(`BDS-Versions publishes no ${channel} builds for ${platform}`); } + + return dir; +} + +/** The full index: which build is current per channel, and every build ever published. */ +export async function fetchIndex(platform: string): Promise { + const key = INDEX_KEYS.get(platform); + + if (!key) { throw new Error(`no BDS-Versions index for ${platform}`); } + + const index = await fetchJson(`${RAW_ROOT}/versions.json`); + const entry = index[key] as Record | undefined; + + if (!entry) { throw new Error(`versions.json has no "${key}" entry`); } + + return { + stable: String(entry.stable), + preview: String(entry.preview), + versions: (entry.versions as string[] | undefined) ?? [], + }; +} + +/** + * Metadata for one exact build. + * + * A missing file means the version does not exist upstream, which is worth saying precisely — a + * typo in a pinned version otherwise surfaces as a bare 404 from a completely different host. + */ +export async function fetchBuild(version: string, channel: Channel, platform: string): Promise { + const dir = platformDir(channel, platform); + + let raw: Record; + + try { + raw = await fetchJson(`${RAW_ROOT}/${dir}/${version}.json`); + } catch (cause) { + const index = await fetchIndex(platform).catch(() => null); + const known = index?.versions.slice(-5).join(', '); + const hint = index + ? ` Current ${channel} build is ${channel === 'preview' ? index.preview : index.stable}` + + (known ? `; most recent published: ${known}.` : '.') + : ''; + + throw new Error(`Bedrock Dedicated Server ${version} (${channel}, ${platform}) is not in BDS-Versions.${hint}`, { cause }); + } + + return { + version: String(raw.version ?? version), + downloadUrl: String(raw.download_url), + sha1: String(raw.sha1 ?? ''), + sizeInBytes: Number(raw.size_in_bytes ?? 0), + date: String(raw.date ?? ''), + releaseNotes: raw.release_notes ? String(raw.release_notes) : undefined, + }; +} + +/** The build a channel currently points at, or `null` when the index cannot be read. */ +export async function currentVersion(channel: Channel, platform: string): Promise { + try { + const index = await fetchIndex(platform); + + return channel === 'preview' ? index.preview : index.stable; + } catch { + return null; + } +} diff --git a/packages/bds-runner/src/cli.ts b/packages/bds-runner/src/cli.ts new file mode 100644 index 0000000..b1c5c21 --- /dev/null +++ b/packages/bds-runner/src/cli.ts @@ -0,0 +1,203 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { cacheDir, pinnedVersion, platformKey, serverDir } from './bds/paths'; +import { fetchIndex } from './bds/versions'; +import { resolveBds } from './bds/resolve'; +import { formatSummary } from './report/summary'; +import { runGameTests } from './run'; + +/** + * Exit codes are the whole point of this tool, so they mean distinct things: + * + * 0 every test passed (or only known failures did) + * 1 tests failed — the suite is red + * 2 the run could not be trusted: no server, no boot, nothing announced, a bad tag + * + * Conflating 1 and 2 would let a broken harness masquerade as a broken codebase. + */ +const EXIT = { ok: 0, testsFailed: 1, infrastructure: 2 }; + +const USAGE = ` +bc-bds — run Minecraft GameTests on a Bedrock Dedicated Server + + bc-bds run --packs --tag [options] + bc-bds fetch download and cache the pinned server + bc-bds where print the resolved server directory and version + +Options for \`run\`: + --packs directory containing BP/ and RP/; repeatable, so + several addons share one world (required) + --tag gametest tag to run (required) + --expect-registered fail unless the engine announces n tests + --known-failure a test expected to fail; repeatable + --origin " " where to place the test plots (default "8 -60 8") + --idle quiet time that ends a run (default 45) + --timeout wall-clock limit for the whole run (default 900) + --port server port (default 19140) + --fresh recreate the server tree and world from scratch + --offline never download; fail if the cache is cold + --quiet do not echo server output + --json also write the result as JSON +`.trimStart(); + +interface Args { + command: string; + values: Map; + flags: Set; +} + +function parseArgs(argv: string[]): Args { + const values = new Map(); + const flags = new Set(); + const command = argv[0] ?? 'help'; + + for (let i = 1; i < argv.length; i++) { + const arg = argv[i]; + + if (!arg.startsWith('--')) { continue; } + + const key = arg.slice(2); + const next = argv[i + 1]; + + if (next === undefined || next.startsWith('--')) { + flags.add(key); + } else { + values.set(key, [...values.get(key) ?? [], next]); + i++; + } + } + + return { command, values, flags }; +} + +const first = (args: Args, key: string): string | undefined => args.values.get(key)?.[0]; + +const number = (args: Args, key: string): number | undefined => { + const raw = first(args, key); + + return raw === undefined ? undefined : Number(raw); +}; + +async function commandRun(args: Args): Promise { + const packsDirs = args.values.get('packs') ?? []; + const tag = first(args, 'tag'); + + if (packsDirs.length === 0 || !tag) { + process.stderr.write('bc-bds run needs both --packs and --tag\n\n'); + process.stderr.write(USAGE); + + return EXIT.infrastructure; + } + + const idle = number(args, 'idle'); + const timeout = number(args, 'timeout'); + + const result = await runGameTests({ + packsDirs, + tag, + expectRegistered: number(args, 'expect-registered'), + knownFailures: args.values.get('known-failure') ?? [], + origin: first(args, 'origin'), + port: number(args, 'port'), + idleMs: idle === undefined ? undefined : idle * 1000, + wallMs: timeout === undefined ? undefined : timeout * 1000, + fresh: args.flags.has('fresh'), + offline: args.flags.has('offline'), + echo: !args.flags.has('quiet'), + onProgress: message => process.stdout.write(` ${message}\n`), + }); + + process.stdout.write('\n'); + process.stdout.write(formatSummary({ + summary: result.summary, + durationMs: result.durationMs, + bdsVersion: result.bdsVersion, + transcript: result.transcript, + knownFailures: args.values.get('known-failure') ?? [], + })); + process.stdout.write(`\n log: ${result.logFile}\n`); + + const jsonPath = first(args, 'json'); + + if (jsonPath) { + await fs.mkdir(path.dirname(path.resolve(jsonPath)), { recursive: true }); + await fs.writeFile(jsonPath, `${JSON.stringify({ + tag, + bdsVersion: result.bdsVersion, + durationMs: result.durationMs, + verdicts: result.summary.verdicts, + regressions: result.regressions, + infraError: result.summary.infraError, + }, null, 2)}\n`); + } + + if (result.summary.infraError) { return EXIT.infrastructure; } + + return result.regressions.length > 0 ? EXIT.testsFailed : EXIT.ok; +} + +async function commandFetch(): Promise { + const resolved = await resolveBds({ onProgress: m => process.stdout.write(` ${m}\n`) }); + + process.stdout.write(`Bedrock Dedicated Server ready (${resolved.source}): ${resolved.dir}\n`); + + return EXIT.ok; +} + +async function commandWhere(): Promise { + const pinned = pinnedVersion(); + const platform = platformKey(); + + process.stdout.write(`pinned: ${pinned.version} (${pinned.channel}, ${platform})\n`); + process.stdout.write(`cache: ${cacheDir(pinned.version, platform)}\n`); + process.stdout.write(`server: ${serverDir(pinned.version)}\n`); + + if (process.env.BC_BDS_PATH) { process.stdout.write(`override: BC_BDS_PATH=${process.env.BC_BDS_PATH}\n`); } + + // Version data comes from github.com/Bedrock-OSS/BDS-Versions, which is reachable on networks + // that block minecraft.net — so this stays useful even where `fetch` cannot run. + const index = await fetchIndex(platform).catch(() => null); + + if (!index) { + process.stdout.write('\ncould not reach BDS-Versions to check for newer builds\n'); + + return EXIT.ok; + } + + process.stdout.write(`\nupstream: stable ${index.stable}, preview ${index.preview} `); + process.stdout.write(`(${index.versions.length} builds indexed)\n`); + + const latest = pinned.channel === 'preview' ? index.preview : index.stable; + + if (latest !== pinned.version) { + process.stdout.write(`\nA newer ${pinned.channel} build is available: ${latest}.\n`); + process.stdout.write('Bump "version" in packages/bds-runner/bds-version.json and re-run the suites.\n'); + } + + if (!index.versions.includes(pinned.version)) { + process.stdout.write(`\nWARNING: ${pinned.version} is not in the BDS-Versions index — check the pin.\n`); + } + + return EXIT.ok; +} + +export async function main(argv: string[]): Promise { + const args = parseArgs(argv); + + switch (args.command) { + case 'run': return commandRun(args); + case 'fetch': return commandFetch(); + case 'where': return commandWhere(); + default: + process.stdout.write(USAGE); + + return args.command === 'help' ? EXIT.ok : EXIT.infrastructure; + } +} + +main(process.argv.slice(2)) + .then((code) => { process.exitCode = code; }) + .catch((error: unknown) => { + process.stderr.write(`\nbc-bds failed: ${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = EXIT.infrastructure; + }); diff --git a/packages/bds-runner/src/index.ts b/packages/bds-runner/src/index.ts new file mode 100644 index 0000000..14aea5f --- /dev/null +++ b/packages/bds-runner/src/index.ts @@ -0,0 +1,18 @@ +export { bdsHome, cacheDir, logsDir, pinnedVersion, platformKey, serverDir } from './bds/paths'; +export { type Channel, fetchBds } from './bds/download'; +export { type BdsBuild, currentVersion, fetchBuild, fetchIndex, type PlatformIndex } from './bds/versions'; +export { type ResolvedBds, resolveBds } from './bds/resolve'; +export { + type Outcome, + parseReport, + reconcile, + type Report, + type Summary, + summarise, + type Verdict, +} from './report/parse'; +export { formatSummary } from './report/summary'; +export { type RunOptions, type RunResult, runGameTests } from './run'; +export { BdsServer } from './server/process'; +export { discoverPacks, type PackInfo } from './server/packs'; +export { enableBetaApis } from './server/world'; diff --git a/packages/bds-runner/src/report/__tests__/fixtures/bds-boot-experiments-1.26.43.1.log b/packages/bds-runner/src/report/__tests__/fixtures/bds-boot-experiments-1.26.43.1.log new file mode 100644 index 0000000..dc25cf3 --- /dev/null +++ b/packages/bds-runner/src/report/__tests__/fixtures/bds-boot-experiments-1.26.43.1.log @@ -0,0 +1,57 @@ +NO LOG FILE! - setting up server logging... +[2026-08-10 21:08:27:676 INFO] Starting Server +[2026-08-10 21:08:27:677 INFO] Version: 1.26.43.1 +[2026-08-10 21:08:27:677 INFO] Session ID: bfa657c1-96ab-4dd1-bbfd-f2a315b39432 +[2026-08-10 21:08:27:677 INFO] Build ID: 49104929 +[2026-08-10 21:08:27:677 INFO] Branch: r/26_u4 +[2026-08-10 21:08:27:677 INFO] Commit ID: 85f9f8204d8a04f4e27d119f83ce61cea4774c1f +[2026-08-10 21:08:27:677 INFO] Configuration: Publish +[2026-08-10 21:08:27:677 INFO] Contents of server.properties: {} +[2026-08-10 21:08:27:677 INFO] Level Name: bc-test +[2026-08-10 21:08:27:677 INFO] Profiler config ('bootstrap.json') load result: success=1, errorMessage=(null) +[2026-08-10 21:08:27:679 INFO] No CDN config file found at: cdn_config.json for dedicated server +[2026-08-10 21:08:27:679 INFO] Game mode: 1 Creative +[2026-08-10 21:08:27:679 INFO] Difficulty: 0 PEACEFUL +[2026-08-10 21:08:27:679 INFO] Content logging to console is enabled. +[2026-08-10 21:08:27:681 INFO] + +##################################################### +# # +# LOADING VANILLA WORLD # +# # +##################################################### +[2026-08-10 21:08:28:181 INFO] Content logging to disk is enabled. Writing log to: ContentLog2026-08-10_21-08-28 +[2026-08-10 21:08:28:183 INFO] Experiment(s) active: gtst +[2026-08-10 21:08:28:183 INFO] Opening level 'worlds/bc-test/db' +[2026-08-10 21:08:28:338 INFO] Pack Stack - None +[2026-08-10 21:08:29:258 INFO] IPv4 supported, port: 19140: Used for gameplay +[2026-08-10 21:08:29:258 INFO] IPv6 supported, port: 19141: Used for gameplay +[2026-08-10 21:08:29:285 INFO] Signed in to signaling service successfully +[2026-08-10 21:08:29:300 INFO] Server started. +[2026-08-10 21:08:29:301 INFO] ================ TELEMETRY MESSAGE =================== +[2026-08-10 21:08:29:301 INFO] Server Telemetry is currently not enabled. +[2026-08-10 21:08:29:301 INFO] Enabling this telemetry helps us improve the game. +[2026-08-10 21:08:29:301 INFO] +[2026-08-10 21:08:29:301 INFO] To enable this feature, add the line 'emit-server-telemetry=true' +[2026-08-10 21:08:29:301 INFO] to the server.properties file in the handheld/src-server directory +[2026-08-10 21:08:29:301 INFO] ====================================================== +### SEND: help gametest +[2026-08-10 21:08:31:585 INFO] §egametest: +§eInteracts with gametest. +Usage: +- /gametest clearall +- /gametest create [width: int] [height: int] [depth: int] +- /gametest pos +- /gametest run [rotationSteps: int] +- /gametest run [rotationSteps: int] +- /gametest runset [tag: string] [rotationSteps: int] +- /gametest runsetuntilfail [tag: string] [rotationSteps: int] +- /gametest runthese +- /gametest runthis +- /gametest stopall +### SEND: gametest runset bc:nope +[2026-08-10 21:08:34:505 ERROR] No tests found for tag 'bc:nope' +### SEND: stop +[2026-08-10 21:08:40:671 INFO] Server stop requested. +[2026-08-10 21:08:40:718 INFO] Stopping server... +Quit correctly diff --git a/packages/bds-runner/src/report/__tests__/fixtures/bds-runset-1.26.43.1.log b/packages/bds-runner/src/report/__tests__/fixtures/bds-runset-1.26.43.1.log new file mode 100644 index 0000000..a6f8ede --- /dev/null +++ b/packages/bds-runner/src/report/__tests__/fixtures/bds-runset-1.26.43.1.log @@ -0,0 +1,99 @@ +NO LOG FILE! - setting up server logging... +[2026-08-10 21:09:39:643 INFO] Starting Server +[2026-08-10 21:09:39:643 INFO] Version: 1.26.43.1 +[2026-08-10 21:09:39:643 INFO] Session ID: c03d64a8-095e-4270-8dae-79fcf7442642 +[2026-08-10 21:09:39:643 INFO] Build ID: 49104929 +[2026-08-10 21:09:39:643 INFO] Branch: r/26_u4 +[2026-08-10 21:09:39:643 INFO] Commit ID: 85f9f8204d8a04f4e27d119f83ce61cea4774c1f +[2026-08-10 21:09:39:643 INFO] Configuration: Publish +[2026-08-10 21:09:39:644 INFO] Contents of server.properties: {} +[2026-08-10 21:09:39:644 INFO] Level Name: bc-test +[2026-08-10 21:09:39:644 INFO] Profiler config ('bootstrap.json') load result: success=1, errorMessage=(null) +[2026-08-10 21:09:39:646 INFO] No CDN config file found at: cdn_config.json for dedicated server +[2026-08-10 21:09:39:646 INFO] Game mode: 1 Creative +[2026-08-10 21:09:39:646 INFO] Difficulty: 0 PEACEFUL +[2026-08-10 21:09:39:646 INFO] Content logging to console is enabled. +[2026-08-10 21:09:39:648 INFO] + +##################################################### +# # +# LOADING VANILLA WORLD # +# # +##################################################### +[2026-08-10 21:09:40:264 INFO] Content logging to disk is enabled. Writing log to: ContentLog2026-08-10_21-09-40 +[2026-08-10 21:09:40:265 INFO] Experiment(s) active: gtst +[2026-08-10 21:09:40:265 INFO] Opening level 'worlds/bc-test/db' +[2026-08-10 21:09:40:417 INFO] Pack Stack - [00] @bedrock-core/constructs (id: 8f2f4e21-6a3d-4c58-b0aa-51e9d3a7c402, version: 0.1.0) @ worlds/bc-test/behavior_packs/bc-constructs_bp +[2026-08-10 21:09:40:490 WARN] [Scripting] [constructs] addon loaded (M7 — /construct:weld a machine, grab it with the physics gun) + + +[2026-08-10 21:09:41:376 INFO] IPv4 supported, port: 19140: Used for gameplay +[2026-08-10 21:09:41:377 INFO] IPv6 supported, port: 19141: Used for gameplay +[2026-08-10 21:09:41:409 INFO] Signed in to signaling service successfully +[2026-08-10 21:09:41:425 INFO] Server started. +[2026-08-10 21:09:41:425 INFO] ================ TELEMETRY MESSAGE =================== +[2026-08-10 21:09:41:425 INFO] Server Telemetry is currently not enabled. +[2026-08-10 21:09:41:425 INFO] Enabling this telemetry helps us improve the game. +[2026-08-10 21:09:41:425 INFO] +[2026-08-10 21:09:41:425 INFO] To enable this feature, add the line 'emit-server-telemetry=true' +[2026-08-10 21:09:41:425 INFO] to the server.properties file in the handheld/src-server directory +[2026-08-10 21:09:41:425 INFO] ====================================================== +[2026-08-10 21:09:41:433 WARN] [Scripting] Custom Command alias [clear] already in use. Required to use full name [construct:clear]. + + +### SEND: gamerule sendcommandfeedback true +[2026-08-10 21:09:43:790 INFO] Game rule sendcommandfeedback has been updated to true +### SEND: tickingarea add 0 -64 0 64 100 64 bc_test true +[2026-08-10 21:09:48:693 INFO] Added ticking area from 0, 0, 0 to 79, 0, 79 marked for preload. +1/10 ticking areas in use. +### SEND: execute in overworld positioned 8 -60 8 run gametest runset bc:constructs:m3 +Running test batch 'bc:constructs:m3:0' (16 tests)... +[2026-08-10 21:09:53:726 INFO] Running 16 tests with tag 'bc:constructs:m3'... +onTestStructureLoaded: bc:constructs:m3:weld_splits_hinged_machine_and_arm_swings +onTestStructureLoaded: bc:constructs:m3:redstone_block_powers_thruster_upward +onTestStructureLoaded: bc:constructs:m3:lever_on_live_construct_toggles_thruster +onTestStructureLoaded: bc:constructs:m3:button_press_pulses_and_self_releases +onTestStructureLoaded: bc:constructs:m3:pickcell_resolves_the_aimed_construct_cell +onTestStructureLoaded: bc:constructs:m3:settle_unweld_stamps_at_the_current_position +onTestStructureLoaded: bc:constructs:m3:unweld_aborts_when_the_landing_spot_is_blocked +onTestStructureLoaded: bc:constructs:m3:anchor_block_locks_body_on_signal +onTestStructureLoaded: bc:constructs:m3:world_thruster_exhaust_pushes_a_physics_cube +onTestStructureLoaded: bc:constructs:m3:welded_rotor_couples_to_a_real_world_bearing +onTestStructureLoaded: bc:constructs:m3:powered_bearing_drives_the_welded_rotor +onTestStructureLoaded: bc:constructs:m3:empty_hand_bearing_weld_shortcut_couples_the_selection +onTestStructureLoaded: bc:constructs:m3:grab_tether_follows_the_target_and_throws +onTestStructureLoaded: bc:constructs:m3:freeze_and_adopt_restore_the_machine +onTestStructureLoaded: bc:constructs:m3:creative_engine_spins_a_coupled_rotor +onTestStructureLoaded: bc:constructs:m3:unweld_restores_blocks_at_origin +onTestPassed: bc:constructs:m3:pickcell_resolves_the_aimed_construct_cell +onTestPassed: bc:constructs:m3:empty_hand_bearing_weld_shortcut_couples_the_selection +onTestPassed: bc:constructs:m3:unweld_aborts_when_the_landing_spot_is_blocked +onTestPassed: bc:constructs:m3:unweld_restores_blocks_at_origin +onTestPassed: bc:constructs:m3:settle_unweld_stamps_at_the_current_position +onTestPassed: bc:constructs:m3:redstone_block_powers_thruster_upward +[2026-08-10 21:09:55:199 WARN] [Scripting] [constructs:m8] world-thruster plume: peak rise 7.05 m, peak v.y 13.26 m/s, final v.y -1.83 (tracked=1) + + +onTestPassed: bc:constructs:m3:world_thruster_exhaust_pushes_a_physics_cube +onTestPassed: bc:constructs:m3:freeze_and_adopt_restore_the_machine +[2026-08-10 21:09:55:553 WARN] [Scripting] [constructs:render] creative engine spun the rotor 267.2° (no redstone) + + +onTestPassed: bc:constructs:m3:creative_engine_spins_a_coupled_rotor +onTestPassed: bc:constructs:m3:anchor_block_locks_body_on_signal +onTestFailed: bc:constructs:m3:lever_on_live_construct_toggles_thruster - GameTestError: powered thruster must lift the construct (v.y=-3.53) +onTestPassed: bc:constructs:m3:grab_tether_follows_the_target_and_throws +onTestPassed: bc:constructs:m3:weld_splits_hinged_machine_and_arm_swings +onTestPassed: bc:constructs:m3:button_press_pulses_and_self_releases +[2026-08-10 21:09:56:204 WARN] [Scripting] [constructs:m8s2] rotor locked on unpowered bearing: dropped 0.00 m, drift 0.00 m, swept 10.7° (brake resists) + + +onTestPassed: bc:constructs:m3:welded_rotor_couples_to_a_real_world_bearing +[2026-08-10 21:09:56:497 WARN] [Scripting] [constructs:m8s2] powered driven bearing: locked drift 0.000, driven swept 178.2° + + +onTestPassed: bc:constructs:m3:powered_bearing_drives_the_welded_rotor +### SEND: stop +[2026-08-10 21:10:41:762 INFO] Server stop requested. +[2026-08-10 21:10:41:827 INFO] Stopping server... +Quit correctly diff --git a/packages/bds-runner/src/report/__tests__/parse.spec.ts b/packages/bds-runner/src/report/__tests__/parse.spec.ts new file mode 100644 index 0000000..8290925 --- /dev/null +++ b/packages/bds-runner/src/report/__tests__/parse.spec.ts @@ -0,0 +1,151 @@ +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { assert, describe, expect, it } from 'vitest'; +import { parseReport, reconcile, summarise } from '../parse'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const fixture = (name: string): string => readFileSync(path.join(here, 'fixtures', name), 'utf8'); + +/** + * Captured from a real BDS 1.26.43.1 run of `constructs-addon`'s 16 gametests, playerless. Every + * expectation below is a fact about the engine, not about our code — which is the point of pinning + * the parser to a transcript rather than to hand-written samples. + */ +const RUNSET = fixture('bds-runset-1.26.43.1.log'); +const BOOT = fixture('bds-boot-experiments-1.26.43.1.log'); + +const KNOWN_FAILURE = 'bc:constructs:m3:lever_on_live_construct_toggles_thruster'; + +describe('report:parse', () => { + it('reads the engine census from a real transcript', () => { + const report = parseReport(RUNSET); + + expect(report.expected).toBe(16); + expect(report.tag).toBe('bc:constructs:m3'); + expect(report.batches).toEqual(['bc:constructs:m3:0']); + }); + + it('accounts for every announced test', () => { + const report = parseReport(RUNSET); + + expect(report.loaded).toHaveLength(16); + expect(report.passed).toHaveLength(15); + expect(report.failed).toHaveLength(1); + // The roster and the census agree: nothing is unaccounted for. + expect(report.loaded.length).toBe(report.expected); + expect(report.passed.length + report.failed.length).toBe(report.expected); + }); + + it('captures the failure id and its full error text', () => { + const report = parseReport(RUNSET); + + expect(report.failed[0].id).toBe(KNOWN_FAILURE); + expect(report.failed[0].error).toBe( + 'GameTestError: powered thruster must lift the construct (v.y=-3.53)', + ); + }); + + it('reads the experiments BDS reported active at boot', () => { + // This is the bootstrap tripwire: `Experiment(s) active: gtst` proves Beta APIs took effect. + expect(parseReport(BOOT).experiments).toContain('gtst'); + }); + + it('detects a tag that matched nothing', () => { + expect(parseReport(BOOT).noTestsForTag).toBe('bc:nope'); + }); + + it('ignores everything before the last run announcement', () => { + // A console stream is append-only across every runset issued in a session. Results from an + // earlier run must not leak into a later one, or a deleted test haunts the output forever. + const stale = RUNSET.replace(/onTestPassed: (\S+)/g, 'onTestPassed: stale:$1'); + const report = parseReport(`${stale}\n${RUNSET}`); + + expect(report.passed).toHaveLength(15); + assert.ok( + report.passed.every(id => !id.startsWith('stale:')), + 'no verdict from the previous run may survive the anchor', + ); + }); + + it('tolerates log prefixes and bare lines alike', () => { + // BDS prefixes some lines with `[ts INFO] ` and leaves onTest* bare; script output arrives as + // `HH:MM:SS-[Scripting][Warning]-`. Matching anywhere in the line survives all three. + const decorated = RUNSET.split('\n') + .map(l => l.startsWith('onTest') ? `[2026-08-10 21:09:53:726 INFO] ${l}` : l) + .join('\n'); + + expect(parseReport(decorated).passed).toHaveLength(15); + }); +}); + +describe('report:reconcile', () => { + it('produces one verdict per test, with the known failure named', () => { + const verdicts = reconcile(parseReport(RUNSET)); + + expect(verdicts).toHaveLength(16); + expect(verdicts.filter(v => v.outcome === 'pass')).toHaveLength(15); + + const failures = verdicts.filter(v => v.outcome === 'fail'); + + expect(failures).toHaveLength(1); + expect(failures[0].id).toBe(KNOWN_FAILURE); + }); + + it('treats a loaded-but-unreported test as a failure, never a pass', () => { + // The load-bearing rule. maxTicks timeouts, unattributed throws and crashes all land here, and + // every one of them must be red. + const truncated = RUNSET.replace( + /onTestPassed: bc:constructs:m3:powered_bearing_drives_the_welded_rotor\n?/, + '', + ); + const verdicts = reconcile(parseReport(truncated)); + const orphan = verdicts.find(v => v.id.endsWith('powered_bearing_drives_the_welded_rotor')); + + expect(orphan?.outcome).toBe('fail'); + expect(orphan?.error).toMatch(/never reported a verdict/); + }); + + it('marks announced-but-never-loaded tests absent, not failed', () => { + // A structural problem (missing .mcstructure, unplaceable plot) reads differently from a test + // that ran and lost, and the message has to say which. + const shortfall = parseReport(RUNSET.replace(/Running 16 tests/, 'Running 18 tests')); + const verdicts = reconcile(shortfall); + + expect(verdicts).toHaveLength(18); + expect(verdicts.filter(v => v.outcome === 'absent')).toHaveLength(2); + expect(verdicts.find(v => v.outcome === 'absent')?.error).toMatch(/never loaded/); + }); +}); + +describe('report:summarise', () => { + it('summarises the real run as 15 passed / 1 failed', () => { + const summary = summarise(parseReport(RUNSET)); + + expect(summary).toMatchObject({ + passed: 15, + failed: 1, + absent: 0, + total: 16, + expected: 16, + tag: 'bc:constructs:m3', + infraError: null, + }); + }); + + it('reports a renamed-log-lines engine as an infrastructure error, not a pass', () => { + // The property that makes reading undocumented strings safe: if the engine stops speaking the + // language we parse, we must go red loudly rather than green quietly. + const alien = RUNSET.replace(/onTest\w+:/g, 'somethingElse:') + .replace(/Running 16 tests with tag '[^']*'/, '') + .replace(/Running test batch '[^']*' \(16 tests\)/, ''); + const summary = summarise(parseReport(alien)); + + expect(summary.passed).toBe(0); + expect(summary.infraError).toMatch(/announced no test run/); + }); + + it('reports an unmatched tag as an infrastructure error', () => { + expect(summarise(parseReport(BOOT)).infraError).toMatch(/no tests for tag 'bc:nope'/); + }); +}); diff --git a/packages/bds-runner/src/report/parse.ts b/packages/bds-runner/src/report/parse.ts new file mode 100644 index 0000000..ed545c9 --- /dev/null +++ b/packages/bds-runner/src/report/parse.ts @@ -0,0 +1,286 @@ +/** + * Parses a Bedrock Dedicated Server transcript into gametest verdicts. + * + * The engine emits its own structured accounting, observed verbatim on BDS 1.26.43.1: + * + * ``` + * Running test batch 'bc:constructs:m3:0' (16 tests)... + * [2026-08-10 21:09:53:726 INFO] Running 16 tests with tag 'bc:constructs:m3'... + * onTestStructureLoaded: bc:constructs:m3:weld_splits_hinged_machine_and_arm_swings + * onTestPassed: bc:constructs:m3:pickcell_resolves_the_aimed_construct_cell + * onTestFailed: bc:constructs:m3:lever_on_live_construct_toggles_thruster - GameTestError: powered thruster must lift the construct (v.y=-3.53) + * ``` + * + * Two properties make reading it sound even though Mojang documents none of these strings: + * + * - the **expected count and the verdicts come from the same channel**, so a format change breaks + * both at once rather than one of them; + * - **anything announced but unaccounted for is a failure**, never a pass. + * + * Together those mean a future engine that renames these lines produces a loud "0 of N accounted" + * infrastructure error. The failure mode is a red build, never a false green — which is the only + * property that actually matters when the strings are a private interface. + * + * There is deliberately no completion line to wait for: the engine prints nothing when a run ends, + * so completion is `accounted === expected` plus the idle/wall timeouts in the caller. + */ + +/** Announced expectation: `Running 16 tests with tag 'bc:constructs:m3'...` */ +const EXPECTED_RE = /Running\s+(\d+)\s+tests?\s+with\s+tag\s+'([^']*)'/; + +/** Batch announcement: `Running test batch 'bc:constructs:m3:0' (16 tests)...` */ +const BATCH_RE = /Running test batch\s+'([^']*)'\s+\((\d+)\s+tests?\)/; + +/** `onTestStructureLoaded: ` — the test's plot was placed, so it is about to run. */ +const LOADED_RE = /onTestStructureLoaded:\s*(\S+)/; + +/** `onTestPassed: ` */ +const PASSED_RE = /onTestPassed:\s*(\S+)/; + +/** `onTestFailed: - ` — the separator is ` - ` and the error may contain anything. */ +const FAILED_RE = /onTestFailed:\s*(\S+)\s+-\s+([\s\S]*)$/; + +/** `[ERROR] No tests found for tag 'bc:nope'` — a tag typo, not a test failure. */ +const NO_TESTS_RE = /No tests found for tag\s+'([^']*)'/; + +/** BDS confirms enabled experiments at boot: `Experiment(s) active: gtst`. */ +const EXPERIMENTS_RE = /Experiment\(s\) active:\s*(.+?)\s*$/; + +/** + * A run is bounded by the batch/expected announcement. BDS appends to one console stream across + * every `runset` issued in a session, so without anchoring to the *last* announcement a second run + * would inherit the first one's verdicts — and a renamed or deleted test would haunt the results + * forever. Everything before the last announcement belongs to a previous run. + */ +export const RUN_ANCHOR_RE = new RegExp(`${BATCH_RE.source}|${EXPECTED_RE.source}`); + +export type Outcome = 'pass' | 'fail' | 'absent' | 'unobservable'; + +export interface Verdict { + id: string; + outcome: Outcome; + error?: string; +} + +export interface Report { + + /** How many tests the engine said it would run, or `null` if it never said. */ + expected: number | null; + tag: string | null; + batches: string[]; + loaded: string[]; + passed: string[]; + failed: { id: string; error: string }[]; + + /** Set when the engine reported the tag matched nothing at all. */ + noTestsForTag: string | null; + + /** Experiments BDS reported active at boot, e.g. `['gtst']`. */ + experiments: string[]; +} + +const EMPTY: Report = { + expected: null, + tag: null, + batches: [], + loaded: [], + passed: [], + failed: [], + noTestsForTag: null, + experiments: [], +}; + +/** + * Reads a transcript into a `Report`, considering only the most recent run. + * + * Lines are matched *anywhere* in the string rather than anchored: BDS prefixes some output with + * `[YYYY-MM-DD HH:MM:SS:mmm INFO] ` and leaves the `onTest*` lines bare, and script output arrives + * as `HH:MM:SS-[Scripting][Warning]-`. Matching loosely costs nothing and survives that + * inconsistency. + */ +export function parseReport(text: string): Report { + const lines = text.split(/\r?\n/); + + // Experiments are announced during boot, i.e. *before* the run anchor, so collect them first + // across the whole transcript. + const experiments: string[] = []; + + for (const line of lines) { + const m = EXPERIMENTS_RE.exec(line); + + if (m) { experiments.push(...m[1].split(/[,\s]+/).filter(Boolean)); } + } + + // Anchor on the last run announcement; everything before it belongs to a previous run. + // + // The engine announces a run as a *cluster* — one `Running test batch …` line per batch, then the + // `Running N tests with tag …` census — so anchoring on the census alone would slice the batch + // lines off the front of the very run they describe. Find the census, then walk back over the + // announcement lines (and the blanks between them) that belong to it. The walk stops at the first + // line that is neither, which in a multi-run stream is the previous run's last verdict. + let start = 0; + + for (let i = lines.length - 1; i >= 0; i--) { + if (!EXPECTED_RE.test(lines[i]) && !BATCH_RE.test(lines[i])) { continue; } + + start = i; + + while (start > 0) { + const previous = lines[start - 1]; + + if (previous.trim() === '' || BATCH_RE.test(previous)) { start--; } else { break; } + } + + break; + } + + const report: Report = { ...EMPTY, experiments, batches: [], loaded: [], passed: [], failed: [] }; + + for (const line of lines.slice(start)) { + const batch = BATCH_RE.exec(line); + + if (batch) { + if (!report.batches.includes(batch[1])) { report.batches.push(batch[1]); } + + continue; + } + + const expected = EXPECTED_RE.exec(line); + + if (expected) { + report.expected = Number(expected[1]); + report.tag = expected[2]; + continue; + } + + const loaded = LOADED_RE.exec(line); + + if (loaded) { + if (!report.loaded.includes(loaded[1])) { report.loaded.push(loaded[1]); } + + continue; + } + + const passed = PASSED_RE.exec(line); + + if (passed) { + if (!report.passed.includes(passed[1])) { report.passed.push(passed[1]); } + + continue; + } + + const failed = FAILED_RE.exec(line); + + if (failed) { + if (!report.failed.some(f => f.id === failed[1])) { + report.failed.push({ id: failed[1], error: failed[2].trim() }); + } + + continue; + } + + const none = NO_TESTS_RE.exec(line); + + if (none) { report.noTestsForTag = none[1]; } + } + + return report; +} + +/** + * Turns a `Report` into one verdict per test. + * + * The engine names every test it loads, so `loaded` is the roster. A test that was loaded but has + * no verdict did not quietly pass — it hit `maxTicks`, threw where the engine could not attribute + * it, or took the server down with it — so it is a **failure**, not a gap in our knowledge. That + * inference is the single load-bearing rule here. + */ +export function reconcile(report: Report): Verdict[] { + const verdicts: Verdict[] = []; + const failedById = new Map(report.failed.map(f => [f.id, f.error])); + + for (const id of report.loaded) { + if (failedById.has(id)) { + verdicts.push({ id, outcome: 'fail', error: failedById.get(id) }); + } else if (report.passed.includes(id)) { + verdicts.push({ id, outcome: 'pass' }); + } else { + verdicts.push({ + id, + outcome: 'fail', + error: 'loaded but never reported a verdict (maxTicks timeout, unattributed throw, or crash)', + }); + } + } + + // A verdict for something never announced as loaded still counts — losing it would be worse than + // the inconsistency it represents. + for (const id of [...report.passed, ...failedById.keys()]) { + if (report.loaded.includes(id)) { continue; } + + verdicts.push( + failedById.has(id) + ? { id, outcome: 'fail', error: failedById.get(id) } + : { id, outcome: 'pass' }, + ); + } + + // Announced but never even loaded: the plot never got placed. Distinct from a failure because the + // cause is structural (missing .mcstructure, tag mismatch), and the message should say so. + const shortfall = (report.expected ?? 0) - verdicts.length; + + for (let i = 0; i < shortfall; i++) { + verdicts.push({ + id: ``, + outcome: 'absent', + error: 'announced by the engine but never loaded (missing structure, or the plot could not be placed)', + }); + } + + return verdicts; +} + +export interface Summary { + verdicts: Verdict[]; + passed: number; + failed: number; + absent: number; + unobservable: number; + total: number; + expected: number | null; + tag: string | null; + experiments: string[]; + + /** + * Set when the transcript itself is untrustworthy — the engine announced nothing, or the tag + * matched nothing. Distinct from test failures: this is exit code 2, not 1. + */ + infraError: string | null; +} + +export function summarise(report: Report): Summary { + const verdicts = reconcile(report); + const count = (o: Outcome): number => verdicts.filter(v => v.outcome === o).length; + + let infraError: string | null = null; + + if (report.noTestsForTag !== null) { + infraError = `the engine found no tests for tag '${report.noTestsForTag}'`; + } else if (report.expected === null && verdicts.length === 0) { + infraError + = 'the engine announced no test run at all — the pack may not have loaded, or these log lines have been renamed by a newer engine'; + } + + return { + verdicts, + passed: count('pass'), + failed: count('fail'), + absent: count('absent'), + unobservable: count('unobservable'), + total: verdicts.length, + expected: report.expected, + tag: report.tag, + experiments: report.experiments, + infraError, + }; +} diff --git a/packages/bds-runner/src/report/summary.ts b/packages/bds-runner/src/report/summary.ts new file mode 100644 index 0000000..de4fce4 --- /dev/null +++ b/packages/bds-runner/src/report/summary.ts @@ -0,0 +1,107 @@ +import type { Summary, Verdict } from './parse'; + +const ANSI = { + reset: '', + dim: '', + red: '', + green: '', + yellow: '', + bold: '', +}; + +/** Colour is noise in a CI log that does not render it, so only use it on a real terminal. */ +function paint(text: string, colour: keyof typeof ANSI): string { + return process.stdout.isTTY ? `${ANSI[colour]}${text}${ANSI.reset}` : text; +} + +function icon(verdict: Verdict): string { + switch (verdict.outcome) { + case 'pass': return paint('✓', 'green'); + case 'fail': return paint('✗', 'red'); + default: return paint('?', 'yellow'); + } +} + +export interface FormatOptions { + summary: Summary; + durationMs: number; + bdsVersion: string; + + /** Server output, so a failure can be shown with the lines around it. */ + transcript?: string; + + /** Ids expected to fail. Reported as `known` rather than as regressions. */ + knownFailures?: string[]; +} + +/** + * Shows the console lines surrounding a failing test. + * + * A gametest failure message says what the assertion was, never what led to it — but the pack's own + * logging usually does, and it is sitting right there in the transcript. Finding it by hand in a + * 4000-line server log is exactly the chore worth automating. + */ +function contextFor(transcript: string, id: string, radius = 6): string[] { + const lines = transcript.split(/\r?\n/); + const at = lines.findIndex(line => line.includes(id) && /onTestFailed/.test(line)); + + if (at < 0) { return []; } + + return lines + .slice(Math.max(0, at - radius), at + 2) + .filter(line => line.trim() !== ''); +} + +export function formatSummary(options: FormatOptions): string { + const { summary, durationMs, bdsVersion, transcript = '', knownFailures = [] } = options; + const out: string[] = []; + const seconds = (durationMs / 1000).toFixed(1); + + const failures = summary.verdicts.filter(v => v.outcome === 'fail' || v.outcome === 'absent'); + const regressions = failures.filter(v => !knownFailures.includes(v.id)); + + for (const verdict of failures) { + const known = knownFailures.includes(verdict.id) ? paint(' (known)', 'dim') : ''; + + out.push(` ${icon(verdict)} ${verdict.id}${known}`); + + if (verdict.error) { out.push(` ${paint(verdict.error, 'dim')}`); } + + for (const line of contextFor(transcript, verdict.id)) { + out.push(` ${paint(line, 'dim')}`); + } + + out.push(''); + } + + const parts = [ + paint(`${summary.passed} passed`, summary.passed > 0 ? 'green' : 'dim'), + `${summary.failed} failed`, + ]; + + if (summary.absent > 0) { parts.push(`${summary.absent} absent`); } + + if (summary.unobservable > 0) { parts.push(`${summary.unobservable} unobservable`); } + + const headline = regressions.length === 0 + ? paint('✓', 'green') + : paint('✗', 'red'); + + out.push( + `${headline} ${paint(parts.join(', '), 'bold')}` + + paint(` (${summary.tag ?? 'no tag'}, BDS ${bdsVersion}, ${seconds}s)`, 'dim'), + ); + + if (knownFailures.length > 0 && failures.length > 0) { + const knownHit = failures.filter(v => knownFailures.includes(v.id)).length; + + out.push(paint(` ${knownHit} of ${failures.length} failure(s) are known and expected`, 'dim')); + } + + if (summary.infraError) { + out.push(''); + out.push(paint(` infrastructure: ${summary.infraError}`, 'red')); + } + + return out.join('\n'); +} diff --git a/packages/bds-runner/src/run.ts b/packages/bds-runner/src/run.ts new file mode 100644 index 0000000..7d12dd0 --- /dev/null +++ b/packages/bds-runner/src/run.ts @@ -0,0 +1,238 @@ +import path from 'node:path'; +import { logsDir, pinnedVersion } from './bds/paths'; +import { resolveBds } from './bds/resolve'; +import { parseReport, type Summary, summarise } from './report/parse'; +import { deployPacks, discoverPacks } from './server/packs'; +import { provisionServer, resetWorldChunks } from './server/provision'; +import { BdsServer } from './server/process'; +import { enableBetaApis, writeWorldPackReferences } from './server/world'; + +export interface RunOptions { + + /** + * Directories holding `BP/` and `RP/`, as exported by a Regolith `exact` profile. + * + * More than one deploys several addons into the same world, which is the only way a cross-addon + * test — one that asserts a *different* pack is present — can pass. + */ + packsDirs: string | readonly string[]; + + /** The gametest tag to run, e.g. `bc:constructs:m3`. */ + tag: string; + + /** Fail if the engine announces a different number of tests. Catches silently dropped suites. */ + expectRegistered?: number; + + /** Ids that are expected to fail; they do not make the run red. */ + knownFailures?: string[]; + + levelName?: string; + port?: number; + origin?: string; + idleMs?: number; + wallMs?: number; + watchdogHangMs?: number; + fresh?: boolean; + echo?: boolean; + offline?: boolean; + onProgress?: (message: string) => void; +} + +export interface RunResult { + summary: Summary; + transcript: string; + logFile: string; + durationMs: number; + bdsVersion: string; + + /** Failures that are not in `knownFailures` — the set that should turn a build red. */ + regressions: string[]; +} + +const DEFAULTS = { + levelName: 'bc-test', + port: 19140, + + /** + * Where the plots get placed. y = -60 sits just above bedrock in a flat world, so a test's + * structure has room below it and nothing above to fall on it. + */ + origin: '8 -60 8', + + /** Quiet for this long with everything accounted for means the run is over. */ + idleMs: 45_000, + wallMs: 15 * 60_000, + + /** The in-game hang detector, raised well past the 10 s default. See `properties.ts`. */ + watchdogHangMs: 60_000, +}; + +function timestamp(): string { + return new Date().toISOString().replace(/[:.]/g, '-'); +} + +/** + * Runs a gametest suite on a real Bedrock Dedicated Server and reports what happened. + * + * The shape of the run comes from what the engine actually does, established by running it: + * + * - the server must be told to keep the test area loaded, because a world with no player connected + * does not tick chunks, and a suite full of redstone and physics would sit still and time out; + * - `runset` is issued through `execute … positioned` so the plots land somewhere known rather + * than wherever the command origin happens to be; + * - there is no "run finished" line to wait for, so the run ends when every announced test has a + * verdict, or the server goes quiet, or the wall clock runs out. + */ +/** + * Drops keys whose value is `undefined`, so spreading the result cannot erase a default. + * + * The CLI builds its options object with a key for every flag it knows about, so an unsupplied + * `--origin` arrives as `origin: undefined`. A plain spread would let that overwrite the default, + * and the symptom is a server command containing the literal text `undefined`. + */ +function definedOnly(source: T): Partial { + return Object.fromEntries( + Object.entries(source).filter(([, value]) => value !== undefined), + ) as Partial; +} + +export async function runGameTests(options: RunOptions): Promise { + const settings = { ...DEFAULTS, ...definedOnly(options) } as RunOptions & typeof DEFAULTS; + const onProgress = options.onProgress ?? ((): void => {}); + const started = Date.now(); + + const packs = await discoverPacks(settings.packsDirs); + + onProgress(`packs: ${packs.map(p => `${p.name} (${p.kind}, ${p.slug})`).join(', ')}`); + + const bds = await resolveBds({ onProgress, offline: settings.offline }); + const version = bds.source === 'env' ? bds.version : pinnedVersion().version; + + const { worldDir, created } = await provisionServer({ + cacheDir: bds.dir, + version: bds.source === 'env' ? path.basename(bds.dir) : version, + levelName: settings.levelName, + port: settings.port, + watchdogHangMs: settings.watchdogHangMs, + fresh: settings.fresh, + onProgress, + }); + + const logFile = path.join(logsDir(), `${timestamp()}-${settings.tag.replace(/[^\w.-]+/g, '_')}.log`); + + // A world only exists after BDS has generated it, and experiments can only be set on a world that + // exists. So the very first run on a new server tree is two boots: one to create the world, one to + // run the tests with Beta APIs on. Every later run is a single boot. + if (created) { + onProgress('first run for this server: generating the world'); + const bootstrap = new BdsServer({ serverDir: path.dirname(path.dirname(worldDir)), logFile: `${logFile}.bootstrap`, echo: settings.echo }); + + await bootstrap.start(); + await bootstrap.waitForReady(); + await bootstrap.stop(); + + const result = await enableBetaApis(worldDir); + + onProgress(`enabled experiments: ${result.experiments.join(', ')}`); + } + + await resetWorldChunks(worldDir); + await deployPacks(worldDir, packs); + await writeWorldPackReferences( + worldDir, + packs.filter(p => p.kind === 'behavior').map(p => ({ packId: p.packId, version: p.version })), + packs.filter(p => p.kind === 'resource').map(p => ({ packId: p.packId, version: p.version })), + ); + + const server = new BdsServer({ + serverDir: path.dirname(path.dirname(worldDir)), + logFile, + echo: settings.echo, + }); + + try { + await server.start(); + await server.waitForReady(); + + // BDS prints the toggles it honoured. Checking the log rather than re-reading the NBT catches + // the case where the file says one thing and the engine did another. + if (!/Experiment\(s\) active:.*gtst/.test(server.transcript)) { + throw new Error( + 'the server started without Beta APIs active, so /gametest does not exist. ' + + `Delete the server tree and retry with --fresh, or check ${path.join(worldDir, 'level.dat')}.`, + ); + } + + server.send('gamerule sendcommandfeedback true'); + + // Without a loaded ticking area a playerless world does not simulate, and every test that waits + // for anything to move times out. `true` preloads it so the first test does not race the load. + server.send(`tickingarea add 0 -64 0 128 120 128 bc_test true`); + onProgress(`running ${settings.tag}`); + server.send(`execute in overworld positioned ${settings.origin} run gametest runset ${settings.tag}`); + + await waitForRun(server, settings.idleMs, settings.wallMs, settings.expectRegistered); + } finally { + await server.dispose(); + } + + const summary = summarise(parseReport(server.transcript)); + const knownFailures = settings.knownFailures ?? []; + const regressions = summary.verdicts + .filter(v => v.outcome !== 'pass' && !knownFailures.includes(v.id)) + .map(v => v.id); + + if (settings.expectRegistered !== undefined && summary.expected !== null + && summary.expected !== settings.expectRegistered) { + summary.infraError + = `expected ${settings.expectRegistered} registered tests but the engine announced ${summary.expected}. ` + + 'A suite was probably added, removed, or failed to register.'; + } + + return { + summary, + transcript: server.transcript, + logFile, + durationMs: Date.now() - started, + bdsVersion: version, + regressions, + }; +} + +/** + * Waits for the run to finish. + * + * "Finished" is a judgement, not an event: the engine announces how many tests it will run and then + * reports each one, but prints nothing at the end. So the run is over once every announced test has + * a verdict — and if that never happens, the server going quiet is the fallback, with the wall clock + * behind that. All three exits are normal; the verdict table decides pass or fail, not this. + */ +async function waitForRun( + server: BdsServer, + idleMs: number, + wallMs: number, + expectRegistered?: number, +): Promise { + const deadline = Date.now() + wallMs; + + for (;;) { + if (server.exited) { return; } + + const report = parseReport(server.transcript); + const accounted = report.passed.length + report.failed.length; + const expected = report.expected ?? expectRegistered; + + if (expected !== undefined && expected !== null && accounted >= expected) { return; } + + if (report.noTestsForTag !== null) { return; } + + if (Date.now() >= deadline) { return; } + + // Silence ends the run whether or not anything was accounted for. Waiting longer because we + // *expected* results is exactly backwards: a run that produced nothing has already failed, and + // making it burn the full wall clock turns a fast, clear failure into a slow, confusing one. + if (server.idleMs >= idleMs) { return; } + + await new Promise(resolve => setTimeout(resolve, 500)); + } +} diff --git a/packages/bds-runner/src/server/__tests__/packs.spec.ts b/packages/bds-runner/src/server/__tests__/packs.spec.ts new file mode 100644 index 0000000..0f55fe9 --- /dev/null +++ b/packages/bds-runner/src/server/__tests__/packs.spec.ts @@ -0,0 +1,98 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterAll, describe, expect, it } from 'vitest'; +import { deployPacks, discoverPacks } from '../packs'; + +const roots: string[] = []; + +afterAll(async () => { + for (const root of roots) { await fs.rm(root, { recursive: true, force: true }); } +}); + +/** + * Builds `//build/test/{BP,RP}` — the exact shape a Regolith `build-test` profile + * exports, because the collision this suite guards against comes from that shape: every addon's + * export is called `BP`. + */ +async function addon(name: string, uuidPrefix: string): Promise { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'bc-packs-')); + + roots.push(tmp); + const root = path.join(tmp, name, 'build', 'test'); + + const write = async (kind: 'BP' | 'RP', manifest: object): Promise => { + await fs.mkdir(path.join(root, kind), { recursive: true }); + await fs.writeFile(path.join(root, kind, 'manifest.json'), JSON.stringify(manifest)); + }; + + await write('BP', { + header: { uuid: `${uuidPrefix}-bp`, version: [1, 0, 0], name: `${name} behaviour` }, + modules: [{ type: 'script' }], + }); + await write('RP', { + header: { uuid: `${uuidPrefix}-rp`, version: [1, 0, 0], name: `${name} resources` }, + modules: [{ type: 'resources' }], + }); + + return root; +} + +describe('discoverPacks', () => { + it('names each pack after its addon, not after the build directory', async () => { + const packs = await discoverPacks([await addon('test-addon', 'a')]); + + expect(packs.map(p => p.slug)).toEqual(['test-addon_bp', 'test-addon_rp']); + }); + + it('keeps two addons apart', async () => { + const packs = await discoverPacks([await addon('test-addon', 'a'), await addon('test-addon-2', 'b')]); + + expect(packs).toHaveLength(4); + expect(new Set(packs.map(p => p.slug)).size).toBe(4); + expect(packs.filter(p => p.kind === 'behavior').map(p => p.packId)).toEqual(['a-bp', 'b-bp']); + }); + + it('ignores a root named twice', async () => { + const root = await addon('test-addon', 'a'); + const packs = await discoverPacks([root, root]); + + expect(packs).toHaveLength(2); + }); + + it('accepts a single root as a bare string', async () => { + const packs = await discoverPacks(await addon('test-addon', 'a')); + + expect(packs).toHaveLength(2); + }); + + it('refuses a build with no behaviour pack, since nothing could register a test', async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'bc-packs-')); + + roots.push(tmp); + await fs.mkdir(path.join(tmp, 'RP'), { recursive: true }); + await fs.writeFile( + path.join(tmp, 'RP', 'manifest.json'), + JSON.stringify({ header: { uuid: 'r', version: [1, 0, 0] }, modules: [{ type: 'resources' }] }), + ); + + await expect(discoverPacks([tmp])).rejects.toThrow(/no behaviour pack/); + }); +}); + +describe('deployPacks', () => { + // The regression this exists for: both addons export a directory called `BP`, so deploying by + // basename copied the second one over the first and the cross-addon test could never pass. + it('gives every behaviour pack its own folder in the world', async () => { + const packs = await discoverPacks([await addon('test-addon', 'a'), await addon('test-addon-2', 'b')]); + const world = await fs.mkdtemp(path.join(os.tmpdir(), 'bc-world-')); + + roots.push(world); + await deployPacks(world, packs); + + expect((await fs.readdir(path.join(world, 'behavior_packs'))).sort()) + .toEqual(['test-addon-2_bp', 'test-addon_bp']); + expect((await fs.readdir(path.join(world, 'resource_packs'))).sort()) + .toEqual(['test-addon-2_rp', 'test-addon_rp']); + }); +}); diff --git a/packages/bds-runner/src/server/packs.ts b/packages/bds-runner/src/server/packs.ts new file mode 100644 index 0000000..07045f6 --- /dev/null +++ b/packages/bds-runner/src/server/packs.ts @@ -0,0 +1,170 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +export interface PackInfo { + + /** The manifest **header** uuid — the only id `world_*_packs.json` matches against. */ + packId: string; + version: number[]; + name: string; + dir: string; + kind: 'behavior' | 'resource'; + + /** + * The directory name this pack is deployed under inside the world. + * + * Every Regolith `exact` export is called `BP/` and `RP/`, so a run covering more than one addon + * has colliding basenames and the second copy silently replaces the first. The slug names the + * addon the pack came from instead, which keeps the world's pack folders unique and keeps the + * server's own pack-stack log readable. + */ + slug: string; +} + +interface Manifest { + header?: { uuid?: string; version?: number[]; name?: string }; + modules?: { type?: string }[]; +} + +/** Directory names that say "this is output" rather than naming the thing that was built. */ +const GENERIC_ROOTS = new Set(['build', 'dist', 'out', 'export', 'packs', 'test', 'release']); + +async function readManifest(packDir: string): Promise { + const file = path.join(packDir, 'manifest.json'); + + try { + return JSON.parse(await fs.readFile(file, 'utf8')) as Manifest; + } catch (cause) { + throw new Error(`could not read ${file}`, { cause }); + } +} + +async function isDirectory(target: string): Promise { + return fs.stat(target).then(s => s.isDirectory(), () => false); +} + +/** + * Names the addon a `--packs` root belongs to. + * + * `packages/test-addon/build/test` is the addon `test-addon`, not `test`, so a root whose own name + * only describes the build step is labelled by the first ancestor that says something. + */ +function rootLabel(root: string): string { + let dir = root; + + for (let depth = 0; depth < 4; depth++) { + const name = path.basename(dir); + + if (name && !GENERIC_ROOTS.has(name.toLowerCase())) { return name.replace(/[^\w.-]+/g, '_'); } + + dir = path.dirname(dir); + } + + return path.basename(root).replace(/[^\w.-]+/g, '_') || 'pack'; +} + +async function discoverRoot(root: string, label: string): Promise { + const candidates: string[] = []; + + if (await isDirectory(path.join(root, 'BP'))) { candidates.push(path.join(root, 'BP')); } + + if (await isDirectory(path.join(root, 'RP'))) { candidates.push(path.join(root, 'RP')); } + + if (candidates.length === 0) { candidates.push(root); } + + const packs: PackInfo[] = []; + + for (const dir of candidates) { + const manifest = await readManifest(dir); + const { uuid, version, name } = manifest.header ?? {}; + + if (!uuid || !version) { + throw new Error(`${path.join(dir, 'manifest.json')} has no header uuid/version`); + } + + // A pack carrying script or data modules is a behaviour pack; anything else is resources. + const types = new Set((manifest.modules ?? []).map(m => m.type)); + const kind = types.has('script') || types.has('data') ? 'behavior' : 'resource'; + + packs.push({ + packId: uuid, + version, + name: name ?? path.basename(dir), + dir, + kind, + slug: `${label}_${kind === 'behavior' ? 'bp' : 'rp'}`, + }); + } + + return packs; +} + +/** + * Finds the behaviour and resource packs inside one or more build directories. + * + * Accepts the `BP/` + `RP/` layout Regolith exports, and also a directory that is itself a single + * pack, so a root can point at either without the caller having to know which. + * + * More than one root is the cross-addon case: a test that asserts another addon is present can only + * pass when both are installed in the same world, so both builds have to be deployed together. + */ +export async function discoverPacks(packsDirs: string | readonly string[]): Promise { + const roots = (typeof packsDirs === 'string' ? [packsDirs] : [...packsDirs]).map(dir => path.resolve(dir)); + + if (roots.length === 0) { throw new Error('no --packs directory was given'); } + + const packs: PackInfo[] = []; + const seenIds = new Set(); + const usedSlugs = new Set(); + + for (const root of roots) { + if (!await isDirectory(root)) { throw new Error(`--packs ${root} is not a directory`); } + + for (const pack of await discoverRoot(root, rootLabel(root))) { + // Naming the same addon twice would deploy it twice and list it twice in + // world_behavior_packs.json, which BDS rejects rather than ignores. + if (seenIds.has(pack.packId)) { continue; } + + seenIds.add(pack.packId); + + // Distinct addons whose roots happen to share a label still need distinct folders. + let slug = pack.slug; + + for (let n = 2; usedSlugs.has(slug); n++) { slug = `${pack.slug}_${n}`; } + + usedSlugs.add(slug); + + packs.push({ ...pack, slug }); + } + } + + if (!packs.some(p => p.kind === 'behavior')) { + throw new Error( + `no behaviour pack found under ${roots.join(', ')}. GameTests are registered from a behaviour ` + + 'pack\'s script module, so there is nothing to run.', + ); + } + + return packs; +} + +/** + * Copies packs into the world. + * + * World-local packs take precedence over the server's top-level `behavior_packs/`, which keeps each + * run self-contained: no state leaks between runs of different addons on the same server tree. + * Existing copies are removed first so a deleted file in the build cannot survive as a stale + * leftover. + */ +export async function deployPacks(worldDir: string, packs: PackInfo[]): Promise { + for (const kind of ['behavior', 'resource'] as const) { + const target = path.join(worldDir, `${kind}_packs`); + + await fs.rm(target, { recursive: true, force: true }); + await fs.mkdir(target, { recursive: true }); + + for (const pack of packs.filter(p => p.kind === kind)) { + await fs.cp(pack.dir, path.join(target, pack.slug), { recursive: true }); + } + } +} diff --git a/packages/bds-runner/src/server/process.ts b/packages/bds-runner/src/server/process.ts new file mode 100644 index 0000000..5136dea --- /dev/null +++ b/packages/bds-runner/src/server/process.ts @@ -0,0 +1,240 @@ +import { type ChildProcessWithoutNullStreams, spawn } from 'node:child_process'; +import { EventEmitter } from 'node:events'; +import { createWriteStream, type WriteStream } from 'node:fs'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { createInterface } from 'node:readline'; +import { serverExecutable } from '../bds/paths'; + +/** `[2026-08-10 21:08:29:300 INFO] Server started.` — verified on BDS 1.26.43.1. */ +const READY_RE = /\bServer started\b/; + +/** + * Boot failures worth aborting on rather than waiting out the ready timeout. A pack that fails to + * load still lets the server start, so these are checked for the whole run, not just during boot. + */ +const FATAL_RES = [ + /Failed to load/i, + /No packs found/i, + /Unable to open level/i, + /\[Scripting\]\s*\[error\]/i, +]; + +export interface BdsServerOptions { + serverDir: string; + logFile: string; + + /** Echo BDS output to the terminal as it arrives. Off in tests; on for a human watching a run. */ + echo?: boolean; +} + +/** + * Owns a `bedrock_server` process: its stdin, its output, and its death. + * + * The output is consumed through a pipe rather than a shell redirect. That is not a style choice — + * redirecting means nothing can react to a line as it arrives, so waiting for "Server started." + * or for a run to go quiet becomes impossible, and a hung server can only be discovered by wall + * clock. + */ +export class BdsServer { + readonly #options: BdsServerOptions; + readonly #events = new EventEmitter(); + readonly #tail: string[] = []; + + #child?: ChildProcessWithoutNullStreams; + #log?: WriteStream; + #transcript = ''; + #exited = false; + #exitCode: number | null = null; + #disposed = false; + #lastLineAt = 0; + #cleanup?: () => void; + + constructor(options: BdsServerOptions) { + this.#options = options; + } + + /** Everything the server has written so far, for the parser. */ + get transcript(): string { + return this.#transcript; + } + + get exited(): boolean { + return this.#exited; + } + + /** Milliseconds since the server last said anything — the basis for the idle timeout. */ + get idleMs(): number { + return this.#lastLineAt === 0 ? 0 : Date.now() - this.#lastLineAt; + } + + /** The last few lines, used to give a failure some context instead of a bare exit code. */ + tail(lines = 20): string[] { + return this.#tail.slice(-lines); + } + + async start(): Promise { + await fs.mkdir(path.dirname(this.#options.logFile), { recursive: true }); + this.#log = createWriteStream(this.#options.logFile, { flags: 'w' }); + + const executable = process.platform === 'win32' + ? path.join(this.#options.serverDir, serverExecutable()) + + // The Linux build loads its bundled shared objects from the working directory, which only + // works if it is invoked as a relative path with LD_LIBRARY_PATH pointing there. + : `./${serverExecutable()}`; + + this.#child = spawn(executable, [], { + cwd: this.#options.serverDir, + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true, + env: process.platform === 'win32' + ? process.env + : { ...process.env, LD_LIBRARY_PATH: '.' }, + }); + + this.#lastLineAt = Date.now(); + + for (const stream of [this.#child.stdout, this.#child.stderr]) { + createInterface({ input: stream }).on('line', line => this.#onLine(line)); + } + + this.#child.once('exit', (code) => { + this.#exited = true; + this.#exitCode = code; + this.#events.emit('exit', code); + }); + this.#child.once('error', error => this.#events.emit('failure', error)); + + // A server that outlives the runner holds the world lock and the port, so the *next* run fails + // for a reason that has nothing to do with the tests — and on Windows it sits there eating + // 200 MB until someone notices. + // + // This kill is deliberately synchronous. Signal and `exit` handlers get no chance to await, so + // asking politely (writing `stop` to stdin and waiting) is exactly what does not work here: the + // runner is gone before the server acts on it. The graceful path lives in `stop()`, which the + // normal flow always reaches; this is only for the abrupt ones. + const cleanup = (): void => { + if (this.#child && !this.#exited) { this.#child.kill('SIGKILL'); } + }; + + process.once('SIGINT', cleanup); + process.once('SIGTERM', cleanup); + process.once('SIGHUP', cleanup); + process.once('exit', cleanup); + + this.#cleanup = (): void => { + process.off('SIGINT', cleanup); + process.off('SIGTERM', cleanup); + process.off('SIGHUP', cleanup); + process.off('exit', cleanup); + }; + } + + #onLine(line: string): void { + this.#lastLineAt = Date.now(); + this.#transcript += `${line}\n`; + this.#log?.write(`${line}\n`); + this.#tail.push(line); + + if (this.#tail.length > 200) { this.#tail.shift(); } + + if (this.#options.echo) { process.stdout.write(` ${line}\n`); } + + this.#events.emit('line', line); + } + + /** Resolves when a line matches, rejects on a fatal line, server exit, or timeout. */ + waitForLine(match: RegExp, timeoutMs: number, what: string): Promise { + return new Promise((resolve, reject) => { + if (this.#exited) { + reject(new Error(`server exited (code ${this.#exitCode}) before ${what}`)); + + return; + } + + const done = (fn: () => void): void => { + clearTimeout(timer); + this.#events.off('line', onLine); + this.#events.off('exit', onExit); + fn(); + }; + + const onLine = (line: string): void => { + if (match.test(line)) { done(() => resolve(line)); } else if (FATAL_RES.some(re => re.test(line))) { + done(() => reject(new Error(`server reported a fatal problem while waiting for ${what}:\n ${line}`))); + } + }; + + const onExit = (code: number | null): void => done(() => reject(new Error(`server exited (code ${code}) before ${what}`))); + + const timer = setTimeout( + () => done(() => reject(new Error(`timed out after ${timeoutMs}ms waiting for ${what}`))), + timeoutMs, + ); + + this.#events.on('line', onLine); + this.#events.once('exit', onExit); + }); + } + + waitForReady(timeoutMs = 180_000): Promise { + return this.waitForLine(READY_RE, timeoutMs, 'the server to start'); + } + + /** Runs a console command. BDS reads its console from stdin, one command per line. */ + send(command: string): void { + if (!this.#child || this.#exited) { throw new Error(`cannot send "${command}": the server is not running`); } + + this.#child.stdin.write(`${command}\n`); + } + + /** Waits until the server has been quiet for `idleMs`, or `wallMs` elapses, or it exits. */ + async waitForQuiet(idleMs: number, wallMs: number): Promise<'quiet' | 'wall' | 'exit'> { + const deadline = Date.now() + wallMs; + + for (;;) { + if (this.#exited) { return 'exit'; } + + if (Date.now() >= deadline) { return 'wall'; } + + if (this.idleMs >= idleMs) { return 'quiet'; } + + await new Promise(resolve => setTimeout(resolve, 250)); + } + } + + /** Asks the server to stop, then escalates. Always resolves. */ + async stop(graceMs = 30_000): Promise { + if (!this.#child || this.#exited) { return; } + + const exited = new Promise(resolve => this.#events.once('exit', () => resolve())); + + try { + this.send('stop'); + } catch { + // Already gone; the kill path below is a no-op. + } + + const waitFor = async (ms: number): Promise => Promise.race([exited.then(() => true), new Promise(r => setTimeout(() => r(false), ms))]); + + if (await waitFor(graceMs)) { return; } + + this.#child.kill('SIGTERM'); + + if (await waitFor(10_000)) { return; } + + this.#child.kill('SIGKILL'); + await waitFor(5_000); + } + + async dispose(): Promise { + if (this.#disposed) { return; } + + this.#disposed = true; + + await this.stop(); + this.#cleanup?.(); + this.#log?.end(); + } +} diff --git a/packages/bds-runner/src/server/properties.ts b/packages/bds-runner/src/server/properties.ts new file mode 100644 index 0000000..c9634a8 --- /dev/null +++ b/packages/bds-runner/src/server/properties.ts @@ -0,0 +1,77 @@ +/** + * The `server.properties` the runner writes before every run. + * + * Only the keys that change behaviour we depend on are listed; everything else keeps the shipped + * default. Each non-obvious choice carries its reason, because the failure they prevent is usually + * silent (a test that never ticks, a run that hangs, a port collision in CI). + */ +export interface ServerPropertiesOptions { + levelName: string; + port: number; + portV6: number; + + /** + * The script hang threshold in milliseconds. + * + * This is the in-game Watchdog that made heavy suites unrunnable in the client and forced the old + * harness to gate them behind an `isHeadless()` check. On a dedicated server it is simply a + * config key, so the gate is unnecessary — raise it and let the slow tests run. + */ + watchdogHangMs: number; +} + +export function renderServerProperties(options: ServerPropertiesOptions): string { + const { levelName, port, portV6, watchdogHangMs } = options; + + const lines: [string, string | number][] = [ + ['level-name', levelName], + ['server-name', 'bc-bds-runner'], + + // Creative + peaceful so nothing wanders into a test plot and no mob AI competes for ticks. + ['gamemode', 'creative'], + ['force-gamemode', 'true'], + ['difficulty', 'peaceful'], + + // `/gametest` needs cheats and permission level >= 1. The console is level 4 regardless, but a + // world without cheats refuses the command outright. + ['allow-cheats', 'true'], + ['default-player-permission-level', 'operator'], + + // No auth, no allowlist: nobody connects to this server, and requiring Xbox Live sign-in would + // make CI depend on an external service. + ['online-mode', 'false'], + ['allow-list', 'false'], + + // Do not advertise on the LAN. Two runs on one machine (or a CI box shared with anything else) + // must not fight over discovery. + ['enable-lan-visibility', 'false'], + ['server-port', port], + ['server-portv6', portV6], + + // Small world, few threads: the tests run in one plot and the rest is wasted simulation. + ['view-distance', 5], + ['tick-distance', 4], + ['max-threads', 4], + ['max-players', 1], + + // Never kick for idleness — there is no player, and a timeout mid-run would look like a hang. + ['player-idle-timeout', 0], + + // A second copy of everything on disk, for when stdout is buffered oddly under CI. + ['content-log-file-enabled', 'true'], + ['content-log-file-max-size-bytes', 33554432], + + ['script-watchdog-hang-threshold', watchdogHangMs], + + // A hung script must fail the run, not take the server down before it can report anything. + ['script-watchdog-enable-shutdown', 'false'], + ]; + + const header = [ + '# Generated by @bedrock-core/bds-runner before each run. Edits here are overwritten.', + '# The world (worlds/) is bootstrapped once per BDS version and then reused.', + '', + ]; + + return [...header, ...lines.map(([key, value]) => `${key}=${value}`), ''].join('\n'); +} diff --git a/packages/bds-runner/src/server/provision.ts b/packages/bds-runner/src/server/provision.ts new file mode 100644 index 0000000..38b0a4d --- /dev/null +++ b/packages/bds-runner/src/server/provision.ts @@ -0,0 +1,99 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { serverDir as serverDirFor, serverExecutable } from '../bds/paths'; +import { renderServerProperties } from './properties'; + +/** + * Modules the world's scripts are permitted to import. + * + * BDS ships a `config/default/permissions.json` that already allows `@minecraft/server-gametest`, + * but writing our own makes the run independent of what a given build happens to default to, and + * documents the surface the tests are allowed to touch. + */ +const ALLOWED_MODULES = [ + '@minecraft/server', + '@minecraft/server-gametest', + '@minecraft/server-ui', + '@minecraft/debug-utilities', +]; + +export interface ProvisionOptions { + cacheDir: string; + version: string; + levelName: string; + port: number; + watchdogHangMs: number; + + /** Delete the whole server tree first, forcing a fresh copy and a fresh world bootstrap. */ + fresh?: boolean; + onProgress?: (message: string) => void; +} + +export interface ProvisionResult { + serverDir: string; + worldDir: string; + + /** True when the tree was created by this call, so the world still needs bootstrapping. */ + created: boolean; +} + +async function exists(target: string): Promise { + return fs.access(target).then(() => true, () => false); +} + +/** + * Prepares the directory BDS runs in. + * + * The tree is a **copy** of the cache, kept between runs. Copying ~200 MB per run would dominate the + * runtime, and symlinking is not an option on Windows without developer mode or elevation. Keeping + * it also means the world bootstrap (which costs a full boot/stop cycle) happens once per BDS + * version rather than once per run. + * + * The *world* is not preserved wholesale — see `resetWorldChunks`. + */ +export async function provisionServer(options: ProvisionOptions): Promise { + const { cacheDir, version, levelName, port, watchdogHangMs, fresh = false } = options; + const onProgress = options.onProgress ?? ((): void => {}); + + const dir = serverDirFor(version); + + if (fresh) { + onProgress('removing the existing server tree (--fresh)'); + await fs.rm(dir, { recursive: true, force: true }); + } + + const created = !await exists(path.join(dir, serverExecutable())); + + if (created) { + onProgress(`copying Bedrock Dedicated Server ${version} into ${dir}`); + await fs.mkdir(path.dirname(dir), { recursive: true }); + await fs.cp(cacheDir, dir, { recursive: true }); + + if (process.platform !== 'win32') { await fs.chmod(path.join(dir, serverExecutable()), 0o755); } + } + + await fs.writeFile( + path.join(dir, 'server.properties'), + renderServerProperties({ levelName, port, portV6: port + 1, watchdogHangMs }), + ); + + await fs.mkdir(path.join(dir, 'config', 'default'), { recursive: true }); + await fs.writeFile( + path.join(dir, 'config', 'default', 'permissions.json'), + `${JSON.stringify({ allowed_modules: ALLOWED_MODULES }, null, 2)}\n`, + ); + + return { serverDir: dir, worldDir: path.join(dir, 'worlds', levelName), created }; +} + +/** + * Throws away the world's chunks while keeping `level.dat`. + * + * Each run must start from unmodified terrain — a gametest that leaves blocks behind would + * otherwise change what the next run sees, and a suite that only passes on a used world is worse + * than useless. `level.dat` survives because it carries the experiment toggles that took a whole + * boot cycle to set up. + */ +export async function resetWorldChunks(worldDir: string): Promise { + await fs.rm(path.join(worldDir, 'db'), { recursive: true, force: true }); +} diff --git a/packages/bds-runner/src/server/world.ts b/packages/bds-runner/src/server/world.ts new file mode 100644 index 0000000..c84d3fb --- /dev/null +++ b/packages/bds-runner/src/server/world.ts @@ -0,0 +1,149 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import nbt from 'prismarine-nbt'; + +/** + * Enabling Beta APIs on a Bedrock Dedicated Server. + * + * There is no `server.properties` key and no command-line flag for experiments — they live only in + * the world's `level.dat`, as a root `experiments` compound. `@minecraft/server-gametest` is a beta + * module, so without this the `/gametest` command does not exist and the pack fails to load. + * + * `level.dat` is an 8-byte header (`uint32 storageVersion`, `uint32 bodyLength`) followed by + * little-endian NBT — verified on BDS 1.26.43.1, where a freshly generated world reads + * `0a000000 b90b0000` for a 3001-byte body. + * + * A generated world already *has* an `experiments` compound holding `experiments_ever_used` and + * `saved_with_toggled_experiments` at 0, so the bootstrap adds the toggle rather than inventing the + * structure. + */ + +/** The NBT key for the "Beta APIs" toggle. The game abbreviates it to `gtst` in its boot log. */ +const BETA_APIS_KEY = 'gametest'; + +/** `Generator`: 2 selects a flat world — a predictable ground plane for gametest plots. */ +const GENERATOR_FLAT = 2; + +export interface BootstrapResult { + changed: boolean; + experiments: string[]; +} + +interface NbtCompound { + type: string; + value: Record; +} + +async function readLevelDat(file: string): Promise<{ storageVersion: number; root: NbtCompound }> { + const buffer = await fs.readFile(file); + + if (buffer.length < 9) { throw new Error(`${file} is too small to be a level.dat`); } + + const storageVersion = buffer.readUInt32LE(0); + const declaredLength = buffer.readUInt32LE(4); + const body = buffer.subarray(8); + + if (declaredLength !== body.length) { + throw new Error( + `${file} declares a ${declaredLength}-byte body but has ${body.length}. ` + + 'Refusing to rewrite a level.dat we do not understand.', + ); + } + + const { parsed } = await nbt.parse(body, 'little'); + + return { storageVersion, root: parsed as unknown as NbtCompound }; +} + +async function writeLevelDat(file: string, storageVersion: number, root: NbtCompound): Promise { + const body = nbt.writeUncompressed(root as never, 'little'); + const header = Buffer.alloc(8); + + header.writeUInt32LE(storageVersion, 0); + + // The length field must describe the body we are about to write, not the one we read. + header.writeUInt32LE(body.length, 4); + + await fs.writeFile(file, Buffer.concat([header, body])); +} + +function activeExperiments(root: NbtCompound): string[] { + const experiments = root.value.experiments as NbtCompound | undefined; + + if (!experiments) { return []; } + + return Object.entries(experiments.value) + .filter(([, entry]) => entry.value === 1) + .map(([key]) => key); +} + +/** + * Turns Beta APIs (and a flat generator) on in a world BDS has already generated. + * + * Returns `changed: false` when the world is already set up, which is the normal case after the + * first run — the server tree is reused across runs precisely so this happens once per BDS version. + * + * The write is verified by reading the file back. It is not a belt-and-braces flourish: a silently + * failed write shows up later as `Unknown command: gametest`, which reads like a completely + * different problem and has cost people hours. + */ +export async function enableBetaApis(worldDir: string): Promise { + const file = path.join(worldDir, 'level.dat'); + const { storageVersion, root } = await readLevelDat(file); + + const experiments = (root.value.experiments as NbtCompound | undefined) ?? { type: 'compound', value: {} }; + const before = JSON.stringify(experiments.value); + + experiments.value[BETA_APIS_KEY] = { type: 'byte', value: 1 }; + experiments.value.experiments_ever_used = { type: 'byte', value: 1 }; + experiments.value.saved_with_toggled_experiments = { type: 'byte', value: 1 }; + root.value.experiments = experiments; + + const generatorWas = (root.value.Generator as { value: number } | undefined)?.value; + + root.value.Generator = { type: 'int', value: GENERATOR_FLAT }; + + const changed = before !== JSON.stringify(experiments.value) || generatorWas !== GENERATOR_FLAT; + + if (!changed) { return { changed: false, experiments: activeExperiments(root) }; } + + await writeLevelDat(file, storageVersion, root); + + const { root: verified } = await readLevelDat(file); + const active = activeExperiments(verified); + + if (!active.includes(BETA_APIS_KEY)) { + throw new Error( + `wrote ${file} but experiments.${BETA_APIS_KEY} did not stick, so Beta APIs are still off.\n` + + 'Without them the /gametest command does not exist and the pack will not load. Workaround: ' + + 'create a flat creative world in the Minecraft client with "Beta APIs" enabled, then copy ' + + `its level.dat over ${file}.`, + ); + } + + return { changed: true, experiments: active }; +} + +interface PackReference { + packId: string; + version: number[]; +} + +/** + * Points the world at the packs to load. + * + * BDS reads `world_behavior_packs.json` / `world_resource_packs.json` from the world directory and + * matches each `pack_id` against the packs it can see, preferring the world-local + * `behavior_packs/` and `resource_packs/` folders. The ids must be the **header** uuids from each + * manifest — a module uuid silently matches nothing. + */ +export async function writeWorldPackReferences( + worldDir: string, + behavior: PackReference[], + resource: PackReference[], +): Promise { + const render = (refs: PackReference[]): string => `${JSON.stringify(refs.map(r => ({ pack_id: r.packId, version: r.version })), null, 2)}\n`; + + await fs.writeFile(path.join(worldDir, 'world_behavior_packs.json'), render(behavior)); + await fs.writeFile(path.join(worldDir, 'world_resource_packs.json'), render(resource)); +} diff --git a/packages/bds-runner/tsconfig.json b/packages/bds-runner/tsconfig.json new file mode 100644 index 0000000..466c3c2 --- /dev/null +++ b/packages/bds-runner/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "noEmit": true, + "rootDir": "./src", + "types": [ + "node" + ] + }, + "include": [ + "src/**/*" + ], + "exclude": [ + "node_modules", + "**/*.test.ts", + "**/*.spec.ts" + ] +} diff --git a/packages/bds-runner/vitest.config.ts b/packages/bds-runner/vitest.config.ts new file mode 100644 index 0000000..b31b820 --- /dev/null +++ b/packages/bds-runner/vitest.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['src/**/*.test.ts', 'src/**/*.spec.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'], + }, + }, +}); diff --git a/packages/test-addon-2/config.json b/packages/test-addon-2/config.json index 452c2a6..a20a0d0 100644 --- a/packages/test-addon-2/config.json +++ b/packages/test-addon-2/config.json @@ -12,7 +12,7 @@ "filterDefinitions": { "bundler": { "url": "github.com/bedrock-core/regolith-filters", - "version": "1.1.1" + "version": "1.1.2" }, "guides": { "runWith": "nodejs", @@ -21,6 +21,10 @@ "i18n": { "runWith": "nodejs", "script": "../../../regolith-filters/i18n/main.js" + }, + "manifest": { + "runWith": "nodejs", + "script": "../../../regolith-filters/manifest/main.js" } }, "formatVersion": "1.4.0", @@ -32,6 +36,38 @@ "target": "development" }, "filters": [ + { + "filter": "manifest" + }, + { + "filter": "guides", + "settings": { + "namespace": "drav0011_shop" + } + }, + { + "filter": "i18n" + }, + { + "filter": "bundler", + "settings": { + "debug": true + } + } + ] + }, + "build-test": { + "export": { + "build": "standard", + "bpPath": "./build/test/BP", + "readOnly": false, + "rpPath": "./build/test/RP", + "target": "exact" + }, + "filters": [ + { + "filter": "manifest" + }, { "filter": "guides", "settings": { diff --git a/packages/test-addon-2/package.json b/packages/test-addon-2/package.json index afc2960..e3d307a 100644 --- a/packages/test-addon-2/package.json +++ b/packages/test-addon-2/package.json @@ -7,6 +7,7 @@ "scripts": { "regolith-install": "regolith install-all", "build": "regolith run build", + "build:test": "regolith run build-test", "watch": "regolith watch", "lint": "eslint ." }, diff --git a/packages/test-addon/config.json b/packages/test-addon/config.json index 2a4a2d1..40bd2af 100644 --- a/packages/test-addon/config.json +++ b/packages/test-addon/config.json @@ -12,7 +12,7 @@ "filterDefinitions": { "bundler": { "url": "github.com/bedrock-core/regolith-filters", - "version": "1.1.1" + "version": "1.1.2" }, "guides": { "runWith": "nodejs", @@ -21,6 +21,10 @@ "i18n": { "runWith": "nodejs", "script": "../../../regolith-filters/i18n/main.js" + }, + "manifest": { + "runWith": "nodejs", + "script": "../../../regolith-filters/manifest/main.js" } }, "formatVersion": "1.4.0", @@ -32,6 +36,9 @@ "target": "development" }, "filters": [ + { + "filter": "manifest" + }, { "filter": "guides", "settings": { @@ -51,7 +58,77 @@ } } ] + }, + "test": { + "export": { + "build": "standard", + "readOnly": false, + "target": "development" + }, + "filters": [ + { + "filter": "manifest", + "settings": { + "manifestPath": "BP/manifest.test.json" + } + }, + { + "filter": "guides", + "settings": { + "namespace": "drav0011_economy" + } + }, + { + "filter": "i18n", + "settings": { + "namespace": "drav0011_economy" + } + }, + { + "filter": "bundler", + "settings": { + "debug": true, + "tsConfigPath": "tsconfig.test.json" + } + } + ] + }, + "build-test": { + "export": { + "build": "standard", + "bpPath": "./build/test/BP", + "readOnly": false, + "rpPath": "./build/test/RP", + "target": "exact" + }, + "filters": [ + { + "filter": "manifest", + "settings": { + "manifestPath": "BP/manifest.test.json" + } + }, + { + "filter": "guides", + "settings": { + "namespace": "drav0011_economy" + } + }, + { + "filter": "i18n", + "settings": { + "namespace": "drav0011_economy" + } + }, + { + "filter": "bundler", + "settings": { + "debug": true, + "tsConfigPath": "tsconfig.test.json" + } + } + ] } } } -} \ No newline at end of file +} diff --git a/packages/test-addon/package.json b/packages/test-addon/package.json index 6016a00..ad3b1e5 100644 --- a/packages/test-addon/package.json +++ b/packages/test-addon/package.json @@ -7,7 +7,9 @@ "scripts": { "regolith-install": "regolith install-all", "build": "regolith run build", + "build:test": "regolith run build-test", "watch": "regolith watch", + "watch:test": "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" @@ -21,7 +23,7 @@ "@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-gametest": "1.0.0-beta.1.26.43-stable", "@minecraft/server-ui": "2.1.0", "@minecraft/vanilla-data": "1.26.31", "typescript": "^6.0.3", diff --git a/packages/test-addon/packs/BP/manifest.json b/packages/test-addon/packs/BP/manifest.json index 10dc0bc..c138ead 100644 --- a/packages/test-addon/packs/BP/manifest.json +++ b/packages/test-addon/packs/BP/manifest.json @@ -54,10 +54,6 @@ { "module_name": "@minecraft/server-ui", "version": "2.1.0" - }, - { - "module_name": "@minecraft/server-gametest", - "version": "1.0.0-beta" } ], "metadata": { diff --git a/packages/test-addon/packs/BP/manifest.test.json b/packages/test-addon/packs/BP/manifest.test.json new file mode 100644 index 0000000..8e8b18f --- /dev/null +++ b/packages/test-addon/packs/BP/manifest.test.json @@ -0,0 +1,25 @@ +{ + "extends": "./manifest.json", + "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" + } + ] +} diff --git a/packages/test-addon/packs/BP/scripts/gametest.ts b/packages/test-addon/packs/BP/scripts/gametest.ts new file mode 100644 index 0000000..5329ad8 --- /dev/null +++ b/packages/test-addon/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-addon/packs/BP/scripts/main.ts b/packages/test-addon/packs/BP/scripts/main.ts index bf0265f..eb8b8f6 100644 --- a/packages/test-addon/packs/BP/scripts/main.ts +++ b/packages/test-addon/packs/BP/scripts/main.ts @@ -9,7 +9,6 @@ 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. diff --git a/packages/test-addon/tsconfig.test.json b/packages/test-addon/tsconfig.test.json new file mode 100644 index 0000000..0ea7f7a --- /dev/null +++ b/packages/test-addon/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/scripts/sync-meta-version.mjs b/scripts/sync-meta-version.mjs new file mode 100644 index 0000000..f8f9d94 --- /dev/null +++ b/scripts/sync-meta-version.mjs @@ -0,0 +1,108 @@ +#!/usr/bin/env node +/** + * Pin the `@bedrock-core/server` meta package to `@bedrock-core/server-runtime`. + * + * The rule: **the meta's MAJOR.MINOR is the runtime's.** The runtime is what the + * meta *is*; `sync` and everything else it curates are support around it. So: + * + * runtime line moved (0.1.x → 0.2.x, 0.x → 1.x) → meta jumps to .0 + * anything else changed, at any level → meta patch + * nothing changed → no-op + * + * A minor on `sync`, or a changeset that asks the meta itself for a minor, is a + * *patch* to the meta: what ships is the meta's support for that change, not a + * new framework line. Left to itself changesets would get this wrong in both + * directions — `updateInternalDependencies: patch` only ever patches the meta + * when the runtime takes a minor, and an explicit meta changeset can move it + * without the runtime moving at all. + * + * Runs inside `yarn version-packages`, right after `changeset version`, so it + * corrects the version changesets just wrote (and retitles the changelog entry + * that went with it) before anything is committed, tagged or published. + * + * The line only ever moves **forward**. npm can't unpublish, so a meta sitting + * ahead of the runtime holds where it is, patching, until the runtime's line + * catches up — from then on the two are pinned. + * + * Idempotent: re-running with the meta already on the right version is a no-op. + */ +import { execSync } from 'node:child_process'; +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; + +const META_PATH = 'packages/server/package.json'; +const META_CHANGELOG = 'packages/server/CHANGELOG.md'; +const RUNTIME_PATH = 'packages/server-runtime/package.json'; + +/** Matches the manifest's version field, capturing the quoted value so only it is replaced. */ +const VERSION_FIELD = /("version"\s*:\s*")([^"]*)(")/; + +const readVersion = (json) => JSON.parse(json).version; +const currentVersion = (path) => readVersion(readFileSync(path, 'utf8')); + +/** Version of a package.json at git HEAD, or null if it isn't committed yet. */ +function headVersion(path) { + try { + return readVersion(execSync(`git show HEAD:${path}`, { encoding: 'utf8' })); + } catch { + return null; + } +} + +/** `1.2.3` → `[1, 2, 3]`. */ +function parse(version) { + const match = /^(\d+)\.(\d+)\.(\d+)/.exec(version ?? ''); + + if (!match) throw new Error(`sync-meta-version: cannot parse the version "${version}"`); + + return match.slice(1, 4).map(Number); +} + +/** Is `a`'s MAJOR.MINOR strictly ahead of `b`'s? */ +const lineAhead = (a, b) => (a[0] === b[0] ? a[1] > b[1] : a[0] > b[0]); + +/** What changesets just wrote, and what was last released. */ +const written = currentVersion(META_PATH); +const released = headVersion(META_PATH) ?? written; +const runtime = parse(currentVersion(RUNTIME_PATH)); +const base = parse(released); + +let next; + +if (lineAhead(runtime, base)) { + next = `${runtime[0]}.${runtime[1]}.0`; + console.log(`sync-meta-version: server-runtime line → ${runtime[0]}.${runtime[1]} — the meta follows it.`); +} else if (written !== released) { + next = `${base[0]}.${base[1]}.${base[2] + 1}`; +} else { + console.log(`sync-meta-version: @bedrock-core/server unchanged (${written}) — nothing to pin.`); + process.exit(0); +} + +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 — these manifests are 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} (released ${released})`); + +// Retitle the entry `changeset version` just wrote, so the changelog and the tag agree. +if (!existsSync(META_CHANGELOG)) process.exit(0); + +const changelog = readFileSync(META_CHANGELOG, 'utf8'); +const heading = new RegExp(`^## ${written.replace(/\./g, '\.')}$`, 'm'); + +if (!heading.test(changelog)) { + console.warn(`sync-meta-version: no "## ${written}" heading in ${META_CHANGELOG} — retitle it by hand.`); + process.exit(0); +} + +writeFileSync(META_CHANGELOG, changelog.replace(heading, `## ${next}`)); +console.log(`sync-meta-version: ${META_CHANGELOG} heading ${written} → ${next}`); diff --git a/yarn.lock b/yarn.lock index 1508848..ea8987d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5,6 +5,31 @@ __metadata: version: 8 cacheKey: 10c0 +"@babel/helper-string-parser@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/helper-string-parser@npm:7.29.7" + checksum: 10c0/194bc0f1716e396d5ffde56ad6119745fb9557662c98611590e5e454906783a4ccb21ce93056b8eb69a4909044834e45d96e50ac695bbe9e3221648fe033c06c + languageName: node + linkType: hard + +"@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: hard + +"@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: hard + "@babel/runtime@npm:^7.5.5": version: 7.29.7 resolution: "@babel/runtime@npm:7.29.7" @@ -12,6 +37,39 @@ __metadata: languageName: node linkType: hard +"@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: hard + +"@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@workspace:^, @bedrock-core/bds-runner@workspace:packages/bds-runner": + version: 0.0.0-use.local + resolution: "@bedrock-core/bds-runner@workspace:packages/bds-runner" + dependencies: + "@types/node": "npm:^26.2.0" + "@types/yauzl": "npm:^2.10.3" + jiti: "npm:^2.7.0" + prismarine-nbt: "npm:^2.7.0" + typescript: "npm:^6.0.3" + vitest: "npm:^4.1.10" + yauzl: "npm:^3.2.0" + bin: + bc-bds: ./bin/bc-bds.mjs + languageName: unknown + linkType: soft + "@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." @@ -73,12 +131,14 @@ __metadata: version: 0.0.0-use.local resolution: "@bedrock-core/server-monorepo@workspace:." dependencies: + "@bedrock-core/bds-runner": "workspace:^" "@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" + "@vitest/coverage-v8": "npm:^4.1.10" concurrently: "npm:^10.0.3" eslint: "npm:^10.5.0" globals: "npm:^17.7.0" @@ -86,6 +146,7 @@ __metadata: nodemon: "npm:^3.1.14" typescript: "npm:^6.0.3" typescript-eslint: "npm:^8.62.0" + vitest: "npm:^4.1.10" languageName: unknown linkType: soft @@ -141,7 +202,7 @@ __metadata: "@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-gametest": "npm:1.0.0-beta.1.26.43-stable" "@minecraft/server-ui": "npm:2.1.0" "@minecraft/vanilla-data": "npm:1.26.31" "@stylistic/eslint-plugin": "npm:^5.10.0" @@ -698,6 +759,30 @@ __metadata: languageName: node linkType: hard +"@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: + "@jridgewell/resolve-uri": "npm:^3.1.0" + "@jridgewell/sourcemap-codec": "npm:^1.4.14" + checksum: 10c0/4b30ec8cd56c5fd9a661f088230af01e0c1a3888d11ffb6b47639700f71225be21d1f7e168048d6d4f9449207b978a235c07c8f15c07705685d16dc06280e9d9 + languageName: node + linkType: hard + "@manypkg/find-root@npm:^1.1.0": version: 1.1.0 resolution: "@manypkg/find-root@npm:1.1.0" @@ -740,13 +825,13 @@ __metadata: 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.43-stable": + version: 1.0.0-beta.1.26.43-stable + resolution: "@minecraft/server-gametest@npm:1.0.0-beta.1.26.43-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.10.0-beta.1.26.43-stable + checksum: 10c0/7c0d7e77d696f71cf27a87ca067f75b2357b1968139cc9bfa2f84c72c2ce5e8e395fdb3701e4ed6289834b4c002b123f04767f2ea522ca66e45a589b02959ca5 languageName: node linkType: hard @@ -804,6 +889,132 @@ __metadata: languageName: node linkType: hard +"@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 + +"@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 + "@stylistic/eslint-plugin@npm:^5.10.0": version: 5.10.0 resolution: "@stylistic/eslint-plugin@npm:5.10.0" @@ -820,6 +1031,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 +1055,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,6 +1069,15 @@ __metadata: languageName: node linkType: hard +"@types/node@npm:*, @types/node@npm:^26.2.0": + version: 26.2.0 + resolution: "@types/node@npm:26.2.0" + dependencies: + undici-types: "npm:~8.3.0" + checksum: 10c0/f8566d88162241eb55a46f7e4ea097188eab0aaafefea7a58e63adab8c4144eb98c08b7911452c846104c339a0f82282f5e1ed4b26b578d60709614fe2843f4d + languageName: node + linkType: hard + "@types/node@npm:^12.7.1": version: 12.20.55 resolution: "@types/node@npm:12.20.55" @@ -866,6 +1103,15 @@ __metadata: languageName: node linkType: hard +"@types/yauzl@npm:^2.10.3": + version: 2.10.3 + resolution: "@types/yauzl@npm:2.10.3" + dependencies: + "@types/node": "npm:*" + checksum: 10c0/f1b7c1b99fef9f2fe7f1985ef7426d0cebe48cd031f1780fcdc7451eec7e31ac97028f16f50121a59bcf53086a1fc8c856fd5b7d3e00970e43d92ae27d6b43dc + 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 +1265,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 +1378,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 +1405,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: @@ -1126,6 +1487,24 @@ __metadata: languageName: node linkType: hard +"assertion-error@npm:^2.0.1": + version: 2.0.1 + resolution: "assertion-error@npm:2.0.1" + checksum: 10c0/bbbcb117ac6480138f8c93cf7f535614282dea9dc828f540cdece85e3c665e8f78958b96afac52f29ff883c72638e6a87d469ecc9fe5bc902df03ed24a55dba8 + languageName: node + linkType: hard + +"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 + "balanced-match@npm:^1.0.0": version: 1.0.2 resolution: "balanced-match@npm:1.0.2" @@ -1140,6 +1519,13 @@ __metadata: languageName: node linkType: hard +"base64-js@npm:^1.3.1": + version: 1.5.1 + resolution: "base64-js@npm:1.5.1" + checksum: 10c0/f23823513b63173a001030fae4f2dabe283b99a9d324ade3ad3d148e218134676f1ee8568c877cd79ec1c53158dcf2d2ba527a97c606618928ba99dd930102bf + languageName: node + linkType: hard + "better-path-resolve@npm:1.0.0": version: 1.0.0 resolution: "better-path-resolve@npm:1.0.0" @@ -1184,6 +1570,16 @@ __metadata: languageName: node linkType: hard +"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 + "callsites@npm:^3.0.0": version: 3.1.0 resolution: "callsites@npm:3.1.0" @@ -1191,6 +1587,13 @@ __metadata: languageName: node linkType: hard +"chai@npm:^6.2.2": + version: 6.2.2 + resolution: "chai@npm:6.2.2" + checksum: 10c0/e6c69e5f0c11dffe6ea13d0290936ebb68fcc1ad688b8e952e131df6a6d5797d5e860bc55cef1aca2e950c3e1f96daf79e9d5a70fb7dbaab4e46355e2635ed53 + languageName: node + linkType: hard + "chalk@npm:5.6.2": version: 5.6.2 resolution: "chalk@npm:5.6.2" @@ -1292,6 +1695,13 @@ __metadata: languageName: node linkType: hard +"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.5, cross-spawn@npm:^7.0.6": version: 7.0.6 resolution: "cross-spawn@npm:7.0.6" @@ -1336,6 +1746,13 @@ __metadata: languageName: node linkType: hard +"detect-libc@npm:^2.0.3": + version: 2.1.2 + resolution: "detect-libc@npm:2.1.2" + checksum: 10c0/acc675c29a5649fa1fb6e255f993b8ee829e510b6b56b0910666949c80c364738833417d0edb5f90e4e46be17228b0f2b66a010513984e18b15deeeac49369c4 + languageName: node + linkType: hard + "dir-glob@npm:^3.0.1": version: 3.0.1 resolution: "dir-glob@npm:3.0.1" @@ -1376,6 +1793,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" @@ -1596,6 +2020,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 +2036,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" @@ -1749,7 +2203,7 @@ __metadata: 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 +2213,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: @@ -1849,6 +2303,13 @@ __metadata: languageName: node linkType: hard +"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.1.1": version: 4.2.0 resolution: "human-id@npm:4.2.0" @@ -1867,6 +2328,13 @@ __metadata: languageName: node linkType: hard +"ieee754@npm:^1.2.1": + version: 1.2.1 + resolution: "ieee754@npm:1.2.1" + checksum: 10c0/b0782ef5e0935b9f12883a2e2aa37baa75da6e66ce6515c168697b42160807d9330de9a32ec1ed73149aea02e0d822e572bca6f1e22bdcbd2149e13b050b17bb + languageName: node + linkType: hard + "ignore-by-default@npm:^1.0.1": version: 1.0.1 resolution: "ignore-by-default@npm:1.0.1" @@ -1967,6 +2435,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,6 +2472,13 @@ __metadata: languageName: node linkType: hard +"js-tokens@npm:^10.0.0": + version: 10.0.0 + resolution: "js-tokens@npm:10.0.0" + checksum: 10c0/a93498747812ba3e0c8626f95f75ab29319f2a13613a0de9e610700405760931624433a0de59eb7c27ff8836e526768fb20783861b86ef89be96676f2c996b64 + languageName: node + linkType: hard + "js-yaml@npm:^3.6.1": version: 3.15.0 resolution: "js-yaml@npm:3.15.0" @@ -2051,6 +2554,126 @@ __metadata: 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 + +"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 + +"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 + +"lightningcss@npm:^1.33.0": + version: 1.33.0 + resolution: "lightningcss@npm:1.33.0" + dependencies: + 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 + "locate-path@npm:^5.0.0": version: 5.0.0 resolution: "locate-path@npm:5.0.0" @@ -2076,6 +2699,13 @@ __metadata: languageName: node linkType: hard +"lodash.reduce@npm:^4.6.0": + version: 4.6.0 + resolution: "lodash.reduce@npm:4.6.0" + checksum: 10c0/5d2dab823523a1a7f81eb5f4c1edcc03aab55504b1299a2385737389644ba6d2ad219169dfc5c16632a67a345d925ef6a5e8816b4e18a36f94ed66f8e7740b36 + languageName: node + linkType: hard + "lodash.startcase@npm:^4.4.0": version: 4.4.0 resolution: "lodash.startcase@npm:4.4.0" @@ -2083,6 +2713,35 @@ __metadata: languageName: node linkType: hard +"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 + +"make-dir@npm:^4.0.0": + version: 4.0.0 + resolution: "make-dir@npm:4.0.0" + dependencies: + semver: "npm:^7.5.3" + checksum: 10c0/69b98a6c0b8e5c4fe9acb61608a9fbcfca1756d910f51e5dbe7a9e5cfb74fca9b8a0c8a0ffdf1294a740826c1ab4871d5bf3f62f72a3049e5eac6541ddffed68 + languageName: node + linkType: hard + "merge2@npm:^1.3.0, merge2@npm:^1.4.1": version: 1.4.1 resolution: "merge2@npm:1.4.1" @@ -2148,6 +2807,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 +2895,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" @@ -2346,7 +3021,21 @@ __metadata: languageName: node linkType: hard -"picocolors@npm:^1.1.0": +"pathe@npm:^2.0.3": + version: 2.0.3 + resolution: "pathe@npm:2.0.3" + checksum: 10c0/c118dc5a8b5c4166011b2b70608762e260085180bb9e33e80a50dcdb1e78c010b1624f4280c492c92b05fc276715a4c357d1f9edc570f8f1b3d90b6839ebaca1 + languageName: node + linkType: hard + +"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.0, picocolors@npm:^1.1.1": version: 1.1.1 resolution: "picocolors@npm:1.1.1" checksum: 10c0/e2e3e8170ab9d7c7421969adaa7e1b31434f789afb9b3f115f6b96d91945041ac3ceb02e9ec6fe6510ff036bcc0bf91e69a1772edc0b707e12b19c0f2d6bcf58 @@ -2367,6 +3056,13 @@ __metadata: languageName: node linkType: hard +"picomatch@npm:^4.0.5": + version: 4.0.5 + resolution: "picomatch@npm:4.0.5" + checksum: 10c0/947bc6b6e1ff1e6c5aaf95b107a0839d12802f4f7b867663f67d47accba939ca1cb582cf99dfc30438efa1c4648ac5990967e783e8929c36b03e8440704ef1bd + languageName: node + linkType: hard + "pify@npm:^4.0.1": version: 4.0.1 resolution: "pify@npm:4.0.1" @@ -2374,6 +3070,17 @@ __metadata: 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 + "prelude-ls@npm:^1.2.1": version: 1.2.1 resolution: "prelude-ls@npm:1.2.1" @@ -2390,6 +3097,15 @@ __metadata: languageName: node linkType: hard +"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 + "proc-log@npm:^7.0.0": version: 7.0.0 resolution: "proc-log@npm:7.0.0" @@ -2397,6 +3113,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" @@ -2437,6 +3182,19 @@ __metadata: languageName: node linkType: hard +"readable-stream@npm:^4.4.0": + version: 4.7.0 + resolution: "readable-stream@npm:4.7.0" + dependencies: + 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 + "readdirp@npm:~3.6.0": version: 3.6.0 resolution: "readdirp@npm:3.6.0" @@ -2467,6 +3225,64 @@ __metadata: languageName: node linkType: hard +"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 + "run-parallel@npm:^1.1.9": version: 1.2.0 resolution: "run-parallel@npm:1.2.0" @@ -2485,6 +3301,13 @@ __metadata: languageName: node linkType: hard +"safe-buffer@npm:~5.2.0": + version: 5.2.1 + resolution: "safe-buffer@npm:5.2.1" + checksum: 10c0/6501914237c0a86e9675d4e51d89ca3c21ffd6a31642efeba25ad65720bce6921c9e7e974e5be91a786b25aa058b5303285d3c15dbabf983a919f5f630d349f3 + languageName: node + linkType: hard + "safer-buffer@npm:>= 2.1.2 < 3.0.0": version: 2.1.2 resolution: "safer-buffer@npm:2.1.2" @@ -2524,6 +3347,13 @@ __metadata: 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 + "signal-exit@npm:^4.0.1": version: 4.1.0 resolution: "signal-exit@npm:4.1.0" @@ -2547,6 +3377,13 @@ __metadata: languageName: node linkType: hard +"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 + "spawndamnit@npm:^3.0.1": version: 3.0.1 resolution: "spawndamnit@npm:3.0.1" @@ -2564,6 +3401,20 @@ __metadata: languageName: node linkType: hard +"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 + "string-width@npm:^7.0.0, string-width@npm:^7.2.0": version: 7.2.0 resolution: "string-width@npm:7.2.0" @@ -2575,6 +3426,15 @@ __metadata: languageName: node linkType: hard +"string_decoder@npm:^1.3.0": + version: 1.3.0 + resolution: "string_decoder@npm:1.3.0" + dependencies: + safe-buffer: "npm:~5.2.0" + checksum: 10c0/810614ddb030e271cd591935dcd5956b2410dd079d64ff92a1844d6b7588bf992b3e1b69b0f4d34a3e06e0bd73046ac646b5264c1987b20d0601f81ef35d731d + languageName: node + linkType: hard + "strip-ansi@npm:^6.0.1": version: 6.0.1 resolution: "strip-ansi@npm:6.0.1" @@ -2652,7 +3512,21 @@ __metadata: languageName: node linkType: hard -"tinyglobby@npm:^0.2.12, tinyglobby@npm:^0.2.15": +"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 + +"tinyglobby@npm:^0.2.12, tinyglobby@npm:^0.2.15, tinyglobby@npm:^0.2.17": version: 0.2.17 resolution: "tinyglobby@npm:0.2.17" dependencies: @@ -2662,6 +3536,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" @@ -2822,6 +3703,131 @@ __metadata: languageName: node linkType: hard +"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: + vitest: ./vitest.mjs + checksum: 10c0/3fa0948cf74adcccc8cbcdb4e6d30ada6933bdfb1816ff99200f3d3b689325b37dc483b22535b57b6d911f7a7b64eaa6a5f8da1606cfe7f2466f48000c21e296 + languageName: node + linkType: hard + "webidl-conversions@npm:^3.0.0": version: 3.0.1 resolution: "webidl-conversions@npm:3.0.1" @@ -2861,6 +3867,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" @@ -2914,6 +3932,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" From df12ff35cd18b344ad22b6a545e6f7f97cd761a2 Mon Sep 17 00:00:00 2001 From: DrAv0011 Date: Thu, 27 Aug 2026 02:11:57 +0200 Subject: [PATCH 10/71] feat(sync): tag the wire format and pack small messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Script-event messages now open with a tag naming their shape: one envelope, a batch of envelopes, or one frame of a chunked envelope. An envelope that fits in a message is sent whole rather than nested inside a frame's `p` field, which removes the JSON escaping nesting forced — a 5-byte message drops from 165 to 104 characters and its round trip costs about 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, so a node that bursts in a single tick now spends a few of its slots instead of one per envelope. Framing charges each character what JSON actually spends escaping it instead of reserving two for every one, so a 16KB envelope splits into 10 frames where it took 18. Adds the benchmarks behind those numbers: `packages/sync/bench` off the engine, a `bench` gametest tag on a real server, and `scripts/bench-report.mjs` to read the results out of the transcript. Also declares the `@bedrock-core/ui-compile` portal resolution. BREAKING CHANGE: PROTOCOL_VERSION is 2. A node on either version ignores the other's traffic rather than misreading it. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/tagged-wire-and-batching.md | 24 ++ package.json | 4 + .../sync/bench/fixtures/registry-16kb.json | 40 +++ packages/sync/bench/payloads.ts | 56 ++++ packages/sync/bench/pipeline.ts | 108 +++++++ packages/sync/bench/wire-size.spec.ts | 107 +++++++ packages/sync/bench/wire.bench.ts | 58 ++++ packages/sync/package.json | 6 +- packages/sync/src/bus.ts | 51 ++- packages/sync/src/chunk.ts | 79 ++++- packages/sync/src/constants.ts | 20 +- packages/sync/src/envelope.ts | 12 +- packages/sync/src/index.ts | 2 +- packages/sync/src/queue.ts | 95 +++++- packages/sync/src/wire.ts | 106 ++++++ packages/sync/test/chunk.spec.ts | 100 ++++++ packages/sync/test/wire.spec.ts | 85 +++++ packages/sync/tsconfig.bench.json | 13 + packages/sync/vitest.config.ts | 12 + .../packs/BP/scripts/tests/bench.ts | 301 ++++++++++++++++++ .../packs/BP/scripts/tests/index.ts | 4 +- scripts/bench-report.mjs | 99 ++++++ yarn.lock | 11 + 23 files changed, 1345 insertions(+), 48 deletions(-) create mode 100644 .changeset/tagged-wire-and-batching.md create mode 100644 packages/sync/bench/fixtures/registry-16kb.json create mode 100644 packages/sync/bench/payloads.ts create mode 100644 packages/sync/bench/pipeline.ts create mode 100644 packages/sync/bench/wire-size.spec.ts create mode 100644 packages/sync/bench/wire.bench.ts create mode 100644 packages/sync/src/wire.ts create mode 100644 packages/sync/test/chunk.spec.ts create mode 100644 packages/sync/test/wire.spec.ts create mode 100644 packages/sync/tsconfig.bench.json create mode 100644 packages/sync/vitest.config.ts create mode 100644 packages/test-addon/packs/BP/scripts/tests/bench.ts create mode 100644 scripts/bench-report.mjs diff --git a/.changeset/tagged-wire-and-batching.md b/.changeset/tagged-wire-and-batching.md new file mode 100644 index 0000000..16b7250 --- /dev/null +++ b/.changeset/tagged-wire-and-batching.md @@ -0,0 +1,24 @@ +--- +"@bedrock-core/sync": minor +--- + +Rework the script-event wire format. `PROTOCOL_VERSION` is now `2`; nodes on either version ignore +the other's traffic rather than misreading it. + +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. + +`MAX_MESSAGE`, the default per-message character budget, is now exported alongside the existing +`BusOptions.maxMessage` override. diff --git a/package.json b/package.json index dda0fc6..8ab7b5d 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "resolutions": { "@bedrock-core/ui": "portal:../ui", "@bedrock-core/ui-runtime": "portal:../ui/packages/ui-runtime", + "@bedrock-core/ui-compile": "portal:../ui/packages/ui-compile", "@bedrock-core/flexbox": "portal:../ui/packages/flexbox", "@bedrock-core/navigation": "portal:../ui/packages/navigation", "@bedrock-core/ore-styled": "portal:../ui/packages/ore-styled", @@ -50,7 +51,10 @@ "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-addon/build/test --packs packages/test-addon-2/build/test --tag core --expect-registered 6", + "test:mc:bench": "yarn workspaces foreach -A --jobs=1 run build:test && bc-bds run --packs packages/test-addon/build/test --packs packages/test-addon-2/build/test --tag bench --expect-registered 5 --idle 120 && node scripts/bench-report.mjs", + "bench:report": "node scripts/bench-report.mjs", "bds:fetch": "yarn workspace @bedrock-core/bds-runner run fetch", "bds:where": "yarn workspace @bedrock-core/bds-runner run where", "changeset": "changeset", 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..809728b --- /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_VERSION } 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_VERSION, + 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..f71ff3f 100644 --- a/packages/sync/package.json +++ b/packages/sync/package.json @@ -36,6 +36,9 @@ ], "scripts": { "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.bench.json", + "test": "vitest run", + "bench": "vitest bench --run", "lint": "eslint ." }, "devDependencies": { @@ -43,7 +46,8 @@ "@stylistic/eslint-plugin": "^5.10.0", "eslint": "^10.5.0", "typescript": "^6.0.3", - "typescript-eslint": "^8.62.0" + "typescript-eslint": "^8.62.0", + "vitest": "^4.1.10" }, "peerDependencies": { "@minecraft/server": ">=2.8.0" diff --git a/packages/sync/src/bus.ts b/packages/sync/src/bus.ts index 335a51f..71fd46c 100644 --- a/packages/sync/src/bus.ts +++ b/packages/sync/src/bus.ts @@ -2,16 +2,19 @@ * 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; + * - 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. */ import { system, type ScriptEventCommandMessageAfterEvent } from '@minecraft/server'; -import { Reassembler, decodeFrame, splitIntoFrames } from './chunk'; +import { Reassembler, splitIntoFrames } from './chunk'; import { BUS_CHANNEL, BUS_NAMESPACE, MAX_MESSAGE, PROTOCOL_VERSION } from './constants'; import { type Envelope, decodeEnvelope, encodeEnvelope } from './envelope'; import { OutboundQueue } from './queue'; +import { decodeWire, tagChunk } from './wire'; // eslint-disable-next-line @typescript-eslint/no-explicit-any export type Unsubscribe = (...args: any[]) => void; @@ -53,7 +56,7 @@ 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 }); } get selfId(): string { @@ -64,7 +67,7 @@ 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; } @@ -121,9 +124,20 @@ export class Bus { return mid; } - const frames = splitIntoFrames(encodeEnvelope(envelope), mid, this._maxMessage); + const encoded = encodeEnvelope(envelope); - for (const frame of frames) { this._queue.enqueue(frame); } + // 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 splitIntoFrames(encoded, mid, this._maxMessage - 1)) { + this._queue.enqueueStandalone(tagChunk(frame)); + } return mid; } @@ -158,20 +172,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..fbe37b1 100644 --- a/packages/sync/src/chunk.ts +++ b/packages/sync/src/chunk.ts @@ -59,24 +59,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..d6d583b 100644 --- a/packages/sync/src/constants.ts +++ b/packages/sync/src/constants.ts @@ -1,7 +1,25 @@ /** Protocol-wide constants shared by every layer. */ /** Bumped on any breaking change to the envelope or frame wire format. */ -export const PROTOCOL_VERSION = 1; +export const PROTOCOL_VERSION = 2; + +/** + * Leading character of a script-event message, saying which shape follows. See `wire.ts` for what + * each one carries and why a one-piece message no longer travels inside a frame. + */ +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; + +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'; diff --git a/packages/sync/src/envelope.ts b/packages/sync/src/envelope.ts index e7b077a..381cb21 100644 --- a/packages/sync/src/envelope.ts +++ b/packages/sync/src/envelope.ts @@ -37,11 +37,10 @@ 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 its protocol version. Exported because a + * batched message arrives as an array of already-parsed objects rather than as JSON text. */ -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; } @@ -59,6 +58,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 mismatched protocol version — 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/index.ts b/packages/sync/src/index.ts index 2651766..4d617c6 100644 --- a/packages/sync/src/index.ts +++ b/packages/sync/src/index.ts @@ -40,4 +40,4 @@ export type { SnapshotEntry, StateChange, StateChangeListener, StateKey, StateOp export type { Unsubscribe } from './bus'; export type { Envelope } from './envelope'; -export { MessageType, PROTOCOL_VERSION } from './constants'; +export { MAX_MESSAGE, MessageType, PROTOCOL_VERSION } from './constants'; diff --git a/packages/sync/src/queue.ts b/packages/sync/src/queue.ts index d05e6fe..5cef702 100644 --- a/packages/sync/src/queue.ts +++ b/packages/sync/src/queue.ts @@ -1,25 +1,47 @@ /** * 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. + * + * 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; +} 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; } 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 _handle: number | undefined; private _dropped = 0; @@ -27,9 +49,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; } @@ -54,18 +77,60 @@ 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]; - for (let i = 0; i < count; i++) { - const message = this._pending.shift(); + if (head === undefined) { return undefined; } + + if (head.standalone) { + 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(); + } + + 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/wire.ts b/packages/sync/src/wire.ts new file mode 100644 index 0000000..266b8b6 --- /dev/null +++ b/packages/sync/src/wire.ts @@ -0,0 +1,106 @@ +/** + * The wire layer: what one script-event message actually contains. + * + * An {@link Envelope} used to be nested inside a {@link Frame}'s `p` field even when it fitted in a + * single message, which meant JSON-escaping the whole thing to sit inside a JSON string — every + * quote paid for a backslash, and a one-piece message still carried a header describing a split + * that never happened. Most bus traffic is one-piece (heartbeats, state deltas, RPC calls), so that + * was the common case paying for the rare one. + * + * A message now 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. + * + * A node speaking the previous protocol emits messages starting with `{`, which matches no tag and + * is discarded by {@link decodeWire} — a version mismatch goes quiet rather than wrong. + */ +import { type Frame, decodeFrame } 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; +} + +/** + * 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; + } + + 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/wire.spec.ts b/packages/sync/test/wire.spec.ts new file mode 100644 index 0000000..3afa8f9 --- /dev/null +++ b/packages/sync/test/wire.spec.ts @@ -0,0 +1,85 @@ +/** + * 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` must be incurious about input + * it does not recognise, since the same channel carries traffic from nodes on other protocol + * versions. + */ +import { describe, expect, it } from 'vitest'; +import { encodeFrame } from '../src/chunk'; +import { PROTOCOL_VERSION, WireTag } from '../src/constants'; +import { type Envelope, encodeEnvelope } from '../src/envelope'; +import { batchLength, decodeWire, encodeBatch, tagChunk } from '../src/wire'; + +function envelope(mid: string, data: unknown = 'x'): Envelope { + return { v: PROTOCOL_VERSION, 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 message from a node on the previous protocol', 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 a mismatched protocol version', `${WireTag.Envelope}{"v":99,"src":"x","iid":"i","type":"t","mid":"m"}`], + ])('ignores %s', (_label, message) => { + expect(decodeWire(message)).toBeUndefined(); + }); +}); 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/packs/BP/scripts/tests/bench.ts b/packages/test-addon/packs/BP/scripts/tests/bench.ts new file mode 100644 index 0000000..bfdced3 --- /dev/null +++ b/packages/test-addon/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('core', 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-addon/packs/BP/scripts/tests/index.ts b/packages/test-addon/packs/BP/scripts/tests/index.ts index c7c31f8..9d15bcb 100644 --- a/packages/test-addon/packs/BP/scripts/tests/index.ts +++ b/packages/test-addon/packs/BP/scripts/tests/index.ts @@ -3,8 +3,10 @@ * 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:`). + * 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 './bench'; import { type Test, register } from '@minecraft/server-gametest'; import { Runtime, core } from '@bedrock-core/server-runtime'; 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/yarn.lock b/yarn.lock index ea8987d..411de61 100644 --- a/yarn.lock +++ b/yarn.lock @@ -172,9 +172,11 @@ __metadata: dependencies: "@bedrock-core/config": "npm:*" "@bedrock-core/i18n": "npm:*" + "@bedrock-core/ore-styled": "npm:*" "@bedrock-core/server-runtime": "workspace:^" "@bedrock-core/sync": "workspace:^" "@bedrock-core/ui": "npm:^0.9.1" + "@bedrock-core/ui-compile": "npm:*" "@eslint/js": "npm:^10.0.1" "@eslint/json": "npm:^2.0.0" "@minecraft/server": "npm:2.8.0" @@ -241,11 +243,20 @@ __metadata: 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" languageName: unknown linkType: soft +"@bedrock-core/ui-compile@portal:../ui/packages/ui-compile::locator=%40bedrock-core%2Fserver-monorepo%40workspace%3A.": + version: 0.0.0-use.local + resolution: "@bedrock-core/ui-compile@portal:../ui/packages/ui-compile::locator=%40bedrock-core%2Fserver-monorepo%40workspace%3A." + dependencies: + "@bedrock-core/ui-runtime": "workspace:*" + languageName: node + 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." From 4643bb1f2215f32d0de98aa47fa8a4e46e54432d Mon Sep 17 00:00:00 2001 From: DrAv0011 Date: Thu, 27 Aug 2026 14:24:44 +0200 Subject: [PATCH 11/71] feat(sync): negotiate the protocol per peer instead of demanding a match 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 one world may hold addons built against different releases instead of splitting into two meshes on a single channel, each listing only its own half and each electing its own UI host. A protocol-1 message is a bare frame with no tag and is read as one: the frame shape never changed, only what wraps it. Announces and whois are pinned to PROTOCOL_MIN, since the message that establishes a version cannot assume one. Broadcasts go out at the lowest version any live peer reads, and packing stops while a peer predating the batch tag is present; both recover 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 on its own without a version bump. A node whose range does not overlap this build's is reported through Discovery.onIncompatible and Registry.onIncompatible, listed by Registry.incompatible() and logged with both ranges, rather than being silently absent from the addon list. negotiateProtocol and capsFor are exported, and live in a module with no engine import so the rule can be tested off a running server. BREAKING CHANGE: PROTOCOL_VERSION is replaced by PROTOCOL_MIN and PROTOCOL_MAX. The support window is two versions wide; raising PROTOCOL_MIN drops support for everything below it. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/tagged-wire-and-batching.md | 29 ++++- packages/server-runtime/src/index.ts | 2 + packages/server-runtime/src/registry.ts | 44 +++++++- packages/sync/bench/pipeline.ts | 4 +- packages/sync/src/bus.ts | 99 ++++++++++++++++- packages/sync/src/constants.ts | 47 ++++++++- packages/sync/src/discovery.ts | 134 +++++++++++++++++++++++- packages/sync/src/envelope.ts | 30 ++++-- packages/sync/src/index.ts | 5 +- packages/sync/src/negotiate.ts | 37 +++++++ packages/sync/src/queue.ts | 17 +++ packages/sync/src/wire.ts | 34 +++++- packages/sync/test/negotiate.spec.ts | 75 +++++++++++++ packages/sync/test/wire.spec.ts | 81 ++++++++++++-- 14 files changed, 600 insertions(+), 38 deletions(-) create mode 100644 packages/sync/src/negotiate.ts create mode 100644 packages/sync/test/negotiate.spec.ts diff --git a/.changeset/tagged-wire-and-batching.md b/.changeset/tagged-wire-and-batching.md index 16b7250..9608577 100644 --- a/.changeset/tagged-wire-and-batching.md +++ b/.changeset/tagged-wire-and-batching.md @@ -1,9 +1,10 @@ --- "@bedrock-core/sync": minor +"@bedrock-core/server-runtime": minor --- -Rework the script-event wire format. `PROTOCOL_VERSION` is now `2`; nodes on either version ignore -the other's traffic rather than misreading it. +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 @@ -20,5 +21,29 @@ Framing charges each character what JSON actually spends escaping it, rather tha 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/packages/server-runtime/src/index.ts b/packages/server-runtime/src/index.ts index c09d3a0..047ec43 100644 --- a/packages/server-runtime/src/index.ts +++ b/packages/server-runtime/src/index.ts @@ -57,6 +57,8 @@ export { addonNamespace, validateManifest } from './manifest'; export type { AddonManifest, ManifestMeta } from './manifest'; export type { TypedClient, RPCHandlerMap } from '@bedrock-core/sync'; +export type { IncompatibleListener, IncompatiblePeer } from '@bedrock-core/sync'; +export { PROTOCOL_MAX, PROTOCOL_MIN } from '@bedrock-core/sync'; export { TranslationsRegistry } from './translations'; export type { TranslationsChangeListener } from './translations'; diff --git a/packages/server-runtime/src/registry.ts b/packages/server-runtime/src/registry.ts index 6965cec..8a0ec5e 100644 --- a/packages/server-runtime/src/registry.ts +++ b/packages/server-runtime/src/registry.ts @@ -7,8 +7,22 @@ * 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 Unsubscribe, +} from '@bedrock-core/sync'; import { addonNamespace, type AddonManifest, manifestFromPeer, runtimeVersionFromPeer } from './manifest'; import { RUNTIME_VERSION } from './runtime-version'; @@ -28,6 +42,7 @@ export class Registry { private readonly _onRegister = new Set(); private readonly _onUnregister = new Set(); private readonly _onCollision = new Set(); + private readonly _onIncompatible = new Set(); private readonly _onDepsSatisfied = new Set<() => void>(); private readonly _disposers: Unsubscribe[] = []; private _depsSatisfied: boolean; @@ -46,6 +61,7 @@ export class Registry { 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)), ); const missing = this.missingDependencies(); @@ -96,6 +112,23 @@ export class Registry { }; } + /** + * 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(): IncompatiblePeer[] { + return this._discovery.incompatiblePeers; + } + + /** Notified the first time an unreachable addon is heard. Returns an unsubscribe function. */ + onIncompatible(listener: IncompatibleListener): Unsubscribe { + this._onIncompatible.add(listener); + + return (): void => { + this._onIncompatible.delete(listener); + }; + } + /** Notified when another addon shares our namespace. Returns an unsubscribe function. */ onNamespaceCollision(listener: CollisionListener): Unsubscribe { this._onCollision.add(listener); @@ -149,6 +182,15 @@ export class Registry { this.evaluateDependencies(); } + 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._onIncompatible) { listener(peer); } + } + private handleCollision(info: CollisionInfo): void { console.error(`[bedrock-core] collision: another instance shares identity '${info.id}'`); diff --git a/packages/sync/bench/pipeline.ts b/packages/sync/bench/pipeline.ts index 809728b..4d53dcd 100644 --- a/packages/sync/bench/pipeline.ts +++ b/packages/sync/bench/pipeline.ts @@ -8,7 +8,7 @@ * what crosses the wire. */ import { Reassembler, splitIntoFrames } from '../src/chunk'; -import { PROTOCOL_VERSION } from '../src/constants'; +import { PROTOCOL_MAX } from '../src/constants'; import { type Envelope, decodeEnvelope, encodeEnvelope } from '../src/envelope'; import { batchLength, decodeWire, encodeBatch, tagChunk } from '../src/wire'; @@ -21,7 +21,7 @@ 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_VERSION, + v: PROTOCOL_MAX, src: 'benchmark', iid: INSTANCE_ID, type: 'state-delta', diff --git a/packages/sync/src/bus.ts b/packages/sync/src/bus.ts index 71fd46c..0487532 100644 --- a/packages/sync/src/bus.ts +++ b/packages/sync/src/bus.ts @@ -4,17 +4,31 @@ * Responsibilities: * - 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, splitIntoFrames } from './chunk'; -import { BUS_CHANNEL, BUS_NAMESPACE, MAX_MESSAGE, PROTOCOL_VERSION } from './constants'; +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, tagChunk } from './wire'; +import { decodeWire, encodeLegacy, tagChunk } from './wire'; // eslint-disable-next-line @typescript-eslint/no-explicit-any export type Unsubscribe = (...args: any[]) => void; @@ -23,6 +37,12 @@ const EVICT_INTERVAL_TICKS = 20; export type EnvelopeHandler = (envelope: Envelope) => void; +/** What a peer negotiated to, as pushed in by discovery. */ +interface PeerProtocol { + protocol: number; + caps: readonly string[]; +} + export interface SendOptions { /** Target addon id; omit to broadcast. */ @@ -32,6 +52,13 @@ 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; } export interface BusOptions { @@ -48,6 +75,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; @@ -57,6 +86,7 @@ export class Bus { 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, maxMessage: this._maxMessage }); + this.recomputeBroadcast(); } get selfId(): string { @@ -72,6 +102,25 @@ export class Bus { 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; } @@ -101,8 +150,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, @@ -126,6 +176,16 @@ export class Bus { 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. @@ -165,6 +225,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}`; } diff --git a/packages/sync/src/constants.ts b/packages/sync/src/constants.ts index d6d583b..6c41f7a 100644 --- a/packages/sync/src/constants.ts +++ b/packages/sync/src/constants.ts @@ -1,11 +1,54 @@ /** Protocol-wide constants shared by every layer. */ -/** Bumped on any breaking change to the envelope or frame wire format. */ -export const PROTOCOL_VERSION = 2; +/** + * 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; + +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 and why a one-piece message no longer travels inside a frame. + * + * 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 = { diff --git a/packages/sync/src/discovery.ts b/packages/sync/src/discovery.ts index cb7b349..5964a78 100644 --- a/packages/sync/src/discovery.ts +++ b/packages/sync/src/discovery.ts @@ -9,9 +9,22 @@ * 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 type { Bus, Unsubscribe } from './bus'; import type { Envelope } from './envelope'; @@ -23,6 +36,15 @@ 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; @@ -36,10 +58,43 @@ 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; + + /** Tick this node was last heard from. */ + lastSeen: 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[]; } export interface DiscoveryOptions { @@ -54,6 +109,7 @@ export interface DiscoveryOptions { export type PeerListener = (peer: PeerInfo) => void; export type CollisionListener = (info: CollisionInfo) => void; +export type IncompatibleListener = (peer: IncompatiblePeer) => void; export class Discovery { private readonly _bus: Bus; @@ -61,9 +117,11 @@ export class Discovery { private readonly _announceIntervalTicks: number; private readonly _peerTtlTicks: number; private readonly _peers = new Map(); + private readonly _incompatible = new Map(); 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,6 +131,9 @@ 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; @@ -83,6 +144,11 @@ export class Discovery { return Array.from(this._peers.values()); } + /** Live nodes whose protocol range does not overlap this build's, so they cannot be talked to. */ + get incompatiblePeers(): IncompatiblePeer[] { + return Array.from(this._incompatible.values()); + } + /** Wire up handlers, announce + whois immediately, then start the heartbeat/sweep loops. */ start(): void { this._disposers.push( @@ -105,14 +171,21 @@ 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 { @@ -146,6 +219,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,24 +244,55 @@ 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._incompatible.delete(envelope.src); this._peers.set(envelope.src, peer); + this._bus.setPeerProtocol(peer.id, peer.protocol, peer.caps); if (!existing) { for (const listener of this._onUp) { listener(peer); } } } + 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, + lastSeen: system.currentTick, + }; + const first = !this._incompatible.has(id); + + this._incompatible.set(id, entry); + + if (first) { + for (const listener of this._onIncompatible) { listener(entry); } + } + } + 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 }); } @@ -186,10 +302,15 @@ export class Discovery { for (const [id, peer] of this._peers) { if (peer.lastSeen < cutoff) { this._peers.delete(id); + this._bus.forgetPeer(id); for (const listener of this._onDown) { listener(peer); } } } + + for (const [id, peer] of this._incompatible) { + if (peer.lastSeen < cutoff) { this._incompatible.delete(id); } + } } private parseAnnounce(data: unknown): AnnounceData | undefined { @@ -203,6 +324,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 381cb21..5eea050 100644 --- a/packages/sync/src/envelope.ts +++ b/packages/sync/src/envelope.ts @@ -1,12 +1,17 @@ /** - * 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'; 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,8 +42,13 @@ export function encodeEnvelope(envelope: Envelope): string { } /** - * Structural check for a parsed envelope, including its protocol version. Exported because a - * batched message arrives as an array of already-parsed objects rather than as JSON text. + * 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. */ export function isEnvelope(value: unknown): value is Envelope { if (typeof value !== 'object' || value === null) { return false; } @@ -49,7 +59,9 @@ export 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' @@ -60,8 +72,8 @@ export 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 mismatched protocol version — callers ignore those rather than throwing, - * so one bad sender can never crash a listener. + * 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/index.ts b/packages/sync/src/index.ts index 4d617c6..6dcc33b 100644 --- a/packages/sync/src/index.ts +++ b/packages/sync/src/index.ts @@ -28,6 +28,8 @@ export type { CollisionInfo, CollisionListener, DiscoveryOptions, + IncompatibleListener, + IncompatiblePeer, PeerInfo, PeerListener, } from './discovery'; @@ -40,4 +42,5 @@ export type { SnapshotEntry, StateChange, StateChangeListener, StateKey, StateOp export type { Unsubscribe } from './bus'; export type { Envelope } from './envelope'; -export { MAX_MESSAGE, 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/queue.ts b/packages/sync/src/queue.ts index 5cef702..30e634e 100644 --- a/packages/sync/src/queue.ts +++ b/packages/sync/src/queue.ts @@ -11,6 +11,9 @@ * 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. */ @@ -42,6 +45,7 @@ export class OutboundQueue { private readonly _flushIntervalTicks: number; private readonly _maxMessage: number; private readonly _pending: Pending[] = []; + private _packing = true; private _handle: number | undefined; private _dropped = 0; @@ -62,6 +66,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; } @@ -121,6 +136,8 @@ export class OutboundQueue { parts.push(next.text); this._pending.shift(); + + if (!this._packing) { break; } } return encodeBatch(parts); diff --git a/packages/sync/src/wire.ts b/packages/sync/src/wire.ts index 266b8b6..b9ef796 100644 --- a/packages/sync/src/wire.ts +++ b/packages/sync/src/wire.ts @@ -19,10 +19,18 @@ * 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. * - * A node speaking the previous protocol emits messages starting with `{`, which matches no tag and - * is discarded by {@link decodeWire} — a version mismatch goes quiet rather than wrong. + * ## 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 } from './chunk'; +import { type Frame, decodeFrame, splitIntoFrames } from './chunk'; import { WireTag } from './constants'; import { type Envelope, isEnvelope } from './envelope'; @@ -61,6 +69,18 @@ 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 @@ -92,6 +112,14 @@ export function decodeWire(message: string): WireMessage | undefined { 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; } 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/wire.spec.ts b/packages/sync/test/wire.spec.ts index 3afa8f9..3055ab7 100644 --- a/packages/sync/test/wire.spec.ts +++ b/packages/sync/test/wire.spec.ts @@ -4,18 +4,18 @@ * 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` must be incurious about input - * it does not recognise, since the same channel carries traffic from nodes on other protocol - * versions. + * 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 { encodeFrame } from '../src/chunk'; -import { PROTOCOL_VERSION, WireTag } from '../src/constants'; -import { type Envelope, encodeEnvelope } from '../src/envelope'; -import { batchLength, decodeWire, encodeBatch, tagChunk } from '../src/wire'; +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_VERSION, src: 'test', iid: 'iid-1', type: 'state-delta', mid, data }; + return { v: PROTOCOL_MAX, src: 'test', iid: 'iid-1', type: 'state-delta', mid, data }; } const ONE = envelope('a/1'); @@ -71,15 +71,76 @@ describe('decodeWire', () => { }); it.each([ - ['a message from a node on the previous protocol', encodeEnvelope(ONE)], + ['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 a mismatched protocol version', `${WireTag.Envelope}{"v":99,"src":"x","iid":"i","type":"t","mid":"m"}`], + ['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); + }); +}); From 71e31051e316c31cd5eaf3e0a581f7bee7e2442b Mon Sep 17 00:00:00 2001 From: DrAv0011 Date: Thu, 27 Aug 2026 14:39:03 +0200 Subject: [PATCH 12/71] feat(test-addon-2): crafting table container screen A compiled screen served over drav0011_shop:crafting_table, spawned when a crafting table is placed, so two addons' screens share one world alongside the ui demo's furnace. The ui-compile filter joins the profiles. Co-Authored-By: Claude Fable 5 --- packages/test-addon-2/config.json | 10 + packages/test-addon-2/package.json | 2 + .../packs/BP/entities/crafting_table.json | 61 ++++++ .../packs/BP/scripts/container/crafting.ts | 35 ++++ .../test-addon-2/packs/BP/scripts/main.ts | 3 + .../scripts/screens/crafting_table.screen.tsx | 190 ++++++++++++++++++ 6 files changed, 301 insertions(+) create mode 100644 packages/test-addon-2/packs/BP/entities/crafting_table.json create mode 100644 packages/test-addon-2/packs/BP/scripts/container/crafting.ts create mode 100644 packages/test-addon-2/packs/BP/scripts/screens/crafting_table.screen.tsx diff --git a/packages/test-addon-2/config.json b/packages/test-addon-2/config.json index a20a0d0..e003202 100644 --- a/packages/test-addon-2/config.json +++ b/packages/test-addon-2/config.json @@ -25,6 +25,10 @@ "manifest": { "runWith": "nodejs", "script": "../../../regolith-filters/manifest/main.js" + }, + "ui-compile": { + "runWith": "nodejs", + "script": "../../../regolith-filters/ui-compile/main.js" } }, "formatVersion": "1.4.0", @@ -48,6 +52,9 @@ { "filter": "i18n" }, + { + "filter": "ui-compile" + }, { "filter": "bundler", "settings": { @@ -77,6 +84,9 @@ { "filter": "i18n" }, + { + "filter": "ui-compile" + }, { "filter": "bundler", "settings": { diff --git a/packages/test-addon-2/package.json b/packages/test-addon-2/package.json index e3d307a..1ee0fbc 100644 --- a/packages/test-addon-2/package.json +++ b/packages/test-addon-2/package.json @@ -14,9 +14,11 @@ "dependencies": { "@bedrock-core/config": "*", "@bedrock-core/i18n": "*", + "@bedrock-core/ore-styled": "*", "@bedrock-core/server-runtime": "workspace:^", "@bedrock-core/sync": "workspace:^", "@bedrock-core/ui": "^0.9.1", + "@bedrock-core/ui-compile": "*", "@minecraft/server": "2.8.0", "@minecraft/server-ui": "2.1.0", "typescript": "^6.0.3" diff --git a/packages/test-addon-2/packs/BP/entities/crafting_table.json b/packages/test-addon-2/packs/BP/entities/crafting_table.json new file mode 100644 index 0000000..6c6c9ff --- /dev/null +++ b/packages/test-addon-2/packs/BP/entities/crafting_table.json @@ -0,0 +1,61 @@ +{ + "format_version": "1.21.0", + "minecraft:entity": { + "description": { + "identifier": "drav0011_shop:crafting_table", + "is_spawnable": false, + "is_summonable": true, + "is_experimental": false + }, + "components": { + // Sized by the ui-compile filter from the screen that names this entity: + // the drawn slots plus the live channels behind them. Hoppers must not + // reach those channels, so nothing may siphon from it. + "minecraft:inventory": { + "container_type": "container", + "private": false, + "can_be_siphoned_from": false + }, + "minecraft:physics": { + "has_gravity": false, + "has_collision": false + }, + "minecraft:pushable": { + "is_pushable": false, + "is_pushable_by_piston": false + }, + "minecraft:knockback_resistance": { + "value": 1 + }, + "minecraft:damage_sensor": { + "triggers": [ + { + "cause": "all", + "deals_damage": "no" + } + ] + }, + "minecraft:health": { + "value": 1024, + "max": 1024 + }, + "minecraft:collision_box": { + "width": 0.8, + "height": 1.0 + }, + "minecraft:fire_immune": {}, + "minecraft:persistent": {}, + "minecraft:type_family": { + "family": [ + "drav0011_shop_crafting_table", + "inanimate" + ] + }, + "minecraft:nameable": { + "allow_name_tag_renaming": false, + "always_show": true + }, + "minecraft:conditional_bandwidth_optimization": {} + } + } +} diff --git a/packages/test-addon-2/packs/BP/scripts/container/crafting.ts b/packages/test-addon-2/packs/BP/scripts/container/crafting.ts new file mode 100644 index 0000000..9865b1c --- /dev/null +++ b/packages/test-addon-2/packs/BP/scripts/container/crafting.ts @@ -0,0 +1,35 @@ +import { world } from '@minecraft/server'; +import { createContainerScreen } from '@bedrock-core/ui/container'; +import CraftingTable from '../screens/crafting_table.screen'; + +/** + * Serving the Shop's compiled screen. The screen module describes itself; the + * build compiled the same module into JSON UI, and the runtime runs it again + * per viewer for the live values. + */ +const screen = createContainerScreen(CraftingTable); + +/** + * Nothing opens a container from script, so the screen is bound to something + * in the world: placing a vanilla crafting table puts the Shop's own on top of + * it, one per world, and interacting with that entity opens the screen. + */ +export const setupCrafting = (): void => { + world.afterEvents.playerPlaceBlock.subscribe(({ block }) => { + if (block.typeId !== 'minecraft:crafting_table') { + return; + } + + const { dimension } = block; + + for (const existing of dimension.getEntities({ type: screen.entity })) { + existing.remove(); + } + + dimension.spawnEntity(screen.entity, { + x: block.x + 0.5, + y: block.y + 1, + z: block.z + 0.5, + }); + }); +}; diff --git a/packages/test-addon-2/packs/BP/scripts/main.ts b/packages/test-addon-2/packs/BP/scripts/main.ts index bbf6582..34a11e1 100644 --- a/packages/test-addon-2/packs/BP/scripts/main.ts +++ b/packages/test-addon-2/packs/BP/scripts/main.ts @@ -8,6 +8,7 @@ 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 { setupCrafting } from './container/crafting'; import { configDef, setupShop } from './example'; // The addon's typed verbs over its resources (packs/data/i18n). Creating the instance @@ -36,6 +37,8 @@ core.register({ }); setupShop(); +// The Shop's crafting table: a compiled container screen, served over an entity. +setupCrafting(); // 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/BP/scripts/screens/crafting_table.screen.tsx b/packages/test-addon-2/packs/BP/scripts/screens/crafting_table.screen.tsx new file mode 100644 index 0000000..0f9639a --- /dev/null +++ b/packages/test-addon-2/packs/BP/scripts/screens/crafting_table.screen.tsx @@ -0,0 +1,190 @@ +import { type Container as ItemContainer, type Entity, EntityComponentTypes, ItemStack } from '@minecraft/server'; +import { Button, Card } from '@bedrock-core/ore-styled'; +import type { JSX } from '@bedrock-core/ui'; +import { GUARD_ITEM } from '@bedrock-core/ui/container'; +import { + Container, + Hotbar, + Panel, + PlayerInventory, + Slot, + Text, + useExit, + useState, +} from '@bedrock-core/ui'; + +/** + * Container slots the screen draws, in tree order: the sentinel takes 0 and + * 1, the nine inputs follow it row by row, the craft button takes 11 — a + * button is a drawn cell too — and the output is 12. Handlers reach the + * result slot by this index, since machinery fills an output by writing over + * its guard. + */ +const INPUTS = [2, 3, 4, 5, 6, 7, 8, 9, 10] as const; +const OUTPUT = 12; + +/** Four planks anywhere on the grid make a table; the recipe is the point, not the crafting. */ +const PLANKS = 'minecraft:oak_planks'; +const RESULT = 'minecraft:crafting_table'; +const COST = 4; + +/** + * The Shop's crafting table: a second compiled screen from a second addon, so + * a world carrying the ui demo pack's furnace and this table exercises two + * addons' routers on the one chest root. + */ +export default function CraftingTable(): JSX.Element { + const [planks, setPlanks] = useState(0); + const [crafted, setCrafted] = useState(0); + const exit = useExit(); + + return ( + setPlanks(countPlanks(host))} + > + + + {'§fSHOP CRAFTING'} + + {`planks ${planks}`} + + + + {/* Nine inputs, row by row: planks go in, and only a craft takes them out. */} + + {[0, 1, 2].map(() => ( + + {[0, 1, 2].map(() => ( + + setPlanks(countPlanks(host))} + onRemove={(_player, _stack, host) => setPlanks(countPlanks(host))} + /> + + ))} + + ))} + + + + + {/* The result: taken out, never put in. */} + + setCrafted(0)} /> + + + {`made ${crafted}`} + + +