diff --git a/AGENTS.md b/AGENTS.md index 4176102..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. @@ -54,6 +55,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..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 @@ -206,6 +208,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/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/core/engine.ts b/src/core/engine.ts index 11b0591..13e22ff 100644 --- a/src/core/engine.ts +++ b/src/core/engine.ts @@ -6,15 +6,16 @@ */ 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'; +import { WorldRegistry } from './worlds.js'; import { InputManager } from '../input/input-manager.js'; import { ActionRegistry } from '../input/actions.js'; import { @@ -61,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 { @@ -164,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; @@ -181,6 +190,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: { @@ -207,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); @@ -222,6 +234,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() }); @@ -249,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; }, }; } @@ -293,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(), ]); @@ -307,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; @@ -370,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: { @@ -892,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( @@ -922,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. @@ -968,6 +1010,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 +1035,8 @@ export class RenderoniEngine { return this.hasher.computeHash( rawEntities, this.transformPipeline.currentBuffer, - this.physics.getActiveContacts() + this.physics.getActiveContacts(), + this.worlds.digests() ); } @@ -1108,6 +1154,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/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/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/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( 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/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/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/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); + }); +}); 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(); + }); +});