openvibers/openvibe.games Commits

Gate joins on content digests and load Scraplandia's default map

OP OpenVibers committed 4 Oct 2026 · 6a1f3a30b8 · 21 files, +229 −66

Asked: T8 steps 4+5 — OpenVibe.Games M2.2 + M2.3 in ONE pull request from current origin/main (git fetch first; main is 545f6c1, which merged M2.1: games-content@2 pack schema, pack.ts mergePackDefs + digest, createContent(packs), contentPack v2, CONTENT_RUNTIME games-content@2, server loads packs before …
Harness job games-t8-steps-4-5-openvibe-games-075246 (plan T8).

Changes

ddiff --git a/README.md b/README.md
iindex 2f0f385..50d06ac 100644
---- a/README.md
++++ b/README.md
@@@ -187,7 +187,7 @@ do not know the platform exists. See [ADR-0006](docs/adr/0006-canonical-subjects
declarative data packs checked against `@openvibe/content` (announcements;
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
++ restart). The client sends its definition digest in `hello`; the server checks it before joining and echoes its digest in `welcome`. A mismatch is rejected so installed definitions cannot silently desync clients. Scraplandia's default blank-slate map is supplied by the built-in pack. 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
ddiff --git a/apps/client/src/main.ts b/apps/client/src/main.ts
iindex 327aba4..17ea2ee 100644
---- a/apps/client/src/main.ts
++++ b/apps/client/src/main.ts
@@@ -391,7 +391,7 @@ async function start(): Promise<void> {
hud.setStatus('connecting…')
try {
-- await connection.connect(gameSocketUrl(), identity.token, name, appearance, slot, sso)
++ await connection.connect(gameSocketUrl(), identity.token, name, appearance, content.digest, slot, sso)
} catch {
// Refused or timed out (a page opened during a restart): wait for the server, then reload.
hud.setStatus('could not reach the server — retrying automatically…')
ddiff --git a/apps/client/src/net/connection.test.ts b/apps/client/src/net/connection.test.ts
nnew file mode 100644
iindex 0000000..af54a17
---- /dev/null
++++ b/apps/client/src/net/connection.test.ts
@@@ -0,0 +1,27 @@
++import { describe, expect, it } from 'vitest'
++import { createContent } from '@openvibe/content'
++import type { ServerWelcome } from '@openvibe/protocol'
++import { defaultAppearance, PROTOCOL_VERSION } from '@openvibe/protocol'
++import { helloForContent, welcomeContentMismatch } from './connection.js'
++
++describe('client content handshake', () => {
++ const digest = createContent().digest
++ const welcome = { t: 'welcome', contentDigest: digest } as ServerWelcome
++
++ it('accepts the matching loaded definition set', () => {
++ expect(welcomeContentMismatch(welcome, digest)).toBeNull()
++ })
++
++ it('sends the local definition digest and protocol version in hello', () => {
++ expect(helloForContent('Player', defaultAppearance(), 0, digest)).toMatchObject({
++ t: 'hello', v: PROTOCOL_VERSION, contentDigest: digest,
++ })
++ })
++
++ it('turns a mismatched welcome into a typed reject before state applies', () => {
++ expect(welcomeContentMismatch({ ...welcome, contentDigest: 'v2-00000000' }, digest)).toEqual({
++ t: 'reject', reason: 'content_mismatch',
++ clientDigest: digest, serverDigest: 'v2-00000000',
++ })
++ })
++})
ddiff --git a/apps/client/src/net/connection.ts b/apps/client/src/net/connection.ts
iindex 9c17192..1f45930 100644
---- a/apps/client/src/net/connection.ts
++++ b/apps/client/src/net/connection.ts
@@@ -3,10 +3,32 @@ import {
decodeServerMessage,
encodeClientMessage,
type Appearance,
++ type ClientHello,
type ClientMessage,
type ServerMessage,
++ type ServerWelcome,
} from '@openvibe/protocol'
++/** A stale server welcome must never start prediction with different definitions. */
++export function welcomeContentMismatch(
++ msg: ServerWelcome,
++ clientDigest: string,
++): ServerMessage | null {
++ return msg.contentDigest === clientDigest
++ ? null
++ : { t: 'reject', reason: 'content_mismatch', clientDigest, serverDigest: msg.contentDigest }
++}
++
++/** Build the versioned hello from the same content registry used for rendering. */
++export function helloForContent(
++ name: string,
++ appearance: Appearance,
++ slot: number,
++ contentDigest: string,
++): ClientHello {
++ return { t: 'hello', v: PROTOCOL_VERSION, contentDigest, slot, name, appearance }
++}
++
/** How long the first WebSocket connect may take before the page falls back to waiting for the server. */
export const CONNECT_TIMEOUT_MS = 10_000
@@@ -69,6 +91,7 @@ export class Connection {
token: string,
name: string,
appearance: Appearance,
++ contentDigest: string,
slot = 0,
/** Network access token; ignored if absent (the identity is the ticket). */
networkToken?: string,
@@@ -102,17 +125,20 @@ export class Connection {
})
ws.onmessage = (event) => {
const msg = decodeServerMessage(String(event.data))
-- if (msg) this.onMessage?.(msg)
++ if (!msg) return
++ if (msg.t === 'welcome') {
++ const mismatch = welcomeContentMismatch(msg, contentDigest)
++ if (mismatch) {
++ this.onMessage?.(mismatch)
++ ws.close()
++ return
++ }
++ }
++ this.onMessage?.(msg)
}
ws.onclose = () => this.onClose?.()
-- this.send({
-- t: 'hello',
-- v: PROTOCOL_VERSION,
-- slot,
-- name,
-- appearance,
-- // Identity was resolved at the upgrade; hello carries no identity field.
-- })
++ // Identity was resolved at the upgrade; hello carries no identity field.
++ this.send(helloForContent(name, appearance, slot, contentDigest))
}
send(msg: ClientMessage): void {
ddiff --git a/apps/server/scripts/sliceTest.ts b/apps/server/scripts/sliceTest.ts
iindex dd33363..91ea792 100644
---- a/apps/server/scripts/sliceTest.ts
++++ b/apps/server/scripts/sliceTest.ts
@@@ -28,7 +28,7 @@ import {
type WirePlayerState,
type WireSkill,
} from '@openvibe/protocol'
--import { encodeHeights } from '@openvibe/content'
++import { createContent, encodeHeights } from '@openvibe/content'
import { quantiseAngle } from '@openvibe/shared'
// A port the OS hands out, not a fixed one: the checks and the verify job can run the slice at the
@@@ -206,6 +206,7 @@ class TestClient {
this.send({
t: 'hello',
v: PROTOCOL_VERSION,
++ contentDigest: createContent().digest,
slot: 0,
name,
appearance: appearance ?? defaultAppearance(),
ddiff --git a/apps/server/src/game/platformIntegration.test.ts b/apps/server/src/game/platformIntegration.test.ts
iindex 594c2d4..c0dc028 100644
---- a/apps/server/src/game/platformIntegration.test.ts
++++ b/apps/server/src/game/platformIntegration.test.ts
@@@ -145,6 +145,7 @@ function connect(game: GameServer) {
const msg = {
t: 'hello',
v: PROTOCOL_VERSION,
++ contentDigest: createContent().digest,
slot: 0,
name: 'Ana',
appearance: defaultAppearance(),
@@@ -190,6 +191,10 @@ describe('Games on platform identity, events and mods (real server, restart)', (
it('runs identity, mods and events against the live world', async () => {
const a = await boot(platform)
++ // Main 545f6c1 seeded no entity ids/positions from SCRAP_CITY. The
++ // built-in pack must leave the fresh authoritative world identical.
++ expect([...a.world.entities.all()].map((e) => [e.id, e.transform.pos])).toEqual([])
++ expect(a.world.content.world.spawnPoint).toEqual([0, 1.2, 4])
// The upgrade gate is the only identity source (ADR-0007 decision 8):
// a hello that arrives on a connection the transport never stamped is
@@@ -200,10 +205,28 @@ describe('Games on platform identity, events and mods (real server, restart)', (
expect(refused.welcome).toBeUndefined()
expect(refused.closed).toEqual({ code: 4012, reason: 'no_identity' })
++ // Reject before loading a character or writing any world state. This also
++ // covers an older hello that has no digest at all.
++ for (const offered of [undefined, 'v2-00000000']) {
++ const stale = connect(a.game)
++ stale.conn.identity = { guestKeyHash: hashGuestKey(`stale-${offered ?? 'missing'}`) }
++ a.game.onMessage(stale.conn, {
++ t: 'hello', v: PROTOCOL_VERSION, slot: 0, name: 'Stale',
++ appearance: defaultAppearance(), ...(offered ? { contentDigest: offered } : {}),
++ })
++ await new Promise((r) => setTimeout(r, 0))
++ expect(stale.inbox).toContainEqual({
++ t: 'reject', reason: 'content_mismatch',
++ clientDigest: offered ?? null, serverDigest: a.world.content.digest,
++ })
++ expect(stale.inbox.some((m) => m.t === 'welcome')).toBe(false)
++ }
++
// The signed-in player is keyed by the canonical subject.
const ana = connect(a.game)
const joined = await ana.hello('abcdefgh12', 'tok-ana')
expect(joined.welcome).toBeDefined()
++ expect(joined.welcome).toMatchObject({ contentDigest: a.world.content.digest })
signedInPlayerId = (joined.welcome as { playerId: string }).playerId
// The tick never awaits I/O; shutdown queues the final checkpoint, drain waits for it.
a.game.shutdown()
ddiff --git a/apps/server/src/game/systems/sessions.ts b/apps/server/src/game/systems/sessions.ts
iindex 35f74a9..5ed8dc8 100644
---- a/apps/server/src/game/systems/sessions.ts
++++ b/apps/server/src/game/systems/sessions.ts
@@@ -86,6 +86,15 @@ export class SessionsSystem implements System {
private async resolveHello(conn: GameConnection, msg: ClientHello): Promise<void> {
const ctx = this.ctx
++ const serverDigest = ctx.world.content.digest
++ if (msg.contentDigest !== serverDigest) {
++ conn.send(encodeServerMessage({
++ t: 'reject', reason: 'content_mismatch',
++ clientDigest: msg.contentDigest ?? null, serverDigest,
++ }))
++ conn.close(4013, 'content_mismatch')
++ return
++ }
if (msg.v !== PROTOCOL_VERSION) {
conn.send(encodeServerMessage({ t: 'reject', reason: 'protocol_mismatch' }))
conn.close(4002, 'protocol_mismatch')
@@@ -238,6 +247,7 @@ export class SessionsSystem implements System {
ctx.net.send(session, {
t: 'welcome',
v: PROTOCOL_VERSION,
++ contentDigest: serverDigest,
rank,
playerId: playerId as string,
entityId: session.entityId as string,
ddiff --git a/apps/server/src/mods/contentPack.test.ts b/apps/server/src/mods/contentPack.test.ts
iindex 7a8205a..5177c5b 100644
---- a/apps/server/src/mods/contentPack.test.ts
++++ b/apps/server/src/mods/contentPack.test.ts
@@@ -1,4 +1,4 @@
--import { createContent, ITEMS } from '@openvibe/content'
++import { createContent, ITEMS, SCRAPLANDIA_PACK } from '@openvibe/content'
import { openTestStore } from '@openvibe/persistence/testing'
import { describe, expect, it } from 'vitest'
import {
@@@ -41,6 +41,12 @@ async function setup() {
}
describe('games-content@2 definitions', () => {
++ it('recognizes an authored map as a definition-capability section', () => {
++ const mapPack = { map: SCRAPLANDIA_PACK.map }
++ expect(capabilitiesUsedBy(mapPack)).toEqual([CAP_DEFINE])
++ expect(validateContentPack(mapPack, createContent()).ok).toBe(true)
++ expect(validateContentPack({ map: { id: 'broken' } }, createContent()).ok).toBe(false)
++ })
it('needs games.def.define for any non-empty defs section', () => {
expect(capabilitiesUsedBy(itemPack('widget'))).toEqual([CAP_DEFINE])
expect(capabilitiesUsedBy({ defs: { items: [] } })).toEqual([])
ddiff --git a/apps/server/src/mods/contentPack.ts b/apps/server/src/mods/contentPack.ts
iindex 7973c50..17df852 100644
---- a/apps/server/src/mods/contentPack.ts
++++ b/apps/server/src/mods/contentPack.ts
@@@ -5,14 +5,14 @@
*
* 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
++ * defs/map games.def.define definitions and an authored map
* 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.
++ * not load installed packs yet: the def-set handshake refuses clients whose
++ * local definitions differ from the server's loaded set.
*
* 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,
@@@ -56,6 +56,7 @@ export interface ContentProp {
export interface ContentPack {
/** Definitions, deep-validated by `mergePackDefs`; the schema only bounds them. */
defs?: NonNullable<ContentPackV2['defs']>
++ map?: ContentPackV2['map']
announcements?: ContentAnnouncement[]
props?: ContentProp[]
}
@@@ -77,6 +78,7 @@ export const CONTENT_PACK_SCHEMA = {
npcs: { type: 'array', maxItems: MAX_PACK_DEFS.npcs, items: { type: 'object' } },
},
},
++ map: { type: 'object', maxProperties: 12 },
announcements: {
type: 'array',
maxItems: 10,
@@@ -119,7 +121,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.map || 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
@@@ -152,7 +154,7 @@ export function validateContentPack(
}
const pack = value as unknown as ContentPack
const errors: string[] = []
-- if (pack.defs) {
++ if (pack.defs || pack.map) {
// The content the pack would be merged onto; its own ids may not already exist.
const merged = mergePackDefs(
{
@@@ -191,7 +193,7 @@ export async function loadDefinitionPacks(
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
++ if (!pack.map && 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,
)
ddiff --git a/docs/adr/0007-rewrite-the-moddable-base.md b/docs/adr/0007-rewrite-the-moddable-base.md
iindex b4d3be1..d7dcc8f 100644
---- a/docs/adr/0007-rewrite-the-moddable-base.md
++++ b/docs/adr/0007-rewrite-the-moddable-base.md
@@@ -117,8 +117,8 @@ The Live legacy import (`platform/liveLegacyImport.ts`, `scripts/importLiveLegac
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`) — 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.2 def-set handshake — done.** Client and server exchange the versioned definition digest in `hello` and `welcome`; a mismatch refuses the join before player state is created.
++ - **M2.3 Scraplandia as a pack — default map done.** The base loads Scraplandia's blank-slate map through the built-in `games-content@2` pack. The released spawn, zone and empty seeded entity set are unchanged. Moving gameplay modules behind pack enablement remains future work.
- **M2.4 touch controls.** Touch maps intents (move vector, look delta, named actions) to the same input stream as the keyboard.
- **M3: scripted mods.** QuickJS runtime, capability API v2, budgets enforced, hot reload, escape tests.
- **M4: instances and scale.** Several instances per place, registry and leases, transfer, region interest, load tests.
ddiff --git a/packages/content/src/defs/scrapcity.ts b/packages/content/src/defs/scrapcity.ts
ddeleted file mode 100644
iindex 4c37a20..0000000
---- a/packages/content/src/defs/scrapcity.ts
++++ /dev/null
@@@ -1,38 +0,0 @@
--import type { WorldDef } from '../schema/world.js'
--
--/**
-- * Scrap City — the persistent world. A walled safe city at the center
-- * (spawn, plaza, future shops), gates opening onto the wilds:
-- * forest to the NE, quarry to the SW, scrapyard to the SE.
-- * City rules: no PvP, no building, no physgun — enforced by the zone
-- * system, never by code that knows what a "city" is.
-- */
--
--// City square: walls at ±20 with 5m gates centered on each side.
--// Wall segments flank each gate: length (40 - 5) / 2 = 17.5.
--
--export const SCRAP_CITY: WorldDef = {
-- id: 'openvibeville_v2',
-- flatTerrain: true,
-- name: 'Scrap City',
-- groundHalfExtent: 80,
-- spawnPoint: [0, 1.2, 4],
-- spawnYaw: 0,
-- // Blank slate: the world ships EMPTY — everything is built with /editor.
-- statics: [],
--
-- // Resource nodes are placed with the map editor (MapFile.nodes).
-- resourceNodes: [],
--
-- initialProps: [],
--
-- zones: [
-- {
-- id: 'city',
-- name: 'Scrap City',
-- min: [-20.5, -1, -20.5],
-- max: [20.5, 8, 20.5],
-- rules: { pvp: false, build: false, physgun: true },
-- },
-- ],
--}
ddiff --git a/packages/content/src/index.ts b/packages/content/src/index.ts
iindex b8134bb..7ba0c36 100644
---- a/packages/content/src/index.ts
++++ b/packages/content/src/index.ts
@@@ -20,11 +20,11 @@ export * from './defs/items.js'
export * from './defs/recipes.js'
export * from './defs/skills.js'
export * from './defs/resources.js'
--export * from './defs/scrapcity.js'
++export * from './packs/scraplandia.js'
import { ContentRegistry } from './registry.js'
import { mergePackDefs, type ContentPackV2 } from './pack.js'
--import { SCRAP_CITY } from './defs/scrapcity.js'
++import { SCRAPLANDIA_PACK } from './packs/scraplandia.js'
import { CROPS } from './defs/crops.js'
import { FACTIONS, NPC_ARCHETYPES } from './defs/npcs.js'
import { MARKETS } from './defs/markets.js'
@@@ -53,9 +53,9 @@ export function createContent(packs: ContentPackV2[] = []): ContentRegistry {
factions: FACTIONS,
markets: MARKETS,
jobs: JOBS,
-- world: SCRAP_CITY,
++ world: { id: 'base', name: 'Base', groundHalfExtent: 80, spawnPoint: [0, 1.2, 4], spawnYaw: 0, flatTerrain: true, statics: [], resourceNodes: [], initialProps: [], zones: [] },
},
-- packs,
++ [SCRAPLANDIA_PACK, ...packs],
)
if (errors.length > 0) throw new Error(`content pack: ${errors.join('; ')}`)
return new ContentRegistry(defs)
ddiff --git a/packages/content/src/pack.test.ts b/packages/content/src/pack.test.ts
iindex e5292a8..332f5a3 100644
---- a/packages/content/src/pack.test.ts
++++ b/packages/content/src/pack.test.ts
@@@ -13,6 +13,7 @@ import {
RECIPES,
RESOURCE_NODES,
SCRAP_CITY,
++ SCRAPLANDIA_PACK,
SKILLS,
type ContentDefs,
type ContentPackV2,
@@@ -178,4 +179,34 @@ describe('contentDigest', () => {
}
expect(contentDigest(edited)).not.toBe(contentDigest(added))
})
++
++ it('matches the merged set on both sides and changes with a pack def', () => {
++ const client = createContent()
++ const server = createContent([])
++ expect(client.digest).toBe(server.digest)
++ expect(createContent([pack('extra')]).digest).not.toBe(client.digest)
++ })
++
++ it('changes when the pack map changes', () => {
++ const changed = { ...base, world: { ...base.world, spawnPoint: [1, 1.2, 4] as [number, number, number] } }
++ expect(contentDigest(changed)).not.toBe(contentDigest(base))
++ })
++})
++
++describe('Scraplandia default pack', () => {
++ it('loads the exact world and boot entity positions from main', () => {
++ // Recorded from main 545f6c1, packages/content/src/defs/scrapcity.ts.
++ // The released city is an editor-built blank slate: no seeded entity ids.
++ const expected = {
++ id: 'openvibeville_v2', name: 'Scrap City', flatTerrain: true,
++ groundHalfExtent: 80, spawnPoint: [0, 1.2, 4], spawnYaw: 0,
++ statics: [], resourceNodes: [], initialProps: [],
++ zones: [{ id: 'city', name: 'Scrap City', min: [-20.5, -1, -20.5],
++ max: [20.5, 8, 20.5], rules: { pvp: false, build: false, physgun: true } }],
++ }
++ expect(SCRAPLANDIA_PACK.map).toEqual(expected)
++ expect(createContent().world).toEqual(expected)
++ expect(createContent().world.initialProps.map((p) => [p.item, p.pos])).toEqual([])
++ expect(createContent().world.resourceNodes.map((n) => [n.node, n.pos])).toEqual([])
++ })
})
ddiff --git a/packages/content/src/pack.ts b/packages/content/src/pack.ts
iindex a2eabfd..f87f482 100644
---- a/packages/content/src/pack.ts
++++ b/packages/content/src/pack.ts
@@@ -21,6 +21,7 @@ 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'
++import { WorldDefSchema, type WorldDef } from './schema/world.js'
/** The pack format version both sides must agree on. */
export const CONTENT_PACK_VERSION = 2
@@@ -33,6 +34,8 @@ export type PackDefKind = keyof typeof MAX_PACK_DEFS
export interface ContentPackV2 {
defs?: { [K in PackDefKind]?: unknown[] }
++ /** One authored map; the built-in Scraplandia pack supplies the default. */
++ map?: WorldDef
announcements?: { text: string; everySeconds: number }[]
props?: { key: string; item: string; pos: [number, number, number]; yaw?: number }[]
}
@@@ -109,6 +112,8 @@ export function mergePackDefs<B extends PackBase>(
const crops = [...base.crops]
const npcs = [...base.npcs]
const errors: string[] = []
++ let map = 'world' in base ? (base as B & { world: WorldDef }).world : undefined
++ let mapSeen = false
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))
@@@ -117,6 +122,15 @@ export function mergePackDefs<B extends PackBase>(
packs.forEach((p, n) => {
const where = packs.length > 1 ? `pack ${n}: ` : ''
const mine: string[] = []
++ if (p.map !== undefined) {
++ const parsed = WorldDefSchema.safeParse(p.map)
++ if (!parsed.success) mine.push(`map: ${parsed.error.message}`)
++ else if (mapSeen) mine.push('map is defined by more than one pack')
++ else {
++ map = parsed.data
++ mapSeen = true
++ }
++ }
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)
@@@ -154,7 +168,7 @@ export function mergePackDefs<B extends PackBase>(
}
errors.push(...mine.map((e) => where + e))
})
-- return { defs: { ...base, items, recipes, crops, npcs }, errors }
++ return { defs: { ...base, items, recipes, crops, npcs, ...(map ? { world: map } : {}) }, errors }
}
/** 32-bit FNV-1a over a string; plain integer maths, identical in every JS runtime. */
ddiff --git a/packages/content/src/packs/scraplandia.ts b/packages/content/src/packs/scraplandia.ts
nnew file mode 100644
iindex 0000000..3e679ef
---- /dev/null
++++ b/packages/content/src/packs/scraplandia.ts
@@@ -0,0 +1,33 @@
++import type { ContentPackV2 } from '../pack.js'
++
++/**
++ * Scraplandia's default map. The released world is a blank slate: city
++ * geometry is authored in /editor, so there are no hard-coded statics or
++ * initial entities. The zone and spawn are unchanged from the former
++ * SCRAP_CITY definition.
++ */
++export const SCRAPLANDIA_PACK: ContentPackV2 = {
++ map: {
++ id: 'openvibeville_v2',
++ flatTerrain: true,
++ name: 'Scrap City',
++ groundHalfExtent: 80,
++ spawnPoint: [0, 1.2, 4],
++ spawnYaw: 0,
++ statics: [],
++ resourceNodes: [],
++ initialProps: [],
++ zones: [
++ {
++ id: 'city',
++ name: 'Scrap City',
++ min: [-20.5, -1, -20.5],
++ max: [20.5, 8, 20.5],
++ rules: { pvp: false, build: false, physgun: true },
++ },
++ ],
++ },
++}
++
++/** The authored map, exported for map tooling and compatibility. */
++export const SCRAP_CITY = SCRAPLANDIA_PACK.map!
ddiff --git a/packages/content/src/registry.ts b/packages/content/src/registry.ts
iindex ef6bcdc..2093dca 100644
---- a/packages/content/src/registry.ts
++++ b/packages/content/src/registry.ts
@@@ -12,6 +12,7 @@ import { RecipeSchema, type Recipe } from './schema/recipe.js'
import { ResourceNodeTypeSchema, type ResourceNodeType } from './schema/resourceNode.js'
import { SkillDefSchema, type SkillDef } from './schema/skill.js'
import { WorldDefSchema, type WorldDef } from './schema/world.js'
++import { contentDigest } from './pack.js'
export interface ContentDefs {
items: ItemDef[]
@@@ -33,6 +34,8 @@ export interface ContentDefs {
* corrupt a live world.
*/
export class ContentRegistry {
++ /** Identity of the exact validated definition set used by this world. */
++ readonly digest: string
private readonly items = new Map<string, ItemDef>()
private readonly recipes = new Map<string, Recipe>()
private readonly skills = new Map<string, SkillDef>()
@@@ -45,6 +48,7 @@ export class ContentRegistry {
readonly world: WorldDef
constructor(defs: ContentDefs) {
++ this.digest = contentDigest(defs)
const errors: string[] = []
for (const raw of defs.items) {
ddiff --git a/packages/protocol/src/codec.test.ts b/packages/protocol/src/codec.test.ts
iindex 51ec420..17938d1 100644
---- a/packages/protocol/src/codec.test.ts
++++ b/packages/protocol/src/codec.test.ts
@@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
import { decodeClientMessage, encodeClientMessage } from './codec.js'
++import { PROTOCOL_VERSION } from './version.js'
import type { ClientMessage } from './messages/client.js'
import { defaultAppearance } from './appearance.js'
@@@ -8,7 +9,7 @@ describe('protocol codec', () => {
const messages: ClientMessage[] = [
// hello carries no identity field: authentication happens at the
// WebSocket upgrade (ADR-0007 decision 8).
-- { t: 'hello', v: 1, slot: 0, name: 'Tester', appearance: defaultAppearance() },
++ { t: 'hello', v: PROTOCOL_VERSION, contentDigest: 'v2-1234abcd', slot: 0, name: 'Tester', appearance: defaultAppearance() },
{ t: 'input', seq: 42, moveX: 1, moveZ: -0.5, yawQ: 12345, pitchQ: -4096, intents: 5 },
{ t: 'use', target: 'abc123' },
{ t: 'craft', recipe: 'craft_wooden_crate' },
@@@ -42,6 +43,18 @@ describe('protocol codec', () => {
).toBeNull()
})
++ it('decodes an older hello without a digest for a typed join rejection', () => {
++ const old = decodeClientMessage(JSON.stringify({
++ t: 'hello', v: PROTOCOL_VERSION - 1, slot: 0, name: 'Old',
++ appearance: defaultAppearance(),
++ }))
++ expect(old).toMatchObject({ t: 'hello', contentDigest: undefined })
++ expect(decodeClientMessage(JSON.stringify({
++ t: 'hello', v: PROTOCOL_VERSION, contentDigest: 'garbage',
++ slot: 0, name: 'Bad', appearance: defaultAppearance(),
++ }))).toBeNull()
++ })
++
it('refuses unknown intent bits and out-of-range quantised angles', () => {
const decodeInput = (fields: string) =>
decodeClientMessage(`{"t":"input","seq":1,"moveX":0,"moveZ":0,${fields}}`)
@@@ -61,6 +74,7 @@ describe('protocol codec', () => {
JSON.stringify({
t: 'hello',
v: 1,
++ contentDigest: 'v2-1234abcd',
token: 'usr_01JABCDEFGHJKMNPQRSTVWXYZ0',
slot: 0,
name: 'Tester',
ddiff --git a/packages/protocol/src/messages/client.ts b/packages/protocol/src/messages/client.ts
iindex 8949b89..1517260 100644
---- a/packages/protocol/src/messages/client.ts
++++ b/packages/protocol/src/messages/client.ts
@@@ -11,6 +11,8 @@ import { AppearanceSchema } from '../appearance.js'
export const ClientHelloSchema = z.object({
t: z.literal('hello'),
v: z.number().int(),
++ /** Digest of the client's loaded content definitions. Omitted by old clients. */
++ contentDigest: z.string().regex(/^v[0-9]+-[0-9a-f]{8}$/).optional(),
/** Character slot under this account (MMO-style, 3 max). */
slot: z.number().int().min(0).max(2).default(0),
name: z.string().min(1).max(24),
ddiff --git a/packages/protocol/src/messages/server.ts b/packages/protocol/src/messages/server.ts
iindex 5cf030b..50190c1 100644
---- a/packages/protocol/src/messages/server.ts
++++ b/packages/protocol/src/messages/server.ts
@@@ -16,6 +16,7 @@ import type {
export interface ServerWelcome {
t: 'welcome'
v: number
++ contentDigest: string
/** openvibe.network rank of this account: owner/admin/moderator, null for players. */
rank: 'owner' | 'admin' | 'moderator' | null
playerId: string
@@@ -30,12 +31,16 @@ export interface ServerReject {
t: 'reject'
reason:
| 'protocol_mismatch'
++ | 'content_mismatch'
| 'server_full'
| 'invalid_hello'
| 'guest_one_character'
| 'auth_failed'
/** The person signed out everywhere (or changed their password, or was banned) on the Network. */
| 'signed_out'
++ /** Present when content_mismatch: the offered and required definition sets. */
++ clientDigest?: string | null
++ serverDigest?: string
}
/** Entities that became relevant to this client (full state). */
ddiff --git a/packages/protocol/src/version.ts b/packages/protocol/src/version.ts
iindex 02c273c..79bd23d 100644
---- a/packages/protocol/src/version.ts
++++ b/packages/protocol/src/version.ts
@@@ -1,2 +1,2 @@
/** Wire protocol version — bump on ANY breaking message change. */
--export const PROTOCOL_VERSION = 20
++export const PROTOCOL_VERSION = 21
ddiff --git a/test/apps__client__src__net__connection.test.js b/test/apps__client__src__net__connection.test.js
nnew file mode 100644
iindex 0000000..4c1f7f0
---- /dev/null
++++ b/test/apps__client__src__net__connection.test.js
@@@ -0,0 +1,3 @@
++// file: 'apps/client/src/net/connection.test.ts'
++// covers: 'apps/client/src/net'
++// Generated by node test/run.js --index; do not edit.