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
+55 -39
View File
@@ -3,7 +3,7 @@ import { RudderClient } from '../../src/client/RudderClient.js';
import { createFakeTokenStore } from '../helpers/FakeTokenStore.js';
import { createFakeGateway, type FakeGateway } from '../helpers/fakeGateway.js';
import { stubDeterministicUuid } from '../helpers/memoryPlanStore.js';
import { plan } from '../helpers/plan.js';
import { effect } from '../helpers/plan.js';
import type { NotificationEffect, StoreOfferEffect } from '../../src/index.js';
const MINUTE = 60_000;
@@ -13,28 +13,38 @@ function makeClient(): RudderClient {
baseUrl: 'https://api.test.rudder.build',
projectKey: 'test-project',
tokenStore: createFakeTokenStore(),
runtime: { planStateStore: null }, // exercised separately in the persistence test
});
}
/**
* The offer scenario both branches share:
*
* login → wait 1m → offer ──onDecline──→ wait 1m → "still available"
* └─onPurchase─→ "thanks for your purchase"
*/
function offerScenario() {
return plan('offer_flow')
.node('wait_intro', 'wait', { duration: 1, unit: 'minutes' })
.node('offer', 'store', { storeSlug: 'starter', offerId: 'pack_1', message: 'Limited starter pack!' })
.node('wait_reminder', 'wait', { duration: 1, unit: 'minutes' })
.node('reminder', 'notification', { message: 'Your offer is still available!' })
.node('thanks', 'notification', { message: 'Thanks for your purchase!' })
.edge('wait_intro', 'onComplete', 'offer')
.edge('offer', 'onDecline', 'wait_reminder')
.edge('wait_reminder', 'onComplete', 'reminder')
.edge('offer', 'onPurchase', 'thanks')
.build();
function offerScenario(now: number) {
const waitIntro = effect('wait', {}, {
scenarioId: 'offer_flow',
runId: 'offer_flow-run',
nodeId: 'wait_intro',
waitDeadline: new Date(now + MINUTE).toISOString(),
});
const offer = effect('store', { storeSlug: 'starter', offerId: 'pack_1', message: 'Limited starter pack!' }, {
scenarioId: 'offer_flow',
runId: 'offer_flow-run',
nodeId: 'offer',
});
const waitReminder = effect('wait', {}, {
scenarioId: 'offer_flow',
runId: 'offer_flow-run',
nodeId: 'wait_reminder',
waitDeadline: new Date(now + 2 * MINUTE).toISOString(),
});
const reminder = effect('notification', { message: 'Your offer is still available!' }, {
scenarioId: 'offer_flow',
runId: 'offer_flow-run',
nodeId: 'reminder',
});
const thanks = effect('notification', { message: 'Thanks for your purchase!' }, {
scenarioId: 'offer_flow',
runId: 'offer_flow-run',
nodeId: 'thanks',
});
return { waitIntro, offer, waitReminder, reminder, thanks };
}
describe('E2E: player offer journey', () => {
@@ -66,43 +76,41 @@ describe('E2E: player offer journey', () => {
expect(res.accessToken).toBeTruthy();
expect(client.options.tokenStore.getAccessToken()).toBe(res.accessToken);
// The login request carried the project key + a device id.
const login = gateway.recorded.find((r) => r.path === '/sdk/v1/authorization/device');
expect(login?.body).toMatchObject({ key: 'test-project', region: 'eu', language: 'en' });
expect((login?.body as { deviceId?: string }).deviceId).toBeTruthy();
// The post-login runtime trigger carries the bearer token.
const trigger = gateway.recorded.find((r) => r.path === '/sdk/v1/scenarios/trigger');
expect(trigger?.authToken).toBe(res.accessToken);
});
it('offer appears after 1 min, player declines, reminder fires 1 min later', async () => {
gateway.onEvent('player_login', offerScenario());
const now = Date.now();
const nodes = offerScenario(now);
gateway.onEvent('player_login', nodes.waitIntro);
gateway.onCallback('wait_intro', 'onComplete', nodes.offer);
gateway.onCallback('offer', 'onDecline', nodes.waitReminder);
gateway.onCallback('wait_reminder', 'onComplete', nodes.reminder);
const notifications: NotificationEffect[] = [];
let offer: StoreOfferEffect | undefined;
client.effects.onStoreOffer((effect) => { offer = effect; });
client.effects.onNotification((effect) => { notifications.push(effect); });
client.effects.onStoreOffer((e) => { offer = e; });
client.effects.onNotification((e) => { notifications.push(e); });
await client.auth.loginWithDevice();
// Immediately after login the player is just waiting — no offer yet.
expect(offer).toBeUndefined();
// The offer must NOT appear before the full minute has elapsed...
await vi.advanceTimersByTimeAsync(MINUTE - 1_000);
expect(offer).toBeUndefined();
// ...and surfaces exactly when the minute is up.
await vi.advanceTimersByTimeAsync(1_000);
await vi.waitFor(() => expect(offer).toBeDefined());
expect(offer!.message).toBe('Limited starter pack!');
// Player declines → the DAG moves to the reminder wait, no notification yet.
await offer!.dismiss();
expect(notifications).toHaveLength(0);
// The reminder also honours the full minute, not a moment sooner.
await vi.advanceTimersByTimeAsync(MINUTE - 1_000);
expect(notifications).toHaveLength(0);
await vi.advanceTimersByTimeAsync(1_000);
@@ -111,38 +119,46 @@ describe('E2E: player offer journey', () => {
});
it('offer appears, player buys → "thanks" branch, and the purchase transacts', async () => {
gateway.onEvent('player_login', offerScenario());
const now = Date.now();
const nodes = offerScenario(now);
gateway.onEvent('player_login', nodes.waitIntro);
gateway.onCallback('wait_intro', 'onComplete', nodes.offer);
gateway.onCallback('offer', 'onPurchase', nodes.thanks);
let offer: StoreOfferEffect | undefined;
const notifications: NotificationEffect[] = [];
client.effects.onStoreOffer((effect) => { offer = effect; });
client.effects.onNotification((effect) => { notifications.push(effect); });
client.effects.onStoreOffer((e) => { offer = e; });
client.effects.onNotification((e) => { notifications.push(e); });
const accessToken = (await client.auth.loginWithDevice()).accessToken;
await vi.advanceTimersByTimeAsync(MINUTE);
await vi.waitFor(() => expect(offer).toBeDefined());
// The game buys a selected offer; the session advances only after success.
const selectedOffer = offer!.offers[0];
const purchase = await offer!.buy(selectedOffer, { idempotencyKey: 'idem-key-123' });
expect(purchase.success).toBe(true);
// The purchase hit the gateway with idempotency key + bearer token.
expect(gateway.purchases).toEqual([
{ storeSlug: 'starter', offerId: 'pack_1', idempotencyKey: 'idem-key-123', authToken: accessToken },
]);
// The scenario took the onPurchase branch → "thanks", and the run finished.
expect(notifications).toHaveLength(1);
expect(notifications[0].message).toBe('Thanks for your purchase!');
});
it('declining does NOT take the purchase branch (handles are isolated)', async () => {
gateway.onEvent('player_login', offerScenario());
const now = Date.now();
const nodes = offerScenario(now);
gateway.onEvent('player_login', nodes.waitIntro);
gateway.onCallback('wait_intro', 'onComplete', nodes.offer);
gateway.onCallback('offer', 'onDecline', nodes.waitReminder);
gateway.onCallback('wait_reminder', 'onComplete', nodes.reminder);
gateway.onCallback('offer', 'onPurchase', nodes.thanks);
const messages: string[] = [];
let offer: StoreOfferEffect | undefined;
client.effects.onStoreOffer((effect) => { offer = effect; });
client.effects.onNotification((effect) => { messages.push(effect.message); });
client.effects.onStoreOffer((e) => { offer = e; });
client.effects.onNotification((e) => { messages.push(e.message); });
await client.auth.loginWithDevice();
await vi.advanceTimersByTimeAsync(MINUTE);