openvibers/openvibe.games Commits
Gate joins on content digests and load Scraplandia's default map
Changes
@@@ -187,7 +187,7 @@ do not know the platform exists. See [ADR-0006](docs/adr/0006-canonical-subjectsdeclarative 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 itsrequested capabilities; every runtime binding checks it at call time, arevoked install or capability stops affecting the world on the next tick,and install/grant/use/deny/revoke are audited (`mod_audit`). Trust tiers are@@@ -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…')nnew file mode 100644@@@ -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',++ })++ })++})@@@ -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 {@@@ -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(),@@@ -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()@@@ -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,@@@ -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([])@@@ -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 ContentPackconst 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') continueconst 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)) continueconst granted = (await mods.grants(mod.id)).some((g) => g.capability === CAP_DEFINE && g.revokedAt === null,)@@@ -117,8 +117,8 @@ The Live legacy import (`platform/liveLegacyImport.ts`, `scripts/importLiveLegacpersistence 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.ddeleted file mode 100644@@@ -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 },-- },-- ],--}@@@ -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)@@@ -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([])++ })})@@@ -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_DEFSexport interface ContentPackV2 {defs?: { [K in PackDefKind]?: unknown[] }++ /** One authored map; the built-in Scraplandia pack supplies the default. */++ map?: WorldDefannouncements?: { 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 = falseconst 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. */nnew file mode 100644@@@ -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!@@@ -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: stringprivate 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: WorldDefconstructor(defs: ContentDefs) {++ this.digest = contentDigest(defs)const errors: string[] = []for (const raw of defs.items) {@@@ -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',@@@ -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),@@@ -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' | nullplayerId: 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). */@@@ -1,2 +1,2 @@/** Wire protocol version — bump on ANY breaking message change. */--export const PROTOCOL_VERSION = 20++export const PROTOCOL_VERSION = 21nnew file mode 100644@@@ -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.
@@ -187,7 +187,7 @@ do not know the platform exists. See [ADR-0006](docs/adr/0006-canonical-subjects
@@ -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;
declarative data packs checked against `@openvibe/content` (announcements;
inert, mod-owned props; definitions — items, recipes, crops, NPC archetypes —
inert, mod-owned props; definitions — items, recipes, crops, NPC archetypes —
merged into the content registry at boot, so they take effect on the next
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
requested capabilities; every runtime binding checks it at call time, a
revoked install or capability stops affecting the world on the next tick,
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
and install/grant/use/deny/revoke are audited (`mod_audit`). Trust tiers are
@@ -391,7 +391,7 @@ async function start(): Promise<void> {
@@ -391,7 +391,7 @@ async function start(): Promise<void> {
hud.setStatus('connecting…')
hud.setStatus('connecting…')
try {
try {
- await connection.connect(gameSocketUrl(), identity.token, name, appearance, slot, sso)
+ await connection.connect(gameSocketUrl(), identity.token, name, appearance, content.digest, slot, sso)
} catch {
} catch {
// Refused or timed out (a page opened during a restart): wait for the server, then reload.
// 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…')
hud.setStatus('could not reach the server — retrying automatically…')
new file mode 100644
new file mode 100644
@@ -0,0 +1,27 @@
@@ -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',
+ })
+ })
+})
@@ -3,10 +3,32 @@ import {
@@ -3,10 +3,32 @@ import {
decodeServerMessage,
decodeServerMessage,
encodeClientMessage,
encodeClientMessage,
type Appearance,
type Appearance,
+ type ClientHello,
type ClientMessage,
type ClientMessage,
type ServerMessage,
type ServerMessage,
+ type ServerWelcome,
} from '@openvibe/protocol'
} 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. */
/** 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
export const CONNECT_TIMEOUT_MS = 10_000
@@ -69,6 +91,7 @@ export class Connection {
@@ -69,6 +91,7 @@ export class Connection {
token: string,
token: string,
name: string,
name: string,
appearance: Appearance,
appearance: Appearance,
+ contentDigest: string,
slot = 0,
slot = 0,
/** Network access token; ignored if absent (the identity is the ticket). */
/** Network access token; ignored if absent (the identity is the ticket). */
networkToken?: string,
networkToken?: string,
@@ -102,17 +125,20 @@ export class Connection {
@@ -102,17 +125,20 @@ export class Connection {
})
})
ws.onmessage = (event) => {
ws.onmessage = (event) => {
const msg = decodeServerMessage(String(event.data))
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?.()
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 {
send(msg: ClientMessage): void {
@@ -28,7 +28,7 @@ import {
@@ -28,7 +28,7 @@ import {
type WirePlayerState,
type WirePlayerState,
type WireSkill,
type WireSkill,
} from '@openvibe/protocol'
} from '@openvibe/protocol'
-import { encodeHeights } from '@openvibe/content'
+import { createContent, encodeHeights } from '@openvibe/content'
import { quantiseAngle } from '@openvibe/shared'
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
// 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 {
@@ -206,6 +206,7 @@ class TestClient {
this.send({
this.send({
t: 'hello',
t: 'hello',
v: PROTOCOL_VERSION,
v: PROTOCOL_VERSION,
+ contentDigest: createContent().digest,
slot: 0,
slot: 0,
name,
name,
appearance: appearance ?? defaultAppearance(),
appearance: appearance ?? defaultAppearance(),
@@ -145,6 +145,7 @@ function connect(game: GameServer) {
@@ -145,6 +145,7 @@ function connect(game: GameServer) {
const msg = {
const msg = {
t: 'hello',
t: 'hello',
v: PROTOCOL_VERSION,
v: PROTOCOL_VERSION,
+ contentDigest: createContent().digest,
slot: 0,
slot: 0,
name: 'Ana',
name: 'Ana',
appearance: defaultAppearance(),
appearance: defaultAppearance(),
@@ -190,6 +191,10 @@ describe('Games on platform identity, events and mods (real server, restart)', (
@@ -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 () => {
it('runs identity, mods and events against the live world', async () => {
const a = await boot(platform)
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):
// The upgrade gate is the only identity source (ADR-0007 decision 8):
// a hello that arrives on a connection the transport never stamped is
// 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)', (
@@ -200,10 +205,28 @@ describe('Games on platform identity, events and mods (real server, restart)', (
expect(refused.welcome).toBeUndefined()
expect(refused.welcome).toBeUndefined()
expect(refused.closed).toEqual({ code: 4012, reason: 'no_identity' })
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.
// The signed-in player is keyed by the canonical subject.
const ana = connect(a.game)
const ana = connect(a.game)
const joined = await ana.hello('abcdefgh12', 'tok-ana')
const joined = await ana.hello('abcdefgh12', 'tok-ana')
expect(joined.welcome).toBeDefined()
expect(joined.welcome).toBeDefined()
+ expect(joined.welcome).toMatchObject({ contentDigest: a.world.content.digest })
signedInPlayerId = (joined.welcome as { playerId: string }).playerId
signedInPlayerId = (joined.welcome as { playerId: string }).playerId
// The tick never awaits I/O; shutdown queues the final checkpoint, drain waits for it.
// The tick never awaits I/O; shutdown queues the final checkpoint, drain waits for it.
a.game.shutdown()
a.game.shutdown()
@@ -86,6 +86,15 @@ export class SessionsSystem implements System {
@@ -86,6 +86,15 @@ export class SessionsSystem implements System {
private async resolveHello(conn: GameConnection, msg: ClientHello): Promise<void> {
private async resolveHello(conn: GameConnection, msg: ClientHello): Promise<void> {
const ctx = this.ctx
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) {
if (msg.v !== PROTOCOL_VERSION) {
conn.send(encodeServerMessage({ t: 'reject', reason: 'protocol_mismatch' }))
conn.send(encodeServerMessage({ t: 'reject', reason: 'protocol_mismatch' }))
conn.close(4002, 'protocol_mismatch')
conn.close(4002, 'protocol_mismatch')
@@ -238,6 +247,7 @@ export class SessionsSystem implements System {
@@ -238,6 +247,7 @@ export class SessionsSystem implements System {
ctx.net.send(session, {
ctx.net.send(session, {
t: 'welcome',
t: 'welcome',
v: PROTOCOL_VERSION,
v: PROTOCOL_VERSION,
+ contentDigest: serverDigest,
rank,
rank,
playerId: playerId as string,
playerId: playerId as string,
entityId: session.entityId as string,
entityId: session.entityId as string,
@@ -1,4 +1,4 @@
@@ -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 { openTestStore } from '@openvibe/persistence/testing'
import { describe, expect, it } from 'vitest'
import { describe, expect, it } from 'vitest'
import {
import {
@@ -41,6 +41,12 @@ async function setup() {
@@ -41,6 +41,12 @@ async function setup() {
}
}
describe('games-content@2 definitions', () => {
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', () => {
it('needs games.def.define for any non-empty defs section', () => {
expect(capabilitiesUsedBy(itemPack('widget'))).toEqual([CAP_DEFINE])
expect(capabilitiesUsedBy(itemPack('widget'))).toEqual([CAP_DEFINE])
expect(capabilitiesUsedBy({ defs: { items: [] } })).toEqual([])
expect(capabilitiesUsedBy({ defs: { items: [] } })).toEqual([])
@@ -5,14 +5,14 @@
@@ -5,14 +5,14 @@
*
*
* announcements games.world.announce periodic server announcements
* announcements games.world.announce periodic server announcements
* props games.prop.place inert props placed in the world
* 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
* merged into the content registry
*
*
* Definitions are validated here with the schemas the base content uses and
* 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
* 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
* 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
* 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,
* picking them up, moving or welding them, and only items without health,
@@ -56,6 +56,7 @@ export interface ContentProp {
@@ -56,6 +56,7 @@ export interface ContentProp {
export interface ContentPack {
export interface ContentPack {
/** Definitions, deep-validated by `mergePackDefs`; the schema only bounds them. */
/** Definitions, deep-validated by `mergePackDefs`; the schema only bounds them. */
defs?: NonNullable<ContentPackV2['defs']>
defs?: NonNullable<ContentPackV2['defs']>
+ map?: ContentPackV2['map']
announcements?: ContentAnnouncement[]
announcements?: ContentAnnouncement[]
props?: ContentProp[]
props?: ContentProp[]
}
}
@@ -77,6 +78,7 @@ export const CONTENT_PACK_SCHEMA = {
@@ -77,6 +78,7 @@ export const CONTENT_PACK_SCHEMA = {
npcs: { type: 'array', maxItems: MAX_PACK_DEFS.npcs, items: { type: 'object' } },
npcs: { type: 'array', maxItems: MAX_PACK_DEFS.npcs, items: { type: 'object' } },
},
},
},
},
+ map: { type: 'object', maxProperties: 12 },
announcements: {
announcements: {
type: 'array',
type: 'array',
maxItems: 10,
maxItems: 10,
@@ -119,7 +121,7 @@ const validateSchema = ajv.compile(CONTENT_PACK_SCHEMA)
@@ -119,7 +121,7 @@ const validateSchema = ajv.compile(CONTENT_PACK_SCHEMA)
/** Which capability each pack section needs. */
/** Which capability each pack section needs. */
export function capabilitiesUsedBy(pack: ContentPack): string[] {
export function capabilitiesUsedBy(pack: ContentPack): string[] {
const used: 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.announcements ?? []).length > 0) used.push(CAP_ANNOUNCE)
if ((pack.props ?? []).length > 0) used.push(CAP_PLACE_PROP)
if ((pack.props ?? []).length > 0) used.push(CAP_PLACE_PROP)
return used
return used
@@ -152,7 +154,7 @@ export function validateContentPack(
@@ -152,7 +154,7 @@ export function validateContentPack(
}
}
const pack = value as unknown as ContentPack
const pack = value as unknown as ContentPack
const errors: string[] = []
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.
// The content the pack would be merged onto; its own ids may not already exist.
const merged = mergePackDefs(
const merged = mergePackDefs(
{
{
@@ -191,7 +193,7 @@ export async function loadDefinitionPacks(
@@ -191,7 +193,7 @@ export async function loadDefinitionPacks(
for (const mod of await mods.list()) {
for (const mod of await mods.list()) {
if (mod.status !== 'enabled') continue
if (mod.status !== 'enabled') continue
const pack = mod.pack as unknown as ContentPackV2
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(
const granted = (await mods.grants(mod.id)).some(
(g) => g.capability === CAP_DEFINE && g.revokedAt === null,
(g) => g.capability === CAP_DEFINE && g.revokedAt === null,
)
)
@@ -117,8 +117,8 @@ The Live legacy import (`platform/liveLegacyImport.ts`, `scripts/importLiveLegac
@@ -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.
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: 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.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.
- **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.
- **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.
- **M4: instances and scale.** Several instances per place, registry and leases, transfer, region interest, load tests.
deleted file mode 100644
deleted file mode 100644
@@ -1,38 +0,0 @@
@@ -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 },
- },
- ],
-}
@@ -20,11 +20,11 @@ export * from './defs/items.js'
@@ -20,11 +20,11 @@ export * from './defs/items.js'
export * from './defs/recipes.js'
export * from './defs/recipes.js'
export * from './defs/skills.js'
export * from './defs/skills.js'
export * from './defs/resources.js'
export * from './defs/resources.js'
-export * from './defs/scrapcity.js'
+export * from './packs/scraplandia.js'
import { ContentRegistry } from './registry.js'
import { ContentRegistry } from './registry.js'
import { mergePackDefs, type ContentPackV2 } from './pack.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 { CROPS } from './defs/crops.js'
import { FACTIONS, NPC_ARCHETYPES } from './defs/npcs.js'
import { FACTIONS, NPC_ARCHETYPES } from './defs/npcs.js'
import { MARKETS } from './defs/markets.js'
import { MARKETS } from './defs/markets.js'
@@ -53,9 +53,9 @@ export function createContent(packs: ContentPackV2[] = []): ContentRegistry {
@@ -53,9 +53,9 @@ export function createContent(packs: ContentPackV2[] = []): ContentRegistry {
factions: FACTIONS,
factions: FACTIONS,
markets: MARKETS,
markets: MARKETS,
jobs: JOBS,
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('; ')}`)
if (errors.length > 0) throw new Error(`content pack: ${errors.join('; ')}`)
return new ContentRegistry(defs)
return new ContentRegistry(defs)
@@ -13,6 +13,7 @@ import {
@@ -13,6 +13,7 @@ import {
RECIPES,
RECIPES,
RESOURCE_NODES,
RESOURCE_NODES,
SCRAP_CITY,
SCRAP_CITY,
+ SCRAPLANDIA_PACK,
SKILLS,
SKILLS,
type ContentDefs,
type ContentDefs,
type ContentPackV2,
type ContentPackV2,
@@ -178,4 +179,34 @@ describe('contentDigest', () => {
@@ -178,4 +179,34 @@ describe('contentDigest', () => {
}
}
expect(contentDigest(edited)).not.toBe(contentDigest(added))
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([])
+ })
})
})
@@ -21,6 +21,7 @@ import { NpcArchetypeSchema, type NpcArchetype } from './schema/npc.js'
@@ -21,6 +21,7 @@ import { NpcArchetypeSchema, type NpcArchetype } from './schema/npc.js'
import { ItemDefSchema, type ItemDef } from './schema/item.js'
import { ItemDefSchema, type ItemDef } from './schema/item.js'
import { RecipeSchema, type Recipe } from './schema/recipe.js'
import { RecipeSchema, type Recipe } from './schema/recipe.js'
import type { ContentDefs } from './registry.js'
import type { ContentDefs } from './registry.js'
+import { WorldDefSchema, type WorldDef } from './schema/world.js'
/** The pack format version both sides must agree on. */
/** The pack format version both sides must agree on. */
export const CONTENT_PACK_VERSION = 2
export const CONTENT_PACK_VERSION = 2
@@ -33,6 +34,8 @@ export type PackDefKind = keyof typeof MAX_PACK_DEFS
@@ -33,6 +34,8 @@ export type PackDefKind = keyof typeof MAX_PACK_DEFS
export interface ContentPackV2 {
export interface ContentPackV2 {
defs?: { [K in PackDefKind]?: unknown[] }
defs?: { [K in PackDefKind]?: unknown[] }
+ /** One authored map; the built-in Scraplandia pack supplies the default. */
+ map?: WorldDef
announcements?: { text: string; everySeconds: number }[]
announcements?: { text: string; everySeconds: number }[]
props?: { key: string; item: string; pos: [number, number, number]; yaw?: number }[]
props?: { key: string; item: string; pos: [number, number, number]; yaw?: number }[]
}
}
@@ -109,6 +112,8 @@ export function mergePackDefs<B extends PackBase>(
@@ -109,6 +112,8 @@ export function mergePackDefs<B extends PackBase>(
const crops = [...base.crops]
const crops = [...base.crops]
const npcs = [...base.npcs]
const npcs = [...base.npcs]
const errors: string[] = []
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 baseItems = new Set(base.items.map((d) => d.id))
const baseRecipes = new Set(base.recipes.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 baseCrops = new Set(base.crops.map((d) => d.id))
@@ -117,6 +122,15 @@ export function mergePackDefs<B extends PackBase>(
@@ -117,6 +122,15 @@ export function mergePackDefs<B extends PackBase>(
packs.forEach((p, n) => {
packs.forEach((p, n) => {
const where = packs.length > 1 ? `pack ${n}: ` : ''
const where = packs.length > 1 ? `pack ${n}: ` : ''
const mine: string[] = []
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 i = parseDefs<ItemDef>('items', ItemDefSchema, p.defs?.items)
const r = parseDefs<Recipe>('recipes', RecipeSchema, p.defs?.recipes)
const r = parseDefs<Recipe>('recipes', RecipeSchema, p.defs?.recipes)
const c = parseDefs<CropDef>('crops', CropDefSchema, p.defs?.crops)
const c = parseDefs<CropDef>('crops', CropDefSchema, p.defs?.crops)
@@ -154,7 +168,7 @@ export function mergePackDefs<B extends PackBase>(
@@ -154,7 +168,7 @@ export function mergePackDefs<B extends PackBase>(
}
}
errors.push(...mine.map((e) => where + e))
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. */
/** 32-bit FNV-1a over a string; plain integer maths, identical in every JS runtime. */
new file mode 100644
new file mode 100644
@@ -0,0 +1,33 @@
@@ -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!
@@ -12,6 +12,7 @@ import { RecipeSchema, type Recipe } from './schema/recipe.js'
@@ -12,6 +12,7 @@ import { RecipeSchema, type Recipe } from './schema/recipe.js'
import { ResourceNodeTypeSchema, type ResourceNodeType } from './schema/resourceNode.js'
import { ResourceNodeTypeSchema, type ResourceNodeType } from './schema/resourceNode.js'
import { SkillDefSchema, type SkillDef } from './schema/skill.js'
import { SkillDefSchema, type SkillDef } from './schema/skill.js'
import { WorldDefSchema, type WorldDef } from './schema/world.js'
import { WorldDefSchema, type WorldDef } from './schema/world.js'
+import { contentDigest } from './pack.js'
export interface ContentDefs {
export interface ContentDefs {
items: ItemDef[]
items: ItemDef[]
@@ -33,6 +34,8 @@ export interface ContentDefs {
@@ -33,6 +34,8 @@ export interface ContentDefs {
* corrupt a live world.
* corrupt a live world.
*/
*/
export class ContentRegistry {
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 items = new Map<string, ItemDef>()
private readonly recipes = new Map<string, Recipe>()
private readonly recipes = new Map<string, Recipe>()
private readonly skills = new Map<string, SkillDef>()
private readonly skills = new Map<string, SkillDef>()
@@ -45,6 +48,7 @@ export class ContentRegistry {
@@ -45,6 +48,7 @@ export class ContentRegistry {
readonly world: WorldDef
readonly world: WorldDef
constructor(defs: ContentDefs) {
constructor(defs: ContentDefs) {
+ this.digest = contentDigest(defs)
const errors: string[] = []
const errors: string[] = []
for (const raw of defs.items) {
for (const raw of defs.items) {
@@ -1,5 +1,6 @@
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
import { describe, expect, it } from 'vitest'
import { decodeClientMessage, encodeClientMessage } from './codec.js'
import { decodeClientMessage, encodeClientMessage } from './codec.js'
+import { PROTOCOL_VERSION } from './version.js'
import type { ClientMessage } from './messages/client.js'
import type { ClientMessage } from './messages/client.js'
import { defaultAppearance } from './appearance.js'
import { defaultAppearance } from './appearance.js'
@@ -8,7 +9,7 @@ describe('protocol codec', () => {
@@ -8,7 +9,7 @@ describe('protocol codec', () => {
const messages: ClientMessage[] = [
const messages: ClientMessage[] = [
// hello carries no identity field: authentication happens at the
// hello carries no identity field: authentication happens at the
// WebSocket upgrade (ADR-0007 decision 8).
// 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: 'input', seq: 42, moveX: 1, moveZ: -0.5, yawQ: 12345, pitchQ: -4096, intents: 5 },
{ t: 'use', target: 'abc123' },
{ t: 'use', target: 'abc123' },
{ t: 'craft', recipe: 'craft_wooden_crate' },
{ t: 'craft', recipe: 'craft_wooden_crate' },
@@ -42,6 +43,18 @@ describe('protocol codec', () => {
@@ -42,6 +43,18 @@ describe('protocol codec', () => {
).toBeNull()
).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', () => {
it('refuses unknown intent bits and out-of-range quantised angles', () => {
const decodeInput = (fields: string) =>
const decodeInput = (fields: string) =>
decodeClientMessage(`{"t":"input","seq":1,"moveX":0,"moveZ":0,${fields}}`)
decodeClientMessage(`{"t":"input","seq":1,"moveX":0,"moveZ":0,${fields}}`)
@@ -61,6 +74,7 @@ describe('protocol codec', () => {
@@ -61,6 +74,7 @@ describe('protocol codec', () => {
JSON.stringify({
JSON.stringify({
t: 'hello',
t: 'hello',
v: 1,
v: 1,
+ contentDigest: 'v2-1234abcd',
token: 'usr_01JABCDEFGHJKMNPQRSTVWXYZ0',
token: 'usr_01JABCDEFGHJKMNPQRSTVWXYZ0',
slot: 0,
slot: 0,
name: 'Tester',
name: 'Tester',
@@ -11,6 +11,8 @@ import { AppearanceSchema } from '../appearance.js'
@@ -11,6 +11,8 @@ import { AppearanceSchema } from '../appearance.js'
export const ClientHelloSchema = z.object({
export const ClientHelloSchema = z.object({
t: z.literal('hello'),
t: z.literal('hello'),
v: z.number().int(),
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). */
/** Character slot under this account (MMO-style, 3 max). */
slot: z.number().int().min(0).max(2).default(0),
slot: z.number().int().min(0).max(2).default(0),
name: z.string().min(1).max(24),
name: z.string().min(1).max(24),
@@ -16,6 +16,7 @@ import type {
@@ -16,6 +16,7 @@ import type {
export interface ServerWelcome {
export interface ServerWelcome {
t: 'welcome'
t: 'welcome'
v: number
v: number
+ contentDigest: string
/** openvibe.network rank of this account: owner/admin/moderator, null for players. */
/** openvibe.network rank of this account: owner/admin/moderator, null for players. */
rank: 'owner' | 'admin' | 'moderator' | null
rank: 'owner' | 'admin' | 'moderator' | null
playerId: string
playerId: string
@@ -30,12 +31,16 @@ export interface ServerReject {
@@ -30,12 +31,16 @@ export interface ServerReject {
t: 'reject'
t: 'reject'
reason:
reason:
| 'protocol_mismatch'
| 'protocol_mismatch'
+ | 'content_mismatch'
| 'server_full'
| 'server_full'
| 'invalid_hello'
| 'invalid_hello'
| 'guest_one_character'
| 'guest_one_character'
| 'auth_failed'
| 'auth_failed'
/** The person signed out everywhere (or changed their password, or was banned) on the Network. */
/** The person signed out everywhere (or changed their password, or was banned) on the Network. */
| 'signed_out'
| '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). */
/** Entities that became relevant to this client (full state). */
@@ -1,2 +1,2 @@
@@ -1,2 +1,2 @@
/** Wire protocol version — bump on ANY breaking message change. */
/** Wire protocol version — bump on ANY breaking message change. */
-export const PROTOCOL_VERSION = 20
+export const PROTOCOL_VERSION = 21
new file mode 100644
new file mode 100644
@@ -0,0 +1,3 @@
@@ -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.