Server-side scenario execution: thin effects client replaces local engine

- engine/ (DagWalker, sessions, IndexedDbPlanStore) deleted; server owns the graph
- trigger/callback/counter carry PendingEffect; GET /sdk/v1/scenarios/pending polled
  (30s jittered heartbeat paused on hidden tab + wait-deadline timers)
- client.effects public API unchanged (done/buy/dismiss/end/claim/battlepass/quest)
- planStateStore removed; loginEvent kept; reconcile() makes server the source of truth
- generated models regenerated; skills/README/CHANGELOG updated
This commit is contained in:
edmand46
2026-09-04 14:08:48 +03:00
parent ecbbf93951
commit 1491b5e357
33 changed files with 976 additions and 2050 deletions
+76 -53
View File
@@ -1,5 +1,5 @@
import { vi } from 'vitest';
import type { ExecutionPlan } from '../../src/generated/common.js';
import type { PendingEffect } from '../../src/generated/scenarios.js';
import type { RemoteConfig } from '../../src/generated/remote-config.js';
import type { Store } from '../../src/generated/stores.js';
import type { StorageItem } from '../../src/generated/storage.js';
@@ -8,18 +8,6 @@ import type {
ListQuestsResponse,
} from '../../src/generated/quests.js';
/**
* In-process fake of the LiveOps API gateway.
*
* Stubs the global `fetch` and routes requests the same way the real gateway
* does — so tests exercise the *real* SDK transport, auth-header injection,
* scenario engine and DAG traversal, just against deterministic in-memory state
* instead of a live backend.
*
* Seed state up front (scenarios per event, remote configs, stores), then drive
* the SDK through a journey and assert on `recorded` requests / server state.
*/
export interface RecordedRequest {
method: string;
path: string;
@@ -29,36 +17,28 @@ export interface RecordedRequest {
}
export interface FakeGatewayState {
/** Plans returned by POST /scenarios/trigger, keyed by event name. */
scenarios: Map<string, ExecutionPlan[]>;
/** Plans returned by POST /scenarios/callback, keyed by `nodeId:handle`. */
callbacks: Map<string, ExecutionPlan>;
/** HTTP status to fail a callback with, keyed by `nodeId:handle`. */
callbackErrors: Map<string, number>;
/** Optional response for POST /scenarios/counter. */
counterPlan?: ExecutionPlan;
scenarios: Map<string, PendingEffect[]>;
callbacks: Map<string, PendingEffect | null>;
callbackErrors: Map<string, { status: number; code?: string }>;
pendingEffects: PendingEffect[];
counterCompleted: boolean;
counterEffect?: PendingEffect;
remoteConfigs: Record<string, RemoteConfig>;
stores: Store[];
quests: ListQuestsResponse;
questClaims: Map<string, ClaimQuestResponse>;
/** Player KV storage, keyed by item id. */
storage: Map<string, StorageItem>;
}
export interface FakeGateway {
state: FakeGatewayState;
recorded: RecordedRequest[];
/** Purchases received, in order. */
purchases: Array<{ storeSlug: string; offerId: string; idempotencyKey: string; authToken: string | null }>;
/** Quest claim requests received, in order. */
questClaims: Array<{ questId: string; authToken: string | null }>;
install(): void;
/** Convenience: register the plans returned for a trigger event. */
onEvent(event: string, ...plans: ExecutionPlan[]): void;
/** Convenience: register the plan returned when a boundary handle calls back. */
onCallback(nodeId: string, handle: string, plan: ExecutionPlan): void;
/** Convenience: make a boundary callback fail with an HTTP status (default 500). */
onCallbackError(nodeId: string, handle: string, status?: number): void;
onEvent(event: string, ...effects: PendingEffect[]): void;
onCallback(nodeId: string, handle: string, next: PendingEffect | null): void;
onCallbackError(nodeId: string, handle: string, status?: number, code?: string): void;
}
function json(body: unknown, status = 200): Response {
@@ -70,12 +50,19 @@ function json(body: unknown, status = 200): Response {
const noContent = (): Response => new Response(null, { status: 204 });
function replaceRun(pending: PendingEffect[], next: PendingEffect[]): PendingEffect[] {
const runIds = new Set(next.map((effect) => effect.runId));
return [...pending.filter((effect) => !runIds.has(effect.runId)), ...next];
}
export function createFakeGateway(seed?: Partial<FakeGatewayState>): FakeGateway {
const state: FakeGatewayState = {
scenarios: seed?.scenarios ?? new Map(),
callbacks: seed?.callbacks ?? new Map(),
callbackErrors: seed?.callbackErrors ?? new Map(),
counterPlan: seed?.counterPlan,
pendingEffects: seed?.pendingEffects ?? [],
counterCompleted: seed?.counterCompleted ?? false,
counterEffect: seed?.counterEffect,
remoteConfigs: seed?.remoteConfigs ?? {},
stores: seed?.stores ?? [],
quests: seed?.quests ?? { quests: [] },
@@ -87,10 +74,32 @@ export function createFakeGateway(seed?: Partial<FakeGatewayState>): FakeGateway
const purchases: FakeGateway['purchases'] = [];
const questClaims: FakeGateway['questClaims'] = [];
function advanceExpiredWaits(): PendingEffect[] {
const now = Date.now();
const next: PendingEffect[] = [];
for (const effect of state.pendingEffects) {
if (
effect.type === 'wait' &&
effect.waitDeadline &&
Date.parse(effect.waitDeadline) <= now
) {
const continued = state.callbacks.get(`${effect.nodeId}:onComplete`);
if (continued === undefined) {
next.push(effect);
} else if (continued !== null) {
next.push(continued);
}
} else {
next.push(effect);
}
}
state.pendingEffects = next;
return next;
}
async function handle(req: RecordedRequest): Promise<Response> {
const { method, path, query, body } = req;
// --- Auth ---
if (method === 'POST' && path === '/sdk/v1/authorization/device') {
const b = body as { deviceId?: string };
return json({
@@ -99,39 +108,56 @@ export function createFakeGateway(seed?: Partial<FakeGatewayState>): FakeGateway
});
}
// --- Player ---
if (method === 'GET' && path === '/sdk/v1/player/information') {
return json({ player: null, wallets: [] });
}
// --- Sync (baseline revision poll after login) ---
if (method === 'GET' && path === '/sdk/v1/sync') {
return json({});
}
// --- Catalog ---
if (method === 'GET' && path === '/sdk/v1/catalog') {
return json({ items: [] });
}
// --- Scenarios ---
if (method === 'POST' && path === '/sdk/v1/scenarios/trigger') {
const event = (body as { event?: string }).event ?? '';
return json({ plans: state.scenarios.get(event) ?? [] });
const effects = state.scenarios.get(event) ?? [];
state.pendingEffects = replaceRun(state.pendingEffects, effects);
return json({ effects });
}
if (method === 'GET' && path === '/sdk/v1/scenarios/pending') {
return json({ effects: advanceExpiredWaits() });
}
if (method === 'POST' && path === '/sdk/v1/scenarios/callback') {
const b = body as { nodeId?: string; handle?: string };
const b = body as { nodeId?: string; handle?: string; runId?: string };
const key = `${b.nodeId}:${b.handle}`;
const errStatus = state.callbackErrors.get(key);
if (errStatus) return json({ error: 'callback failed' }, errStatus);
const plan = state.callbacks.get(key);
return json(plan ? { plan } : {});
const err = state.callbackErrors.get(key);
if (err) {
return json({ code: err.code ?? 'callback failed', error: 'callback failed' }, err.status);
}
const next = state.callbacks.get(key);
state.pendingEffects = state.pendingEffects.filter(
(effect) => !(effect.nodeId === b.nodeId && effect.runId === (b.runId ?? effect.runId)),
);
if (next) {
state.pendingEffects = replaceRun(state.pendingEffects, [next]);
return json({ effect: next });
}
return json({});
}
if (method === 'POST' && path === '/sdk/v1/scenarios/counter') {
return json(state.counterPlan ? { plan: state.counterPlan, completed: false } : { completed: false });
if (state.counterCompleted && state.counterEffect) {
state.pendingEffects = replaceRun(state.pendingEffects, [state.counterEffect]);
} else if (state.counterCompleted) {
const b = body as { nodeId?: string; runId?: string };
state.pendingEffects = state.pendingEffects.filter(
(effect) => !(effect.nodeId === b.nodeId && effect.runId === (b.runId ?? effect.runId)),
);
}
return json({ completed: state.counterCompleted, effect: state.counterEffect });
}
// --- Remote config ---
if (method === 'GET' && path === '/sdk/v1/remote-configs') {
return json({ configs: state.remoteConfigs });
}
@@ -141,7 +167,6 @@ export function createFakeGateway(seed?: Partial<FakeGatewayState>): FakeGateway
return cfg ? json(cfg) : json({ error: 'not found' }, 404);
}
// --- Stores ---
if (method === 'GET' && path === '/sdk/v1/stores') {
return json({ stores: state.stores, total: state.stores.length });
}
@@ -163,7 +188,6 @@ export function createFakeGateway(seed?: Partial<FakeGatewayState>): FakeGateway
return store ? json(store) : json({ error: 'not found' }, 404);
}
// --- Quests ---
if (method === 'POST' && path === '/sdk/v1/quests/list') {
return json(state.quests);
}
@@ -174,7 +198,6 @@ export function createFakeGateway(seed?: Partial<FakeGatewayState>): FakeGateway
return json(state.questClaims.get(questId) ?? { success: false, error: 'not found' });
}
// --- Storage ---
if (path === '/sdk/v1/storage') {
if (method === 'GET') {
const typeFilter = query.get('types');
@@ -228,14 +251,14 @@ export function createFakeGateway(seed?: Partial<FakeGatewayState>): FakeGateway
purchases,
questClaims,
install,
onEvent(event, ...plans) {
state.scenarios.set(event, plans);
onEvent(event, ...effects) {
state.scenarios.set(event, effects);
},
onCallback(nodeId, handle, plan) {
state.callbacks.set(`${nodeId}:${handle}`, plan);
onCallback(nodeId, handle, next) {
state.callbacks.set(`${nodeId}:${handle}`, next);
},
onCallbackError(nodeId, handle, status = 500) {
state.callbackErrors.set(`${nodeId}:${handle}`, status);
onCallbackError(nodeId, handle, status = 500, code) {
state.callbackErrors.set(`${nodeId}:${handle}`, { status, code });
},
};
}