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:
+76
-53
@@ -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 });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user