From b70a498e91509d0b67ac34992e6d47ebde38feb6 Mon Sep 17 00:00:00 2001 From: Chadwick Maycumber <36460656+cmaycumber@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:37:07 -0700 Subject: [PATCH 1/3] feat(core): world providers for game-owned simulations Games whose state lives in their own simulation (run as a renderoni system) were invisible to agents: describe/observe listed only entities, check only resolved entities.* paths, and the state hash never changed. engine.worlds.register({ name, describe?, observe?, resolve?, hash? }) now feeds all of them: - describe: worlds. = describe() (key present only with providers) - observe tier 0: "## " sections after the entity section; entities reserve up to half the 500-byte budget, providers split the rest evenly, unused provider bytes flow back to entities - check: world.. via resolve(), with explicit failures for a missing provider, missing resolve() or a throwing resolve() - getStateHash: provider digests appended in name order; with no hashing providers the digest bytes are unchanged (pinned against upstream main) Diagnostics: RND_0410 invalid provider, RND_0411 duplicate name, RND_0413 invalid hash value. Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 1 + README.md | 33 +++ src/core/engine.ts | 11 +- src/core/hashing.ts | 55 ++++- src/core/index.ts | 1 + src/core/observations.ts | 65 +++++- src/core/worlds.ts | 164 +++++++++++++++ src/mcp/index.ts | 12 +- src/testing/check.ts | 91 ++++++++- tests/world_providers.test.ts | 373 ++++++++++++++++++++++++++++++++++ 10 files changed, 792 insertions(+), 14 deletions(-) create mode 100644 src/core/worlds.ts create mode 100644 tests/world_providers.test.ts diff --git a/AGENTS.md b/AGENTS.md index 4176102..bba74dc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,6 +54,7 @@ When connected over MCP (`bin/renderoni.js mcp`), use: - **`act`**: Dispatch typed gameplay actions (`{ name: string, payload?: any }`). - **`step`**: Advance simulation by $N$ fixed ticks. - **`check`**: Run AST assertions. +- **World providers**: games with their own simulation register `engine.worlds.register({ name, describe?, observe?, resolve?, hash? })`; providers appear under `worlds` in `describe`, as `## ` sections in Tier 0 `observe`, as `world..` in `check`, and in the state hash (provider-free hashes are unchanged). --- diff --git a/README.md b/README.md index 65b4f80..5df7659 100644 --- a/README.md +++ b/README.md @@ -206,6 +206,39 @@ npx renderoni mcp - **`step`**: Advance simulation by $N$ fixed ticks. - **`check`**: Run AST assertions headlessly. +### World providers: games that run their own simulation + +A game whose state lives in its own simulation (run as an `engine.systems` system) rather than in renderoni entities can still be driven by agents. Register a world provider and it shows up in every MCP tool: + +```ts +import { createRenderoni, type WorldProvider } from 'renderoni'; + +const towns = [{ id: 'ashford', pop: 12 }]; +const game = await createRenderoni({ mode: 'headless', seed: 7 }); +game.systems.add({ update: () => { towns[0].pop++; } }); + +const sim: WorldProvider = { + name: 'sim', // 1-64 chars of A-Z a-z 0-9 _ - + describe: () => ({ towns }), + observe: (budgetBytes) => towns.map((t) => `${t.id}: pop ${t.pop}`), + resolve: ([kind, id, field]) => + kind === 'towns' ? (towns.find((t) => t.id === id) as Record | undefined)?.[field] : undefined, + hash: () => towns.map((t) => `${t.id}:${t.pop}`).join('|'), +}; +const unregister = game.worlds.register(sim); + +game.step(10); +game.check([{ op: 'greaterThan', path: 'world.sim.towns.ashford.pop', value: 20 }]); +unregister(); +``` + +- **`describe`** adds `worlds: { : describe() }` (only when a provider is registered). +- **`observe`** (Tier 0) appends a `## ` section per provider after the entity lines. Entities reserve up to half of the 500-byte budget, providers split the rest evenly and are called with their share, and bytes a provider leaves unused go back to the entities. Tier 1 deltas are entity-only. +- **`check`** resolves `world..` through `resolve(path)`; a missing provider or a provider without `resolve()` is reported as a failure. +- **`step`** / `getStateHash()` fold every `hash()` (string, finite number or `Uint8Array`) into the digest in name order. Games without providers hash exactly as before. + +Invalid names and hooks fail with `RND_0410`, duplicate names with `RND_0411`, invalid hash values with `RND_0413`. + --- ## 🚦 Feature Status diff --git a/src/core/engine.ts b/src/core/engine.ts index 11b0591..21d2c5a 100644 --- a/src/core/engine.ts +++ b/src/core/engine.ts @@ -15,6 +15,7 @@ import { PhysicsEngine, type CollisionEvent, type SensorEvent } from './physics. import { StateHasher } from './hashing.js'; import { ResourceOwnershipTracker } from './ownership.js'; import { DiagnosticLogger } from './diagnostics.js'; +import { WorldRegistry } from './worlds.js'; import { InputManager } from '../input/input-manager.js'; import { ActionRegistry } from '../input/actions.js'; import { @@ -181,6 +182,8 @@ export class RenderoniEngine { readonly input: InputManager; readonly actions: ActionRegistry; readonly loop: GameLoop; + /** Game-owned simulations exposed to MCP describe/observe/check and the state hash. */ + readonly worlds: WorldRegistry; // Native 3D Presentation Objects readonly native: { @@ -222,6 +225,7 @@ export class RenderoniEngine { this.input = new InputManager(); this.actions = new ActionRegistry(); this.loop = new GameLoop(config.loop); + this.worlds = new WorldRegistry(this.diagnostics, () => this.clock.tick); if (this.loop.enabled) { this.actions.register({ name: 'loop.start', handle: () => this.loop.start() }); @@ -968,6 +972,9 @@ export class RenderoniEngine { * Bodies skipped by the canonical sync are audited first, so a native move * Rapier cannot report is repaired and diagnosed instead of being hashed as * stale state. + * + * Registered world providers that implement `hash()` are folded in by name + * order; without them the digest is byte-identical to a provider-free engine. */ getStateHash(): string { if (!this.hasher.isReady) { @@ -990,7 +997,8 @@ export class RenderoniEngine { return this.hasher.computeHash( rawEntities, this.transformPipeline.currentBuffer, - this.physics.getActiveContacts() + this.physics.getActiveContacts(), + this.worlds.digests() ); } @@ -1108,6 +1116,7 @@ export class RenderoniEngine { guard(() => this.actions.clear()); guard(() => this.commands.clear()); guard(() => this.systems.clear()); + guard(() => this.worlds.clear()); guard(() => { const disposalErrors = this.reportResourceDisposalErrors( '', diff --git a/src/core/hashing.ts b/src/core/hashing.ts index d3266cc..bfb7b15 100644 --- a/src/core/hashing.ts +++ b/src/core/hashing.ts @@ -39,6 +39,7 @@ import { OFFSET_ANGVEL_Y, OFFSET_ANGVEL_Z, } from './transform-buffer.js'; +import type { WorldDigest } from './worlds.js'; export const SCALE_Q12 = 4096.0; // 2^12 @@ -150,11 +151,16 @@ export class StateHasher { * * Throws when called before {@link StateHasher.init} resolves; a placeholder * digest would make an uninitialized engine look deterministic. + * + * World provider digests are appended after the entity metadata in name + * order. With no digests nothing is appended, so games without providers + * hash exactly the bytes they always did. */ computeHash( entities: StateEntityRecord[], transformBuffer: Float32Array, - contacts: ContactPairRecord[] = [] + contacts: ContactPairRecord[] = [], + worlds: WorldDigest[] = [] ): string { const h64 = this.h64; if (!h64) { @@ -249,15 +255,60 @@ export class StateHasher { sortedEntities.map((entity) => `${entity.id}:${stableStringify(entity.state ?? {})}`).join('|') ); const transformBytes = new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength); - const bytes = new Uint8Array(transformBytes.length + metadata.length); + const worldBytes = encodeWorldDigests(worlds); + const bytes = new Uint8Array(transformBytes.length + metadata.length + worldBytes.length); bytes.set(transformBytes); bytes.set(metadata, transformBytes.length); + bytes.set(worldBytes, transformBytes.length + metadata.length); const hashBigInt = h64(bytes); return '0x' + hashBigInt.toString(16).padStart(16, '0'); } } +/** + * Encodes world digests as `\0worlds` followed, per provider in code-unit + * name order, by `\0\0\0` where the tag is + * `s` (UTF-8 string), `n` (little-endian float64) or `b` (raw bytes). The + * explicit lengths keep adjacent providers from aliasing each other. + */ +function encodeWorldDigests(worlds: WorldDigest[]): Uint8Array { + if (worlds.length === 0) return new Uint8Array(0); + + const encoder = new TextEncoder(); + const sorted = [...worlds].sort((a, b) => compareCodeUnits(a.name, b.name)); + const parts: Uint8Array[] = [encoder.encode('\0worlds')]; + + for (const { name, digest } of sorted) { + let tag: string; + let payload: Uint8Array; + if (typeof digest === 'string') { + tag = 's'; + payload = encoder.encode(digest); + } else if (typeof digest === 'number') { + if (!Number.isFinite(digest)) { + throw new Error(`RND_0413: world provider "${name}" returned a non-finite hash value ${digest}.`); + } + tag = 'n'; + payload = new Uint8Array(8); + new DataView(payload.buffer).setFloat64(0, digest, true); + } else { + tag = 'b'; + payload = digest; + } + parts.push(encoder.encode(`\0${name}\0${tag}${payload.length}\0`), payload); + } + + const total = parts.reduce((sum, part) => sum + part.length, 0); + const out = new Uint8Array(total); + let offset = 0; + for (const part of parts) { + out.set(part, offset); + offset += part.length; + } + return out; +} + function quantizeChecked(value: number | undefined, entityId: string, label: string): number { if (typeof value !== 'number' || !Number.isFinite(value)) { throw new Error( diff --git a/src/core/index.ts b/src/core/index.ts index cf5e788..d06cfb9 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -8,6 +8,7 @@ export * from './ownership.js'; export * from './diagnostics.js'; export * from './observations.js'; export * from './loop.js'; +export * from './worlds.js'; export * from './engine.js'; export interface EntityRecord { diff --git a/src/core/observations.ts b/src/core/observations.ts index 13f0c46..871d72a 100644 --- a/src/core/observations.ts +++ b/src/core/observations.ts @@ -6,6 +6,7 @@ */ import type { RenderoniEngine } from './engine.js'; +import type { WorldProvider } from './worlds.js'; export interface Tier0Observation { markdown: string; @@ -19,9 +20,25 @@ export interface DeltaObservation { recentEvents: Array<{ event: string; payload: unknown; tick: number }>; } +/** UTF-8 byte budget of a Tier 0 observation. */ +export const TIER0_BUDGET_BYTES = 500; + export class ObservationEngine { /** * Tier 0: High-density Semantic Markdown Topology (<=500 bytes) + * + * World providers with `observe()` are appended after the entity section + * (entity lines plus the RecentEvents line), each under a `## ` + * heading, in name order. The budget left after the header line is shared: + * + * 1. Entities reserve what they need, up to half of it. + * 2. The rest is split evenly between providers; each is called with its + * share (heading included) and truncated to it. + * 3. Whatever providers leave unused goes back to the entity section. + * + * So neither side can starve the other: entities always keep up to half the + * budget and every provider keeps at least an equal slice of the other half. + * Without providers the output is unchanged. */ static generateTier0(game: RenderoniEngine): Tier0Observation { const tick = game.tick; @@ -49,7 +66,11 @@ export class ObservationEngine { lines.push(`RecentEvents: [${evts}]`); } - const markdown = truncateToUtf8Budget(lines.join('\n'), 500); + const providers = game.worlds.list().filter((provider) => typeof provider.observe === 'function'); + const markdown = + providers.length === 0 + ? truncateToUtf8Budget(lines.join('\n'), TIER0_BUDGET_BYTES) + : composeWithProviders(lines[0], lines.slice(1), providers); const bytes = new TextEncoder().encode(markdown).length; return { @@ -83,6 +104,48 @@ export class ObservationEngine { const textEncoder = new TextEncoder(); const TRUNCATION_MARKER = '\n… [truncated]'; +function utf8Length(value: string): number { + return textEncoder.encode(value).length; +} + +/** + * Lays out the Tier 0 header, entity section and provider sections within + * {@link TIER0_BUDGET_BYTES}. Every section after the header is budgeted + * including the newline that separates it from the previous one. + */ +function composeWithProviders( + header: string, + entityLines: string[], + providers: WorldProvider[] +): string { + const remaining = Math.max(0, TIER0_BUDGET_BYTES - utf8Length(header)); + const entityText = entityLines.length > 0 ? `\n${entityLines.join('\n')}` : ''; + const entityReserve = Math.min(utf8Length(entityText), Math.floor(remaining / 2)); + const share = Math.floor((remaining - entityReserve) / providers.length); + + let providerText = ''; + for (const provider of providers) { + const heading = `\n## ${provider.name}`; + const bodyBudget = Math.max(0, share - utf8Length(heading)); + const body = provider.observe!(bodyBudget) + .map((line) => `\n${line}`) + .join(''); + const section = `${heading}${body}`; + if (utf8Length(section) <= share) { + providerText += section; + } else if (share > utf8Length(TRUNCATION_MARKER)) { + providerText += truncateToUtf8Budget(section, share); + } + // A share too small to hold even the truncation marker shows nothing. + } + + const entityBudget = remaining - utf8Length(providerText); + const entitySection = + utf8Length(entityText) <= entityBudget ? entityText : truncateToUtf8Budget(entityText, entityBudget); + + return truncateToUtf8Budget(`${header}${entitySection}${providerText}`, TIER0_BUDGET_BYTES); +} + function truncateToUtf8Budget(value: string, budget: number): string { if (textEncoder.encode(value).length <= budget) { return value; diff --git a/src/core/worlds.ts b/src/core/worlds.ts new file mode 100644 index 0000000..b6d663a --- /dev/null +++ b/src/core/worlds.ts @@ -0,0 +1,164 @@ +/** + * Renderoni World Providers + * + * Lets a game whose state does not live in renderoni entities (its own + * simulation, run as a renderoni system) stay visible to agents: providers + * feed the MCP `describe` and `observe` tools, `check` paths of the form + * `world..`, and the deterministic state hash. + */ + +import type { DiagnosticLogger } from './diagnostics.js'; +import { compareCodeUnits } from './hashing.js'; + +export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }; + +/** Value a provider folds into the state hash. Numbers must be finite. */ +export type WorldHashValue = string | number | Uint8Array; + +export interface WorldProvider { + /** + * Unique, path-safe name: 1–64 characters of `A-Z a-z 0-9 _ -`. It is the + * second segment of `world..` check paths, so no dots. + */ + readonly name: string; + /** JSON summary returned under `worlds.` by the MCP `describe` tool. */ + describe?(): JsonValue; + /** + * Compact Tier 0 observation lines. Keep the output within `budgetBytes` + * (UTF-8); anything past it is truncated. + */ + observe?(budgetBytes: number): string[]; + /** Resolves the path segments after `world..` for `check` assertions. */ + resolve?(path: string[]): unknown; + /** Deterministic digest of the provider's simulation state. */ + hash?(): WorldHashValue; +} + +/** A provider's hash contribution, as passed to {@link StateHasher.computeHash}. */ +export interface WorldDigest { + name: string; + digest: WorldHashValue; +} + +const WORLD_NAME_PATTERN = /^[A-Za-z0-9_-]{1,64}$/; +const PROVIDER_METHODS = ['describe', 'observe', 'resolve', 'hash'] as const; + +/** + * Registry of world providers, iterated in code-unit order of their names so + * every consumer (observe, describe, hash) is deterministic. + */ +export class WorldRegistry { + private providers: Map = new Map(); + + constructor( + private readonly diagnostics: DiagnosticLogger, + private readonly currentTick: () => number = () => 0 + ) {} + + /** + * Registers a provider and returns a function that unregisters it. + * + * Invalid providers (bad name, non-function hooks) and duplicate names are + * rejected with a diagnostic and a thrown error; nothing is registered. + */ + register(provider: WorldProvider): () => void { + this.validate(provider); + + if (this.providers.has(provider.name)) { + throw this.fail( + 'RND_0411', + `RND_0411: a world provider named "${provider.name}" is already registered.`, + 'Use a unique provider name, or call the unregister function returned by worlds.register() first.' + ); + } + + this.providers.set(provider.name, provider); + return () => { + // Only remove this exact provider; a later replacement stays registered. + if (this.providers.get(provider.name) === provider) { + this.providers.delete(provider.name); + } + }; + } + + get(name: string): WorldProvider | undefined { + return this.providers.get(name); + } + + has(name: string): boolean { + return this.providers.has(name); + } + + get size(): number { + return this.providers.size; + } + + /** Registered providers in deterministic (code-unit) name order. */ + list(): WorldProvider[] { + return Array.from(this.providers.values()).sort((a, b) => compareCodeUnits(a.name, b.name)); + } + + /** Hash contributions of every provider that implements `hash()`, in name order. */ + digests(): WorldDigest[] { + const digests: WorldDigest[] = []; + for (const provider of this.list()) { + if (typeof provider.hash !== 'function') continue; + const digest = provider.hash(); + const valid = + typeof digest === 'string' || + digest instanceof Uint8Array || + (typeof digest === 'number' && Number.isFinite(digest)); + if (!valid) { + throw this.fail( + 'RND_0413', + `RND_0413: world provider "${provider.name}" returned an invalid hash value ${String(digest)}. ` + + 'hash() must return a string, a finite number or a Uint8Array.', + 'Return a deterministic string, finite number or Uint8Array digest of the provider state.' + ); + } + digests.push({ name: provider.name, digest }); + } + return digests; + } + + clear(): void { + this.providers.clear(); + } + + private validate(provider: WorldProvider): void { + if (!provider || typeof provider !== 'object') { + throw this.fail( + 'RND_0410', + 'RND_0410: worlds.register() requires a provider object.', + 'Pass an object such as { name: "sim", describe() {...}, hash() {...} }.' + ); + } + if (typeof provider.name !== 'string' || !WORLD_NAME_PATTERN.test(provider.name)) { + throw this.fail( + 'RND_0410', + `RND_0410: invalid world provider name ${JSON.stringify(provider.name)}. ` + + 'Names must be 1-64 characters of A-Z, a-z, 0-9, "_" or "-" because they appear in check paths.', + 'Pick a path-safe name such as "sim" or "sunder-march".' + ); + } + for (const method of PROVIDER_METHODS) { + const hook = provider[method]; + if (hook !== undefined && typeof hook !== 'function') { + throw this.fail( + 'RND_0410', + `RND_0410: world provider "${provider.name}" has a non-function ${method}.`, + `Make ${method} a method, or omit it.` + ); + } + } + } + + private fail(code: string, message: string, remediation: string): Error { + this.diagnostics.emit(code, message, { + severity: 'error', + tick: this.currentTick(), + remediation, + }); + return new Error(message); + } +} diff --git a/src/mcp/index.ts b/src/mcp/index.ts index a077736..7163b7a 100644 --- a/src/mcp/index.ts +++ b/src/mcp/index.ts @@ -8,6 +8,7 @@ import * as readline from 'node:readline'; import { Value } from '@sinclair/typebox/value'; import type { RenderoniEngine } from '../core/engine.js'; +import type { JsonValue } from '../core/worlds.js'; import { ObservationEngine } from '../core/observations.js'; import { evaluateCheck, isAssertionOp, type AssertionOp } from '../testing/check.js'; import { RENDERONI_VERSION } from '../version.js'; @@ -29,7 +30,7 @@ export const MCP_SERVER_INFO = { } as const; export const MCP_INSTRUCTIONS = - `Inspect and drive a headless Renderoni simulation over stdio. describe reports configuration, entities, and registered actions; observe supports tiers 0 and 1; act dispatches registered gameplay actions; step advances 1–${MAX_MCP_STEP_TICKS} whole ticks; check evaluates supported assertions. The world starts empty until entities are spawned by game code.`; + `Inspect and drive a headless Renderoni simulation over stdio. describe reports configuration, entities, registered actions and game-registered worlds; observe supports tiers 0 and 1; act dispatches registered gameplay actions; step advances 1–${MAX_MCP_STEP_TICKS} whole ticks; check evaluates supported assertions, including world.. values. The world starts empty until entities are spawned by game code.`; export interface MCPToolDefinition { name: string; @@ -257,9 +258,15 @@ function toolError(message: string): { content: Array<{ type: 'text'; text: stri export const MCP_TOOLS: Record = { describe: { name: 'describe', - description: 'Inspect engine configuration, active entities, and registered actions', + description: 'Inspect engine configuration, active entities, registered actions and world providers', parameters: {}, execute: (game: RenderoniEngine) => { + // Present only when the game registered providers, so provider-free + // games describe exactly as before. + const worlds: Record = {}; + for (const provider of game.worlds.list()) { + if (typeof provider.describe === 'function') worlds[provider.name] = provider.describe(); + } return { tick: game.tick, mode: game.mode, @@ -274,6 +281,7 @@ export const MCP_TOOLS: Record = { state: e.state, position: e.position, })), + ...(game.worlds.size > 0 ? { worlds } : {}), }; }, }, diff --git a/src/testing/check.ts b/src/testing/check.ts index a5b4bcd..af6388e 100644 --- a/src/testing/check.ts +++ b/src/testing/check.ts @@ -57,23 +57,43 @@ export function evaluateCheck(game: RenderoniEngine, assertions: AssertionOp[]): break; } case 'greaterThan': { - const val = resolvePath(game, ast.path!); + const resolved = resolvePath(game, ast.path!); + if ('error' in resolved) { + failures.push(`greaterThan failed for ${ast.path}: ${resolved.error}`); + break; + } + const val = resolved.value; if (typeof val !== 'number' || val <= (ast.value as number)) { - failures.push(`greaterThan failed for ${ast.path}: expected > ${ast.value}, got ${val}`); + failures.push(`greaterThan failed for ${ast.path}: expected > ${ast.value}, got ${formatValue(val, resolved.world)}`); } break; } case 'lessThan': { - const val = resolvePath(game, ast.path!); + const resolved = resolvePath(game, ast.path!); + if ('error' in resolved) { + failures.push(`lessThan failed for ${ast.path}: ${resolved.error}`); + break; + } + const val = resolved.value; if (typeof val !== 'number' || val >= (ast.value as number)) { - failures.push(`lessThan failed for ${ast.path}: expected < ${ast.value}, got ${val}`); + failures.push(`lessThan failed for ${ast.path}: expected < ${ast.value}, got ${formatValue(val, resolved.world)}`); } break; } case 'equals': { - const val = resolvePath(game, ast.path!); - if (val !== ast.value) { - failures.push(`equals failed for ${ast.path}: expected ${ast.value}, got ${val}`); + const resolved = resolvePath(game, ast.path!); + if ('error' in resolved) { + failures.push(`equals failed for ${ast.path}: ${resolved.error}`); + break; + } + const val = resolved.value; + // World values may be arrays or records; compare those structurally. + // Entity paths keep strict identity. + const matches = resolved.world ? jsonEquals(val, ast.value) : val === ast.value; + if (!matches) { + failures.push( + `equals failed for ${ast.path}: expected ${formatValue(ast.value, resolved.world)}, got ${formatValue(val, resolved.world)}` + ); } break; } @@ -134,8 +154,36 @@ export function evaluateCheck(game: RenderoniEngine, assertions: AssertionOp[]): }; } -function resolvePath(game: RenderoniEngine, path: string): unknown { +type ResolvedPath = { value: unknown; world?: boolean } | { error: string }; + +/** + * Resolves `entities..position[.x|y|z]`, `entities..state.` and + * `world..`. A missing world provider, one without `resolve()` + * or one whose `resolve()` throws is reported as an error rather than a value. + */ +function resolvePath(game: RenderoniEngine, path: string): ResolvedPath { const parts = path.split('.'); + if (parts[0] === 'world') { + const name = parts[1]; + const provider = name === undefined ? undefined : game.worlds.get(name); + if (!provider) { + return { error: `no world provider named "${name ?? ''}" is registered` }; + } + if (typeof provider.resolve !== 'function') { + return { error: `world provider "${name}" does not implement resolve()` }; + } + try { + return { value: provider.resolve(parts.slice(2)), world: true }; + } catch (error) { + return { + error: `world provider "${name}" failed to resolve: ${error instanceof Error ? error.message : String(error)}`, + }; + } + } + return { value: resolveEntityPath(game, parts) }; +} + +function resolveEntityPath(game: RenderoniEngine, parts: string[]): unknown { if (parts[0] === 'entities') { if (!game.entities.has(parts[1])) return undefined; const ent = game.entities.get(parts[1]); @@ -152,3 +200,30 @@ function resolvePath(game: RenderoniEngine, path: string): unknown { } return undefined; } + +/** Renders world values as JSON; entity values keep their original String() form. */ +function formatValue(value: unknown, world?: boolean): string { + if (world && value !== null && typeof value === 'object') { + try { + return JSON.stringify(value); + } catch { + return String(value); + } + } + return String(value); +} + +function jsonEquals(a: unknown, b: unknown): boolean { + if (a === b) return true; + if (a === null || b === null || typeof a !== 'object' || typeof b !== 'object') return false; + if (Array.isArray(a) !== Array.isArray(b)) return false; + if (Array.isArray(a)) { + const other = b as unknown[]; + return a.length === other.length && a.every((item, index) => jsonEquals(item, other[index])); + } + const left = a as Record; + const right = b as Record; + const keys = Object.keys(left); + if (keys.length !== Object.keys(right).length) return false; + return keys.every((key) => Object.hasOwn(right, key) && jsonEquals(left[key], right[key])); +} diff --git a/tests/world_providers.test.ts b/tests/world_providers.test.ts new file mode 100644 index 0000000..121deb2 --- /dev/null +++ b/tests/world_providers.test.ts @@ -0,0 +1,373 @@ +import { describe, it, expect } from 'vitest'; +import { createRenderoni, type RenderoniEngine, type WorldProvider } from '../src/index.js'; +import { ObservationEngine, TIER0_BUDGET_BYTES } from '../src/core/observations.js'; +import { createMCPServer } from '../src/mcp/index.js'; + +/** + * World providers let a game whose simulation lives outside renderoni entities + * (an RTS with its own Sim class, say) stay visible to agents through + * describe, observe, check and the state hash. + */ + +/** Minimal stand-in for a game-owned simulation run as a renderoni system. */ +class TownSim { + towns = [ + { id: 'ashford', pop: 12, food: 30 }, + { id: 'brine', pop: 5, food: 8 }, + ]; + tick = 0; + + update(): void { + this.tick++; + for (const town of this.towns) town.food += town.pop % 3; + } + + provider(name = 'sim'): WorldProvider { + return { + name, + describe: () => ({ tick: this.tick, towns: this.towns.map((t) => ({ ...t })) }), + observe: (budget) => { + const lines = this.towns.map((t) => `${t.id}: pop ${t.pop} food ${t.food}`); + return budget > 0 ? lines : []; + }, + resolve: (path) => { + if (path[0] === 'tick') return this.tick; + if (path[0] === 'towns') { + const town = this.towns.find((t) => t.id === path[1]); + if (!town) return undefined; + return path[2] === undefined ? { ...town } : (town as Record)[path[2]]; + } + return undefined; + }, + hash: () => this.towns.map((t) => `${t.id}:${t.pop}:${t.food}`).join('|'), + }; + } +} + +async function gameWithSim(): Promise<{ game: RenderoniEngine; sim: TownSim }> { + const game = await createRenderoni({ mode: 'headless', seed: 11 }); + const sim = new TownSim(); + game.systems.add({ update: () => sim.update() }); + game.worlds.register(sim.provider()); + return { game, sim }; +} + +async function call(mcp: ReturnType, name: string, args?: unknown) { + return mcp.handleRequest({ + method: 'tools/call', + params: args === undefined ? { name } : { name, arguments: args }, + }); +} + +describe('World providers: registration', () => { + it('registers, lists in name order and unregisters', async () => { + const game = await createRenderoni({ mode: 'headless' }); + const offB = game.worlds.register({ name: 'beta' }); + const offA = game.worlds.register({ name: 'alpha' }); + + expect(game.worlds.size).toBe(2); + expect(game.worlds.list().map((p) => p.name)).toEqual(['alpha', 'beta']); + expect(game.worlds.has('beta')).toBe(true); + + offB(); + offB(); + expect(game.worlds.has('beta')).toBe(false); + expect(game.worlds.size).toBe(1); + + offA(); + expect(game.worlds.size).toBe(0); + game.dispose(); + }); + + it('rejects duplicate names with RND_0411 and keeps the first provider', async () => { + const game = await createRenderoni({ mode: 'headless' }); + const first = { name: 'sim' }; + game.worlds.register(first); + + expect(() => game.worlds.register({ name: 'sim' })).toThrow(/RND_0411/); + expect(game.worlds.get('sim')).toBe(first); + expect(game.diagnostics.getRecords().map((r) => r.code)).toContain('RND_0411'); + game.dispose(); + }); + + it('rejects names that are not path-safe and non-function hooks with RND_0410', async () => { + const game = await createRenderoni({ mode: 'headless' }); + + for (const name of ['', 'a.b', 'has space', 'x'.repeat(65), 42 as unknown as string]) { + expect(() => game.worlds.register({ name })).toThrow(/RND_0410/); + } + expect(() => game.worlds.register({ name: 'ok', hash: 'nope' } as unknown as WorldProvider)).toThrow( + /RND_0410/ + ); + expect(() => game.worlds.register(null as unknown as WorldProvider)).toThrow(/RND_0410/); + expect(game.worlds.size).toBe(0); + expect(game.diagnostics.getRecords().every((r) => r.code === 'RND_0410')).toBe(true); + game.dispose(); + }); + + it('an unregister function never removes a later provider with the same name', async () => { + const game = await createRenderoni({ mode: 'headless' }); + const off = game.worlds.register({ name: 'sim' }); + off(); + const replacement = { name: 'sim' }; + game.worlds.register(replacement); + off(); + expect(game.worlds.get('sim')).toBe(replacement); + game.dispose(); + }); + + it('is cleared on dispose', async () => { + const { game } = await gameWithSim(); + game.dispose(); + expect(game.worlds.size).toBe(0); + }); +}); + +describe('World providers: observe', () => { + it('appends provider lines after the entity lines within the Tier 0 budget', async () => { + const { game } = await gameWithSim(); + game.add({ id: 'scout', state: { hp: 3 } }); + + const { markdown, bytes } = ObservationEngine.generateTier0(game); + const lines = markdown.split('\n'); + + expect(bytes).toBeLessThanOrEqual(TIER0_BUDGET_BYTES); + expect(lines[0]).toMatch(/^# Tick: 0/); + expect(lines.indexOf('## sim')).toBeGreaterThan(lines.findIndex((l) => l.startsWith('scout:'))); + expect(lines).toContain('ashford: pop 12 food 30'); + expect(lines).toContain('brine: pop 5 food 8'); + game.dispose(); + }); + + it('does not let a chatty provider starve the entities, nor the reverse', async () => { + const game = await createRenderoni({ mode: 'headless' }); + for (let i = 0; i < 40; i++) game.add({ id: `unit_${i}`, state: { hp: 100 } }); + let askedFor = -1; + game.worlds.register({ + name: 'chatty', + observe: (budget) => { + askedFor = budget; + return Array.from({ length: 200 }, (_, i) => `line ${i} ${'x'.repeat(20)}`); + }, + }); + + const { markdown, bytes } = ObservationEngine.generateTier0(game); + expect(bytes).toBeLessThanOrEqual(TIER0_BUDGET_BYTES); + expect(askedFor).toBeGreaterThan(150); + expect(askedFor).toBeLessThan(TIER0_BUDGET_BYTES / 2); + + const [entityPart, providerPart] = markdown.split('\n## chatty'); + expect(providerPart).toBeDefined(); + expect(entityPart).toContain('unit_0:'); + expect(new TextEncoder().encode(entityPart).length).toBeGreaterThan(200); + expect(providerPart).toContain('line 0'); + game.dispose(); + }); + + it('gives unused provider budget back to the entities', async () => { + const game = await createRenderoni({ mode: 'headless' }); + for (let i = 0; i < 40; i++) game.add({ id: `unit_${i}`, state: { hp: 100 } }); + const without = ObservationEngine.generateTier0(game).markdown; + + game.worlds.register({ name: 'q', observe: () => ['ok'] }); + const withQuiet = ObservationEngine.generateTier0(game); + expect(withQuiet.bytes).toBeLessThanOrEqual(TIER0_BUDGET_BYTES); + expect(withQuiet.markdown).toContain('\n## q\nok'); + // Entities lose only the bytes the quiet provider actually used. + const entityLines = (md: string) => md.split('\n').filter((l) => l.startsWith('unit_')).length; + expect(entityLines(withQuiet.markdown)).toBeGreaterThanOrEqual(entityLines(without) - 1); + game.dispose(); + }); + + it('is unchanged for games without providers', async () => { + const game = await createRenderoni({ mode: 'headless' }); + game.add({ id: 'crate', state: { hp: 1 } }); + const before = ObservationEngine.generateTier0(game).markdown; + game.worlds.register({ name: 'silent', describe: () => null }); + expect(ObservationEngine.generateTier0(game).markdown).toBe(before); + game.dispose(); + }); +}); + +describe('World providers: check', () => { + it('resolves world.. for greaterThan, lessThan and equals', async () => { + const { game } = await gameWithSim(); + game.step(4); + + const result = game.check([ + { op: 'equals', path: 'world.sim.tick', value: 4 }, + { op: 'greaterThan', path: 'world.sim.towns.ashford.food', value: 29 }, + { op: 'lessThan', path: 'world.sim.towns.brine.pop', value: 6 }, + { op: 'equals', path: 'world.sim.towns.brine', value: { id: 'brine', pop: 5, food: 16 } }, + ]); + expect(result).toEqual({ passed: true, failures: [] }); + game.dispose(); + }); + + it('reports failing values, missing providers and missing resolve()', async () => { + const { game } = await gameWithSim(); + game.worlds.register({ name: 'opaque' }); + game.worlds.register({ + name: 'broken', + resolve: () => { + throw new Error('index out of date'); + }, + }); + + const result = game.check([ + { op: 'greaterThan', path: 'world.sim.towns.brine.pop', value: 50 }, + { op: 'equals', path: 'world.sim.towns.nowhere', value: 1 }, + { op: 'equals', path: 'world.missing.anything', value: 1 }, + { op: 'lessThan', path: 'world.opaque.x', value: 1 }, + { op: 'equals', path: 'world.broken.x', value: 1 }, + ]); + + expect(result.passed).toBe(false); + expect(result.failures).toEqual([ + 'greaterThan failed for world.sim.towns.brine.pop: expected > 50, got 5', + 'equals failed for world.sim.towns.nowhere: expected 1, got undefined', + 'equals failed for world.missing.anything: no world provider named "missing" is registered', + 'lessThan failed for world.opaque.x: world provider "opaque" does not implement resolve()', + 'equals failed for world.broken.x: world provider "broken" failed to resolve: index out of date', + ]); + game.dispose(); + }); + + it('keeps entity paths working exactly as before', async () => { + const { game } = await gameWithSim(); + game.add({ id: 'hero', state: { hp: 10 } }); + const result = game.check([ + { op: 'equals', path: 'entities.hero.state.hp', value: 10 }, + { op: 'equals', path: 'entities.hero.position', value: [0, 0, 0] }, + ]); + expect(result.failures).toEqual(['equals failed for entities.hero.position: expected 0,0,0, got 0,0,0']); + game.dispose(); + }); +}); + +describe('World providers: state hash', () => { + // Computed with upstream main (eef080a) before world providers existed. The + // scenario has no physics bodies, so the digest is not platform dependent. + const PRE_PROVIDER_HASH = '0xefe1328288cc3308'; + + async function slotlessScenario(): Promise { + const game = await createRenderoni({ mode: 'headless', seed: 11 }); + game.add({ id: 'town-a', tags: ['town'], state: { pop: 7, owner: 'dust' } }); + game.add({ id: 'squad-1', state: { hp: 40, order: 'march' } }); + return game; + } + + it('is byte-identical to the pre-provider digest without providers', async () => { + const game = await slotlessScenario(); + game.step(3); + expect(game.getStateHash()).toBe(PRE_PROVIDER_HASH); + game.dispose(); + }); + + it('is unchanged by providers that do not implement hash()', async () => { + const game = await slotlessScenario(); + game.worlds.register({ name: 'view', describe: () => ({ ok: true }), observe: () => ['ok'] }); + game.step(3); + expect(game.getStateHash()).toBe(PRE_PROVIDER_HASH); + game.dispose(); + }); + + it('changes when provider state changes and is stable run to run', async () => { + const run = async () => { + const { game } = await gameWithSim(); + const hashes = [game.getStateHash()]; + game.step(1); + hashes.push(game.getStateHash()); + game.dispose(); + return hashes; + }; + + const [first, second] = [await run(), await run()]; + expect(first).toEqual(second); + expect(first[0]).not.toBe(first[1]); + }); + + it('folds providers in name order regardless of registration order and value type', async () => { + const make = async (order: string[]) => { + const game = await createRenderoni({ mode: 'headless', seed: 3 }); + const hashes: Record = { + a: () => 'alpha', + b: () => 2.5, + c: () => new Uint8Array([1, 2, 3]), + }; + for (const name of order) game.worlds.register({ name, hash: hashes[name] }); + const digest = game.getStateHash(); + game.dispose(); + return digest; + }; + + const forward = await make(['a', 'b', 'c']); + expect(await make(['c', 'a', 'b'])).toBe(forward); + expect(forward).not.toBe(await make(['a', 'b'])); + }); + + it('rejects invalid provider hash values with RND_0413', async () => { + const game = await createRenderoni({ mode: 'headless' }); + game.worlds.register({ name: 'nan', hash: () => Number.NaN }); + expect(() => game.getStateHash()).toThrow(/RND_0413/); + expect(game.diagnostics.getRecords().map((r) => r.code)).toContain('RND_0413'); + game.dispose(); + }); +}); + +describe('World providers: MCP end to end', () => { + it('describes, observes, steps, acts and checks a provider-backed game', async () => { + const { game, sim } = await gameWithSim(); + game.actions.register({ + name: 'sim.settle', + handle: (payload: unknown) => { + const { town, pop } = payload as { town: string; pop: number }; + sim.towns.find((t) => t.id === town)!.pop += pop; + }, + }); + const mcp = createMCPServer({ game }); + + const described = JSON.parse((await call(mcp, 'describe')).content[0].text); + expect(described.worlds.sim.tick).toBe(0); + expect(described.worlds.sim.towns).toHaveLength(2); + expect(described.entitiesCount).toBe(0); + + const observed = JSON.parse((await call(mcp, 'observe')).content[0].text); + expect(observed.markdown).toContain('## sim\nashford: pop 12 food 30'); + expect(observed.bytes).toBeLessThanOrEqual(TIER0_BUDGET_BYTES); + + const firstStep = JSON.parse((await call(mcp, 'step', { ticks: 1 })).content[0].text); + const secondStep = JSON.parse((await call(mcp, 'step', { ticks: 1 })).content[0].text); + expect(secondStep.stateHash).not.toBe(firstStep.stateHash); + + await call(mcp, 'act', { name: 'sim.settle', payload: { town: 'brine', pop: 3 } }); + await call(mcp, 'step', { ticks: 1 }); + + const passed = JSON.parse( + ( + await call(mcp, 'check', { + assertions: [ + { op: 'equals', path: 'world.sim.tick', value: 3 }, + { op: 'equals', path: 'world.sim.towns.brine.pop', value: 8 }, + ], + }) + ).content[0].text + ); + expect(passed).toEqual({ passed: true, failures: [] }); + + const failed = JSON.parse( + (await call(mcp, 'check', { assertions: [{ op: 'greaterThan', path: 'world.nope.x', value: 0 }] })) + .content[0].text + ); + expect(failed.passed).toBe(false); + expect(failed.failures[0]).toMatch(/no world provider named "nope"/); + game.dispose(); + }); + + it('omits worlds from describe when no provider is registered', async () => { + const game = await createRenderoni({ mode: 'headless' }); + const described = JSON.parse((await call(createMCPServer({ game }), 'describe')).content[0].text); + expect(described).not.toHaveProperty('worlds'); + game.dispose(); + }); +}); From 9581ef60d175c3b1e2c405ef31745b43468612bd Mon Sep 17 00:00:00 2001 From: Chadwick Maycumber <36460656+cmaycumber@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:39:34 -0700 Subject: [PATCH 2/3] feat(core): optional physics with lazily loaded Rapier createRenderoni({ physics: false }) / new RenderoniEngine({ physics: false }) never load Rapier. The default engine is unchanged. - physics.ts imports Rapier through a cached dynamic import() (loadRapier), so bundlers split the ~2 MB inlined WASM into its own chunk. Every other module uses `import type` only. - Presets build descriptors from ctx.native.rapier (the loaded module) instead of a static import. - With physics disabled, step() skips the physics step, contact/sensor processing and the canonical re-sync; engine.native.world and ctx.native.rapier throw RND_0412 (physics presets fail before creating anything); getStateHash works with no contacts. A physics-free consumer bundle (esbuild, esm + splitting, three external) goes from one 2,206,271-byte chunk containing the WASM to a 116,789-byte entry plus a 2,101,004-byte Rapier chunk that is never loaded. Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 1 + README.md | 2 + src/core/engine.ts | 62 +++++++++++++---- src/core/physics.ts | 45 ++++++++++-- src/presets/body.ts | 24 ++++--- src/presets/define-preset.ts | 11 ++- src/presets/dynamic-player.ts | 9 +-- src/presets/kcc-player.ts | 15 ++-- src/presets/mesh.ts | 16 +++-- src/presets/model.ts | 11 +-- src/presets/procedural-model.ts | 27 ++++---- src/presets/sensor.ts | 16 +++-- tests/physics_optional.test.ts | 119 ++++++++++++++++++++++++++++++++ 13 files changed, 288 insertions(+), 70 deletions(-) create mode 100644 tests/physics_optional.test.ts diff --git a/AGENTS.md b/AGENTS.md index bba74dc..886926e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,6 +10,7 @@ Renderoni is structured in 4 strict hierarchical layers: - **L0: Deterministic Kernel (`src/core/`)**: Integer tick clock (`clock.ts`), seeded PRNG streams (`prng.ts`), dual-buffer transform pipeline (`transform-buffer.ts`), XXH3 state hashing (`hashing.ts`), resource ownership matrix (`ownership.ts`), and diagnostics (`diagnostics.ts`). - **Rule 1**: NEVER call `Math.random()`, `Date.now()`, `performance.now()`, or `requestAnimationFrame()` inside simulation logic or entity updates. Always use `engine.prng` and `engine.clock.tick`. - **Rule 2**: NEVER bypass the dual-buffer transform pipeline. Write physics transforms into canonical buffer slots, never directly into render scene graphs. + - **Rule 3**: NEVER import `@dimforge/rapier3d-compat` as a value outside `src/core/physics.ts` (`import type` is fine). Presets get the module from `ctx.native.rapier`, so `physics: false` bundles never include the Rapier WASM. - **L1: Batteries & Subsystems (`src/presets/`, `src/animation/`, `src/audio/`, `src/vfx/`, `src/ui/`, `src/scene/`)**: High-level declarative presets (`body`, `sensor`, `light`, `kccPlayer`, `dynamicPlayer`, `proceduralModel`) and compact scene inventories for prompt → img2threejs factories. - **L2: Agent Tooling & MCP (`src/mcp/`, `src/testing/`)**: Stdio Model Context Protocol server, custom Vitest matchers, and headless CLI verification. - **L3: Web Application & Demos (`src/demo/`, `index.html`)**: Interactive playground and multi-archetype web showcases. diff --git a/README.md b/README.md index 5df7659..3b5a87e 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,8 @@ resize(); game.start(); ``` +Games that never use Rapier (their own simulation, no bodies or colliders) can opt out: `createRenderoni({ physics: false })` never loads the Rapier WASM, since Rapier is imported lazily at engine init, so bundlers split it into a separate chunk that is never fetched. `step()` skips the physics step, and Rapier-backed APIs (`native.world`, the `body`/`sensor`/`kccPlayer` presets, `mesh`/`model` with physics) fail with `RND_0412`. + --- ## ⚔ CLI & Asset Generation diff --git a/src/core/engine.ts b/src/core/engine.ts index 21d2c5a..13e22ff 100644 --- a/src/core/engine.ts +++ b/src/core/engine.ts @@ -6,12 +6,12 @@ */ import * as THREE from 'three'; -import RAPIER from '@dimforge/rapier3d-compat'; +import type RAPIER from '@dimforge/rapier3d-compat'; import { SimulationClock, type SimulationClockOptions } from './clock.js'; import { PRNG } from './prng.js'; import { StructuralCommandQueue } from './commands.js'; import { DualBufferTransformPipeline } from './transform-buffer.js'; -import { PhysicsEngine, type CollisionEvent, type SensorEvent } from './physics.js'; +import { PhysicsEngine, type CollisionEvent, type RapierModule, type SensorEvent } from './physics.js'; import { StateHasher } from './hashing.js'; import { ResourceOwnershipTracker } from './ownership.js'; import { DiagnosticLogger } from './diagnostics.js'; @@ -62,6 +62,12 @@ export interface RenderoniConfig { subsystems?: Array<(engine: RenderoniEngine) => void>; /** Opt-in play / win / lose / restart match loop. */ loop?: boolean | GameLoopOptions; + /** + * Set to `false` for games that never use Rapier: the WASM runtime is not + * loaded, `step()` skips the physics step and contact/sensor processing, and + * Rapier-backed APIs fail with RND_0412. Defaults to `true`. + */ + physics?: boolean; } export class EventEmitter { @@ -165,6 +171,8 @@ export class SystemManager { export class RenderoniEngine { readonly mode: EngineMode; readonly seed: number | string; + /** False when created with `physics: false`; Rapier is then never loaded. */ + readonly physicsEnabled: boolean; // L0 Deterministic Kernel Components readonly clock: SimulationClock; @@ -210,6 +218,7 @@ export class RenderoniEngine { this.initialConfig = config; this.mode = config.mode ?? 'headless'; this.seed = config.seed ?? 42; + this.physicsEnabled = config.physics !== false; this.physics = new PhysicsEngine(); this.clock = new SimulationClock(config.clock); @@ -253,13 +262,14 @@ export class RenderoniEngine { renderer.shadowMap.enabled = true; } - const physicsRef = this.physics; + const engine = this; this.native = { scene, camera, renderer, get world() { - return physicsRef.world; + if (!engine.physicsEnabled) throw engine.physicsDisabledError('engine.native.world'); + return engine.physics.world; }, }; } @@ -297,10 +307,12 @@ export class RenderoniEngine { private async runInit(resolvedConfig: RenderoniConfig): Promise { await Promise.all([ - this.physics.init({ - gravity: resolvedConfig.gravity, - integrationParameters: { dt: this.clock.fixedDt }, - }), + this.physicsEnabled + ? this.physics.init({ + gravity: resolvedConfig.gravity, + integrationParameters: { dt: this.clock.fixedDt }, + }) + : undefined, this.hasher.init(), ]); @@ -311,6 +323,23 @@ export class RenderoniEngine { } } + /** + * Error for a Rapier-backed API used on an engine created with + * `physics: false`, reported as an RND_0412 diagnostic. + */ + private physicsDisabledError(operation: string): Error { + const message = + `RND_0412: ${operation} is unavailable because this engine was created with physics: false. ` + + 'Rapier was never loaded, so bodies, colliders and physics presets cannot be created.'; + this.diagnostics.emit('RND_0412', message, { + severity: 'error', + tick: this.clock.tick, + remediation: + 'Omit `physics` (or pass `physics: true`) to use Rapier, or build the entity without a body (plain EntityConfig, mesh/model with physics: "none").', + }); + return new Error(message); + } + /** True once dispose() has run; disposed engines reject further simulation. */ get disposed(): boolean { return this.isDisposed; @@ -374,10 +403,19 @@ export class RenderoniEngine { throw this.duplicateEntityIdError(requestedId); } + // Physics engines keep failing fast when add() runs before init(). + if (this.physicsEnabled) void this.native.world; + const engine = this; const ctx: EntityContext = { id: requestedId ?? generateId(), native: { - world: this.native.world, + get world() { + return engine.native.world; + }, + get rapier(): RapierModule { + if (!engine.physicsEnabled) throw engine.physicsDisabledError('ctx.native.rapier'); + return engine.physics.rapier; + }, threeScene: this.native.scene, }, events: { @@ -896,9 +934,9 @@ export class RenderoniEngine { ent.update?.(this.clock.fixedDt); } - // 5. Step Rapier Physics World + // 5. Step Rapier Physics World (skipped entirely without physics) const currentTick = this.clock.tick; - this.physics.step( + if (this.physicsEnabled) this.physics.step( this.transformPipeline, (contact: CollisionEvent) => { this.events.emit( @@ -926,7 +964,7 @@ export class RenderoniEngine { // 7. Re-sync authoritative Rapier state so impulses, velocity writes and // teleports applied by post-physics systems are canonical for this tick. - this.physics.syncCanonicalState(this.transformPipeline); + if (this.physicsEnabled) this.physics.syncCanonicalState(this.transformPipeline); // 8. Periodically audit skipped bodies so a native move that Rapier // cannot report can never become silent stale canonical state. diff --git a/src/core/physics.ts b/src/core/physics.ts index d28576d..0ef7db9 100644 --- a/src/core/physics.ts +++ b/src/core/physics.ts @@ -5,10 +5,38 @@ * pair sorting and bulk transform copying to the Canonical Physics Buffer. */ -import RAPIER from '@dimforge/rapier3d-compat'; +import type RAPIER from '@dimforge/rapier3d-compat'; import type { DualBufferTransformPipeline } from './transform-buffer.js'; import { compareCodeUnits } from './hashing.js'; +/** The Rapier module namespace, as returned by {@link loadRapier}. */ +export type RapierModule = typeof RAPIER; + +let rapierLoad: Promise | null = null; + +/** + * Loads and initializes the Rapier WASM runtime once per process. + * + * Rapier is imported dynamically so bundlers split its ~2 MB inlined WASM into + * a chunk that is only fetched when an engine initializes physics; engines + * created with `physics: false` never load it. A failed load stays retryable. + */ +export function loadRapier(): Promise { + if (!rapierLoad) { + rapierLoad = import('@dimforge/rapier3d-compat') + .then(async (mod) => { + const rapier = ((mod as { default?: RapierModule }).default ?? mod) as RapierModule; + await rapier.init(); + return rapier; + }) + .catch((error: unknown) => { + rapierLoad = null; + throw error; + }); + } + return rapierLoad; +} + export interface PhysicsWorldConfig { gravity?: [number, number, number]; integrationParameters?: { @@ -44,7 +72,7 @@ export interface SyncStats { export class PhysicsEngine { private _world: RAPIER.World | null = null; private _eventQueue: RAPIER.EventQueue | null = null; - private isInitialized = false; + private _rapier: RapierModule | null = null; private colliderToEntity: Map = new Map(); private sensorColliders: Set = new Set(); @@ -66,10 +94,7 @@ export class PhysicsEngine { * previous world's WASM memory. */ async init(config: PhysicsWorldConfig = {}): Promise { - if (!this.isInitialized) { - await RAPIER.init(); - this.isInitialized = true; - } + const RAPIER = this._rapier ?? (this._rapier = await loadRapier()); if (this._world) return; @@ -86,6 +111,14 @@ export class PhysicsEngine { } } + /** The initialized Rapier module. Throws before {@link PhysicsEngine.init} resolves. */ + get rapier(): RapierModule { + if (!this._rapier) { + throw new Error('PhysicsEngine not initialized. Call await physics.init() first.'); + } + return this._rapier; + } + get hasWorld(): boolean { return this._world !== null; } diff --git a/src/presets/body.ts b/src/presets/body.ts index 4d4a15a..836c8cb 100644 --- a/src/presets/body.ts +++ b/src/presets/body.ts @@ -6,7 +6,7 @@ import { Type, type Static } from '@sinclair/typebox'; import * as THREE from 'three'; -import RAPIER from '@dimforge/rapier3d-compat'; +import type RAPIER from '@dimforge/rapier3d-compat'; import { definePreset, type EntityContext } from './define-preset.js'; export const BodyShapeSchema = Type.Union([ @@ -46,6 +46,8 @@ export const body = definePreset({ version: 1, schema: BodyOptionsSchema, create(ctx: EntityContext, options: BodyOptions) { + // Throws RND_0412 up front when the engine runs without physics. + const R = ctx.native.rapier; const shape = options.shape ?? 'box'; const bodyType = options.type ?? 'fixed'; const pos = options.position ?? [0, 0, 0]; @@ -58,24 +60,24 @@ export const body = definePreset({ if (shape === 'sphere') { const radius = options.radius ?? (options.size?.[0] ? options.size[0] / 2 : 0.5); geometry = new THREE.SphereGeometry(radius, 16, 16); - colliderDesc = RAPIER.ColliderDesc.ball(radius); + colliderDesc = R.ColliderDesc.ball(radius); } else if (shape === 'cylinder') { const radius = options.radius ?? 0.5; const height = options.size?.[1] ?? 2.0; geometry = new THREE.CylinderGeometry(radius, radius, height, 16); - colliderDesc = RAPIER.ColliderDesc.cylinder(height / 2, radius); + colliderDesc = R.ColliderDesc.cylinder(height / 2, radius); } else if (shape === 'capsule') { const radius = options.radius ?? 0.5; const halfHeight = options.size?.[1] ? options.size[1] / 2 : 1.0; geometry = new THREE.CapsuleGeometry(radius, halfHeight * 2, 8, 16); - colliderDesc = RAPIER.ColliderDesc.capsule(halfHeight, radius); + colliderDesc = R.ColliderDesc.capsule(halfHeight, radius); } else { // Default: box const sx = options.size?.[0] ?? 1.0; const sy = options.size?.[1] ?? 1.0; const sz = options.size?.[2] ?? 1.0; geometry = new THREE.BoxGeometry(sx, sy, sz); - colliderDesc = RAPIER.ColliderDesc.cuboid(sx / 2, sy / 2, sz / 2); + colliderDesc = R.ColliderDesc.cuboid(sx / 2, sy / 2, sz / 2); } const material = new THREE.MeshStandardMaterial({ color }); @@ -93,13 +95,13 @@ export const body = definePreset({ // 2. Create Rapier Rigid Body & Collider let rigidBodyDesc: RAPIER.RigidBodyDesc; if (bodyType === 'dynamic') { - rigidBodyDesc = RAPIER.RigidBodyDesc.dynamic(); + rigidBodyDesc = R.RigidBodyDesc.dynamic(); } else if (bodyType === 'kinematicPositionBased') { - rigidBodyDesc = RAPIER.RigidBodyDesc.kinematicPositionBased(); + rigidBodyDesc = R.RigidBodyDesc.kinematicPositionBased(); } else if (bodyType === 'kinematicVelocityBased') { - rigidBodyDesc = RAPIER.RigidBodyDesc.kinematicVelocityBased(); + rigidBodyDesc = R.RigidBodyDesc.kinematicVelocityBased(); } else { - rigidBodyDesc = RAPIER.RigidBodyDesc.fixed(); + rigidBodyDesc = R.RigidBodyDesc.fixed(); } rigidBodyDesc.setTranslation(pos[0], pos[1], pos[2]); @@ -124,8 +126,8 @@ export const body = definePreset({ if (options.restitution !== undefined) { colliderDesc.setRestitution(options.restitution); } - colliderDesc.setActiveEvents(RAPIER.ActiveEvents.COLLISION_EVENTS); - colliderDesc.setActiveCollisionTypes(RAPIER.ActiveCollisionTypes.ALL); + colliderDesc.setActiveEvents(R.ActiveEvents.COLLISION_EVENTS); + colliderDesc.setActiveCollisionTypes(R.ActiveCollisionTypes.ALL); const collider = ctx.native.world.createCollider(colliderDesc, rigidBody); diff --git a/src/presets/define-preset.ts b/src/presets/define-preset.ts index d9d7935..4ebb110 100644 --- a/src/presets/define-preset.ts +++ b/src/presets/define-preset.ts @@ -7,14 +7,23 @@ import { type TSchema, type Static } from '@sinclair/typebox'; import * as THREE from 'three'; -import RAPIER from '@dimforge/rapier3d-compat'; +import type RAPIER from '@dimforge/rapier3d-compat'; +import type { RapierModule } from '../core/physics.js'; import type { DisposableResource, ResourceOwnership } from '../core/ownership.js'; import type { PRNG } from '../core/prng.js'; export interface EntityContext { id: string; native: { + /** The Rapier world. Throws RND_0412 when the engine runs with `physics: false`. */ world: RAPIER.World; + /** + * The loaded Rapier module (loaded lazily at engine init). Presets build + * their descriptors from it instead of importing Rapier statically, so + * physics-free bundles never include the WASM. Throws RND_0412 when the + * engine runs with `physics: false`. + */ + rapier: RapierModule; threeScene?: THREE.Scene; }; events: { diff --git a/src/presets/dynamic-player.ts b/src/presets/dynamic-player.ts index 9ee1193..c0e4915 100644 --- a/src/presets/dynamic-player.ts +++ b/src/presets/dynamic-player.ts @@ -6,7 +6,6 @@ import { Type, type Static } from '@sinclair/typebox'; import * as THREE from 'three'; -import RAPIER from '@dimforge/rapier3d-compat'; import { definePreset, type EntityContext } from './define-preset.js'; export const DynamicPlayerOptionsSchema = Type.Object({ @@ -29,6 +28,8 @@ export const dynamicPlayer = definePreset({ version: 1, schema: DynamicPlayerOptionsSchema, create(ctx: EntityContext, options: DynamicPlayerOptions) { + // Throws RND_0412 up front when the engine runs without physics. + const R = ctx.native.rapier; const pos = options.position ?? [0, 1, 0]; const radius = options.radius ?? 0.5; const mass = options.mass ?? 1.0; @@ -41,15 +42,15 @@ export const dynamicPlayer = definePreset({ const mesh = new THREE.Mesh(geometry, material); mesh.position.set(pos[0], pos[1], pos[2]); - const bodyDesc = RAPIER.RigidBodyDesc.dynamic() + const bodyDesc = R.RigidBodyDesc.dynamic() .setTranslation(pos[0], pos[1], pos[2]) .setAdditionalMass(mass) .setLinearDamping(options.linearDamping ?? 0.5) .setAngularDamping(options.angularDamping ?? 0.5); const body = ctx.native.world.createRigidBody(bodyDesc); - const colliderDesc = RAPIER.ColliderDesc.ball(radius); - colliderDesc.setActiveEvents(RAPIER.ActiveEvents.COLLISION_EVENTS); + const colliderDesc = R.ColliderDesc.ball(radius); + colliderDesc.setActiveEvents(R.ActiveEvents.COLLISION_EVENTS); const collider = ctx.native.world.createCollider(colliderDesc, body); let moveInput = { x: 0, z: 0 }; diff --git a/src/presets/kcc-player.ts b/src/presets/kcc-player.ts index 0c1eacf..425447f 100644 --- a/src/presets/kcc-player.ts +++ b/src/presets/kcc-player.ts @@ -7,7 +7,6 @@ import { Type, type Static } from '@sinclair/typebox'; import * as THREE from 'three'; -import RAPIER from '@dimforge/rapier3d-compat'; import { definePreset, type EntityContext } from './define-preset.js'; export const KCCPlayerOptionsSchema = Type.Object({ @@ -38,6 +37,8 @@ export const kccPlayer = definePreset({ version: 1, schema: KCCPlayerOptionsSchema, create(ctx: EntityContext, options: KCCPlayerOptions) { + // Throws RND_0412 up front when the engine runs without physics. + const R = ctx.native.rapier; const pos = options.position ?? [0, 1, 0]; const radius = options.radius ?? 0.4; const halfHeight = options.height ? options.height / 2 : 0.8; @@ -54,12 +55,12 @@ export const kccPlayer = definePreset({ mesh.position.set(pos[0], pos[1], pos[2]); // 2. Create Kinematic Position-Based Rigid Body & Capsule Collider - const bodyDesc = RAPIER.RigidBodyDesc.kinematicPositionBased().setTranslation(pos[0], pos[1], pos[2]); + const bodyDesc = R.RigidBodyDesc.kinematicPositionBased().setTranslation(pos[0], pos[1], pos[2]); const body = ctx.native.world.createRigidBody(bodyDesc); - const colliderDesc = RAPIER.ColliderDesc.capsule(halfHeight, radius); - colliderDesc.setActiveEvents(RAPIER.ActiveEvents.COLLISION_EVENTS); - colliderDesc.setActiveCollisionTypes(RAPIER.ActiveCollisionTypes.ALL); + const colliderDesc = R.ColliderDesc.capsule(halfHeight, radius); + colliderDesc.setActiveEvents(R.ActiveEvents.COLLISION_EVENTS); + colliderDesc.setActiveCollisionTypes(R.ActiveCollisionTypes.ALL); const collider = ctx.native.world.createCollider(colliderDesc, body); // 3. Create Rapier Kinematic Character Controller @@ -144,7 +145,7 @@ export const kccPlayer = definePreset({ } // Compute desired movement vector - const desiredTranslation = new RAPIER.Vector3( + const desiredTranslation = new R.Vector3( inputVector.x * moveSpeedRuntime * dt, verticalVelocity * dt, inputVector.z * moveSpeedRuntime * dt @@ -154,7 +155,7 @@ export const kccPlayer = definePreset({ characterController.computeColliderMovement( collider, desiredTranslation, - RAPIER.QueryFilterFlags.EXCLUDE_SENSORS + R.QueryFilterFlags.EXCLUDE_SENSORS ); const correctedMovement = characterController.computedMovement(); isGrounded = characterController.computedGrounded(); diff --git a/src/presets/mesh.ts b/src/presets/mesh.ts index 8715057..9ee5838 100644 --- a/src/presets/mesh.ts +++ b/src/presets/mesh.ts @@ -4,7 +4,8 @@ import { Type, type Static } from '@sinclair/typebox'; import * as THREE from 'three'; -import RAPIER from '@dimforge/rapier3d-compat'; +import type RAPIER from '@dimforge/rapier3d-compat'; +import type { RapierModule } from '../core/physics.js'; import { definePreset, type EntityContext } from './define-preset.js'; export const MeshOptionsSchema = Type.Object({ @@ -48,12 +49,16 @@ function makeGeometry(options: MeshOptions): THREE.BufferGeometry { return new THREE.BoxGeometry(size[0] ?? 1, size[1] ?? 1, size[2] ?? 1); } -function makeCollider(options: MeshOptions, object: THREE.Object3D): RAPIER.ColliderDesc | null { +function makeCollider( + R: RapierModule, + options: MeshOptions, + object: THREE.Object3D +): RAPIER.ColliderDesc | null { if ((options.physics ?? 'none') === 'none') return null; const box = new THREE.Box3().setFromObject(object); const size = new THREE.Vector3(); box.getSize(size); - return RAPIER.ColliderDesc.cuboid(Math.max(size.x / 2, 0.05), Math.max(size.y / 2, 0.05), Math.max(size.z / 2, 0.05)); + return R.ColliderDesc.cuboid(Math.max(size.x / 2, 0.05), Math.max(size.y / 2, 0.05), Math.max(size.z / 2, 0.05)); } export const mesh = definePreset({ @@ -86,11 +91,12 @@ export const mesh = definePreset({ let body: RAPIER.RigidBody | undefined; let collider: RAPIER.Collider | undefined; if (physics !== 'none') { + const R = ctx.native.rapier; const desc = - physics === 'dynamic' ? RAPIER.RigidBodyDesc.dynamic() : RAPIER.RigidBodyDesc.fixed(); + physics === 'dynamic' ? R.RigidBodyDesc.dynamic() : R.RigidBodyDesc.fixed(); desc.setTranslation(pos[0], pos[1], pos[2]); body = ctx.native.world.createRigidBody(desc); - const col = makeCollider(options, object); + const col = makeCollider(R, options, object); if (col) collider = ctx.native.world.createCollider(col, body); } diff --git a/src/presets/model.ts b/src/presets/model.ts index 0913be2..5148916 100644 --- a/src/presets/model.ts +++ b/src/presets/model.ts @@ -4,7 +4,7 @@ import { Type, type Static } from '@sinclair/typebox'; import * as THREE from 'three'; -import RAPIER from '@dimforge/rapier3d-compat'; +import type RAPIER from '@dimforge/rapier3d-compat'; import { definePreset, type EntityContext } from './define-preset.js'; export const ModelOptionsSchema = Type.Object({ @@ -50,19 +50,20 @@ export const model = definePreset({ let collider: RAPIER.Collider | undefined; if (physics !== 'none') { + const R = ctx.native.rapier; const desc = - physics === 'dynamic' ? RAPIER.RigidBodyDesc.dynamic() : RAPIER.RigidBodyDesc.fixed(); + physics === 'dynamic' ? R.RigidBodyDesc.dynamic() : R.RigidBodyDesc.fixed(); desc.setTranslation(pos[0], pos[1], pos[2]); body = ctx.native.world.createRigidBody(desc); const size = options.colliderSize ?? [1, 1, 1]; let col: RAPIER.ColliderDesc; if (options.colliderShape === 'sphere') { - col = RAPIER.ColliderDesc.ball(size[0] ?? 0.5); + col = R.ColliderDesc.ball(size[0] ?? 0.5); } else if (options.colliderShape === 'cylinder') { - col = RAPIER.ColliderDesc.cylinder((size[1] ?? 1) / 2, size[0] ?? 0.5); + col = R.ColliderDesc.cylinder((size[1] ?? 1) / 2, size[0] ?? 0.5); } else { - col = RAPIER.ColliderDesc.cuboid((size[0] ?? 1) / 2, (size[1] ?? 1) / 2, (size[2] ?? 1) / 2); + col = R.ColliderDesc.cuboid((size[0] ?? 1) / 2, (size[1] ?? 1) / 2, (size[2] ?? 1) / 2); } collider = ctx.native.world.createCollider(col, body); } diff --git a/src/presets/procedural-model.ts b/src/presets/procedural-model.ts index 37611fd..395f498 100644 --- a/src/presets/procedural-model.ts +++ b/src/presets/procedural-model.ts @@ -4,7 +4,8 @@ import { Type, type Static } from '@sinclair/typebox'; import * as THREE from 'three'; -import RAPIER from '@dimforge/rapier3d-compat'; +import type RAPIER from '@dimforge/rapier3d-compat'; +import type { RapierModule } from '../core/physics.js'; import { definePreset, type EntityContext } from './define-preset.js'; export const ProceduralColliderSchema = Type.Object({ @@ -44,7 +45,7 @@ export type ProceduralModelOptions = Omit THREE.Object3D; }; -function colliderDesc(options: ProceduralModelOptions): RAPIER.ColliderDesc { +function colliderDesc(R: RapierModule, options: ProceduralModelOptions): RAPIER.ColliderDesc { const hint = options.collider ?? { shape: 'box' as const, size: [1, 1, 1] }; const scale = options.scale ?? 1; const size = hint.size ?? []; @@ -52,26 +53,26 @@ function colliderDesc(options: ProceduralModelOptions): RAPIER.ColliderDesc { let desc: RAPIER.ColliderDesc; if (hint.shape === 'sphere') { const radius = (hint.radius ?? size[0] ?? 0.5) * scale; - desc = RAPIER.ColliderDesc.ball(radius); + desc = R.ColliderDesc.ball(radius); } else if (hint.shape === 'cylinder') { const radius = (hint.radius ?? size[0] ?? 0.5) * scale; const height = (size[1] ?? 1) * scale; - desc = RAPIER.ColliderDesc.cylinder(height / 2, radius); + desc = R.ColliderDesc.cylinder(height / 2, radius); } else if (hint.shape === 'capsule') { const radius = (hint.radius ?? size[0] ?? 0.4) * scale; const height = (size[1] ?? 1.6) * scale; - desc = RAPIER.ColliderDesc.capsule(Math.max(height / 2 - radius, 0.05), radius); + desc = R.ColliderDesc.capsule(Math.max(height / 2 - radius, 0.05), radius); } else { const sx = (size[0] ?? 1) * scale; const sy = (size[1] ?? 1) * scale; const sz = (size[2] ?? 1) * scale; - desc = RAPIER.ColliderDesc.cuboid(sx / 2, sy / 2, sz / 2); + desc = R.ColliderDesc.cuboid(sx / 2, sy / 2, sz / 2); } if (hint.sensor) { desc.setSensor(true); - desc.setActiveEvents(RAPIER.ActiveEvents.COLLISION_EVENTS); - desc.setActiveCollisionTypes(RAPIER.ActiveCollisionTypes.ALL); + desc.setActiveEvents(R.ActiveEvents.COLLISION_EVENTS); + desc.setActiveCollisionTypes(R.ActiveCollisionTypes.ALL); } if (options.friction !== undefined) desc.setFriction(options.friction); if (options.restitution !== undefined) desc.setRestitution(options.restitution); @@ -84,6 +85,8 @@ export const proceduralModel = definePreset({ version: 1, schema: ProceduralModelOptionsSchema, create(ctx: EntityContext, options: ProceduralModelOptions) { + // Throws RND_0412 up front when the engine runs without physics. + const R = ctx.native.rapier; const pos = options.position ?? [0, 0, 0]; const rot = options.rotation ?? [0, 0, 0, 1]; const object = options.create(); @@ -94,17 +97,17 @@ export const proceduralModel = definePreset({ const bodyType = options.type ?? 'fixed'; let bodyDesc: RAPIER.RigidBodyDesc; if (bodyType === 'dynamic') { - bodyDesc = RAPIER.RigidBodyDesc.dynamic(); + bodyDesc = R.RigidBodyDesc.dynamic(); } else if (bodyType === 'kinematicPositionBased') { - bodyDesc = RAPIER.RigidBodyDesc.kinematicPositionBased(); + bodyDesc = R.RigidBodyDesc.kinematicPositionBased(); } else { - bodyDesc = RAPIER.RigidBodyDesc.fixed(); + bodyDesc = R.RigidBodyDesc.fixed(); } bodyDesc.setTranslation(pos[0], pos[1], pos[2]); bodyDesc.setRotation({ x: rot[0], y: rot[1], z: rot[2], w: rot[3] }); const body = ctx.native.world.createRigidBody(bodyDesc); - const collider = ctx.native.world.createCollider(colliderDesc(options), body); + const collider = ctx.native.world.createCollider(colliderDesc(R, options), body); const tags = ['procedural', bodyType, ...(options.tags ?? [])]; if (options.collider?.sensor) tags.push('sensor'); diff --git a/src/presets/sensor.ts b/src/presets/sensor.ts index 3fa03bb..adc0be3 100644 --- a/src/presets/sensor.ts +++ b/src/presets/sensor.ts @@ -6,7 +6,7 @@ import { Type, type Static } from '@sinclair/typebox'; import * as THREE from 'three'; -import RAPIER from '@dimforge/rapier3d-compat'; +import type RAPIER from '@dimforge/rapier3d-compat'; import { definePreset, type EntityContext } from './define-preset.js'; export const SensorShapeSchema = Type.Union([ @@ -32,6 +32,8 @@ export const sensor = definePreset({ version: 1, schema: SensorOptionsSchema, create(ctx: EntityContext, options: SensorOptions) { + // Throws RND_0412 up front when the engine runs without physics. + const R = ctx.native.rapier; const shape = options.shape ?? 'box'; const pos = options.position ?? [0, 0, 0]; @@ -40,27 +42,27 @@ export const sensor = definePreset({ if (shape === 'sphere') { const radius = options.radius ?? (options.size?.[0] ? options.size[0] / 2 : 1.0); - colliderDesc = RAPIER.ColliderDesc.ball(radius); + colliderDesc = R.ColliderDesc.ball(radius); if (options.debugMesh) geometry = new THREE.SphereGeometry(radius, 8, 8); } else if (shape === 'cylinder') { const radius = options.radius ?? 1.0; const height = options.size?.[1] ?? 2.0; - colliderDesc = RAPIER.ColliderDesc.cylinder(height / 2, radius); + colliderDesc = R.ColliderDesc.cylinder(height / 2, radius); if (options.debugMesh) geometry = new THREE.CylinderGeometry(radius, radius, height, 8); } else { const sx = options.size?.[0] ?? 1.0; const sy = options.size?.[1] ?? 1.0; const sz = options.size?.[2] ?? 1.0; - colliderDesc = RAPIER.ColliderDesc.cuboid(sx / 2, sy / 2, sz / 2); + colliderDesc = R.ColliderDesc.cuboid(sx / 2, sy / 2, sz / 2); if (options.debugMesh) geometry = new THREE.BoxGeometry(sx, sy, sz); } colliderDesc.setSensor(true); - colliderDesc.setActiveEvents(RAPIER.ActiveEvents.COLLISION_EVENTS); - colliderDesc.setActiveCollisionTypes(RAPIER.ActiveCollisionTypes.ALL); + colliderDesc.setActiveEvents(R.ActiveEvents.COLLISION_EVENTS); + colliderDesc.setActiveCollisionTypes(R.ActiveCollisionTypes.ALL); // Kinematic sensor body ensures collision pairs with dynamic, kinematic, and KCC players - const bodyDesc = RAPIER.RigidBodyDesc.kinematicPositionBased().setTranslation(pos[0], pos[1], pos[2]); + const bodyDesc = R.RigidBodyDesc.kinematicPositionBased().setTranslation(pos[0], pos[1], pos[2]); const body = ctx.native.world.createRigidBody(bodyDesc); const collider = ctx.native.world.createCollider(colliderDesc, body); diff --git a/tests/physics_optional.test.ts b/tests/physics_optional.test.ts new file mode 100644 index 0000000..87c7567 --- /dev/null +++ b/tests/physics_optional.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect, vi } from 'vitest'; + +/** + * Optional physics: `physics: false` engines must never load Rapier. + * + * The Rapier module is wrapped so the test can see whether anything imported + * it. Tests run in order: every physics-free test runs before the first + * default engine is created, which is the first (and only) load. + */ +const rapierLoads = vi.hoisted(() => ({ count: 0 })); +vi.mock('@dimforge/rapier3d-compat', async (importOriginal) => { + rapierLoads.count++; + return importOriginal(); +}); + +import { createRenderoni, RenderoniEngine } from '../src/index.js'; +import { body, kccPlayer, mesh, sensor } from '../src/presets/index.js'; + +describe('Optional physics: physics: false', () => { + it('initializes, steps and hashes without loading Rapier', async () => { + const game = await createRenderoni({ mode: 'headless', seed: 11, physics: false }); + expect(game.physicsEnabled).toBe(false); + expect(game.physics.hasWorld).toBe(false); + + game.add({ id: 'town-a', tags: ['town'], state: { pop: 7, owner: 'dust' } }); + game.add({ id: 'squad-1', state: { hp: 40, order: 'march' } }); + game.step(3); + + expect(game.tick).toBe(3); + // Same digest as the physics-enabled engine on upstream main: slotless + // entities hash identically with and without a Rapier world. + expect(game.getStateHash()).toBe('0xefe1328288cc3308'); + expect(rapierLoads.count).toBe(0); + game.dispose(); + }); + + it('runs systems, actions and world providers', async () => { + const game = new RenderoniEngine({ mode: 'headless', physics: false }); + await game.init(); + + const seen: string[] = []; + let counter = 0; + game.systems.add({ phase: 'prePhysics', update: ({ tick }) => seen.push(`pre:${tick}`) }); + game.systems.add({ update: ({ tick }) => seen.push(`post:${tick}`) }); + game.actions.register({ name: 'count', handle: (by: unknown) => (counter += by as number) }); + game.worlds.register({ name: 'sim', hash: () => counter, resolve: () => counter }); + + const before = game.getStateHash(); + game.act({ name: 'count', payload: 5 }); + game.step(2); + + expect(seen).toEqual(['pre:0', 'post:0', 'pre:1', 'post:1']); + expect(counter).toBe(5); + expect(game.check([{ op: 'equals', path: 'world.sim.x', value: 5 }]).passed).toBe(true); + expect(game.getStateHash()).not.toBe(before); + expect(game.physics.getActiveContacts()).toEqual([]); + expect(rapierLoads.count).toBe(0); + game.dispose(); + }); + + it('reports RND_0412 from native.world', async () => { + const game = await createRenderoni({ mode: 'headless', physics: false }); + expect(() => game.native.world).toThrow(/RND_0412: engine\.native\.world is unavailable/); + expect(game.diagnostics.getRecords().map((r) => r.code)).toEqual(['RND_0412']); + game.dispose(); + }); + + it('rejects Rapier-backed presets with RND_0412 and leaves nothing behind', async () => { + const game = await createRenderoni({ mode: 'headless', physics: false }); + + for (const preset of [ + body({ id: 'crate', shape: 'box', type: 'dynamic' }), + sensor({ id: 'coin' }), + kccPlayer({ id: 'hero' }), + mesh({ id: 'wall', physics: 'static' }), + ]) { + expect(() => game.add(preset)).toThrow(/RND_0412: ctx\.native\.rapier is unavailable/); + } + + expect(game.entities.list()).toEqual([]); + expect(game.native.scene.children).toHaveLength(0); + expect(game.diagnostics.getRecords().every((r) => r.code === 'RND_0412')).toBe(true); + expect(rapierLoads.count).toBe(0); + game.dispose(); + }); + + it('still adds physics-free presets', async () => { + const game = await createRenderoni({ mode: 'headless', physics: false }); + const wall = game.add(mesh({ id: 'wall', position: [1, 2, 3] })); + game.step(1); + expect(wall.position).toEqual([1, 2, 3]); + expect(game.diagnostics.getRecords()).toEqual([]); + game.dispose(); + }); +}); + +describe('Optional physics: default engine', () => { + it('loads Rapier lazily at init and simulates bodies as before', async () => { + expect(rapierLoads.count).toBe(0); + const game = await createRenderoni({ mode: 'headless', seed: 3 }); + expect(rapierLoads.count).toBe(1); + expect(game.physicsEnabled).toBe(true); + expect(game.physics.hasWorld).toBe(true); + + game.add(body({ id: 'floor', shape: 'box', type: 'fixed', size: [10, 1, 10], position: [0, 0, 0] })); + const crate = game.add(body({ id: 'crate', shape: 'box', type: 'dynamic', position: [0, 3, 0] })); + game.step(120); + + expect(crate.position[1]).toBeGreaterThan(0.5); + expect(crate.position[1]).toBeLessThan(1.5); + expect(game.physics.getActiveContacts()).toEqual([ + { entityA: 'crate', entityB: 'floor', started: true }, + ]); + game.dispose(); + + await createRenderoni({ mode: 'headless' }).then((second) => second.dispose()); + expect(rapierLoads.count).toBe(1); + }); +}); From f70654d1ab523647b6e5de2d1f8c764d0e87f3f8 Mon Sep 17 00:00:00 2001 From: Chadwick Maycumber <36460656+cmaycumber@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:40:25 -0700 Subject: [PATCH 3/3] build: prepare script so git installs ship a built dist `npm install github:/renderoni#` now runs `prepare` (`npm run build`), producing dist/ from source. The build does not need the network or the optional @github/copilot-sdk: the editor imports it through a non-literal specifier, so tsc and the tsup DTS pass no longer try to resolve its types when it is not installed. prepare sends the build log to stderr because npm also runs it for `npm pack --json`, whose stdout the release contract gate parses as JSON. Co-Authored-By: Claude Opus 5.5 (1M context) --- package.json | 1 + src/editor/copilot-session.ts | 6 +++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 02e2fca..1eb7bb9 100644 --- a/package.json +++ b/package.json @@ -92,6 +92,7 @@ "scripts": { "dev": "vite", "build": "tsup && npm run copy:editor-assets", + "prepare": "npm run build 1>&2", "copy:editor-assets": "node -e \"require('fs').cpSync('src/editor/public','dist/editor/public',{recursive:true})\"", "build:web": "vite build", "preview": "vite preview", diff --git a/src/editor/copilot-session.ts b/src/editor/copilot-session.ts index a7af172..6da945c 100644 --- a/src/editor/copilot-session.ts +++ b/src/editor/copilot-session.ts @@ -28,7 +28,11 @@ export async function getClient(): Promise { clientPromise = (async () => { let mod: any; try { - mod = await import('@github/copilot-sdk'); + // A non-literal specifier keeps tsc and the tsup DTS build from + // resolving the optional dependency, so the package builds (e.g. in + // `prepare` on a git install) when it is not installed. + const specifier = '@github/copilot-sdk'; + mod = await import(/* @vite-ignore */ specifier); } catch (err: any) { if (err?.code === 'ERR_MODULE_NOT_FOUND' || err?.message?.includes('Cannot find package')) { throw new Error(