diff --git a/design/30-slices.md b/design/30-slices.md index 2b11910c..fb25241a 100644 --- a/design/30-slices.md +++ b/design/30-slices.md @@ -404,7 +404,7 @@ Out of scope: Simulation and world-graph adoption rules (the contract's *Unresol ## S133 — Running Sessions Pick Up New Content -Status: todo +Status: done Delivers: A player's session moves onto newly published content between their moves: when they submit an action or come back to the game. A refusal, or a host that fails to answer, never costs them a move. Text they have already been shown never goes diff --git a/design/90-decisions.md b/design/90-decisions.md index 99b7564c..a2d3eec2 100644 --- a/design/90-decisions.md +++ b/design/90-decisions.md @@ -2646,6 +2646,26 @@ Context: S132.4 adopts the Bureaucracy campaign onto a copy of itself extended b Chosen: the S132.4 extension carries no `migrateState`, and a further test pins the real behaviour: the same extension, keeping the real `migrateState`, pins with `migration_failed`. Nothing in the campaign changed. Whether a content `migrateState` should pass through a version it does not migrate is authoring guidance, which this slice leaves out of scope. Rejected: **Changing `migrateV1AdventureState` to pass other versions through.** That is a campaign change outside this slice. It would also change load-time behaviour, where a save from an unknown version is refused today. +### 2026-10-09 — S133: `createSession` resolves the kind and profile data from the offered epoch, and an unheld offer fails creation +Context: §7 says `createSession` asks the channel once and passes the answer to `createGame(config, version)`. Before content epochs, the store looked up the campaign in the registry to find the kind and its profile data. An offer can name a version the registry does not hold, or one the archive does not hold either. +Chosen: the store resolves the campaign through `engine.content.resolve(campaignId, version)` when the channel offers a version, and through the registry when it does not. An offer naming a version the archive does not hold fails creation with `unknown_campaign`, as `createGame` does. A channel answer that is not a string is read as no offer, like a throw. +Rejected: **Falling back to the registry's version on an unheld offer.** Creation is not adoption, so §7's "never fails a command" rule does not cover it, and a silent fallback would start the player on content the host did not choose. + +### 2026-10-09 — S133: the profile fold receives the campaign of the epoch the action resolved on +Context: `submitAction` folds an accepted action's changes into the profile with `Kind.profileData.fold(current, campaign, changes)`. It passed the registry's campaign. Once a session can be on any held version, that campaign can be a different version from the one the action played. +Chosen: the fold receives the campaign of the state's own epoch before adoption, through `engine.content.resolve`, falling back to the registry's campaign only when the archive does not resolve it. The action's messages come from that epoch too. +Rejected: **The adopted epoch's campaign.** The changes being folded were produced against the old one. + +### 2026-10-09 — S133: a failed adoption write at `resumeSession` restores the record, and a conflict also evicts it +Context: §7 says a failed adoption-only write at `resumeSession` restores the unadopted state, returns its scene, and the next adoption point offers again. A `concurrent_modification` means another writer committed past the cached record, which `submitAction` handles by evicting it (S122). +Chosen: both failures restore the cached record's blob, `updatedAt` and revision and return the unadopted scene. A conflict also evicts the record, so the next read goes back to persistence. Any other error still propagates. The `core.content.adopted` event has already been emitted when the write fails, because the engine emits it during `adoptContent`, so an event stream can show an adoption that never persisted. +Rejected: **Suppressing the event on a failed write.** The emitter belongs to the engine call, which completed. Withdrawing an emitted event would need a new event or a buffered emitter, which is a contract change for a case the next adoption point already repairs. + +### 2026-10-09 — S133: `getStrings` skips an epoch the archive no longer resolves +Context: C27's union resolves every live epoch through `engine.content.resolve`. The reference archive is append-only, but a host-supplied archive may stop holding an epoch. +Chosen: an epoch that does not resolve contributes nothing, and the union of the rest is returned. +Rejected: **Throwing.** `getStrings` is a query, and a missing table for an older epoch should not stop a client from reading the current one. A session on an unresolvable current epoch already fails its other operations. + ### 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 36f95a2e..31cf4a54 100644 --- a/docs/docs/engine/TODO.md +++ b/docs/docs/engine/TODO.md @@ -398,7 +398,7 @@ Out of scope: Simulation and world-graph adoption rules (the contract's *Unresol ## S133 — Running Sessions Pick Up New Content -Status: todo +Status: done Delivers: A player's session moves onto newly published content between their moves: when they submit an action or come back to the game. A refusal, or a host that fails to answer, never costs them a move. Text they have already been shown never goes diff --git a/src/engine/src/core/composition/types.ts b/src/engine/src/core/composition/types.ts index f4e03f4b..c0a87075 100644 --- a/src/engine/src/core/composition/types.ts +++ b/src/engine/src/core/composition/types.ts @@ -12,6 +12,7 @@ */ import type { ContentRegistry } from "../registry/types.js"; +import type { ContentChannel } from "../registry/archive.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"; @@ -99,4 +100,8 @@ export interface SessionHost { /** §5.2. With `persistence`, the most sessions held in memory, evicted least recently * used first. Omitted → unbounded. A positive integer, and refused without `persistence`. */ readonly sessionCacheLimit?: number; + /** 16 §4.2, §5.3. The version each session should be on, asked under the session lock at + * `createSession` and at every adoption point. Omitted → no session is ever offered + * content, and every operation behaves as it did before content epochs. */ + readonly content?: ContentChannel; } diff --git a/src/engine/src/core/session/store.test.ts b/src/engine/src/core/session/store.test.ts index 7918c2cc..0d4abff7 100644 --- a/src/engine/src/core/session/store.test.ts +++ b/src/engine/src/core/session/store.test.ts @@ -1,7 +1,8 @@ import { describe, it, expect } from "vitest"; import { createEngine } from "../kernel/engine.js"; import { createCountingIds } from "../determinism/counting-ids.js"; -import { createInMemorySessionStore, runExclusive } from "./store.js"; +import { createInMemorySessionStore, createSessionLayer, runExclusive } from "./store.js"; +import { createContentArchive, type ContentChannel, type ContentScope } from "../registry/archive.js"; import type { AdvanceResult, AvailableAction, @@ -2088,3 +2089,386 @@ describe("S121 — the store returns only visible changes and messages", () => { expect(profile.achievements).toEqual([{ campaignId: "test-campaign", achievementId: "hidden-path" }]); }); }); + + +// ── S133 — running sessions pick up new content (16 §5.2, §5.3, §5.7) ── + +interface EpochContent { + sceneKey: string; + messageKey: string; + /** The test kind refuses to adopt onto content that sets this. */ + refuse?: boolean; +} + +/** One campaign across epochs. The scene and each action's message are drawn from the epoch the + * state is on, so a result shows which epoch built each half of it. */ +function makeEpochKind(): Kind { + const content = (campaign: Campaign) => campaign.content as EpochContent; + return { + ...makeTestKind(), + scene: (state, ctx): SceneBody => ({ textKey: content(ctx.campaign).sceneKey, text: `counter=${state.counter}` }), + advance: (state, actionId, _params, ctx): AdvanceResult => { + if (actionId === "increment") { + return { + state: { counter: state.counter + 1 }, + status: "active", + changes: [], + messages: [{ key: content(ctx.campaign).messageKey, visible: true }], + }; + } + if (actionId === "end") return { state, status: "ended", changes: [], messages: [] }; + return { + state, + status: "active", + changes: [], + messages: [], + error: { code: "unknown_action", messageKey: "core.reason.unknown_action" }, + }; + }, + adoptContent: (state, _from, to) => + content(to).refuse ? { adopt: false, reason: "content_incompatible" } : { adopt: true, state }, + profileData: { version: 1, fold: (_current, campaign) => ({ foldedOn: campaign.version }) }, + }; +} + +function epochRegistry(version: string, strings: Record, extra?: Partial): ContentRegistry { + const content: EpochContent = { sceneKey: `scene.v${version}`, messageKey: `message.v${version}`, ...extra }; + return { + campaigns: new Map([["test-campaign", makeCampaign({ version, content })]]), + strings: new Map(Object.entries({ "test.title": "Test Campaign", ...strings })), + }; +} + +const EPOCH_V1 = epochRegistry("1", { + "scene.v1": "The old hall.", + "message.v1": "The old clerk nods.", + shared: "Old wording.", + dropped: "Only the first epoch has this.", +}); +const EPOCH_V2 = epochRegistry("2", { + "scene.v2": "The new hall.", + "message.v2": "The new clerk nods.", + shared: "New wording.", +}); +const EPOCH_V3_REFUSED = epochRegistry("3", { "scene.v3": "Nowhere to stand." }, { refuse: true }); + +/** A channel that records every scope it is asked about and answers `offer` — or throws, when + * `offer` is the string "throw". */ +function recordingChannel(initial?: string): ContentChannel & { calls: ContentScope[]; offer: string | undefined } { + const channel = { + calls: [] as ContentScope[], + offer: initial, + current(scope: ContentScope): string | undefined { + channel.calls.push(scope); + if (channel.offer === "throw") throw new Error("channel defect"); + return channel.offer; + }, + }; + return channel; +} + +function makeEpochStore(options: { + channel?: ContentChannel; + persistence?: SessionPersistence; + profiles?: ProfileStore; + recordSink?: EmittedRecordSink; +}): { store: SessionStore; engine: Engine } { + const kinds = { "story-graph": makeEpochKind() } as unknown as KindRegistry; + const archive = createContentArchive({ kinds, initial: EPOCH_V1 }); + for (const registry of [EPOCH_V2, EPOCH_V3_REFUSED]) { + if (!archive.publish(registry).ok) throw new Error("expected the test epoch to publish"); + } + const engine = createEngine({ kinds, registry: EPOCH_V1, archive, ids: createCountingIds() }); + const store = createSessionLayer({ + engine, + registry: EPOCH_V1, + recordIds: makeCountingRecordIds(), + ...(options.channel ? { content: options.channel } : {}), + ...(options.persistence ? { persistence: options.persistence } : {}), + ...(options.profiles ? { profiles: options.profiles } : {}), + ...(options.recordSink ? { recordSink: options.recordSink } : {}), + }); + return { store, engine }; +} + +interface StoredState { + campaignVersion: string; + formatVersion: number; + actionLog: { seq: number; actionId?: string; system?: string; from?: string; to?: string }[]; +} + +const storedState = (row: StoredSessionRecord | undefined): StoredState => JSON.parse(row!.blob) as StoredState; + +describe("S133.1 — createSession starts on the channel's version", () => { + it("asks the channel once, for this session's scope, and starts on its answer with no content entry", async () => { + const channel = recordingChannel("2"); + const cas = makeCasPersistence(); + const { store } = makeEpochStore({ channel, persistence: cas.persistence }); + const { sessionId, scene } = await store.createSession({ campaignId: "test-campaign", profileId: "p1" }); + + expect(channel.calls).toEqual([{ campaignId: "test-campaign", sessionId, profileId: "p1" }]); + expect(scene.body.textKey).toBe("scene.v2"); + const state = storedState(cas.rows.get(sessionId)); + expect(state.campaignVersion).toBe("2"); + expect(state.formatVersion).toBe(1); + expect(state.actionLog).toEqual([]); + }); + + it("starts on the registry's version with no channel, no answer, or a channel that throws", async () => { + for (const channel of [undefined, recordingChannel(undefined), recordingChannel("throw")]) { + const { store } = makeEpochStore(channel ? { channel } : {}); + const { scene } = await store.createSession({ campaignId: "test-campaign" }); + expect(scene.body.textKey).toBe("scene.v1"); + } + }); + + it("omits profileId from the scope of an anonymous session", async () => { + const channel = recordingChannel(undefined); + const { store } = makeEpochStore({ channel }); + const { sessionId } = await store.createSession({ campaignId: "test-campaign" }); + expect(channel.calls).toEqual([{ campaignId: "test-campaign", sessionId }]); + }); +}); + +describe("S133.2 — an accepted action adopts after it commits, in its own write", () => { + it("persists the action and the adoption in one write; messages are the old epoch's, the scene the new one's", async () => { + const channel = recordingChannel(undefined); + const cas = makeCasPersistence(); + let puts = 0; + const counted: SessionPersistence = { + ...cas.persistence, + sessions: { ...cas.persistence.sessions, put: async (record) => { puts += 1; await cas.persistence.sessions.put(record); } }, + }; + const { store } = makeEpochStore({ channel, persistence: counted }); + const { sessionId } = await store.createSession({ campaignId: "test-campaign" }); + puts = 0; + channel.calls.length = 0; + + channel.offer = "2"; + const result = await store.submitAction(sessionId, "increment"); + + expect(result.ok).toBe(true); + expect(result.messages).toEqual([{ key: "message.v1", visible: true }]); + expect(result.scene?.body.textKey).toBe("scene.v2"); + expect(channel.calls).toEqual([{ campaignId: "test-campaign", sessionId }]); + expect(puts).toBe(1); + const row = cas.rows.get(sessionId)!; + expect(row.revision).toBe(1); + expect(storedState(row)).toMatchObject({ + campaignVersion: "2", + formatVersion: 2, + actionLog: [ + { seq: 0, actionId: "increment" }, + { seq: 1, system: "content", from: "1", to: "2" }, + ], + }); + }); + + it("folds the profile against the epoch the action resolved on", async () => { + const profiles = createInMemoryProfileStore(); + const channel = recordingChannel("2"); + const { store } = makeEpochStore({ channel, profiles }); + // Created on 2 by the channel, so the registry's campaign is not the one this action played. + const { sessionId } = await store.createSession({ campaignId: "test-campaign", profileId: "p1" }); + await store.submitAction(sessionId, "increment"); + + const { profile } = await profiles.load("p1"); + expect(profile.kindData.find((r) => r.kindId === "story-graph")?.data).toEqual({ foldedOn: "2" }); + }); +}); + +describe("S133.3 — resumeSession adopts in its own write", () => { + async function sessionOnV1(cas: ReturnType, channel: ReturnType) { + const { store } = makeEpochStore({ channel, persistence: cas.persistence }); + const { sessionId } = await store.createSession({ campaignId: "test-campaign" }); + return { store, sessionId }; + } + + it("writes the adoption at revision + 1 and returns the adopted scene", async () => { + const cas = makeCasPersistence(); + const channel = recordingChannel(undefined); + const { store, sessionId } = await sessionOnV1(cas, channel); + + channel.offer = "2"; + const scene = await store.resumeSession(sessionId); + + expect(scene.body.textKey).toBe("scene.v2"); + const row = cas.rows.get(sessionId)!; + expect(row.revision).toBe(1); + expect(row.attemptCounter).toBe(0); + expect(storedState(row)).toMatchObject({ campaignVersion: "2", actionLog: [{ seq: 0, system: "content", from: "1", to: "2" }] }); + expect((await store.getScene(sessionId)).body.textKey).toBe("scene.v2"); + }); + + it("writes nothing when the offer is the version the session is on", async () => { + const cas = makeCasPersistence(); + const channel = recordingChannel("1"); + const { store, sessionId } = await sessionOnV1(cas, channel); + await store.resumeSession(sessionId); + expect(cas.rows.get(sessionId)!.revision).toBe(0); + }); + + for (const [code, error] of [ + ["storage_failure", new Error("disk full")], + ["concurrent_modification", { name: SESSION_PERSISTENCE_CONFLICT }], + ] as const) { + it(`restores the unadopted state when the write fails with ${code}, and the next adoption point offers again`, async () => { + const cas = makeCasPersistence(); + const channel = recordingChannel(undefined); + const { store, sessionId } = await sessionOnV1(cas, channel); + + channel.offer = "2"; + cas.failNextPut(error); + const scene = await store.resumeSession(sessionId); + + expect(scene.body.textKey).toBe("scene.v1"); + expect(cas.rows.get(sessionId)!.revision).toBe(0); + expect(storedState(cas.rows.get(sessionId)).campaignVersion).toBe("1"); + // A conflict evicts the cached record, as submitAction's does; a storage failure keeps it. + const reads = cas.reads; + expect((await store.getScene(sessionId)).body.textKey).toBe("scene.v1"); + expect(cas.reads).toBe(code === "concurrent_modification" ? reads + 1 : reads); + + const callsBefore = channel.calls.length; + const retried = await store.resumeSession(sessionId); + expect(channel.calls.length).toBe(callsBefore + 1); + expect(retried.body.textKey).toBe("scene.v2"); + expect(cas.rows.get(sessionId)!.revision).toBe(1); + }); + } +}); + +describe("S133.4 — a refusal or a failing channel never fails the command", () => { + it("a refusal at submitAction leaves the session pinned, the action committed, and emits core.content.pinned", async () => { + const records: EmittedRecord[] = []; + const channel = recordingChannel(undefined); + const cas = makeCasPersistence(); + const { store } = makeEpochStore({ channel, persistence: cas.persistence, recordSink: { write: (r) => records.push(r) } }); + const { sessionId } = await store.createSession({ campaignId: "test-campaign" }); + + channel.offer = "3"; + const result = await store.submitAction(sessionId, "increment"); + + expect(result.ok).toBe(true); + expect(result.errors).toEqual([]); + expect(result.scene?.body.textKey).toBe("scene.v1"); + expect(storedState(cas.rows.get(sessionId))).toMatchObject({ campaignVersion: "1", actionLog: [{ seq: 0, actionId: "increment" }] }); + const pinned = records.filter((r) => r.event.name === "core.content.pinned"); + expect(pinned.map((r) => r.event.reason)).toEqual(["content_incompatible"]); + }); + + it("a refusal at resumeSession returns the pinned scene and writes nothing", async () => { + const channel = recordingChannel(undefined); + const cas = makeCasPersistence(); + const { store } = makeEpochStore({ channel, persistence: cas.persistence }); + const { sessionId } = await store.createSession({ campaignId: "test-campaign" }); + + channel.offer = "3"; + expect((await store.resumeSession(sessionId)).body.textKey).toBe("scene.v1"); + expect(cas.rows.get(sessionId)!.revision).toBe(0); + }); + + it("an offer the archive does not hold pins with unknown_campaign rather than failing", async () => { + const channel = recordingChannel(undefined); + const { store } = makeEpochStore({ channel }); + const { sessionId } = await store.createSession({ campaignId: "test-campaign" }); + channel.offer = "9"; + expect((await store.submitAction(sessionId, "increment")).ok).toBe(true); + expect((await store.resumeSession(sessionId)).body.textKey).toBe("scene.v1"); + }); + + it("a throwing channel is read as no offer at submitAction and resumeSession", async () => { + const channel = recordingChannel(undefined); + const { store } = makeEpochStore({ channel }); + const { sessionId } = await store.createSession({ campaignId: "test-campaign" }); + + channel.offer = "throw"; + const result = await store.submitAction(sessionId, "increment"); + expect(result.ok).toBe(true); + expect(result.scene?.body.textKey).toBe("scene.v1"); + expect((await store.resumeSession(sessionId)).body.textKey).toBe("scene.v1"); + expect(channel.calls.length).toBe(3); + }); +}); + +describe("S133.5 — queries, previews and rejected actions never consult the channel", () => { + it("records zero calls across every query, a preview, a rejected action and a save", async () => { + const channel = recordingChannel("2"); + const cas = makeCasPersistence(); + const { store } = makeEpochStore({ channel, persistence: cas.persistence }); + channel.offer = undefined; + const { sessionId } = await store.createSession({ campaignId: "test-campaign", profileId: "p1" }); + channel.offer = "2"; + channel.calls.length = 0; + + await store.getScene(sessionId); + await store.getView(sessionId); + await store.getStrings(sessionId); + await store.listCampaigns("p1"); + await store.listSaves("p1"); + expect((await store.previewAction(sessionId, "increment")).ok).toBe(true); + expect((await store.submitAction(sessionId, "no-such-action")).ok).toBe(false); + + expect(channel.calls).toEqual([]); + expect(storedState(cas.rows.get(sessionId)).campaignVersion).toBe("1"); + expect(cas.rows.get(sessionId)!.revision).toBe(0); + }); +}); + +describe("S133.6 — getStrings spans every live epoch", () => { + const tableOf = (registry: ContentRegistry) => Object.fromEntries(registry.strings); + + it("a session that never adopted gets exactly its one epoch's table", async () => { + const { store } = makeEpochStore({}); + const { sessionId } = await store.createSession({ campaignId: "test-campaign" }); + expect(await store.getStrings(sessionId)).toEqual(tableOf(EPOCH_V1)); + + const { store: startedOnTwo } = makeEpochStore({ channel: recordingChannel("2") }); + const created = await startedOnTwo.createSession({ campaignId: "test-campaign" }); + expect(await startedOnTwo.getStrings(created.sessionId)).toEqual(tableOf(EPOCH_V2)); + }); + + it("after an adoption, a dropped key still resolves and a key both define resolves to the newer text", async () => { + const channel = recordingChannel(undefined); + const { store } = makeEpochStore({ channel }); + const { sessionId } = await store.createSession({ campaignId: "test-campaign" }); + channel.offer = "2"; + const result = await store.submitAction(sessionId, "increment"); + + const strings = await store.getStrings(sessionId); + expect(strings).toEqual({ ...tableOf(EPOCH_V1), ...tableOf(EPOCH_V2) }); + expect(strings.dropped).toBe("Only the first epoch has this."); + expect(strings.shared).toBe("New wording."); + // Both halves of the adopting result resolve in one table (C27). + expect(strings[result.messages[0]!.key]).toBe("The old clerk nods."); + expect(strings[result.scene!.body.textKey]).toBe("The new hall."); + }); + + it("starts from the last migration entry's to, not the log's first epoch", async () => { + const { engine } = makeEpochStore({}); + const created = engine.createGame({ campaignId: "test-campaign", seed: "s" }, "2"); + const migrated = { + ...created.value!, + formatVersion: 2, + actionLog: [{ seq: 0, system: "migration" as const, from: "1", to: "2" }], + }; + const blob = engine.serialize(migrated); + expect(engine.deserialize(blob).ok).toBe(true); + const row: StoredSessionRecord = { + sessionId: "migrated", + blob, + audience: "player", + attemptCounter: 0, + revision: 0, + replayCompatible: false, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }; + const store = createSessionLayer({ + engine, + registry: EPOCH_V1, + persistence: persistenceWith({ sessions: { get: async (id) => (id === "migrated" ? row : undefined) } }), + }); + expect(await store.getStrings("migrated")).toEqual(tableOf(EPOCH_V2)); + }); +}); diff --git a/src/engine/src/core/session/store.ts b/src/engine/src/core/session/store.ts index 7e7d5728..c08b6c5a 100644 --- a/src/engine/src/core/session/store.ts +++ b/src/engine/src/core/session/store.ts @@ -20,7 +20,8 @@ import type { } from "../kernel/types.js"; import type { OutcomeMessage, StateChange } from "../kernel/reasons.js"; import type { Campaign, ContentRegistry } from "../registry/types.js"; -import { isLoggedAction } from "../kernel/log.js"; +import type { ContentChannel, ContentScope } from "../registry/archive.js"; +import { isEpochEntry, isLoggedAction, startingCampaignVersion } from "../kernel/log.js"; import { buildSaveEnvelope, resolveSaveEnvelope, serializeSaveEnvelope } from "../persistence/envelope.js"; import { canonicalize as canonicalStringify } from "subzerodev-data-json"; import type { PlayerView, ProjectionAudience } from "../projection/types.js"; @@ -334,6 +335,31 @@ export interface InMemorySessionStoreOptions { * idle one is dropped and re-read from persistence on its next use. Omitted → unbounded. * A positive integer, and only with `persistence` — without it the map is the storage. */ sessionCacheLimit?: number; + /** 16 §4.2, §5.3. Omitted → no session is ever offered content. */ + content?: ContentChannel; +} + +/** + * 16 §5.7: the versions a session's strings are drawn from, oldest first — its starting version, + * or the last migration entry's `to` when the log carries one, then every content entry's `to` + * after it. A migration restamps the save onto content that may not hold the earlier epochs' + * keys at all, so nothing before the last one is live. + */ +function liveEpochs(state: GameState): string[] { + const log = state.actionLog; + let lastMigration = -1; + log.forEach((entry, index) => { + if (isEpochEntry(entry) && entry.system === "migration") lastMigration = index; + }); + const migration = log[lastMigration]; + const start = migration !== undefined && isEpochEntry(migration) + ? migration.to + : startingCampaignVersion(log, state.campaignVersion)!; + const versions = [start]; + for (const entry of log.slice(lastMigration + 1)) { + if (isEpochEntry(entry)) versions.push(entry.to); + } + return versions; } /** @@ -600,6 +626,36 @@ function createStore(options: InMemorySessionStoreOptions): SessionStore { * than a restatement of JS's run-to-completion semantics), then hands the caller an * engine rebound to this command's stamping decorator. */ + /** The channel's offer for `scope`, or undefined for none. A throw, or an answer that is not + * a version, is read as no offer (16 §5.3): a host defect in a policy hook must not stop a + * player mid-turn. */ + function offeredVersion(scope: ContentScope): string | undefined { + if (!options.content) return undefined; + try { + const offered = options.content.current(scope); + return typeof offered === "string" ? offered : undefined; + } catch { + return undefined; + } + } + + /** + * One adoption point (16 §5.3), called under the session lock: `state` moved onto the + * channel's offer, or `state` itself — the same object — when there is no offer, the offer + * is the version it is on, or the engine refuses it. A refusal is never an error: the + * session stays pinned and the command it rides on completes (C25). + */ + function adoptOffered(decoratedEngine: Engine, state: GameState, record: SessionRecord): GameState { + const offered = offeredVersion({ + campaignId: state.campaignId, + sessionId: record.sessionId, + ...(record.profileId !== undefined ? { profileId: record.profileId } : {}), + }); + if (offered === undefined || offered === state.campaignVersion) return state; + const adoption = decoratedEngine.adoptContent(state, offered); + return adoption.adopted ? adoption.state : state; + } + async function withCommand( sessionId: string | undefined, attempt: number, @@ -664,13 +720,21 @@ function createStore(options: InMemorySessionStoreOptions): SessionStore { }, async getStrings(sessionId: string): Promise { - // Validates the session exists even though the returned table doesn't depend on - // which one — plan 14 Decision 7: the registry has no per-campaign string - // partition to narrow by, so the whole frozen table is returned. - await getSession(sessionId); + // C27 (16 §5.7): the union of the live epochs' tables, oldest first, so each key ends on + // the most recent epoch that defines it. Each epoch contributes its whole frozen + // registry table — plan 14 Decision 7: a registry has no per-campaign string partition + // to narrow by. A session that never adopted has one live epoch, and gets its table. + const record = await getSession(sessionId); + const state = mustDeserialize(engine, record.blob); const table: Record = {}; - for (const [key, text] of registry.strings) { - table[key] = text; + for (const version of liveEpochs(state)) { + // Every live epoch resolves through an append-only archive; one a host-supplied + // archive no longer holds contributes nothing rather than failing the query. + const epoch = engine.content.resolve(state.campaignId, version); + if (!epoch) continue; + for (const [key, text] of epoch.strings) { + table[key] = text; + } } return table; }, @@ -711,7 +775,16 @@ function createStore(options: InMemorySessionStoreOptions): SessionStore { async createSession(config: CreateSessionConfig): Promise { const sessionId = newSessionId(recordIds); const audience = config.audience ?? "player"; - const campaign = registry.campaigns.get(config.campaignId); + // 16 §5.2: asked once, for the version to start on. Creation, not adoption — the game + // starts on that epoch with no content entry. No offer → the registry's version. + const version = offeredVersion({ + campaignId: config.campaignId, + sessionId, + ...(config.profileId !== undefined ? { profileId: config.profileId } : {}), + }); + const campaign = version === undefined + ? registry.campaigns.get(config.campaignId) + : engine.content.resolve(config.campaignId, version)?.campaigns.get(config.campaignId); const kind = campaign ? kinds[campaign.kindId] : undefined; const kindProfileData = await resolveKindProfileData(options.profiles, config.profileId, kind); const newGameConfig: NewGameConfig = { @@ -722,7 +795,7 @@ function createStore(options: InMemorySessionStoreOptions): SessionStore { }; return withCommand(sessionId, 0, async (decoratedEngine) => { - const created = decoratedEngine.createGame(newGameConfig); + const created = decoratedEngine.createGame(newGameConfig, version); if (!created.ok || !created.value) { // createSession's return type carries no error channel (session/types.ts) — // same reasoning as getSession's throw above. @@ -753,7 +826,32 @@ function createStore(options: InMemorySessionStoreOptions): SessionStore { return runExclusive(sessionLocks, sessionId, () => withCommand(sessionId, record.attemptCounter, async (decoratedEngine) => { const state = mustDeserialize(decoratedEngine, record.blob); - return decoratedEngine.scene(state); + const adopted = adoptOffered(decoratedEngine, state, record); + if (adopted === state) return decoratedEngine.scene(state); + + // 16 §5.3: its own write, at revision + 1. Adoption never fails a command, so a + // refused write restores the unadopted record and serves its scene; the next + // adoption point offers again. A conflict evicts the restored record as + // `submitAction` does, since another writer committed past it. + const previousBlob = record.blob; + const previousUpdatedAt = record.updatedAt; + const previousRevision = record.revision; + record.blob = decoratedEngine.serialize(adopted); + record.updatedAt = clock.now(); + record.revision = previousRevision + 1; + try { + await writeSession(record); + } catch (error) { + record.blob = previousBlob; + record.updatedAt = previousUpdatedAt; + record.revision = previousRevision; + if (!(error instanceof SessionStoreErrorValue)) throw error; + if (error.code === "concurrent_modification" && sessions.get(sessionId) === record) { + sessions.delete(sessionId); + } + return decoratedEngine.scene(state); + } + return decoratedEngine.scene(adopted); }), ); }); @@ -776,10 +874,14 @@ function createStore(options: InMemorySessionStoreOptions): SessionStore { if (result.ok && result.value) { const newState = result.value; + // 16 §5.3: after the action commits, in the action's own write. The player + // resolved what they were shown; the next scene is the first drawn from the + // adopted content, and the messages stay the old epoch's. + const committed = adoptOffered(decoratedEngine, newState, record); const previousBlob = record.blob; const previousUpdatedAt = record.updatedAt; const previousRevision = record.revision; - record.blob = decoratedEngine.serialize(newState); + record.blob = decoratedEngine.serialize(committed); record.updatedAt = clock.now(); record.revision = previousRevision + 1; try { @@ -824,7 +926,10 @@ function createStore(options: InMemorySessionStoreOptions): SessionStore { profileWarnings = await runExclusive(profileLocks, profileId, async () => { const achievementWarnings = await upsertAchievements(profiles, profileId, state.campaignId, result.changes); const kind = kinds[state.kindId]; - const campaign = registry.campaigns.get(state.campaignId)!; + // The epoch the action resolved on, which is the registry's only while the + // session has never been on another (16 §5.2). + const campaign = decoratedEngine.content.resolve(state.campaignId, state.campaignVersion)?.campaigns.get(state.campaignId) + ?? registry.campaigns.get(state.campaignId)!; const kindDataWarnings = await upsertKindProfileData(profiles, profileId, kind, campaign, result.changes); // "After an action whose AdvanceResult.status is ended" (04 §7.1) — the // same write as the achievement and kind-data upserts, on the same lock. @@ -840,7 +945,7 @@ function createStore(options: InMemorySessionStoreOptions): SessionStore { return toPlayerResult({ ok: true, - scene: decoratedEngine.scene(result.value), + scene: decoratedEngine.scene(committed), errors: result.errors, warnings: [...result.warnings, ...profileWarnings], changes: result.changes, diff --git a/src/engine/src/core/session/types.ts b/src/engine/src/core/session/types.ts index 7f575243..58166817 100644 --- a/src/engine/src/core/session/types.ts +++ b/src/engine/src/core/session/types.ts @@ -187,7 +187,9 @@ export interface SessionStore { listCampaigns(profileId?: string): Promise; getScene(sessionId: string): Promise; getView(sessionId: string): Promise; - /** Resolves `LocKey`s. Without this a compliant client cannot render a single label. */ + /** Resolves `LocKey`s. Without this a compliant client cannot render a single label. The + * union of the session's live epochs' tables, each key from the most recent epoch that + * defines it, so a key any earlier result carried still resolves (C27). */ getStrings(sessionId: string): Promise; /** Player-keyed, totally ordered (04 §7.4). Every profile's own saves, `savedAt` * descending then `saveId` ascending, and no other profile's. */