From 467385c8706dc03ee1381b902ee0582c2c605114 Mon Sep 17 00:00:00 2001 From: Ben Richards Date: Fri, 9 Oct 2026 12:53:29 +0300 Subject: [PATCH] =?UTF-8?q?S129=20=E2=80=94=20Every=20game=20plays=20the?= =?UTF-8?q?=20content=20it=20names?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Engine resolves every state's content through a ResolutionArchive keyed by (campaignId, campaignVersion); EngineHost.archive is optional and defaults to the construction-time registry, read once. createGame takes an optional starting version; deserialize and migrate refuse an epoch the archive does not hold. --- design/30-slices.md | 2 +- design/90-decisions.md | 10 ++ docs/docs/engine/TODO.md | 2 +- src/engine/src/core/composition/types.ts | 5 +- src/engine/src/core/kernel/engine.test.ts | 180 ++++++++++++++++++++++ src/engine/src/core/kernel/engine.ts | 137 +++++++++++----- src/engine/src/core/kernel/types.ts | 18 ++- src/engine/src/index.ts | 2 +- 8 files changed, 314 insertions(+), 42 deletions(-) diff --git a/design/30-slices.md b/design/30-slices.md index 401f576..ad8babf 100644 --- a/design/30-slices.md +++ b/design/30-slices.md @@ -258,7 +258,7 @@ Out of scope: Generating the docs' other volatile facts, one of the review's non ## S129 — Every Game Plays the Content It Names -Status: todo +Status: done Delivers: A host can keep more than one published version of a campaign available at once. A new game can start on any of them, and every game plays the version it names rather than whichever version the host registered under the campaign's name. A stored game diff --git a/design/90-decisions.md b/design/90-decisions.md index c7bc8b0..b91fe4a 100644 --- a/design/90-decisions.md +++ b/design/90-decisions.md @@ -2581,6 +2581,16 @@ Chosen: the first predicate now also requires the target node at `currentNodeId` Rejected: **Settling at adoption.** Settle walks nodes and may draw randomness, which is play, and C24 forbids adoption to invent play. **Requiring only that the node kind is unchanged.** That misses a choice node whose every choice the target gated away, which strands the player the same way through a different edit. Reversibility: tightening a predicate is cheap before build. After build, a relaxed rule would change which recorded adoptions replay, so a change is a 07 divergence and needs a decision. +### 2026-10-09 — S129: the engine reads its registry's campaign map once, at construction +Context: `20-contract.md` §4 makes the construction-time `registry` the default epoch, and C23 says a key names one content for the archive's lifetime. Neither says what happens when a host mutates the registry's campaign map after `createEngine`. One existing store test does exactly that, re-registering a campaign at a new version under a live engine, and expects `branchSession` to refuse with `unknown_campaign`. +Chosen: `createEngine` copies the campaign map once. The default archive and the default version `createGame` starts on both come from that copy, so a later mutation changes neither which content a key names nor where a new game starts. +Rejected: **Reading the live registry.** The default archive would then stop resolving an epoch it had resolved before, which breaks C23, and a stored state would fail `deserialize` inside the store, which would report a defensive throw where it reported `unknown_campaign` (S129.5 requires every existing test to pass unchanged). + +### 2026-10-09 — S129: an epoch resolves only when the archive's registry holds the campaign at that version +Context: `ResolutionArchive.resolve(id, version)` returns a registry. The contract does not say what the engine does when that registry holds the campaign at some other version. `createGame` stamps `campaign.version`, so such a registry would start a game on a version other than the one asked for. +Chosen: The engine treats that answer as unresolved, so `createGame`, `deserialize` and `migrate` refuse it with `unknown_campaign`, and `createEngine` refuses an archive that answers the registry's own epoch that way. +Rejected: **Trusting the archive's answer.** A faulty host archive would then play a state against content it does not name, which is the C22 failure this slice exists to close. + ### 2026-10-08 — `getStrings` returns the union of a session's live epochs, newest winning per key (red-team F3) Context: red-team F3 (`design/redteam/2026-10-08-10-design.md`, STRUCTURAL). A client calls `getScene` on `e1`, and then another client's `resumeSession` adopts `e2`, which renamed a label key. The first client's `getStrings` then returned only the `e2` table, so its scene carried a key it could not resolve. Neither `Scene` nor `StringTable` carries an epoch, so the client could not detect this. The race is between completed calls, so the session lock cannot close it. Chosen: `getStrings(sessionId)` returns the union of the string tables of the session's live epochs. Those are the starting version, or the last migration entry's `to`, and every content entry's `to` after it. Each key resolves from the most recent of those epochs that defines it. New invariant C27: a session's table never loses a key, so any key in any result the store returned for a session resolves in every later `getStrings`. A client whose cached table lacks a key fetches the table again. The union also covers a `submitAction` that adopts after commit, whose messages come from the old epoch while its scene comes from the new one. Accepted residual: a key both epochs define resolves to the newer wording, even on an older scene. No type, signature or MCP schema changes, and the union spans only epochs the log already names, which retention already keeps. The design is `16-content-epochs.md` §5.7 and Clients §2; the contract is `20-contract.md` C27 and §7. diff --git a/docs/docs/engine/TODO.md b/docs/docs/engine/TODO.md index 5f9c592..ce5c572 100644 --- a/docs/docs/engine/TODO.md +++ b/docs/docs/engine/TODO.md @@ -252,7 +252,7 @@ Out of scope: Generating the docs' other volatile facts, one of the review's non ## S129 — Every Game Plays the Content It Names -Status: todo +Status: done Delivers: A host can keep more than one published version of a campaign available at once. A new game can start on any of them, and every game plays the version it names rather than whichever version the host registered under the campaign's name. A stored game diff --git a/src/engine/src/core/composition/types.ts b/src/engine/src/core/composition/types.ts index 96003fc..f4e03f4 100644 --- a/src/engine/src/core/composition/types.ts +++ b/src/engine/src/core/composition/types.ts @@ -12,7 +12,7 @@ */ import type { ContentRegistry } from "../registry/types.js"; -import type { Engine, KindRegistry } from "../kernel/types.js"; +import type { Engine, KindRegistry, ResolutionArchive } from "../kernel/types.js"; import type { Emitter } from "../observability/types.js"; import type { ProfileStore, SessionPersistence } from "../session/types.js"; import type { EmittedRecordSink } from "../observability/types.js"; @@ -76,6 +76,9 @@ export interface EngineHost { readonly registry: ContentRegistry; readonly ids?: IdSource; readonly emitter?: Emitter; + /** Every epoch this engine can play (20-contract.md §4). Omitted → an archive holding only + * `registry`. Must resolve every campaign of `registry` at its registered version. */ + readonly archive?: ResolutionArchive; } /** Composition root for the session layer — the only place a clock appears. */ diff --git a/src/engine/src/core/kernel/engine.test.ts b/src/engine/src/core/kernel/engine.test.ts index 8da45c0..43a7a7d 100644 --- a/src/engine/src/core/kernel/engine.test.ts +++ b/src/engine/src/core/kernel/engine.test.ts @@ -14,6 +14,8 @@ import type { ValidationResult } from "../validation/types.js"; import type { EngineHost } from "../composition/types.js"; import { createRecordingEmitter, nullEmitter } from "../observability/emitter.js"; import type { GameEvent } from "../observability/types.js"; +// S129.6: the archive type is reachable from the package root, not only from the kernel. +import type { ResolutionArchive as RootResolutionArchive } from "../../index.js"; interface TestKindState { counter: number; @@ -809,3 +811,181 @@ describe("withEmitter", () => { expect(engine.serialize(viaRebound.value as GameState)).toBe(engine.serialize(viaOriginal.value as GameState)); }); }); + +describe("S129 — every state plays the epoch it names", () => { + // Two epochs of one campaign that differ only in one node's text — once in the campaign's + // own content and once in the registry's strings, so both halves of the KindContext show. + function epochRegistry(version: string, text: string): ContentRegistry { + const campaign = makeCampaign({ version, content: { text } }); + return { campaigns: new Map([[campaign.id, campaign]]), strings: new Map([["node.start", text]]) }; + } + const first = epochRegistry("1", "The old clerk sighs."); + const second = epochRegistry("2", "The new clerk yawns."); + + const archive: RootResolutionArchive = { + resolve: (campaignId, campaignVersion) => + campaignId !== "test-campaign" ? undefined : campaignVersion === "1" ? first : campaignVersion === "2" ? second : undefined, + }; + + const validated: string[] = []; + const epochKind = makeTestKind({ + scene: (_state, ctx): SceneBody => ({ + textKey: "node.start", + text: `${(ctx.campaign.content as { text: string }).text} / ${ctx.registry.strings.get("node.start") ?? ""}`, + }), + project: (state, _audience, ctx) => ({ counter: state.counter, text: ctx.registry.strings.get("node.start") }), + availableActions: (_state, ctx): AvailableAction[] => [ + { id: `increment-v${ctx.campaign.version}`, labelKey: "node.start", available: true }, + ], + advance: (state, _actionId, _params, ctx): AdvanceResult => ({ + state: { counter: state.counter + (ctx.campaign.version === "2" ? 100 : 1) }, + status: "active", + changes: [], + messages: [], + }), + validateState: (_kindState, campaign) => { + validated.push(campaign.version); + return true; + }, + }); + + function epochHost(overrides?: Partial): EngineHost { + return { kinds: makeKinds(epochKind), registry: first, archive, ...overrides }; + } + + describe("S129.1 — the archive in use", () => { + it("without an archive, Engine.content holds only the registry, at its registered version", () => { + const engine = createEngine(makeHost()); + expect(engine.content.resolve("test-campaign", "1")?.campaigns.get("test-campaign")?.version).toBe("1"); + expect(engine.content.resolve("test-campaign", "2")).toBeUndefined(); + expect(engine.content.resolve("some-other-campaign", "1")).toBeUndefined(); + }); + + it("with an archive, Engine.content is that archive, and withEmitter keeps it", () => { + const engine = createEngine(epochHost()); + expect(engine.content).toBe(archive); + expect(engine.withEmitter(nullEmitter).content).toBe(archive); + }); + + it("createEngine throws when the archive does not resolve a registry campaign at its registered version", () => { + const onlySecond: RootResolutionArchive = { + resolve: (id, version) => (id === "test-campaign" && version === "2" ? second : undefined), + }; + expect(() => createEngine(epochHost({ archive: onlySecond }))).toThrow( + /does not resolve campaign "test-campaign" at its registered version "1"/, + ); + }); + + it("createEngine throws when the archive answers the key with a registry holding another version", () => { + const wrongContent: RootResolutionArchive = { resolve: () => second }; + expect(() => createEngine(epochHost({ archive: wrongContent }))).toThrow(/does not resolve campaign/); + }); + + it("the default epoch is read at construction: mutating the registry afterwards changes nothing", () => { + const campaigns = new Map([["test-campaign", makeCampaign({ version: "1" })]]); + const engine = createEngine(makeHost({ registry: { campaigns, strings: new Map() } })); + campaigns.set("test-campaign", makeCampaign({ version: "2" })); + expect(engine.createGame({ campaignId: "test-campaign" }).value?.campaignVersion).toBe("1"); + expect(engine.content.resolve("test-campaign", "2")).toBeUndefined(); + }); + }); + + describe("S129.2 — every state-taking operation reads its own epoch", () => { + const engine = createEngine(epochHost()); + const onFirst = engine.createGame({ campaignId: "test-campaign", seed: "fixed-seed" }).value as GameState; + const onSecond = engine.createGame({ campaignId: "test-campaign", seed: "fixed-seed" }, "2").value as GameState; + + it("scene renders each epoch's own text", () => { + expect(engine.scene(onFirst).body.text).toBe("The old clerk sighs. / The old clerk sighs."); + expect(engine.scene(onSecond).body.text).toBe("The new clerk yawns. / The new clerk yawns."); + expect(engine.scene(onSecond).view.kindView).toEqual({ counter: 0, text: "The new clerk yawns." }); + }); + + it("view and availableActions read the state's epoch", () => { + expect(engine.view(onFirst, "player").kindView).toEqual({ counter: 0, text: "The old clerk sighs." }); + expect(engine.view(onSecond, "player").kindView).toEqual({ counter: 0, text: "The new clerk yawns." }); + expect(engine.availableActions(onFirst).map((a) => a.id)).toEqual(["increment-v1"]); + expect(engine.availableActions(onSecond).map((a) => a.id)).toEqual(["increment-v2"]); + }); + + it("submitAction and previewAction advance against the state's epoch", () => { + expect((engine.submitAction(onFirst, "increment").value?.kindState as TestKindState).counter).toBe(1); + expect((engine.submitAction(onSecond, "increment").value?.kindState as TestKindState).counter).toBe(100); + expect((engine.previewAction(onSecond, "increment").value?.kindState as TestKindState).counter).toBe(100); + }); + + it("deserialize and migrate judge the kind state against the state's epoch and keep it", () => { + const blob = engine.serialize(onSecond); + validated.length = 0; + const loaded = engine.deserialize(blob); + const migrated = engine.migrate(blob); + expect(validated).toEqual(["2", "2"]); + expect(loaded.ok).toBe(true); + expect(migrated.ok).toBe(true); + expect(engine.scene(loaded.value as GameState).body.text).toBe("The new clerk yawns. / The new clerk yawns."); + expect(engine.serialize(migrated.value as GameState)).toBe(blob); + }); + }); + + describe("S129.3 — createGame starts on the default or a named epoch", () => { + const engine = createEngine(epochHost()); + + it("starts on the registry's version when no version is given", () => { + expect(engine.createGame({ campaignId: "test-campaign" }).value?.campaignVersion).toBe("1"); + }); + + it("starts on the named epoch and stamps it", () => { + const created = engine.createGame({ campaignId: "test-campaign" }, "2"); + expect(created.ok).toBe(true); + expect(created.value?.campaignVersion).toBe("2"); + }); + + it("fails with unknown_campaign for a version that does not resolve", () => { + const created = engine.createGame({ campaignId: "test-campaign" }, "3"); + expect(created.ok).toBe(false); + expect(created.errors).toEqual([ + { code: "unknown_campaign", messageKey: "core.reason.unknown_campaign", path: "test-campaign" }, + ]); + }); + + it("fails with unknown_campaign for a campaign id with no default epoch, version or not", () => { + expect(engine.createGame({ campaignId: "some-other-campaign" }).errors[0]?.code).toBe("unknown_campaign"); + expect(engine.createGame({ campaignId: "some-other-campaign" }, "1").errors[0]?.code).toBe("unknown_campaign"); + }); + }); + + describe("S129.4 — deserialize and migrate refuse an epoch that does not resolve", () => { + function atVersion(engine: ReturnType, campaignVersion: string): string { + const created = engine.createGame({ campaignId: "test-campaign" }); + const raw = JSON.parse(engine.serialize(created.value as GameState)) as Record; + raw["campaignVersion"] = campaignVersion; + return JSON.stringify(raw); + } + + for (const operation of ["deserialize", "migrate"] as const) { + it(`${operation} refuses a campaign id the registry holds at another version (closes S125.5)`, () => { + const recorder = createRecordingEmitter(); + const engine = createEngine(makeHost({ emitter: recorder })); + const blob = atVersion(engine, "2"); + const before = recorder.events.length; + + const result = engine[operation](blob); + + expect(result.ok).toBe(false); + expect(result.value).toBeUndefined(); + expect(result.errors).toEqual([ + { code: "unknown_campaign", messageKey: "core.reason.unknown_campaign", path: "test-campaign" }, + ]); + const emitted = recorder.events.slice(before); + expect(emitted).toHaveLength(1); + expect(emitted[0]).toMatchObject({ scope: "system", name: "core.deserialize.rejected", reason: "unknown_campaign" }); + }); + + it(`${operation} refuses a version the archive does not hold`, () => { + const engine = createEngine(epochHost()); + expect(engine[operation](atVersion(engine, "3")).errors[0]?.code).toBe("unknown_campaign"); + expect(engine[operation](atVersion(engine, "2")).ok).toBe(true); + }); + } + }); +}); diff --git a/src/engine/src/core/kernel/engine.ts b/src/engine/src/core/kernel/engine.ts index d3f67a7..b73cb0d 100644 --- a/src/engine/src/core/kernel/engine.ts +++ b/src/engine/src/core/kernel/engine.ts @@ -20,6 +20,7 @@ import type { KindContext, LoggedAction, NewGameConfig, + ResolutionArchive, Scene, } from "./types.js"; import type { CommandResult, OutcomeMessage } from "./reasons.js"; @@ -34,6 +35,47 @@ import { canonicalize as canonicalStringify } from "subzerodev-data-json"; import { makeResolutionEmitters, nullEmitter, emitSystemEvent, type ResolutionEmitters } from "../observability/emitter.js"; import { CORE_EVENTS } from "../observability/events.js"; +// --------------------------------------------------------------------------- +// Content resolution (20-contract.md §4, C22) +// --------------------------------------------------------------------------- + +/** `EngineHost` once `createEngine` has settled which archive is in use. */ +type ResolvedHost = EngineHost & { readonly archive: ResolutionArchive }; + +interface ResolvedEpoch { + readonly registry: ContentRegistry; + readonly campaign: Campaign; +} + +/** + * The epoch a state names, or undefined. A registry that answers the key but holds the + * campaign at another version does not resolve it: the state is played against the content + * it names or not at all (C22). + */ +function resolveEpoch(content: ResolutionArchive, campaignId: string, campaignVersion: string): ResolvedEpoch | undefined { + const registry = content.resolve(campaignId, campaignVersion); + const campaign = registry?.campaigns.get(campaignId); + if (!registry || !campaign || campaign.version !== campaignVersion) return undefined; + return { registry, campaign }; +} + +/** The archive an engine uses when its host supplies none: only the construction-time registry. */ +function registryArchive(registry: ContentRegistry): ResolutionArchive { + return { + resolve: (campaignId, campaignVersion) => + registry.campaigns.get(campaignId)?.version === campaignVersion ? registry : undefined, + }; +} + +/** + * Read paths only. `createGame`, `submitAction` and `deserialize` are the only producers of a + * `GameState` this engine accepts, and each resolves the epoch before returning one, so the + * non-null assertion holds (the same boundary argument as the comment above `view`). + */ +function epochOf(host: ResolvedHost, state: GameState): ResolvedEpoch { + return resolveEpoch(host.archive, state.campaignId, state.campaignVersion)!; +} + // --------------------------------------------------------------------------- // KindContext construction // --------------------------------------------------------------------------- @@ -78,10 +120,10 @@ function buildKindContext( * resolution — no core *lifecycle* event fires for a read (none is named for one in 05 * §8), but `core.rng.stream.derived` and any `kind.*` event still reach the sink. */ -function buildReadContext(host: EngineHost, campaign: Campaign, kind: Kind, state: GameState): KindContext { +function buildReadContext(host: ResolvedHost, epoch: ResolvedEpoch, kind: Kind, state: GameState): KindContext { const seq = state.actionLog.length; const emitters = makeResolutionEmitters(host.emitter ?? nullEmitter, state.gameId, seq); - return buildKindContext(host.registry, campaign, kind, state.seed, { kind: "system", system: "view", seq }, seq, emitters); + return buildKindContext(epoch.registry, epoch.campaign, kind, state.seed, { kind: "system", system: "view", seq }, seq, emitters); } // --------------------------------------------------------------------------- @@ -96,9 +138,11 @@ function buildReadContext(host: EngineHost, campaign: Campaign, kind: Kind { - const campaign = host.registry.campaigns.get(config.campaignId); - if (!campaign) { +function createGame(host: ResolvedHost, config: NewGameConfig, campaignVersion?: string): CommandResult { + // No version asked for: the default epoch, the construction-time registry's (20 §4). + const version = campaignVersion ?? host.registry.campaigns.get(config.campaignId)?.version; + const epoch = version === undefined ? undefined : resolveEpoch(host.archive, config.campaignId, version); + if (!epoch) { const error: ValidationError = { code: "unknown_campaign", messageKey: "core.reason.unknown_campaign", @@ -106,6 +150,7 @@ function createGame(host: EngineHost, config: NewGameConfig): CommandResult — see @@ -158,7 +203,7 @@ function createGame(host: EngineHost, config: NewGameConfig): CommandResult { +function deserializeState(host: ResolvedHost, data: string): CommandResult { const sink = host.emitter ?? nullEmitter; let parsed: unknown; try { @@ -435,7 +477,10 @@ function deserializeState(host: EngineHost, data: string): CommandResult { +function migrateState(host: ResolvedHost, data: string): CommandResult { // Migration mechanism is specified (04-core.md §10.2) but unexercised by the MVP // (MVP.md §4): exactly one formatVersion exists, so there is nothing to migrate from // yet. See plans/09-w3-pure-engine-kernel.md, Decision 5. @@ -512,20 +557,38 @@ export function createEngine(host: EngineHost): Engine { } } + // The construction-time registry is the default epoch (20-contract.md §4), so it is read + // once, here: a caller mutating its campaign map afterwards must not change which content + // a key names (C23), nor which version `createGame` starts on. + const registry: ContentRegistry = { ...host.registry, campaigns: new Map(host.registry.campaigns) }; + // An archive that cannot resolve the default epoch would leave `createGame` with nothing to + // start on — a construction error, the treatment a missing kind gets. + const archive = host.archive ?? registryArchive(registry); + for (const campaign of registry.campaigns.values()) { + if (!resolveEpoch(archive, campaign.id, campaign.version)) { + throw new Error( + `createEngine: the archive does not resolve campaign "${campaign.id}" at its registered ` + + `version "${campaign.version}" (20-contract.md §4)`, + ); + } + } + const resolved: ResolvedHost = { ...host, registry, archive }; + return { - kinds: host.kinds, - createGame: (config) => createGame(host, config), - scene: (state) => scene(host, state), - view: (state, audience) => view(host, state, audience), - availableActions: (state) => availableActions(host, state), - submitAction: (state, actionId, params) => submitAction(host, state, actionId, params), - previewAction: (state, actionId, params) => previewAction(host, state, actionId, params), + kinds: resolved.kinds, + content: archive, + createGame: (config, campaignVersion) => createGame(resolved, config, campaignVersion), + scene: (state) => scene(resolved, state), + view: (state, audience) => view(resolved, state, audience), + availableActions: (state) => availableActions(resolved, state), + submitAction: (state, actionId, params) => submitAction(resolved, state, actionId, params), + previewAction: (state, actionId, params) => previewAction(resolved, state, actionId, params), serialize: (state) => serializeState(state), - deserialize: (data) => deserializeState(host, data), - migrate: (data) => migrateState(host, data), + deserialize: (data) => deserializeState(resolved, data), + migrate: (data) => migrateState(resolved, data), // 05-observability.md §6.1: reconstructing over the new emitter is cheap and pure — - // createEngine does no I/O, only the in-namespace event-name check (above), which is - // idempotent over the same host.kinds. - withEmitter: (emitter) => createEngine({ ...host, emitter }), + // createEngine does no I/O, only the construction checks above, which are idempotent + // over the same host. The resolved host carries the archive, so `content` survives. + withEmitter: (emitter) => createEngine({ ...resolved, emitter }), }; } diff --git a/src/engine/src/core/kernel/types.ts b/src/engine/src/core/kernel/types.ts index 562a010..e79e35f 100644 --- a/src/engine/src/core/kernel/types.ts +++ b/src/engine/src/core/kernel/types.ts @@ -312,6 +312,15 @@ export interface NewGameConfig { kindProfileData?: unknown; } +/** + * Content by epoch (16-content-epochs.md §4; 20-contract.md §4). One key names one content + * forever (C23), so the engine treats `resolve` as pure. + */ +export interface ResolutionArchive { + /** The registry the epoch `(campaignId, campaignVersion)` was published in, or undefined. */ + resolve(campaignId: string, campaignVersion: string): ContentRegistry | undefined; +} + /** Kind-agnostic operations over the envelope. Resolves the kind by `state.kindId`, * derives the RNG handle, delegates, and reassembles. */ export interface Engine { @@ -321,7 +330,14 @@ export interface Engine { * taking a second, independently-suppliable `kinds` option that could silently * disagree with what this engine actually plays against. */ readonly kinds: KindRegistry; - createGame(config: NewGameConfig): CommandResult; + /** The archive this engine resolves every state's content through (`EngineHost.archive`) + * — by default one holding only the construction-time registry. Exposed for the same + * reason as `kinds`: callers read the epochs the engine actually plays against, never a + * second copy (20-contract.md §4). */ + readonly content: ResolutionArchive; + /** `campaignVersion` absent → the construction-time registry's version for that campaign + * (the default epoch); present → that epoch, or `unknown_campaign`. */ + createGame(config: NewGameConfig, campaignVersion?: string): CommandResult; scene(state: GameState): Scene; view(state: GameState, audience: ProjectionAudience): PlayerView; availableActions(state: GameState): AvailableAction[]; diff --git a/src/engine/src/index.ts b/src/engine/src/index.ts index 6c01041..6d11fe8 100644 --- a/src/engine/src/index.ts +++ b/src/engine/src/index.ts @@ -57,7 +57,7 @@ export type { PortableMigration, } from "./portable/format.js"; -export type { Engine } from "./core/kernel/types.js"; +export type { Engine, ResolutionArchive } from "./core/kernel/types.js"; export type { EngineHost, SessionHost } from "./core/composition/types.js"; export type { IdSource, RecordIdSource, Clock, ExperimentSource } from "./core/composition/types.js"; export type { GameState, GameStatus } from "./core/kernel/types.js";