Slug-based scenarios, quests and offers per the environments contract; regenerated models; e2e seed passes environment to admin mirrors

Claude-Session: https://claude.ai/code/session_01SMCvdwDmuxoaqGgvGBLk1V
This commit is contained in:
edmand46
2026-09-06 21:44:54 +03:00
parent e863cc3bf7
commit a7401b534a
23 changed files with 127 additions and 115 deletions
+3 -3
View File
@@ -2,7 +2,7 @@
* BattlePassService — call-and-response access to the battle pass endpoints. * BattlePassService — call-and-response access to the battle pass endpoints.
* *
* Battle pass state is tied to a scenario battle pass node, so every call * Battle pass state is tied to a scenario battle pass node, so every call
* carries a `scenarioId` + `nodeId` (and a `runId` for the mutating calls). * carries a `scenarioSlug` + `nodeId` (and a `runId` for the mutating calls).
* Battle pass has no sync-revision key, so this is a plain service, not an * Battle pass has no sync-revision key, so this is a plain service, not an
* observable domain; re-fetch progress explicitly after a mutation. * observable domain; re-fetch progress explicitly after a mutation.
*/ */
@@ -23,8 +23,8 @@ export class BattlePassService {
constructor(private readonly ctx: RudderContext) {} constructor(private readonly ctx: RudderContext) {}
/** Reads current progress (xp, level, premium, claimed tiers) for a node. */ /** Reads current progress (xp, level, premium, claimed tiers) for a node. */
getProgress(scenarioId: string, nodeId: string): Promise<GetBattlePassProgressResponse> { getProgress(scenarioSlug: string, nodeId: string): Promise<GetBattlePassProgressResponse> {
return api.getBattlePassProgress(this.ctx, { scenarioId, nodeId }); return api.getBattlePassProgress(this.ctx, { scenarioSlug, nodeId });
} }
/** Credits XP from a configured source. Returns the new xp/level. */ /** Credits XP from a configured source. Returns the new xp/level. */
+4 -4
View File
@@ -14,7 +14,7 @@ import type { PurchaseOfferResponse } from '../generated/stores.js';
export class StoresDomain extends SyncedState<ShopHandle[]> { export class StoresDomain extends SyncedState<ShopHandle[]> {
readonly syncKey = 'stores'; readonly syncKey = 'stores';
/** Shared purchase executor: `stores.purchase(slug, offerId, opts)`. */ /** Shared purchase executor: `stores.purchase(slug, offerSlug, opts)`. */
readonly purchase: PurchaseOffer; readonly purchase: PurchaseOffer;
private readonly ctx: RudderContext; private readonly ctx: RudderContext;
@@ -24,15 +24,15 @@ export class StoresDomain extends SyncedState<ShopHandle[]> {
// handles created in the loader all delegate to this one executor. // handles created in the loader all delegate to this one executor.
const purchase: PurchaseOffer = async ( const purchase: PurchaseOffer = async (
storeSlug: string, storeSlug: string,
offerId: string, offerSlug: string,
options: BuyOptions = {}, options: BuyOptions = {},
): Promise<PurchaseOfferResponse> => { ): Promise<PurchaseOfferResponse> => {
const response = await api.purchaseOffer( const response = await api.purchaseOffer(
ctx, ctx,
{ storeSlug, offerId }, { storeSlug, offerSlug },
{ {
storeSlug, storeSlug,
offerId, offerSlug,
idempotencyKey: options.idempotencyKey ?? crypto.randomUUID(), idempotencyKey: options.idempotencyKey ?? crypto.randomUUID(),
}, },
); );
+2 -2
View File
@@ -40,12 +40,12 @@ export interface WaitEffect {
export interface ScenarioCompletedEffect { export interface ScenarioCompletedEffect {
readonly runId: string; readonly runId: string;
readonly scenarioId: string; readonly scenarioSlug: string;
} }
export interface ScenarioFailedEffect { export interface ScenarioFailedEffect {
readonly runId: string; readonly runId: string;
readonly scenarioId: string; readonly scenarioSlug: string;
readonly nodeId: string; readonly nodeId: string;
readonly error: Error; readonly error: Error;
} }
+2 -2
View File
@@ -163,10 +163,10 @@ export const api = {
purchaseOffer: ( purchaseOffer: (
t: Transport, t: Transport,
path: { storeSlug: string; offerId: string }, path: { storeSlug: string; offerSlug: string },
body: PurchaseOfferRequest, body: PurchaseOfferRequest,
): Promise<PurchaseOfferResponse> => ): Promise<PurchaseOfferResponse> =>
t.request<PurchaseOfferResponse>('POST', `/sdk/v1/stores/${enc(path.storeSlug)}/offers/${enc(path.offerId)}/purchase`, body), t.request<PurchaseOfferResponse>('POST', `/sdk/v1/stores/${enc(path.storeSlug)}/offers/${enc(path.offerSlug)}/purchase`, body),
refreshAccessToken: ( refreshAccessToken: (
t: Transport, t: Transport,
+4 -4
View File
@@ -6,7 +6,7 @@ export interface AddBattlePassXpRequest {
"amount"?: number; "amount"?: number;
"nodeId"?: string; "nodeId"?: string;
"runId"?: string; "runId"?: string;
"scenarioId"?: string; "scenarioSlug"?: string;
"source"?: string; "source"?: string;
} }
@@ -21,7 +21,7 @@ export interface ClaimBattlePassRewardRequest {
"level"?: number; "level"?: number;
"nodeId"?: string; "nodeId"?: string;
"runId"?: string; "runId"?: string;
"scenarioId"?: string; "scenarioSlug"?: string;
"track"?: "free" | "premium"; "track"?: "free" | "premium";
} }
@@ -39,7 +39,7 @@ export interface ClaimedTier {
export interface GetBattlePassProgressRequest { export interface GetBattlePassProgressRequest {
"nodeId"?: string; "nodeId"?: string;
"scenarioId"?: string; "scenarioSlug"?: string;
} }
export interface GetBattlePassProgressResponse { export interface GetBattlePassProgressResponse {
@@ -53,7 +53,7 @@ export interface PurchaseBattlePassPremiumRequest {
"idempotencyKey"?: string; "idempotencyKey"?: string;
"nodeId"?: string; "nodeId"?: string;
"runId"?: string; "runId"?: string;
"scenarioId"?: string; "scenarioSlug"?: string;
} }
export interface PurchaseBattlePassPremiumResponse { export interface PurchaseBattlePassPremiumResponse {
+3 -3
View File
@@ -3,7 +3,7 @@
import type { Reward } from './common.js'; import type { Reward } from './common.js';
export interface ClaimQuestRequest { export interface ClaimQuestRequest {
"questId"?: string; "questSlug"?: string;
} }
export interface ClaimQuestResponse { export interface ClaimQuestResponse {
@@ -18,10 +18,10 @@ export interface ListQuestsResponse {
} }
export interface Quest { export interface Quest {
"id"?: string;
"name"?: string; "name"?: string;
"objectives"?: QuestObjectiveProgress[]; "objectives"?: QuestObjectiveProgress[];
"rewards"?: Reward[]; "rewards"?: Reward[];
"slug"?: string;
"status"?: "active" | "claimed" | "completed"; "status"?: "active" | "claimed" | "completed";
} }
@@ -39,6 +39,6 @@ export interface ReportQuestProgressRequest {
} }
export interface ReportQuestProgressResponse { export interface ReportQuestProgressResponse {
"completedQuestIds"?: string[]; "completedQuestSlugs"?: string[];
} }
+3 -3
View File
@@ -4,7 +4,7 @@ export interface HandleScenarioCallbackRequest {
"handle"?: string; "handle"?: string;
"nodeId"?: string; "nodeId"?: string;
"runId"?: string; "runId"?: string;
"scenarioId"?: string; "scenarioSlug"?: string;
} }
export interface HandleScenarioCallbackResponse { export interface HandleScenarioCallbackResponse {
@@ -19,7 +19,7 @@ export interface PendingEffect {
"data": { [key: string]: unknown }; "data": { [key: string]: unknown };
"nodeId": string; "nodeId": string;
"runId": string; "runId": string;
"scenarioId": string; "scenarioSlug": string;
"type": string; "type": string;
"waitDeadline"?: string; "waitDeadline"?: string;
} }
@@ -37,7 +37,7 @@ export interface UpdateScenarioCounterRequest {
"counterKey"?: string; "counterKey"?: string;
"nodeId"?: string; "nodeId"?: string;
"runId"?: string; "runId"?: string;
"scenarioId"?: string; "scenarioSlug"?: string;
} }
export interface UpdateScenarioCounterResponse { export interface UpdateScenarioCounterResponse {
+2 -1
View File
@@ -12,6 +12,7 @@ export interface Offer {
"maxPurchases"?: number; "maxPurchases"?: number;
"name"?: string; "name"?: string;
"price"?: OfferPrice; "price"?: OfferPrice;
"slug"?: string;
"updatedAt"?: string; "updatedAt"?: string;
} }
@@ -27,7 +28,7 @@ export interface OfferPrice {
export interface PurchaseOfferRequest { export interface PurchaseOfferRequest {
"idempotencyKey"?: string; "idempotencyKey"?: string;
"offerId"?: string; "offerSlug"?: string;
"storeSlug"?: string; "storeSlug"?: string;
} }
+2 -2
View File
@@ -9,8 +9,8 @@
*/ */
/** Metric for purchasing a store offer; auto-reported on purchase. */ /** Metric for purchasing a store offer; auto-reported on purchase. */
export function purchaseOffer(offerId: string): string { export function purchaseOffer(offerSlug: string): string {
return `purchase.offer:${offerId}`; return `purchase.offer:${offerSlug}`;
} }
/** Metric for purchasing a catalog item; auto-reported on purchase. */ /** Metric for purchasing a catalog item; auto-reported on purchase. */
+3 -3
View File
@@ -20,13 +20,13 @@ export class QuestsService {
} }
/** Claims a completed quest's rewards (idempotent server-side). */ /** Claims a completed quest's rewards (idempotent server-side). */
claim(questId: string): Promise<ClaimQuestResponse> { claim(questSlug: string): Promise<ClaimQuestResponse> {
return api.claimQuest(this.ctx, { questId }); return api.claimQuest(this.ctx, { questSlug });
} }
/** Reports metric progress; returns ids of quests completed by this report. */ /** Reports metric progress; returns ids of quests completed by this report. */
async reportProgress(metric: string, amount: number): Promise<string[]> { async reportProgress(metric: string, amount: number): Promise<string[]> {
const response = await api.reportQuestProgress(this.ctx, { metric, amount }); const response = await api.reportQuestProgress(this.ctx, { metric, amount });
return response.completedQuestIds ?? []; return response.completedQuestSlugs ?? [];
} }
} }
+17 -17
View File
@@ -22,7 +22,7 @@ const JITTER_RATIO = 0.2;
type ActiveRecord = { type ActiveRecord = {
runId: string; runId: string;
nodeId: string; nodeId: string;
scenarioId: string; scenarioSlug: string;
storePurchased?: boolean; storePurchased?: boolean;
lastPurchase?: PurchaseOfferResponse; lastPurchase?: PurchaseOfferResponse;
}; };
@@ -177,7 +177,7 @@ export class ScenarioService {
for (const [key, record] of [...this.active.entries()]) { for (const [key, record] of [...this.active.entries()]) {
if (incomingKeys.has(key)) continue; if (incomingKeys.has(key)) continue;
this.deactivate(key); this.deactivate(key);
this.emitCompletedIfIdle(record.runId, record.scenarioId); this.emitCompletedIfIdle(record.runId, record.scenarioSlug);
} }
const after = new Set(this.active.keys()); const after = new Set(this.active.keys());
@@ -206,7 +206,7 @@ export class ScenarioService {
const record: ActiveRecord = { const record: ActiveRecord = {
runId: pending.runId, runId: pending.runId,
nodeId: pending.nodeId, nodeId: pending.nodeId,
scenarioId: pending.scenarioId, scenarioSlug: pending.scenarioSlug,
}; };
this.active.set(key, record); this.active.set(key, record);
this.completedRuns.delete(pending.runId); this.completedRuns.delete(pending.runId);
@@ -289,16 +289,16 @@ export class ScenarioService {
} }
private dispatchBattlePass(record: ActiveRecord): void { private dispatchBattlePass(record: ActiveRecord): void {
const { scenarioId, nodeId, runId } = record; const { scenarioSlug, nodeId, runId } = record;
this.ctx.effects.emitBattlePass({ this.ctx.effects.emitBattlePass({
getProgress: () => this.battlePass.getProgress(scenarioId, nodeId), getProgress: () => this.battlePass.getProgress(scenarioSlug, nodeId),
addXp: (source, amount) => addXp: (source, amount) =>
this.battlePass.addXp({ scenarioId, nodeId, source, amount, runId }), this.battlePass.addXp({ scenarioSlug, nodeId, source, amount, runId }),
claimReward: (level, track) => claimReward: (level, track) =>
this.battlePass.claimReward({ scenarioId, nodeId, level, track, runId }), this.battlePass.claimReward({ scenarioSlug, nodeId, level, track, runId }),
purchasePremium: async () => { purchasePremium: async () => {
const response = await this.battlePass.purchasePremium({ const response = await this.battlePass.purchasePremium({
scenarioId, scenarioSlug,
nodeId, nodeId,
idempotencyKey: crypto.randomUUID(), idempotencyKey: crypto.randomUUID(),
runId, runId,
@@ -346,7 +346,7 @@ export class ScenarioService {
): Promise<void> { ): Promise<void> {
try { try {
const response = await api.updateScenarioCounter(this.ctx, { const response = await api.updateScenarioCounter(this.ctx, {
scenarioId: record.scenarioId, scenarioSlug: record.scenarioSlug,
nodeId: record.nodeId, nodeId: record.nodeId,
counterKey: objectiveId, counterKey: objectiveId,
amount, amount,
@@ -362,7 +362,7 @@ export class ScenarioService {
if (response.effect) { if (response.effect) {
this.ingest([response.effect]); this.ingest([response.effect]);
} else { } else {
this.emitCompletedIfIdle(record.runId, record.scenarioId); this.emitCompletedIfIdle(record.runId, record.scenarioSlug);
} }
} catch { } catch {
} }
@@ -374,7 +374,7 @@ export class ScenarioService {
if (this.droppedRuns.has(record.runId)) return; if (this.droppedRuns.has(record.runId)) return;
try { try {
const response = await api.handleScenarioCallback(this.ctx, { const response = await api.handleScenarioCallback(this.ctx, {
scenarioId: record.scenarioId, scenarioSlug: record.scenarioSlug,
nodeId: record.nodeId, nodeId: record.nodeId,
handle, handle,
runId: record.runId, runId: record.runId,
@@ -386,7 +386,7 @@ export class ScenarioService {
if (response?.effect) { if (response?.effect) {
this.ingest([response.effect]); this.ingest([response.effect]);
} else { } else {
this.emitCompletedIfIdle(record.runId, record.scenarioId); this.emitCompletedIfIdle(record.runId, record.scenarioSlug);
} }
} catch (err) { } catch (err) {
if (isTransientHttpError(err)) return; if (isTransientHttpError(err)) return;
@@ -425,10 +425,10 @@ export class ScenarioService {
private dropRun(runId: string, nodeId: string, error: Error): void { private dropRun(runId: string, nodeId: string, error: Error): void {
this.droppedRuns.add(runId); this.droppedRuns.add(runId);
let scenarioId = ''; let scenarioSlug = '';
for (const [key, record] of [...this.active.entries()]) { for (const [key, record] of [...this.active.entries()]) {
if (record.runId !== runId) continue; if (record.runId !== runId) continue;
if (!scenarioId) scenarioId = record.scenarioId; if (!scenarioSlug) scenarioSlug = record.scenarioSlug;
this.deactivate(key); this.deactivate(key);
} }
console.warn( console.warn(
@@ -436,19 +436,19 @@ export class ScenarioService {
); );
this.ctx.effects.emitScenarioFailed({ this.ctx.effects.emitScenarioFailed({
runId, runId,
scenarioId, scenarioSlug,
nodeId, nodeId,
error, error,
}); });
} }
private emitCompletedIfIdle(runId: string, scenarioId: string): void { private emitCompletedIfIdle(runId: string, scenarioSlug: string): void {
if (this.droppedRuns.has(runId)) return; if (this.droppedRuns.has(runId)) return;
if (this.completedRuns.has(runId)) return; if (this.completedRuns.has(runId)) return;
for (const record of this.active.values()) { for (const record of this.active.values()) {
if (record.runId === runId) return; if (record.runId === runId) return;
} }
this.completedRuns.add(runId); this.completedRuns.add(runId);
this.ctx.effects.emitScenarioCompleted({ runId, scenarioId }); this.ctx.effects.emitScenarioCompleted({ runId, scenarioSlug });
} }
} }
+7 -2
View File
@@ -16,12 +16,13 @@ export interface BuyOptions {
*/ */
export type PurchaseOffer = ( export type PurchaseOffer = (
storeSlug: string, storeSlug: string,
offerId: string, offerSlug: string,
options?: BuyOptions, options?: BuyOptions,
) => Promise<PurchaseOfferResponse>; ) => Promise<PurchaseOfferResponse>;
export class OfferHandle { export class OfferHandle {
public readonly id: string; public readonly id: string;
public readonly slug: string;
public readonly name?: string; public readonly name?: string;
public readonly price?: OfferPrice; public readonly price?: OfferPrice;
public readonly contents: OfferContent[]; public readonly contents: OfferContent[];
@@ -35,7 +36,11 @@ export class OfferHandle {
if (!offer.id) { if (!offer.id) {
throw new Error('Rudder Stores: offer.id is required'); throw new Error('Rudder Stores: offer.id is required');
} }
if (!offer.slug) {
throw new Error('Rudder Stores: offer.slug is required');
}
this.id = offer.id; this.id = offer.id;
this.slug = offer.slug;
this.name = offer.name; this.name = offer.name;
this.price = offer.price; this.price = offer.price;
this.contents = offer.contents ?? []; this.contents = offer.contents ?? [];
@@ -43,7 +48,7 @@ export class OfferHandle {
} }
async buy(options: BuyOptions = {}): Promise<PurchaseOfferResponse> { async buy(options: BuyOptions = {}): Promise<PurchaseOfferResponse> {
return this.purchase(this.storeSlug, this.id, options); return this.purchase(this.storeSlug, this.slug, options);
} }
} }
+1 -1
View File
@@ -184,7 +184,7 @@ describe('lazy scenario runtime', () => {
return Promise.resolve(new Response(JSON.stringify({ return Promise.resolve(new Response(JSON.stringify({
effects: [{ effects: [{
runId: 'run-1', runId: 'run-1',
scenarioId: 'scenario-1', scenarioSlug: 'scenario-1',
nodeId: 'start', nodeId: 'start',
type: 'notification', type: 'notification',
data: { message: 'Welcome!' }, data: { message: 'Welcome!' },
+6 -6
View File
@@ -88,7 +88,7 @@ describe('ScenarioService', () => {
}); });
} }
if (path === '/sdk/v1/stores/starter') { if (path === '/sdk/v1/stores/starter') {
return jsonResponse({ slug: 'starter', name: 'Starter', offers: [{ id: 'pack_1', name: 'Pack' }] }); return jsonResponse({ slug: 'starter', name: 'Starter', offers: [{ id: 'pack_1', slug: 'pack-1', name: 'Pack' }] });
} }
return jsonResponse({}); return jsonResponse({});
}); });
@@ -203,7 +203,7 @@ describe('ScenarioService', () => {
return url.includes('/sdk/v1/scenarios/callback'); return url.includes('/sdk/v1/scenarios/callback');
}); });
expect(JSON.parse(callbackCall![1]!.body as string)).toEqual({ expect(JSON.parse(callbackCall![1]!.body as string)).toEqual({
scenarioId: 'scenario-1', scenarioSlug: 'scenario-1',
nodeId: 'n1', nodeId: 'n1',
handle: 'output', handle: 'output',
runId: 'run-1', runId: 'run-1',
@@ -227,7 +227,7 @@ describe('ScenarioService', () => {
expect(onCompleted).toHaveBeenCalledOnce(); expect(onCompleted).toHaveBeenCalledOnce();
expect(onCompleted.mock.calls[0][0]).toEqual({ expect(onCompleted.mock.calls[0][0]).toEqual({
runId: 'run-1', runId: 'run-1',
scenarioId: 'scenario-1', scenarioSlug: 'scenario-1',
}); });
expect(scenarios.isRunning).toBe(false); expect(scenarios.isRunning).toBe(false);
}); });
@@ -359,10 +359,10 @@ describe('ScenarioService', () => {
return jsonResponse({ return jsonResponse({
name: 'Starter', name: 'Starter',
slug: 'starter', slug: 'starter',
offers: [{ id: 'pack_1', name: 'Starter Pack' }], offers: [{ id: 'pack_1', slug: 'pack-1', name: 'Starter Pack' }],
}); });
} }
if (method === 'POST' && path === '/sdk/v1/stores/starter/offers/pack_1/purchase') { if (method === 'POST' && path === '/sdk/v1/stores/starter/offers/pack-1/purchase') {
return jsonResponse({ success: true, purchaseId: 'purchase-1' }); return jsonResponse({ success: true, purchaseId: 'purchase-1' });
} }
return jsonResponse({}); return jsonResponse({});
@@ -387,7 +387,7 @@ describe('ScenarioService', () => {
return jsonResponse({ effects: [effect('store', { storeSlug: 'starter' })] }); return jsonResponse({ effects: [effect('store', { storeSlug: 'starter' })] });
} }
if (path === '/sdk/v1/stores/starter') { if (path === '/sdk/v1/stores/starter') {
return jsonResponse({ slug: 'starter', offers: [{ id: 'pack_1' }] }); return jsonResponse({ slug: 'starter', offers: [{ id: 'pack_1', slug: 'pack-1' }] });
} }
return jsonResponse({}); return jsonResponse({});
}); });
+1 -1
View File
@@ -82,7 +82,7 @@ export async function grantCurrency(
amount: number, amount: number,
): Promise<void> { ): Promise<void> {
await adminApi(artifact).post( await adminApi(artifact).post(
`/platform/v1/projects/${artifact.projectId}/players/wallet/adjust`, `/platform/v1/projects/${artifact.projectId}/players/wallet/adjust?environment=${artifact.environment}`,
{ items: [{ playerId, currencyCode, amount, reason: 'e2e grant' }] }, { items: [{ playerId, currencyCode, amount, reason: 'e2e grant' }] },
); );
} }
+36 -30
View File
@@ -19,6 +19,7 @@ interface ReleaseResponse {
interface QuestResponse { interface QuestResponse {
id: string; id: string;
slug: string;
name: string; name: string;
status: string; status: string;
objectives?: Array<{ id: string; metric: string; target: number }>; objectives?: Array<{ id: string; metric: string; target: number }>;
@@ -28,10 +29,10 @@ interface QuestResponse {
interface StoreResponse { interface StoreResponse {
id: string; id: string;
name: string; name: string;
slug?: string; slug: string;
data?: { slug?: string };
offers?: Array<{ offers?: Array<{
id: string; id: string;
slug: string;
name: string; name: string;
contents?: Array<{ itemId: string; amount: number }>; contents?: Array<{ itemId: string; amount: number }>;
}>; }>;
@@ -176,18 +177,24 @@ export async function runSeed(
}); });
// 7. Store with a free offer and a paid (currency) offer with a purchase limit. // 7. Store with a free offer and a paid (currency) offer with a purchase limit.
// The runtime resolves a store's slug from data.slug, and the CreateStore API has // Stores and offers carry an authored slug that survives promotes.
// no slug field — so we set it via data.
const storeSlug = 'starter-shop'; const storeSlug = 'starter-shop';
const paidOfferSlug = 'gold-pack';
const store = await api.post<StoreResponse>(`${projPath}/stores${q}`, { const store = await api.post<StoreResponse>(`${projPath}/stores${q}`, {
projectId: project.id, projectId: project.id,
environment: authoringEnv, environment: authoringEnv,
slug: storeSlug,
name: 'Starter Shop', name: 'Starter Shop',
description: 'E2E shop', description: 'E2E shop',
data: { slug: storeSlug },
offers: [ offers: [
{ name: 'Welcome Gift', contents: [{ itemId: item.id, amount: 10 }], price: { currency, amount: 0 } },
{ {
slug: 'welcome-gift',
name: 'Welcome Gift',
contents: [{ itemId: item.id, amount: 10 }],
price: { currency, amount: 0 },
},
{
slug: paidOfferSlug,
name: 'Gold Pack', name: 'Gold Pack',
contents: [{ itemId: item.id, amount: 50 }], contents: [{ itemId: item.id, amount: 50 }],
price: { currency, amount: 100 }, price: { currency, amount: 100 },
@@ -196,20 +203,18 @@ export async function runSeed(
], ],
}); });
log(`store ${storeSlug} (${store.id}) with ${store.offers?.length ?? 0} offers`); log(`store ${storeSlug} (${store.id}) with ${store.offers?.length ?? 0} offers`);
const paidOffer = store.offers?.find((o) => o.name === 'Gold Pack');
if (!paidOffer?.id) {
throw new Error('seeded paid offer did not return an id');
}
// 8. Global quest: buying the paid offer completes it; claim grants extra item reward. // 8. Global quest: buying the paid offer completes it; claim grants extra item reward.
const questName = 'Buy Gold Pack Quest'; const questName = 'Buy Gold Pack Quest';
const questObjectiveId = 'buy-gold-pack'; const questObjectiveId = 'buy-gold-pack';
const questTarget = 1; const questTarget = 1;
const questMetric = `purchase.offer:${paidOffer.id}`; const questSlug = 'buy-gold-pack-quest';
const questMetric = `purchase.offer:${paidOfferSlug}`;
const questRewardAmount = 7; const questRewardAmount = 7;
const quest = await api.post<QuestResponse>(`${projPath}/quests${q}`, { const quest = await api.post<QuestResponse>(`${projPath}/quests${q}`, {
projectId: project.id, projectId: project.id,
environment: authoringEnv, environment: authoringEnv,
slug: questSlug,
name: questName, name: questName,
objectives: [{ id: questObjectiveId, metric: questMetric, target: questTarget }], objectives: [{ id: questObjectiveId, metric: questMetric, target: questTarget }],
rewards: [{ itemId: item.id, amount: questRewardAmount }], rewards: [{ itemId: item.id, amount: questRewardAmount }],
@@ -220,9 +225,11 @@ export async function runSeed(
const startAt = new Date(ts - 3600_000).toISOString(); const startAt = new Date(ts - 3600_000).toISOString();
const endAt = new Date(ts + 365 * 24 * 3600_000).toISOString(); const endAt = new Date(ts + 365 * 24 * 3600_000).toISOString();
const scenarioEvent = 'session_start'; const scenarioEvent = 'session_start';
const scenarioSlug = 'onboarding';
const scenario = await api.post<{ id: string }>(`${projPath}/scenarios${q}`, { const scenario = await api.post<{ id: string }>(`${projPath}/scenarios${q}`, {
projectId: project.id, projectId: project.id,
environment: authoringEnv, environment: authoringEnv,
slug: scenarioSlug,
name: 'Onboarding', name: 'Onboarding',
description: 'e2e onboarding', description: 'e2e onboarding',
startAt, startAt,
@@ -230,9 +237,8 @@ export async function runSeed(
}); });
// 10. Scenario flow (trigger -> notification -> rc override -> store -> condition -> notification). // 10. Scenario flow (trigger -> notification -> rc override -> store -> condition -> notification).
await api.put(`${projPath}/scenarios/${scenario.id}`, { await api.put(`${projPath}/scenarios/${scenario.id}?environment=${authoringEnv}`, {
projectId: project.id, projectId: project.id,
scenarioId: scenario.id,
flow: buildFlow(storeSlug), flow: buildFlow(storeSlug),
}); });
log(`scenario ${scenario.id} flow set`); log(`scenario ${scenario.id} flow set`);
@@ -245,15 +251,15 @@ export async function runSeed(
}); });
await pollRelease(api, projPath, runtimeEnv, release.id, log); await pollRelease(api, projPath, runtimeEnv, release.id, log);
// Promotion re-creates every entity with fresh ids — read the prod ones back. // Promotion upserts by slug: prod keeps its own ids, so read the prod rows back by slug.
const prodStores = await api.get<StoreResponse[]>(`${projPath}/stores${prodQ}`); const prodStores = await api.get<StoreResponse[]>(`${projPath}/stores${prodQ}`);
const prodStore = prodStores.find((s) => s.data?.slug === storeSlug || s.name === 'Starter Shop'); const prodStore = prodStores.find((s) => s.slug === storeSlug);
if (!prodStore) { if (!prodStore) {
throw new Error(`promoted store ${storeSlug} was not found in ${runtimeEnv}`); throw new Error(`promoted store ${storeSlug} was not found in ${runtimeEnv}`);
} }
const prodPaidOffer = prodStore.offers?.find((o) => o.name === 'Gold Pack'); const prodPaidOffer = prodStore.offers?.find((o) => o.slug === paidOfferSlug);
if (!prodPaidOffer?.id) { if (!prodPaidOffer?.slug) {
throw new Error('promoted paid offer did not return an id'); throw new Error('promoted paid offer did not return a slug');
} }
const prodRewardItemId = prodPaidOffer.contents?.[0]?.itemId; const prodRewardItemId = prodPaidOffer.contents?.[0]?.itemId;
if (!prodRewardItemId) { if (!prodRewardItemId) {
@@ -261,25 +267,25 @@ export async function runSeed(
} }
const prodQuests = await api.get<QuestResponse[]>(`${projPath}/quests${prodQ}`); const prodQuests = await api.get<QuestResponse[]>(`${projPath}/quests${prodQ}`);
const prodQuest = prodQuests.find((q) => q.name === questName); const prodQuest = prodQuests.find((qq) => qq.slug === questSlug);
if (!prodQuest?.id) { if (!prodQuest?.slug) {
throw new Error(`promoted quest ${questName} was not found in ${runtimeEnv}`); throw new Error(`promoted quest ${questSlug} was not found in ${runtimeEnv}`);
} }
const prodQuestMetric = prodQuest.objectives?.find((o) => o.id === questObjectiveId)?.metric; const prodQuestMetric = prodQuest.objectives?.find((o) => o.id === questObjectiveId)?.metric;
if (!prodQuestMetric) { if (!prodQuestMetric) {
throw new Error(`promoted quest ${questName} did not return metric ${questObjectiveId}`); throw new Error(`promoted quest ${questSlug} did not return metric ${questObjectiveId}`);
} }
const prodQuestRewardItemId = prodQuest.rewards?.[0]?.itemId; const prodQuestRewardItemId = prodQuest.rewards?.[0]?.itemId;
if (!prodQuestRewardItemId) { if (!prodQuestRewardItemId) {
throw new Error(`promoted quest ${questName} did not return reward item id`); throw new Error(`promoted quest ${questSlug} did not return reward item id`);
} }
const prodScenarios = await api.get<Array<{ id: string; name: string }>>( const prodScenarios = await api.get<Array<{ id: string; slug: string; name: string }>>(
`${projPath}/scenarios${prodQ}`, `${projPath}/scenarios${prodQ}`,
); );
const prodScenario = prodScenarios.find((s) => s.name === 'Onboarding'); const prodScenario = prodScenarios.find((s) => s.slug === scenarioSlug);
if (!prodScenario?.id) { if (!prodScenario?.slug) {
throw new Error(`promoted scenario Onboarding was not found in ${runtimeEnv}`); throw new Error(`promoted scenario ${scenarioSlug} was not found in ${runtimeEnv}`);
} }
return { return {
@@ -296,14 +302,14 @@ export async function runSeed(
slug: storeSlug, slug: storeSlug,
freeOfferName: 'Welcome Gift', freeOfferName: 'Welcome Gift',
paidOfferName: 'Gold Pack', paidOfferName: 'Gold Pack',
paidOfferId: prodPaidOffer.id, paidOfferSlug: prodPaidOffer.slug,
paidPrice: { currency, amount: 100 }, paidPrice: { currency, amount: 100 },
paidMaxPurchases: 2, paidMaxPurchases: 2,
grantedItemName: 'Gold Coin', grantedItemName: 'Gold Coin',
paidGrantAmount: 50, paidGrantAmount: 50,
}, },
quest: { quest: {
id: prodQuest.id, slug: prodQuest.slug,
name: questName, name: questName,
objectiveId: questObjectiveId, objectiveId: questObjectiveId,
metric: prodQuestMetric, metric: prodQuestMetric,
@@ -321,7 +327,7 @@ export async function runSeed(
shopLayout: { cols: 3 }, shopLayout: { cols: 3 },
spawnRateOverride: 3.0, spawnRateOverride: 3.0,
}, },
scenario: { id: prodScenario.id, event: scenarioEvent }, scenario: { slug: prodScenario.slug, event: scenarioEvent },
}; };
} }
+3 -3
View File
@@ -16,14 +16,14 @@ export interface SeedArtifact {
slug: string; slug: string;
freeOfferName: string; freeOfferName: string;
paidOfferName: string; paidOfferName: string;
paidOfferId: string; paidOfferSlug: string;
paidPrice: { currency: string; amount: number }; paidPrice: { currency: string; amount: number };
paidMaxPurchases: number; paidMaxPurchases: number;
grantedItemName: string; grantedItemName: string;
paidGrantAmount: number; paidGrantAmount: number;
}; };
quest: { quest: {
id: string; slug: string;
name: string; name: string;
objectiveId: string; objectiveId: string;
metric: string; metric: string;
@@ -42,5 +42,5 @@ export interface SeedArtifact {
/** spawn_rate value the scenario's remote_config_override applies. */ /** spawn_rate value the scenario's remote_config_override applies. */
spawnRateOverride: number; spawnRateOverride: number;
}; };
scenario: { id: string; event: string }; scenario: { slug: string; event: string };
} }
+1 -1
View File
@@ -64,7 +64,7 @@ describe('e2e-prod: purchase (wallet/inventory/limits)', () => {
// 3. Inventory credited (verified via admin player details). // 3. Inventory credited (verified via admin player details).
const details = await api.get<AdminPlayerDetails>( const details = await api.get<AdminPlayerDetails>(
`/platform/v1/projects/${artifact.projectId}/players/${playerId}`, `/platform/v1/projects/${artifact.projectId}/players/${playerId}?environment=${artifact.environment}`,
); );
const inv = details.inventory?.find((i) => i.slug === artifact.item.id); const inv = details.inventory?.find((i) => i.slug === artifact.item.id);
expect(Number(inv?.amount)).toBe(artifact.store.paidGrantAmount); expect(Number(inv?.amount)).toBe(artifact.store.paidGrantAmount);
+8 -8
View File
@@ -24,7 +24,7 @@ describe('E2E: boundary nodes (server-side continuation)', () => {
stores: [{ stores: [{
slug: 'starter', slug: 'starter',
name: 'Starter', name: 'Starter',
offers: [{ id: 'pack_1', name: 'Starter Pack' }], offers: [{ id: 'pack_1', slug: 'pack-1', name: 'Starter Pack' }],
}], }],
}); });
gateway.install(); gateway.install();
@@ -35,12 +35,12 @@ describe('E2E: boundary nodes (server-side continuation)', () => {
it('purchase crosses the boundary → callback fires and continues the scenario', async () => { it('purchase crosses the boundary → callback fires and continues the scenario', async () => {
const offerEffect = effect('store', { storeSlug: 'starter', message: 'Buy the starter pack!' }, { const offerEffect = effect('store', { storeSlug: 'starter', message: 'Buy the starter pack!' }, {
scenarioId: 'offer_flow', scenarioSlug: 'offer_flow',
runId: 'offer_flow-run', runId: 'offer_flow-run',
nodeId: 'offer', nodeId: 'offer',
}); });
const reward = effect('notification', { message: 'Reward granted: 500 gems!' }, { const reward = effect('notification', { message: 'Reward granted: 500 gems!' }, {
scenarioId: 'offer_flow', scenarioSlug: 'offer_flow',
runId: 'offer_flow-run', runId: 'offer_flow-run',
nodeId: 'reward', nodeId: 'reward',
}); });
@@ -52,7 +52,7 @@ describe('E2E: boundary nodes (server-side continuation)', () => {
let offer: StoreOfferEffect | undefined; let offer: StoreOfferEffect | undefined;
client.effects.onStoreOffer((e) => { offer = e; }); client.effects.onStoreOffer((e) => { offer = e; });
client.effects.onNotification((e) => { messages.push(e.message); }); client.effects.onNotification((e) => { messages.push(e.message); });
client.effects.onScenarioCompleted((e) => { completed.push(e.scenarioId); }); client.effects.onScenarioCompleted((e) => { completed.push(e.scenarioSlug); });
const token = (await client.auth.loginWithDevice()).accessToken; const token = (await client.auth.loginWithDevice()).accessToken;
await vi.waitFor(() => expect(offer).toBeDefined()); await vi.waitFor(() => expect(offer).toBeDefined());
@@ -61,7 +61,7 @@ describe('E2E: boundary nodes (server-side continuation)', () => {
const callback = gateway.recorded.find((r) => r.path === '/sdk/v1/scenarios/callback'); const callback = gateway.recorded.find((r) => r.path === '/sdk/v1/scenarios/callback');
expect(callback?.body).toEqual({ expect(callback?.body).toEqual({
scenarioId: 'offer_flow', scenarioSlug: 'offer_flow',
nodeId: 'offer', nodeId: 'offer',
handle: 'onPurchase', handle: 'onPurchase',
runId: 'offer_flow-run', runId: 'offer_flow-run',
@@ -75,12 +75,12 @@ describe('E2E: boundary nodes (server-side continuation)', () => {
it('decline posts callback and continues with the server-supplied next effect', async () => { it('decline posts callback and continues with the server-supplied next effect', async () => {
const offerEffect = effect('store', { storeSlug: 'starter', message: 'Buy the starter pack!' }, { const offerEffect = effect('store', { storeSlug: 'starter', message: 'Buy the starter pack!' }, {
scenarioId: 'offer_flow', scenarioSlug: 'offer_flow',
runId: 'offer_flow-run', runId: 'offer_flow-run',
nodeId: 'offer', nodeId: 'offer',
}); });
const consolation = effect('notification', { message: 'Maybe next time!' }, { const consolation = effect('notification', { message: 'Maybe next time!' }, {
scenarioId: 'offer_flow', scenarioSlug: 'offer_flow',
runId: 'offer_flow-run', runId: 'offer_flow-run',
nodeId: 'consolation', nodeId: 'consolation',
}); });
@@ -102,7 +102,7 @@ describe('E2E: boundary nodes (server-side continuation)', () => {
it('a failing callback does not fail or crash the run', async () => { it('a failing callback does not fail or crash the run', async () => {
const offerEffect = effect('store', { storeSlug: 'starter' }, { const offerEffect = effect('store', { storeSlug: 'starter' }, {
scenarioId: 'offer_flow', scenarioSlug: 'offer_flow',
runId: 'offer_flow-run', runId: 'offer_flow-run',
nodeId: 'offer', nodeId: 'offer',
}); });
+8 -8
View File
@@ -18,29 +18,29 @@ function makeClient(): RudderClient {
function offerScenario(now: number) { function offerScenario(now: number) {
const waitIntro = effect('wait', {}, { const waitIntro = effect('wait', {}, {
scenarioId: 'offer_flow', scenarioSlug: 'offer_flow',
runId: 'offer_flow-run', runId: 'offer_flow-run',
nodeId: 'wait_intro', nodeId: 'wait_intro',
waitDeadline: new Date(now + MINUTE).toISOString(), waitDeadline: new Date(now + MINUTE).toISOString(),
}); });
const offer = effect('store', { storeSlug: 'starter', offerId: 'pack_1', message: 'Limited starter pack!' }, { const offer = effect('store', { storeSlug: 'starter', offerSlug: 'pack-1', message: 'Limited starter pack!' }, {
scenarioId: 'offer_flow', scenarioSlug: 'offer_flow',
runId: 'offer_flow-run', runId: 'offer_flow-run',
nodeId: 'offer', nodeId: 'offer',
}); });
const waitReminder = effect('wait', {}, { const waitReminder = effect('wait', {}, {
scenarioId: 'offer_flow', scenarioSlug: 'offer_flow',
runId: 'offer_flow-run', runId: 'offer_flow-run',
nodeId: 'wait_reminder', nodeId: 'wait_reminder',
waitDeadline: new Date(now + 2 * MINUTE).toISOString(), waitDeadline: new Date(now + 2 * MINUTE).toISOString(),
}); });
const reminder = effect('notification', { message: 'Your offer is still available!' }, { const reminder = effect('notification', { message: 'Your offer is still available!' }, {
scenarioId: 'offer_flow', scenarioSlug: 'offer_flow',
runId: 'offer_flow-run', runId: 'offer_flow-run',
nodeId: 'reminder', nodeId: 'reminder',
}); });
const thanks = effect('notification', { message: 'Thanks for your purchase!' }, { const thanks = effect('notification', { message: 'Thanks for your purchase!' }, {
scenarioId: 'offer_flow', scenarioSlug: 'offer_flow',
runId: 'offer_flow-run', runId: 'offer_flow-run',
nodeId: 'thanks', nodeId: 'thanks',
}); });
@@ -58,7 +58,7 @@ describe('E2E: player offer journey', () => {
stores: [{ stores: [{
slug: 'starter', slug: 'starter',
name: 'Starter', name: 'Starter',
offers: [{ id: 'pack_1', name: 'Starter Pack' }], offers: [{ id: 'pack_1', slug: 'pack-1', name: 'Starter Pack' }],
}], }],
}); });
gateway.install(); gateway.install();
@@ -139,7 +139,7 @@ describe('E2E: player offer journey', () => {
expect(purchase.success).toBe(true); expect(purchase.success).toBe(true);
expect(gateway.purchases).toEqual([ expect(gateway.purchases).toEqual([
{ storeSlug: 'starter', offerId: 'pack_1', idempotencyKey: 'idem-key-123', authToken: accessToken }, { storeSlug: 'starter', offerSlug: 'pack-1', idempotencyKey: 'idem-key-123', authToken: accessToken },
]); ]);
expect(notifications).toHaveLength(1); expect(notifications).toHaveLength(1);
+3 -3
View File
@@ -24,17 +24,17 @@ describe('E2E: pending effects resume after a new login', () => {
stubDeterministicUuid(); stubDeterministicUuid();
vi.useFakeTimers(); vi.useFakeTimers();
gateway = createFakeGateway({ gateway = createFakeGateway({
stores: [{ slug: 'starter', name: 'Starter', offers: [{ id: 'pack_1', name: 'Pack' }] }], stores: [{ slug: 'starter', name: 'Starter', offers: [{ id: 'pack_1', slug: 'pack-1', name: 'Pack' }] }],
}); });
const now = Date.now(); const now = Date.now();
const wait = effect('wait', {}, { const wait = effect('wait', {}, {
scenarioId: 'offer_flow', scenarioSlug: 'offer_flow',
runId: 'offer_flow-run', runId: 'offer_flow-run',
nodeId: 'wait_intro', nodeId: 'wait_intro',
waitDeadline: new Date(now + MINUTE).toISOString(), waitDeadline: new Date(now + MINUTE).toISOString(),
}); });
const offer = effect('store', { message: 'Limited starter pack!', storeSlug: 'starter' }, { const offer = effect('store', { message: 'Limited starter pack!', storeSlug: 'starter' }, {
scenarioId: 'offer_flow', scenarioSlug: 'offer_flow',
runId: 'offer_flow-run', runId: 'offer_flow-run',
nodeId: 'offer', nodeId: 'offer',
}); });
+7 -7
View File
@@ -33,8 +33,8 @@ export interface FakeGatewayState {
export interface FakeGateway { export interface FakeGateway {
state: FakeGatewayState; state: FakeGatewayState;
recorded: RecordedRequest[]; recorded: RecordedRequest[];
purchases: Array<{ storeSlug: string; offerId: string; idempotencyKey: string; authToken: string | null }>; purchases: Array<{ storeSlug: string; offerSlug: string; idempotencyKey: string; authToken: string | null }>;
questClaims: Array<{ questId: string; authToken: string | null }>; questClaims: Array<{ questSlug: string; authToken: string | null }>;
install(): void; install(): void;
onEvent(event: string, ...effects: PendingEffect[]): void; onEvent(event: string, ...effects: PendingEffect[]): void;
onCallback(nodeId: string, handle: string, next: PendingEffect | null): void; onCallback(nodeId: string, handle: string, next: PendingEffect | null): void;
@@ -175,7 +175,7 @@ export function createFakeGateway(seed?: Partial<FakeGatewayState>): FakeGateway
const b = body as { idempotencyKey?: string }; const b = body as { idempotencyKey?: string };
purchases.push({ purchases.push({
storeSlug: decodeURIComponent(purchaseMatch[1]), storeSlug: decodeURIComponent(purchaseMatch[1]),
offerId: decodeURIComponent(purchaseMatch[2]), offerSlug: decodeURIComponent(purchaseMatch[2]),
idempotencyKey: b.idempotencyKey ?? '', idempotencyKey: b.idempotencyKey ?? '',
authToken: req.authToken, authToken: req.authToken,
}); });
@@ -192,10 +192,10 @@ export function createFakeGateway(seed?: Partial<FakeGatewayState>): FakeGateway
return json(state.quests); return json(state.quests);
} }
if (method === 'POST' && path === '/sdk/v1/quests/claim') { if (method === 'POST' && path === '/sdk/v1/quests/claim') {
const b = body as { questId?: string }; const b = body as { questSlug?: string };
const questId = b.questId ?? ''; const questSlug = b.questSlug ?? '';
questClaims.push({ questId, authToken: req.authToken }); questClaims.push({ questSlug, authToken: req.authToken });
return json(state.questClaims.get(questId) ?? { success: false, error: 'not found' }); return json(state.questClaims.get(questSlug) ?? { success: false, error: 'not found' });
} }
if (path === '/sdk/v1/storage') { if (path === '/sdk/v1/storage') {
+1 -1
View File
@@ -7,7 +7,7 @@ export function effect(
): PendingEffect { ): PendingEffect {
return { return {
runId: 'run-1', runId: 'run-1',
scenarioId: 'scenario-1', scenarioSlug: 'scenario-1',
nodeId: `${type}-1`, nodeId: `${type}-1`,
type, type,
data, data,