openvibers/openvibe.games Commits

Merge games-content@2 pack definitions into content at server boot (#3)

OP OpenVibers committed 4 Oct 2026 · 545f6c1dcc · 21 files, +718 −55

local checks ✓ 7/7 · CI ✓ 3/3 · review SHIP by DeepSeek harness · 1 fix round

https://github.com/OpenVibers/OpenVibe.Games/pull/3

Changes

ddiff --git a/README.md b/README.md
iindex 443abd5..2f0f385 100644
---- a/README.md
++++ b/README.md
@@@ -17,7 +17,7 @@ WebSocket protocol · PostgreSQL 18 + Valkey persistence (openvibe-sdk) · pnpm
- the game: the authoritative simulation, the world, characters, inventories, skills, blueprints and
props, in PostgreSQL (ADR-0007 decision 5: schema from scratch, migrations under
`packages/persistence/migrations`, a write-behind flusher — the tick never awaits I/O)
--- the map editor and its assets, the mod registry (`games-content@1` packs, grants, audit) and the
++- the map editor and its assets, the mod registry (`games-content@2` packs, grants, audit) and the
`games.*` events
- the game WebSocket protocol and the portal, `/play` and `/editor` pages
@@@ -183,9 +183,11 @@ do not know the platform exists. See [ADR-0006](docs/adr/0006-canonical-subjects
- **Mods** (ADR-013 in OpenVibe.Contracts). Manifests follow
`[email protected]` (Games validates against the schema in the
installed `openvibe-contracts` package — pin drift is a defect, ADR-0007
-- decision 12 — currently v0.77.0). The only runtime today is `games-content@1`:
++ decision 12 — currently v0.77.0). The only runtime today is `games-content@2`:
declarative data packs checked against `@openvibe/content` (announcements;
-- inert, mod-owned props). Each install stores the approved subset of its
++ inert, mod-owned props; definitions — items, recipes, crops, NPC archetypes —
++ merged into the content registry at boot, so they take effect on the next
++ restart, and only on the server until the def-set handshake lands). Each install stores the approved subset of its
requested capabilities; every runtime binding checks it at call time, a
revoked install or capability stops affecting the world on the next tick,
and install/grant/use/deny/revoke are audited (`mod_audit`). Trust tiers are
@@@ -223,8 +225,8 @@ Platform environment (all optional; unset = off):
## Capabilities
Implemented here (the service manifest's `capabilities`): `games.mod.manage` and `games.mod.read` (the
--mods API, for a principal token or an owner/admin session), and `games.world.announce` and
--`games.prop.place`, the capabilities a mod install may be granted and every runtime binding checks at
++mods API, for a principal token or an owner/admin session), and `games.world.announce`,
++`games.prop.place` and `games.def.define`, the capabilities a mod install may be granted and every runtime binding checks at
call time.
Grants the `games` principal needs in OpenVibe.Network:
ddiff --git a/STATUS.json b/STATUS.json
iindex 1b62050..0bef2d6 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -11,7 +11,8 @@
"games.mod.manage",
"games.mod.read",
"games.world.announce",
-- "games.prop.place"
++ "games.prop.place",
++ "games.def.define"
],
"deployed": true,
"deployment": "Public at https://openvibe.games and https://play.openvibe.games: deployed with `sudo ovhost deploy games` (OpenVibe.Host strategy pnpm-build since the 2026-09-27 cutover; deploy/scripts/deploy.sh is the wrapper), unit openvibe-games on 127.0.0.1:8000, env /etc/openvibe/games.env, principal games.",
@@@ -20,7 +21,7 @@
"Source-style movement, physgun building, gathering, crafting, skills",
"persistent world, characters and props in PostgreSQL (write-behind flusher; the tick never awaits I/O)",
"map editor with assets mirrored to OpenVibe.Media",
-- "declarative mods (games-content@1) with per-install grants and audit",
++ "declarative mods (games-content@2: definitions merged into the content registry at boot, placements, announcements) with per-install grants and audit",
"players keyed by Network subjects",
"games.* events through a transactional outbox",
"games.progress.summary user module",
ddiff --git a/apps/server/scripts/modLifecycleProof.ts b/apps/server/scripts/modLifecycleProof.ts
iindex 32a8ac9..bd6184d 100644
---- a/apps/server/scripts/modLifecycleProof.ts
++++ b/apps/server/scripts/modLifecycleProof.ts
@@@ -82,10 +82,10 @@ async function main(): Promise<void> {
// A publisher is a user or an app; the proof publishes as a synthetic app of its own.
publisher: { type: 'app', id: `app_${ulid()}` },
target: 'games.browser',
-- runtime: 'games-content@1',
++ runtime: 'games-content@2',
permissions: { capabilities: [CAP] },
resources: { cpuMs: 1, memoryMb: 0, storageMb: 0 },
-- compatibility: { runtime: '>=1.0.0 <2.0.0' },
++ compatibility: { runtime: '>=2.0.0 <3.0.0' },
}
const pack = { props: [{ key: 'proof-bench', item: 'workbench', pos: [3900, 0, 3900] }] }
ddiff --git a/apps/server/scripts/sliceTest.ts b/apps/server/scripts/sliceTest.ts
iindex 07af1d4..dd33363 100644
---- a/apps/server/scripts/sliceTest.ts
++++ b/apps/server/scripts/sliceTest.ts
@@@ -1773,7 +1773,15 @@ async function main(): Promise<void> {
`extraction recalled the player to the city (at ${me.pos[0].toFixed(1)},${me.pos[2].toFixed(1)})`,
)
const coreStack = a.inventory?.slots.find((s) => s.stack.def === 'salvage_core')
-- assert(coreStack?.stack.meta?.secured === 1, 'looted valuables are SECURED after extraction')
++ // A death or rescue respawn also lands at the spawn point, so say which one happened.
++ const lastAnnounces = a.announces
++ .slice(-6)
++ .map((n) => n.text)
++ .join(' | ')
++ assert(
++ coreStack?.stack.meta?.secured === 1,
++ `looted valuables are SECURED after extraction (core stack ${JSON.stringify(coreStack?.stack ?? null)}; recent announces: ${lastAnnounces})`,
++ )
}
console.log('phase: persistence across restart (props, frozen state, skills, friends, depletion)')
ddiff --git a/apps/server/src/main.ts b/apps/server/src/main.ts
iindex 6bbdd39..e25339f 100644
---- a/apps/server/src/main.ts
++++ b/apps/server/src/main.ts
@@@ -28,6 +28,7 @@ import { ServerMetrics } from './observability/metrics.js'
import { createReadiness } from './observability/readiness.js'
import { buildRelease, releaseHandler } from './observability/release.js'
import { sharedAssetsHandler } from './net/sharedAssets.js'
++import { loadDefinitionPacks } from './mods/contentPack.js'
import { ModRegistry } from './mods/registry.js'
import { createNetworkModGrants } from './mods/networkGrants.js'
import { createGamesActorLimits } from './net/actorLimits.js'
@@@ -54,8 +55,8 @@ import { createWsTicketStore } from './net/wsTicket.js'
/**
* Dedicated authoritative server entry point.
-- * Boot order: config -> content validation (fail fast) -> physics -> database + migrations ->
-- * persistence -> world restore -> network -> fixed-tick loop.
++ * Boot order: config -> database + migrations -> persistence -> content validation with enabled
++ * packs (fail fast) -> physics -> world restore -> network -> fixed-tick loop.
*/
async function main(): Promise<void> {
const log = createConsoleLogger(
@@@ -65,8 +66,18 @@ async function main(): Promise<void> {
const config = loadConfig(process.env)
log.info('starting', { port: config.port, place: config.placeId })
-- // Content validates at construction — invalid definitions kill the boot.
-- const content = createContent()
++ // PostgreSQL (or embedded PGlite in development), migrated before anything writes. It opens
++ // before content because enabled mod packs add definitions to the content registry.
++ const db = await openDb(config.db, log)
++ const store = openPgStore(db, { placeId: config.placeId })
++ await store.ensurePlace()
++
++ // Content validates at construction — invalid definitions kill the boot. Packs are the enabled
++ // installs granted games.def.define; one that no longer builds is skipped and logged.
++ const packs = await loadDefinitionPacks(store.mods, (modId, reason) =>
++ log.warn('mod definitions skipped', { mod: modId, reason }),
++ )
++ const content = createContent(packs)
// Edited map (from /editor): heightfield + extra statics replace the
// procedural terrain for EVERYTHING (physics, spawns, clients fetch the
// same file over /map.json).
@@@ -96,15 +107,11 @@ async function main(): Promise<void> {
items: content.allItems().length,
recipes: content.allRecipes().length,
world: content.world.id,
++ packs: packs.length,
})
const physics = createRapierWorld(await loadRapier())
-- // PostgreSQL (or embedded PGlite in development), migrated before anything writes.
-- const db = await openDb(config.db, log)
-- const store = openPgStore(db, { placeId: config.placeId })
-- await store.ensurePlace()
--
const metrics = new ServerMetrics()
const world = new GameWorld(content, physics, log.child({ system: 'world' }))
await world.seedOrRestore(store)
ddiff --git a/apps/server/src/mods/contentPack.test.ts b/apps/server/src/mods/contentPack.test.ts
nnew file mode 100644
iindex 0000000..7a8205a
---- /dev/null
++++ b/apps/server/src/mods/contentPack.test.ts
@@@ -0,0 +1,110 @@
++import { createContent, ITEMS } from '@openvibe/content'
++import { openTestStore } from '@openvibe/persistence/testing'
++import { describe, expect, it } from 'vitest'
++import {
++ CAP_DEFINE,
++ capabilitiesUsedBy,
++ loadDefinitionPacks,
++ validateContentPack,
++} from './contentPack.js'
++import { ModError, ModRegistry, type ModActor } from './registry.js'
++import { sampleManifest } from './testFixtures.js'
++
++/**
++ * games-content@2 definitions: the install gate (games.def.define) and the boot loader that merges the
++ * enabled, granted packs into the content registry before the world is built.
++ */
++const STAFF: ModActor = {
++ audit: 'usr_01JABCDEFGHJKMNPQRSTVWXYZ0',
++ subject: { type: 'user', id: 'usr_01JABCDEFGHJKMNPQRSTVWXYZ0' },
++}
++const A = 'mod_01JABCDEFGHJKMNPQRSTVWXYZA'
++const B = 'mod_01JABCDEFGHJKMNPQRSTVWXYZB'
++const plain = ITEMS.find(
++ (i) => !i.health && !i.container && !i.machine && !i.shop && !i.seed && !i.blueprint,
++)!
++const itemPack = (id: string) => ({ defs: { items: [{ ...plain, id }] } })
++const manifestFor = (id: string, capabilities: string[]) =>
++ sampleManifest({ id, permissions: { capabilities } })
++
++async function setup() {
++ const store = await openTestStore()
++ const registry = new ModRegistry(store, createContent())
++ const install = (
++ id: string,
++ pack: unknown,
++ capabilities: string[],
++ approve: string[],
++ enable = true,
++ ) => registry.install({ manifest: manifestFor(id, capabilities), pack, approve, enable }, STAFF)
++ return { store, registry, install }
++}
++
++describe('games-content@2 definitions', () => {
++ it('needs games.def.define for any non-empty defs section', () => {
++ expect(capabilitiesUsedBy(itemPack('widget'))).toEqual([CAP_DEFINE])
++ expect(capabilitiesUsedBy({ defs: { items: [] } })).toEqual([])
++ expect(capabilitiesUsedBy({})).toEqual([])
++ })
++
++ it('refuses a defs pack whose manifest does not request games.def.define, accepts it when it does', async () => {
++ const { registry, install } = await setup()
++ const refused = await install(A, itemPack('widget'), ['games.world.announce'], []).catch(
++ (e) => e,
++ )
++ expect(refused).toBeInstanceOf(ModError)
++ expect((refused as ModError).errors).toContain(`pack needs ${CAP_DEFINE}`)
++ await install(A, itemPack('widget'), [CAP_DEFINE], [])
++ expect(registry.get(A)).not.toBeNull()
++ })
++
++ it('validates definitions at install: a base id, a bad def and an unknown section are refused', async () => {
++ const content = createContent()
++ expect(validateContentPack(itemPack('widget'), content).ok).toBe(true)
++ expect(validateContentPack({ defs: { items: [{ ...plain }] } }, content).ok).toBe(false)
++ expect(validateContentPack({ defs: { items: [{ id: 'Bad Id' }] } }, content).ok).toBe(false)
++ expect(validateContentPack({ defs: { shaders: [] } }, content).ok).toBe(false)
++ // an install is checked against the content the server is running, which already has loaded packs
++ expect(validateContentPack(itemPack('widget'), createContent([itemPack('widget')])).ok).toBe(
++ false,
++ )
++ })
++
++ it('boots with one enabled v2 pack: its definitions exist in the content registry', async () => {
++ const { store, install } = await setup()
++ await install(A, itemPack('widget'), [CAP_DEFINE], [CAP_DEFINE])
++ const skipped: string[] = []
++ const packs = await loadDefinitionPacks(store.mods, (id) => skipped.push(id))
++ expect(packs).toHaveLength(1)
++ expect(skipped).toEqual([])
++ expect(createContent(packs).item('widget')).toMatchObject({ id: 'widget' })
++ expect(createContent().item('widget')).toBeUndefined()
++ })
++
++ it('does not load a pack that is disabled, not granted, or whose grant was revoked', async () => {
++ const { store, registry, install } = await setup()
++ await install(A, itemPack('off'), [CAP_DEFINE], [CAP_DEFINE], false)
++ await install(B, itemPack('ungranted'), [CAP_DEFINE], [])
++ expect(await loadDefinitionPacks(store.mods)).toEqual([])
++ const C = 'mod_01JABCDEFGHJKMNPQRSTVWXYZC'
++ await install(C, itemPack('revoked'), [CAP_DEFINE], [CAP_DEFINE])
++ expect(await loadDefinitionPacks(store.mods)).toHaveLength(1)
++ await registry.revokeGrant(C, CAP_DEFINE, STAFF)
++ expect(await loadDefinitionPacks(store.mods)).toEqual([])
++ })
++
++ it('skips a pack that no longer builds instead of failing the boot', async () => {
++ const { store, install } = await setup()
++ // each is valid alone, so both install; together the second collides with the first
++ await install(A, itemPack('same_id'), [CAP_DEFINE], [CAP_DEFINE])
++ await install(B, itemPack('same_id'), [CAP_DEFINE], [CAP_DEFINE])
++ const skipped: string[] = []
++ const packs = await loadDefinitionPacks(store.mods, (id, reason) => {
++ skipped.push(id)
++ expect(reason).toMatch(/already exists/)
++ })
++ expect(packs).toHaveLength(1)
++ expect(skipped).toHaveLength(1)
++ expect(createContent(packs).item('same_id')).toBeDefined()
++ })
++})
ddiff --git a/apps/server/src/mods/contentPack.ts b/apps/server/src/mods/contentPack.ts
iindex b4cb1c9..7973c50 100644
---- a/apps/server/src/mods/contentPack.ts
++++ b/apps/server/src/mods/contentPack.ts
@@@ -1,26 +1,45 @@
/**
-- * `games-content@1`: the first mod kind — a declarative data pack, checked
-- * against the live `@openvibe/content` registry. It adds no code and no new
-- * definitions (the client ships the same registry, so a pack can only use
-- * what both sides already know). Each section needs one capability:
++ * `games-content@2`: the first mod kind — a declarative data pack, checked
++ * against the live `@openvibe/content` registry. It adds no code. Each section
++ * needs one capability:
*
* announcements games.world.announce periodic server announcements
* props games.prop.place inert props placed in the world
++ * defs games.def.define items, recipes, crops and npc archetypes
++ * merged into the content registry
++ *
++ * Definitions are validated here with the schemas the base content uses and
++ * merged by `createContent(packs)` when the server boots, so a new pack's defs
++ * exist after the next restart (the registry is immutable). The client does
++ * not load packs yet: until the def-set handshake (M2.2) a pack's items render
++ * server-side only.
*
* Mod props are owned by the mod, so prop protection keeps players from
* picking them up, moving or welding them, and only items without health,
* storage, shops, machines or vehicle parts may be placed: nothing a player
* could destroy for loot or use to move items into or out of the world.
*/
--import type { ContentRegistry } from '@openvibe/content'
++import {
++ createContent,
++ MAX_PACK_DEFS,
++ mergePackDefs,
++ type ContentPackV2,
++ type ContentRegistry,
++} from '@openvibe/content'
++import type { ModRepository } from '@openvibe/persistence'
import { Ajv2020 } from 'ajv/dist/2020.js'
import type { Validation } from './manifest.js'
export const CAP_ANNOUNCE = 'games.world.announce'
export const CAP_PLACE_PROP = 'games.prop.place'
++export const CAP_DEFINE = 'games.def.define'
--/** Capabilities the games-content@1 runtime can bind. Anything else is never granted here. */
--export const CONTENT_RUNTIME_CAPABILITIES: readonly string[] = [CAP_ANNOUNCE, CAP_PLACE_PROP]
++/** Capabilities the games-content@2 runtime can bind. Anything else is never granted here. */
++export const CONTENT_RUNTIME_CAPABILITIES: readonly string[] = [
++ CAP_ANNOUNCE,
++ CAP_PLACE_PROP,
++ CAP_DEFINE,
++]
export interface ContentAnnouncement {
text: string
@@@ -35,17 +54,29 @@ export interface ContentProp {
}
export interface ContentPack {
++ /** Definitions, deep-validated by `mergePackDefs`; the schema only bounds them. */
++ defs?: NonNullable<ContentPackV2['defs']>
announcements?: ContentAnnouncement[]
props?: ContentProp[]
}
export const CONTENT_PACK_SCHEMA = {
$schema: 'https://json-schema.org/draft/2020-12/schema',
-- $id: 'https://openvibe.games/schemas/games-content-pack.v1.json',
++ $id: 'https://openvibe.games/schemas/games-content-pack.v2.json',
title: 'GamesContentPack',
type: 'object',
additionalProperties: false,
properties: {
++ defs: {
++ type: 'object',
++ additionalProperties: false,
++ properties: {
++ items: { type: 'array', maxItems: MAX_PACK_DEFS.items, items: { type: 'object' } },
++ recipes: { type: 'array', maxItems: MAX_PACK_DEFS.recipes, items: { type: 'object' } },
++ crops: { type: 'array', maxItems: MAX_PACK_DEFS.crops, items: { type: 'object' } },
++ npcs: { type: 'array', maxItems: MAX_PACK_DEFS.npcs, items: { type: 'object' } },
++ },
++ },
announcements: {
type: 'array',
maxItems: 10,
@@@ -88,6 +119,7 @@ const validateSchema = ajv.compile(CONTENT_PACK_SCHEMA)
/** Which capability each pack section needs. */
export function capabilitiesUsedBy(pack: ContentPack): string[] {
const used: string[] = []
++ if (Object.values(pack.defs ?? {}).some((list) => (list ?? []).length > 0)) used.push(CAP_DEFINE)
if ((pack.announcements ?? []).length > 0) used.push(CAP_ANNOUNCE)
if ((pack.props ?? []).length > 0) used.push(CAP_PLACE_PROP)
return used
@@@ -120,6 +152,20 @@ export function validateContentPack(
}
const pack = value as unknown as ContentPack
const errors: string[] = []
++ if (pack.defs) {
++ // The content the pack would be merged onto; its own ids may not already exist.
++ const merged = mergePackDefs(
++ {
++ items: [...content.allItems()],
++ recipes: [...content.allRecipes()],
++ crops: [...content.allCrops()],
++ npcs: [...content.allNpcs()],
++ factions: [...content.allFactions()],
++ },
++ [pack],
++ )
++ errors.push(...merged.errors)
++ }
const keys = new Set<string>()
for (const prop of pack.props ?? []) {
if (keys.has(prop.key)) errors.push(`duplicate prop key '${prop.key}'`)
@@@ -129,3 +175,33 @@ export function validateContentPack(
}
return errors.length > 0 ? { ok: false, errors } : { ok: true, value: pack }
}
++
++/**
++ * The packs whose definitions the server merges at boot: enabled installs
++ * with `games.def.define` actively granted. A pack is only taken if the
++ * content still builds with it, so one bad install (say two packs defining the
++ * same id, each valid when installed alone) is reported and skipped rather
++ * than keeping the server from starting.
++ */
++export async function loadDefinitionPacks(
++ mods: ModRepository,
++ onSkip: (modId: string, reason: string) => void = () => {},
++): Promise<ContentPackV2[]> {
++ const accepted: ContentPackV2[] = []
++ for (const mod of await mods.list()) {
++ if (mod.status !== 'enabled') continue
++ const pack = mod.pack as unknown as ContentPackV2
++ if (Object.values(pack.defs ?? {}).every((list) => (list ?? []).length === 0)) continue
++ const granted = (await mods.grants(mod.id)).some(
++ (g) => g.capability === CAP_DEFINE && g.revokedAt === null,
++ )
++ if (!granted) continue
++ try {
++ createContent([...accepted, pack])
++ accepted.push(pack)
++ } catch (err) {
++ onSkip(mod.id, err instanceof Error ? err.message : String(err))
++ }
++ }
++ return accepted
++}
ddiff --git a/apps/server/src/mods/manifest.test.ts b/apps/server/src/mods/manifest.test.ts
iindex 16c7816..0efd946 100644
---- a/apps/server/src/mods/manifest.test.ts
++++ b/apps/server/src/mods/manifest.test.ts
@@@ -152,10 +152,19 @@ describe('mod manifest schema', () => {
it('only admits declarative content packs in Games (executable mods wait for Host)', () => {
expect(checkForGames(sampleManifest())).toEqual([])
expect(checkForGames(sampleManifest({ runtime: 'source-quickjs@1' }))[0]).toMatch(
-- /only games-content@1/,
++ /only games-content@2/,
)
expect(checkForGames(sampleManifest({ target: 'games.source' }))).toHaveLength(1)
-- expect(checkForGames(sampleManifest({ compatibility: { runtime: '>=2.0.0' } }))).toHaveLength(1)
++ // games-content@1 manifests are refused: packs are v2 now.
++ expect(
++ checkForGames(
++ sampleManifest({
++ runtime: 'games-content@1',
++ compatibility: { runtime: '>=1.0.0 <2.0.0' },
++ }),
++ )[0],
++ ).toMatch(/runtime games-content@1 is not available here: only games-content@2/)
++ expect(checkForGames(sampleManifest({ compatibility: { runtime: '>=3.0.0' } }))).toHaveLength(1)
expect(
checkForGames(
sampleManifest({
@@@ -166,7 +175,7 @@ describe('mod manifest schema', () => {
})
})
--describe('games-content@1 packs', () => {
++describe('games-content@2 packs', () => {
const content = createContent()
it('accept existing, inert items and announcements', () => {
@@@ -180,6 +189,38 @@ describe('games-content@1 packs', () => {
expect(r.ok).toBe(true)
})
++ it('refuse definitions that collide, dangle or exceed the bounds', () => {
++ const item = (id: string, extra: Record<string, unknown> = {}) => ({
++ id,
++ name: id,
++ category: 'misc',
++ ...extra,
++ })
++ const first = content.allItems()[0]!
++ const reject = (pack: unknown) => validateContentPack(pack, content)
++ expect(reject({ defs: { items: [{ id: 5 }] } }).ok).toBe(false)
++ expect(reject({ defs: { items: [first] } }).ok).toBe(false)
++ expect(
++ reject({ defs: { items: Array.from({ length: 201 }, (_, i) => item(`x${i}`)) } }).ok,
++ ).toBe(false)
++ expect(
++ reject({
++ defs: {
++ recipes: [
++ {
++ id: 'r_dangling',
++ name: 'R',
++ category: 'misc',
++ inputs: [{ item: 'nope', count: 1 }],
++ outputs: [{ item: 'nope', count: 1 }],
++ },
++ ],
++ },
++ }).ok,
++ ).toBe(false)
++ expect(reject({ defs: { unknown: [] } }).ok).toBe(false)
++ })
++
it('refuse unknown items, loot-bearing or storage props, duplicates and bad shapes', () => {
const reject = (pack: unknown) => validateContentPack(pack, content).ok
expect(reject({ props: [{ key: 'x', item: 'no_such_item', pos: [0, 0, 0] }] })).toBe(false)
ddiff --git a/apps/server/src/mods/manifest.ts b/apps/server/src/mods/manifest.ts
iindex 738da37..efba5e0 100644
---- a/apps/server/src/mods/manifest.ts
++++ b/apps/server/src/mods/manifest.ts
@@@ -55,9 +55,9 @@ export function validateManifest(value: unknown): Validation<ModManifest> {
/** The browser game's only mod target and runtime today. */
export const GAMES_TARGET = 'games.browser'
--export const CONTENT_RUNTIME = 'games-content@1'
++export const CONTENT_RUNTIME = 'games-content@2'
/** Version of the content runtime, matched against compatibility.runtime. */
--export const CONTENT_RUNTIME_VERSION = '1.0.0'
++export const CONTENT_RUNTIME_VERSION = '2.0.0'
/**
* Whether this server can run the manifest at all. Executable runtimes
ddiff --git a/apps/server/src/mods/runtime.ts b/apps/server/src/mods/runtime.ts
iindex c287918..01fd6ef 100644
---- a/apps/server/src/mods/runtime.ts
++++ b/apps/server/src/mods/runtime.ts
@@@ -7,7 +7,7 @@
* was not granted — or one revoked a moment ago — throws before anything
* happens, so it cannot be reached from the mod side at all.
*
-- * `games-content@1` packs are interpreted here through that same API; an
++ * `games-content@2` packs are interpreted here through that same API; an
* executable runtime (a script sandbox) would receive the same bindings.
* Executable mods are not accepted yet: sandboxed execution with enforced
* CPU, memory, storage and network budgets belongs to OpenVibe.Host
ddiff --git a/apps/server/src/mods/testFixtures.ts b/apps/server/src/mods/testFixtures.ts
iindex a4bb6e6..5b2fea1 100644
---- a/apps/server/src/mods/testFixtures.ts
++++ b/apps/server/src/mods/testFixtures.ts
@@@ -11,10 +11,10 @@ export function sampleManifest(overrides: Partial<ModManifest> = {}): ModManifes
description: 'A public workbench and a greeting.',
publisher: { type: 'user', id: 'usr_01JABCDEFGHJKMNPQRSTVWXYZ0' },
target: 'games.browser',
-- runtime: 'games-content@1',
++ runtime: 'games-content@2',
permissions: { capabilities: ['games.world.announce', 'games.prop.place'] },
resources: { cpuMs: 1, memoryMb: 0, storageMb: 0 },
-- compatibility: { runtime: '>=1.0.0 <2.0.0' },
++ compatibility: { runtime: '>=2.0.0 <3.0.0' },
...overrides,
}
}
ddiff --git a/docs/adr/0007-rewrite-the-moddable-base.md b/docs/adr/0007-rewrite-the-moddable-base.md
iindex e2af54f..b4d3be1 100644
---- a/docs/adr/0007-rewrite-the-moddable-base.md
++++ b/docs/adr/0007-rewrite-the-moddable-base.md
@@@ -116,7 +116,7 @@ The Live legacy import (`platform/liveLegacyImport.ts`, `scripts/importLiveLegac
- **M1: same game, new foundation.** Deletions above; `gameServer.ts` split into systems; PostgreSQL + Valkey
persistence with the write-behind flusher; Rapier; intent input; authenticated upgrade; pins current.
- **M2: moddable content.** Pack loader, `games-content@2`, def-set handshake, Scraplandia as a pack, touch controls.
-- - **M2.1 pack format and loader (`games-content@2`).** A pack declares definitions (items, recipes, crops, NPCs, zones, maps), not only placements, and the runtime loader merges them into the content registry.
++ - **M2.1 pack format and loader (`games-content@2`) — done for items, recipes, crops and NPC archetypes (`packages/content/src/pack.ts`, `createContent(packs)`; the server merges enabled packs granted `games.def.define` at boot; the client still builds base content until M2.2; zones and maps are not pack defs yet).** A pack declares definitions (items, recipes, crops, NPCs, zones, maps), not only placements, and the runtime loader merges them into the content registry.
- **M2.2 def-set handshake.** Client and server agree on the loaded def set (and its content version) before play.
- **M2.3 Scraplandia as a pack.** Scraplandia (survival, crafting, farming, extraction) becomes the content pack the base loads by default, and its gameplay modules become systems the pack enables.
- **M2.4 touch controls.** Touch maps intents (move vector, look delta, named actions) to the same input stream as the keyboard.
ddiff --git a/docs/capabilities-proposal/games.def.define.json b/docs/capabilities-proposal/games.def.define.json
nnew file mode 100644
iindex 0000000..5f4c740
---- /dev/null
++++ b/docs/capabilities-proposal/games.def.define.json
@@@ -0,0 +1,15 @@
++{
++ "id": "games.def.define",
++ "version": "1.0",
++ "owner": "games",
++ "status": "active",
++ "visibility": "public",
++ "description": "Runtime binding for mods (games-content@2): add definitions (items, recipes, crops, npc archetypes) to the content registry. Definitions are validated by the schemas the base content uses, may reference only base ids and the pack's own ids, and may not replace an existing id. They are merged when the server boots, so a newly installed or enabled pack takes effect at the next restart; only enabled installs with this capability granted contribute.",
++ "permissions": ["games:content:define"],
++ "resourceConstraints": ["none"],
++ "quotaClass": "games-mod-runtime",
++ "events": [],
++ "implementedBy": [
++ "pack merge mergePackDefs / createContent(packs) (packages/content/src/pack.ts) and the boot loader loadDefinitionPacks (apps/server/src/mods/contentPack.ts)"
++ ]
++}
ddiff --git a/docs/service-manifest-proposal.json b/docs/service-manifest-proposal.json
iindex 45b7a0a..656190e 100644
---- a/docs/service-manifest-proposal.json
++++ b/docs/service-manifest-proposal.json
@@@ -14,7 +14,8 @@
"games.mod.manage",
"games.mod.read",
"games.world.announce",
-- "games.prop.place"
++ "games.prop.place",
++ "games.def.define"
],
"eventsProduced": [
"games.player.joined",
@@@ -31,5 +32,5 @@
],
"eventsConsumed": [],
"namespacesOwned": ["games.*"],
-- "notes": "Scraplandia (browser runtime). Players are keyed by canonical Network subjects (ADR-0006 in the repo). Calls Events (events.event.publish), Media (media.object.upload, namespace games: map-editor assets) and Network identity (identity.subject.resolve, batch legacy adoption) with the games client-credentials principal. Mods: games-content@1 declarative packs only; executable mods wait for OpenVibe.Host Stage C. No achievement system exists, so there are no games.achievement.* events."
++ "notes": "Scraplandia (browser runtime). Players are keyed by canonical Network subjects (ADR-0006 in the repo). Calls Events (events.event.publish), Media (media.object.upload, namespace games: map-editor assets) and Network identity (identity.subject.resolve, batch legacy adoption) with the games client-credentials principal. Mods: games-content@2 declarative packs only (definitions plus placements); executable mods wait for OpenVibe.Host Stage C. No achievement system exists, so there are no games.achievement.* events."
}
ddiff --git a/packages/content/src/index.ts b/packages/content/src/index.ts
iindex a6af5c5..b8134bb 100644
---- a/packages/content/src/index.ts
++++ b/packages/content/src/index.ts
@@@ -11,6 +11,7 @@ export * from './surface.js'
export * from './mapFileV2.js'
export * from './mapDiff.js'
export * from './registry.js'
++export * from './pack.js'
export * from './defs/crops.js'
export * from './defs/npcs.js'
export * from './defs/markets.js'
@@@ -22,6 +23,7 @@ export * from './defs/resources.js'
export * from './defs/scrapcity.js'
import { ContentRegistry } from './registry.js'
++import { mergePackDefs, type ContentPackV2 } from './pack.js'
import { SCRAP_CITY } from './defs/scrapcity.js'
import { CROPS } from './defs/crops.js'
import { FACTIONS, NPC_ARCHETYPES } from './defs/npcs.js'
@@@ -32,20 +34,31 @@ import { RECIPES } from './defs/recipes.js'
import { RESOURCE_NODES } from './defs/resources.js'
import { SKILLS } from './defs/skills.js'
--/** The game's full validated content set (server and client build the same one). */
--export function createContent(): ContentRegistry {
-- return new ContentRegistry({
-- items: ITEMS,
-- recipes: RECIPES,
-- skills: SKILLS,
-- nodeTypes: RESOURCE_NODES,
-- crops: CROPS,
-- npcs: NPC_ARCHETYPES,
-- factions: FACTIONS,
-- markets: MARKETS,
-- jobs: JOBS,
-- world: SCRAP_CITY,
-- })
++/**
++ * The game's full validated content set (server and client build the same one).
++ * `packs` are games-content@2 packs whose definitions merge onto the base
++ * defs before validation (see pack.ts); a merge or validation error throws, so
++ * a bad pack kills the boot instead of corrupting a world. The registry is
++ * immutable: packs take effect when it is built, i.e. at the next start.
++ */
++export function createContent(packs: ContentPackV2[] = []): ContentRegistry {
++ const { defs, errors } = mergePackDefs(
++ {
++ items: ITEMS,
++ recipes: RECIPES,
++ skills: SKILLS,
++ nodeTypes: RESOURCE_NODES,
++ crops: CROPS,
++ npcs: NPC_ARCHETYPES,
++ factions: FACTIONS,
++ markets: MARKETS,
++ jobs: JOBS,
++ world: SCRAP_CITY,
++ },
++ packs,
++ )
++ if (errors.length > 0) throw new Error(`content pack: ${errors.join('; ')}`)
++ return new ContentRegistry(defs)
}
export * from './terrain.js'
export * from './defs/economy.js'
ddiff --git a/packages/content/src/pack.test.ts b/packages/content/src/pack.test.ts
nnew file mode 100644
iindex 0000000..e5292a8
---- /dev/null
++++ b/packages/content/src/pack.test.ts
@@@ -0,0 +1,181 @@
++import { describe, expect, it } from 'vitest'
++import {
++ contentDigest,
++ createContent,
++ mergePackDefs,
++ CROPS,
++ FACTIONS,
++ ITEMS,
++ JOBS,
++ MARKETS,
++ MAX_PACK_DEFS,
++ NPC_ARCHETYPES,
++ RECIPES,
++ RESOURCE_NODES,
++ SCRAP_CITY,
++ SKILLS,
++ type ContentDefs,
++ type ContentPackV2,
++} from './index.js'
++
++const base: ContentDefs = {
++ items: ITEMS,
++ recipes: RECIPES,
++ skills: SKILLS,
++ nodeTypes: RESOURCE_NODES,
++ crops: CROPS,
++ npcs: NPC_ARCHETYPES,
++ factions: FACTIONS,
++ markets: MARKETS,
++ jobs: JOBS,
++ world: SCRAP_CITY,
++}
++
++const plainItem = ITEMS.find(
++ (i) => !i.health && !i.container && !i.machine && !i.shop && !i.seed && !i.blueprint,
++)!
++const anItem = (id: string): unknown => ({ ...plainItem, id })
++const aRecipe = (id: string, item: string): unknown => ({
++ ...RECIPES.find((r) => !r.machine && !r.requiredSkill && !r.blueprint)!,
++ id,
++ inputs: [{ item, count: 1 }],
++ outputs: [{ item, count: 1 }],
++})
++const aCrop = (id: string, item: string): unknown => ({
++ ...CROPS[0]!,
++ id,
++ yield: [{ item, count: 1 }],
++})
++const anNpc = (id: string): unknown => ({ ...NPC_ARCHETYPES[0]!, id })
++
++describe('mergePackDefs', () => {
++ it('appends a pack item, recipe, crop and npc', () => {
++ const pack: ContentPackV2 = {
++ defs: {
++ items: [anItem('pack_widget')],
++ recipes: [aRecipe('pack_recipe', 'pack_widget')],
++ crops: [aCrop('pack_crop', 'pack_widget')],
++ npcs: [anNpc('pack_npc')],
++ },
++ }
++ const { defs, errors } = mergePackDefs(base, [pack])
++ expect(errors).toEqual([])
++ expect(defs.items.at(-1)!.id).toBe('pack_widget')
++ expect(defs.recipes.at(-1)!.id).toBe('pack_recipe')
++ expect(defs.crops.at(-1)!.id).toBe('pack_crop')
++ expect(defs.npcs.at(-1)!.id).toBe('pack_npc')
++ expect(base.items.some((i) => i.id === 'pack_widget')).toBe(false)
++ const content = createContent([pack])
++ expect(content.item('pack_widget')).toBeDefined()
++ expect(content.recipe('pack_recipe')).toBeDefined()
++ expect(content.crop('pack_crop')).toBeDefined()
++ expect(content.npc('pack_npc')).toBeDefined()
++ })
++
++ it('refuses an id that already exists unless override is allowed', () => {
++ const clash = { ...(anItem(plainItem.id) as object), name: 'Replaced' }
++ const pack: ContentPackV2 = { defs: { items: [clash] } }
++ const refused = mergePackDefs(base, [pack])
++ expect(refused.errors).toEqual([
++ `item '${plainItem.id}' already exists and a pack may not override it`,
++ ])
++ expect(refused.defs.items.find((i) => i.id === plainItem.id)!.name).toBe(plainItem.name)
++
++ const allowed = mergePackDefs(base, [pack], { allowOverride: true })
++ expect(allowed.errors).toEqual([])
++ expect(allowed.defs.items).toHaveLength(base.items.length)
++ expect(allowed.defs.items.find((i) => i.id === plainItem.id)!.name).toBe('Replaced')
++ expect(() => createContent([pack])).toThrow(/may not override/)
++ })
++
++ it('applies a later pack over an earlier one only when override is allowed', () => {
++ const a: ContentPackV2 = { defs: { items: [{ ...(anItem('shared') as object), name: 'A' }] } }
++ const b: ContentPackV2 = { defs: { items: [{ ...(anItem('shared') as object), name: 'B' }] } }
++ expect(mergePackDefs(base, [a, b]).errors[0]).toMatch(/pack 1: item 'shared' already exists/)
++ const merged = mergePackDefs(base, [a, b], { allowOverride: true })
++ expect(merged.errors).toEqual([])
++ expect(merged.defs.items.filter((i) => i.id === 'shared').map((i) => i.name)).toEqual(['B'])
++ })
++
++ it('refuses an id defined twice in one pack', () => {
++ const { errors } = mergePackDefs(base, [{ defs: { items: [anItem('dup'), anItem('dup')] } }], {
++ allowOverride: true,
++ })
++ expect(errors).toEqual(["item 'dup' is defined twice in one pack"])
++ })
++
++ it('reports a malformed def by index and keeps it out of the set', () => {
++ const { defs, errors } = mergePackDefs(base, [
++ { defs: { items: [anItem('ok_item'), { id: 'Bad Id' }] } },
++ ])
++ expect(errors).toHaveLength(1)
++ expect(errors[0]).toMatch(/^items\[1\]: /)
++ expect(defs.items.some((i) => i.id === 'Bad Id')).toBe(false)
++ expect(defs.items.some((i) => i.id === 'ok_item')).toBe(true)
++ })
++
++ it('reports dangling references, and one pack cannot reference another pack', () => {
++ const dangling = mergePackDefs(base, [{ defs: { recipes: [aRecipe('r1', 'no_such_item')] } }])
++ // one error each for the input and the output
++ expect(dangling.errors).toEqual(
++ Array(2).fill("recipe 'r1' references unknown item 'no_such_item'"),
++ )
++ const npc = { ...(anNpc('n1') as object), faction: 'no_such_faction' }
++ expect(mergePackDefs(base, [{ defs: { npcs: [npc] } }]).errors).toEqual([
++ "npc 'n1' references unknown faction 'no_such_faction'",
++ ])
++ const owner: ContentPackV2 = { defs: { items: [anItem('only_in_a')] } }
++ const user: ContentPackV2 = { defs: { recipes: [aRecipe('r2', 'only_in_a')] } }
++ expect(mergePackDefs(base, [owner, user]).errors).toEqual(
++ Array(2).fill("pack 1: recipe 'r2' references unknown item 'only_in_a'"),
++ )
++ expect(() => createContent([{ defs: { recipes: [aRecipe('r1', 'no_such_item')] } }])).toThrow(
++ /unknown item 'no_such_item'/,
++ )
++ })
++
++ it('refuses a section over its bound or not an array', () => {
++ const many = Array.from({ length: MAX_PACK_DEFS.crops + 1 }, (_, i) =>
++ aCrop(`c${i}`, plainItem.id),
++ )
++ expect(mergePackDefs(base, [{ defs: { crops: many } }]).errors).toEqual([
++ `crops: ${many.length} defs exceeds the limit of ${MAX_PACK_DEFS.crops}`,
++ ])
++ const bad = { defs: { items: 'nope' } } as unknown as ContentPackV2
++ expect(mergePackDefs(base, [bad]).errors).toEqual(['items: must be an array'])
++ const atLimit = Array.from({ length: MAX_PACK_DEFS.items }, (_, i) => anItem(`bulk_${i}`))
++ expect(mergePackDefs(base, [{ defs: { items: atLimit } }]).errors).toEqual([])
++ })
++})
++
++describe('contentDigest', () => {
++ const pack = (...ids: string[]): ContentPackV2 => ({ defs: { items: ids.map(anItem) } })
++
++ it('is stable and has the v2 prefix', () => {
++ expect(contentDigest(base)).toBe(contentDigest(base))
++ expect(contentDigest(base)).toMatch(/^v2-[0-9a-f]{8}$/)
++ })
++
++ it('does not depend on key order or on pack order', () => {
++ const forward = mergePackDefs(base, [pack('d_a'), pack('d_b')]).defs
++ const backward = mergePackDefs(base, [pack('d_b'), pack('d_a')]).defs
++ expect(forward.items.map((i) => i.id)).not.toEqual(backward.items.map((i) => i.id))
++ expect(contentDigest(forward)).toBe(contentDigest(backward))
++ const reordered = {
++ ...forward,
++ items: forward.items.map((i) => Object.fromEntries(Object.entries(i).reverse()) as typeof i),
++ }
++ expect(contentDigest(reordered)).toBe(contentDigest(forward))
++ })
++
++ it('changes when a def is added or edited', () => {
++ const plain = contentDigest(base)
++ const added = mergePackDefs(base, [pack('d_a')]).defs
++ expect(contentDigest(added)).not.toBe(plain)
++ const edited = {
++ ...added,
++ items: added.items.map((i) => (i.id === 'd_a' ? { ...i, name: 'Changed' } : i)),
++ }
++ expect(contentDigest(edited)).not.toBe(contentDigest(added))
++ })
++})
ddiff --git a/packages/content/src/pack.ts b/packages/content/src/pack.ts
nnew file mode 100644
iindex 0000000..a2eabfd
---- /dev/null
++++ b/packages/content/src/pack.ts
@@@ -0,0 +1,202 @@
++/**
++ * `games-content@2` pack definitions: a pack adds DEFINITIONS (items, recipes,
++ * crops, npc archetypes), not only placements. Every def is parsed by the same
++ * schema the base content uses and merged onto the base before the registry is
++ * built, so the registry's own checks (duplicates, dangling references) still
++ * run over the merged set.
++ *
++ * Rules, in pack order:
++ * - an id may be defined once per pack;
++ * - an id that already exists (base or an earlier pack) is refused, unless the
++ * caller opts into `allowOverride`, in which case the later def replaces it
++ * in place;
++ * - a pack's defs may reference only base ids and the pack's own ids, never
++ * another pack's, so a pack never depends on install order.
++ *
++ * Client- and server-safe: no node built-ins.
++ */
++import type { z } from 'zod'
++import { CropDefSchema, type CropDef } from './schema/crop.js'
++import { NpcArchetypeSchema, type NpcArchetype } from './schema/npc.js'
++import { ItemDefSchema, type ItemDef } from './schema/item.js'
++import { RecipeSchema, type Recipe } from './schema/recipe.js'
++import type { ContentDefs } from './registry.js'
++
++/** The pack format version both sides must agree on. */
++export const CONTENT_PACK_VERSION = 2
++
++/** Most defs of each kind one pack may carry (the manifest schema uses the same bounds). */
++export const MAX_PACK_DEFS = { items: 200, recipes: 200, crops: 100, npcs: 100 } as const
++
++/** The def sections a pack may carry. */
++export type PackDefKind = keyof typeof MAX_PACK_DEFS
++
++export interface ContentPackV2 {
++ defs?: { [K in PackDefKind]?: unknown[] }
++ announcements?: { text: string; everySeconds: number }[]
++ props?: { key: string; item: string; pos: [number, number, number]; yaw?: number }[]
++}
++
++/** What a merge reads from the base: the four def lists a pack extends and the factions npcs may join. */
++export type PackBase = Pick<ContentDefs, 'items' | 'recipes' | 'crops' | 'npcs' | 'factions'>
++
++export interface MergeOptions {
++ /** Let a pack def replace a base or earlier-pack def with the same id (default: refused). */
++ allowOverride?: boolean
++}
++
++interface Parsed<T> {
++ out: T[]
++ errors: string[]
++}
++
++/** Parse one def array with the base schema; report the bad index otherwise. */
++function parseDefs<T>(
++ kind: PackDefKind,
++ schema: z.ZodType<T, z.ZodTypeDef, unknown>,
++ raw: unknown,
++): Parsed<T> {
++ const out: T[] = []
++ const errors: string[] = []
++ if (raw === undefined) return { out, errors }
++ if (!Array.isArray(raw)) return { out, errors: [`${kind}: must be an array`] }
++ if (raw.length > MAX_PACK_DEFS[kind]) {
++ return {
++ out,
++ errors: [`${kind}: ${raw.length} defs exceeds the limit of ${MAX_PACK_DEFS[kind]}`],
++ }
++ }
++ raw.forEach((v, i) => {
++ const r = schema.safeParse(v)
++ if (!r.success) errors.push(`${kind}[${i}]: ${r.error.message}`)
++ else out.push(r.data)
++ })
++ return { out, errors }
++}
++
++/** Add `defs` to `list`, applying the duplicate / override rule; returns the ids this pack defined. */
++function addDefs<T extends { id: string }>(
++ kind: string,
++ list: T[],
++ defs: T[],
++ allowOverride: boolean,
++ errors: string[],
++): Set<string> {
++ const own = new Set<string>()
++ for (const def of defs) {
++ if (own.has(def.id)) {
++ errors.push(`${kind} '${def.id}' is defined twice in one pack`)
++ continue
++ }
++ own.add(def.id)
++ const at = list.findIndex((d) => d.id === def.id)
++ if (at < 0) list.push(def)
++ else if (allowOverride) list[at] = def
++ else errors.push(`${kind} '${def.id}' already exists and a pack may not override it`)
++ }
++ return own
++}
++
++/** Merge packs onto `base`. Errors are fatal for the boot; defs are appended in pack order. */
++export function mergePackDefs<B extends PackBase>(
++ base: B,
++ packs: ContentPackV2[],
++ options: MergeOptions = {},
++): { defs: B; errors: string[] } {
++ const allowOverride = options.allowOverride ?? false
++ const items = [...base.items]
++ const recipes = [...base.recipes]
++ const crops = [...base.crops]
++ const npcs = [...base.npcs]
++ const errors: string[] = []
++ const baseItems = new Set(base.items.map((d) => d.id))
++ const baseRecipes = new Set(base.recipes.map((d) => d.id))
++ const baseCrops = new Set(base.crops.map((d) => d.id))
++ const factions = new Set(base.factions.map((d) => d.id))
++
++ packs.forEach((p, n) => {
++ const where = packs.length > 1 ? `pack ${n}: ` : ''
++ const mine: string[] = []
++ const i = parseDefs<ItemDef>('items', ItemDefSchema, p.defs?.items)
++ const r = parseDefs<Recipe>('recipes', RecipeSchema, p.defs?.recipes)
++ const c = parseDefs<CropDef>('crops', CropDefSchema, p.defs?.crops)
++ const v = parseDefs<NpcArchetype>('npcs', NpcArchetypeSchema, p.defs?.npcs)
++ mine.push(...i.errors, ...r.errors, ...c.errors, ...v.errors)
++
++ const ownItems = addDefs('item', items, i.out, allowOverride, mine)
++ const ownRecipes = addDefs('recipe', recipes, r.out, allowOverride, mine)
++ const ownCrops = addDefs('crop', crops, c.out, allowOverride, mine)
++ addDefs('npc', npcs, v.out, allowOverride, mine)
++
++ // References may reach base ids and this pack's own ids only.
++ const hasItem = (id: string): boolean => baseItems.has(id) || ownItems.has(id)
++ const hasRecipe = (id: string): boolean => baseRecipes.has(id) || ownRecipes.has(id)
++ const hasCrop = (id: string): boolean => baseCrops.has(id) || ownCrops.has(id)
++ const needItem = (from: string, id: string): void => {
++ if (!hasItem(id)) mine.push(`${from} references unknown item '${id}'`)
++ }
++ for (const d of i.out) {
++ if (d.health?.repair) needItem(`item '${d.id}'`, d.health.repair.item)
++ for (const loot of d.health?.destroyLoot ?? []) needItem(`item '${d.id}'`, loot.item)
++ if (d.seed && !hasCrop(d.seed.crop))
++ mine.push(`item '${d.id}' references unknown crop '${d.seed.crop}'`)
++ if (d.blueprint && !hasRecipe(d.blueprint.recipe)) {
++ mine.push(`item '${d.id}' references unknown recipe '${d.blueprint.recipe}'`)
++ }
++ }
++ for (const d of r.out)
++ for (const ref of [...d.inputs, ...d.outputs]) needItem(`recipe '${d.id}'`, ref.item)
++ for (const d of c.out) for (const y of d.yield) needItem(`crop '${d.id}'`, y.item)
++ for (const d of v.out) {
++ if (!factions.has(d.faction))
++ mine.push(`npc '${d.id}' references unknown faction '${d.faction}'`)
++ for (const loot of d.loot) needItem(`npc '${d.id}'`, loot.item)
++ }
++ errors.push(...mine.map((e) => where + e))
++ })
++ return { defs: { ...base, items, recipes, crops, npcs }, errors }
++}
++
++/** 32-bit FNV-1a over a string; plain integer maths, identical in every JS runtime. */
++function fnv1a(s: string): string {
++ let h = 0x811c9dc5
++ for (let i = 0; i < s.length; i++) {
++ h ^= s.charCodeAt(i)
++ h = Math.imul(h, 0x01000193) >>> 0
++ }
++ return h.toString(16).padStart(8, '0')
++}
++
++/** JSON with object keys sorted and `undefined` members dropped, so key order never matters. */
++function canonical(v: unknown): string {
++ if (Array.isArray(v)) return `[${v.map(canonical).join(',')}]`
++ if (v && typeof v === 'object') {
++ const o = v as Record<string, unknown>
++ return `{${Object.keys(o)
++ .filter((k) => o[k] !== undefined)
++ .sort()
++ .map((k) => `${JSON.stringify(k)}:${canonical(o[k])}`)
++ .join(',')}}`
++ }
++ return JSON.stringify(v)
++}
++
++/** The def-set format version in the digest. */
++export const CONTENT_VERSION = 2
++
++/**
++ * The def-set identity (version + digest) the client/server handshake (M2.2)
++ * will compare. It hashes the merged `ContentDefs`, with every id-keyed list
++ * sorted by id, so neither pack order nor key order changes it.
++ */
++export function contentDigest(defs: ContentDefs): string {
++ const sorted: Record<string, unknown> = {}
++ for (const [key, value] of Object.entries(defs)) {
++ sorted[key] = Array.isArray(value)
++ ? [...value].sort((a: { id: string }, b: { id: string }) =>
++ a.id < b.id ? -1 : a.id > b.id ? 1 : 0,
++ )
++ : value
++ }
++ return `v${CONTENT_VERSION}-${fnv1a(canonical(sorted))}`
++}
ddiff --git a/packages/persistence/src/dto.ts b/packages/persistence/src/dto.ts
iindex 7652047..ee9dd39 100644
---- a/packages/persistence/src/dto.ts
++++ b/packages/persistence/src/dto.ts
@@@ -89,11 +89,11 @@ export interface ModInstallDto {
version: string
/** Runtime target, e.g. `games.browser`. */
target: string
-- /** Runtime adapter, e.g. `games-content@1`. */
++ /** Runtime adapter, e.g. `games-content@2`. */
runtime: string
/** The full manifest as installed (mods/mod-manifest.v1). */
manifest: Record<string, unknown>
-- /** The runtime-specific payload (a content data pack for `games-content@1`). */
++ /** The runtime-specific payload (a content data pack for `games-content@2`). */
pack: Record<string, unknown>
trustTier: ModTrustTier
status: ModStatus
ddiff --git a/packages/persistence/src/pg/platformRepositories.test.ts b/packages/persistence/src/pg/platformRepositories.test.ts
iindex 0e215de..23f799b 100644
---- a/packages/persistence/src/pg/platformRepositories.test.ts
++++ b/packages/persistence/src/pg/platformRepositories.test.ts
@@@ -189,7 +189,7 @@ describe('mod repository', () => {
name: 'Test',
version: '1.0.0',
target: 'games.browser',
-- runtime: 'games-content@1',
++ runtime: 'games-content@2',
manifest: { a: 1 },
pack: { props: [] },
trustTier: 'unreviewed',
ddiff --git a/test/apps__server__src__mods__contentPack.test.js b/test/apps__server__src__mods__contentPack.test.js
nnew file mode 100644
iindex 0000000..4a4f73c
---- /dev/null
++++ b/test/apps__server__src__mods__contentPack.test.js
@@@ -0,0 +1,3 @@
++// file: 'apps/server/src/mods/contentPack.test.ts'
++// covers: 'apps/server/src/mods'
++// Generated by node test/run.js --index; do not edit.
ddiff --git a/test/packages__content__src__pack.test.js b/test/packages__content__src__pack.test.js
nnew file mode 100644
iindex 0000000..753cc15
---- /dev/null
++++ b/test/packages__content__src__pack.test.js
@@@ -0,0 +1,3 @@
++// file: 'packages/content/src/pack.test.ts'
++// covers: 'packages/content/src'
++// Generated by node test/run.js --index; do not edit.