3 Commits

Author SHA1 Message Date
edmand46 7271874cac 2.0.0: changelog and skill docs for slugs and environments
CI / check (push) Successful in 16s
CI / publish (push) Has been skipped
Claude-Session: https://claude.ai/code/session_01SMCvdwDmuxoaqGgvGBLk1V
2026-09-06 22:39:03 +03:00
edmand46 a7401b534a 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
2026-09-06 21:44:54 +03:00
edmand46 e863cc3bf7 Align e2e-prod suite with the monolith API; drop dead applyOverride; treat run_not_active as terminal
CI / check (push) Successful in 18s
CI / publish (push) Has been skipped
2026-09-06 09:53:45 +03:00
35 changed files with 284 additions and 216 deletions
+33
View File
@@ -1,5 +1,38 @@
# Changelog
## 2.0.0
Breaking change — major bump, required by the backend environments release.
Every project now has exactly two environments, `staging` and `prod`, and the
SDK key passed as `projectKey` decides which one the player belongs to. The
environment never appears in the client API: it is resolved at login and
carried inside the access and refresh tokens. Tokens issued before this
release have no environment claim and are rejected with 401, so the first call
after the backend upgrade refreshes, fails, clears the token store and emits
`'signed-out'` to every `onAuthStateChange` listener. Log the player in again
with `client.auth.loginWithDevice()` / `loginWithCustom()`.
Quests, scenarios and offers are addressed by their slug instead of their id,
because ids differ between staging and prod while slugs are stable. Renamed
accordingly: `Quest.id` is now `Quest.slug`, `client.quests.claim()` takes a
quest slug, `reportProgress()` resolves to the slugs of the completed quests
(`ReportQuestProgressResponse.completedQuestSlugs`),
`client.stores.purchase(storeSlug, offerSlug, options?)` takes an offer slug
and `OfferHandle` carries a `slug` alongside its `id`,
`QuestMetrics.purchaseOffer(offerSlug)` builds its metric from the offer slug,
and every scenario-scoped type exposes `scenarioSlug` instead of `scenarioId`
`PendingEffect`, `ScenarioCompletedEffect`, `ScenarioFailedEffect`, the battle
pass requests and `client.battlePass.getProgress(scenarioSlug, nodeId)`.
`offer.buy()` is unchanged; it now binds the slug for you. Leaderboards, items
and stores already used slugs and are untouched.
Also shipped here, previously committed but never published: the effects client
reconciles against every `GET /sdk/v1/scenarios/pending` response, so a run
that disappears server-side (finished elsewhere, expired after a promote) now
emits `onScenarioCompleted` instead of lingering; and `run_not_active` joins
`unknown_run` and `run_expired` as a terminal rejection that drops the run and
emits `onScenarioFailed`.
## 1.0.0
- Scenario execution moved server-side. The SDK no longer walks a local DAG
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@rudder/js-sdk",
"version": "1.0.0",
"version": "2.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@rudder/js-sdk",
"version": "1.0.0",
"version": "2.0.0",
"devDependencies": {
"@types/node": "^26.1.1",
"jsdom": "^29.1.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@rudder/js-sdk",
"version": "1.0.0",
"version": "2.0.0",
"publishConfig": {
"registry": "https://hub.rudder.build/api/packages/rudder/npm/"
},
+1 -1
View File
@@ -74,7 +74,7 @@ service, or standalone economy service in this SDK — wallets live on
| Player profile + wallets | `client.player` | observable `SyncedState<PlayerProfile>` |
| Inventory (catalog-merged) | `client.inventory` | observable `SyncedState<InventoryItem[]>` |
| Catalog | `client.catalog` | observable `SyncedState<Map<string, CatalogItem>>` |
| Stores / purchases | `client.stores` + `client.stores.purchase(slug, offerId, opts?)` | observable `SyncedState<ShopHandle[]>` |
| Stores / purchases | `client.stores` + `client.stores.purchase(slug, offerSlug, opts?)` | observable `SyncedState<ShopHandle[]>` |
| Remote config | `client.remoteConfig` + `.get(key, default?)` | observable, typed |
| Player storage | `client.storage` + `.save(items)` / `.delete(type)` | observable + mutations |
| Project storage | `client.projectStorage` + `.save(items)` | observable + mutation |
+13
View File
@@ -50,6 +50,19 @@ On success both tokens are saved to the client's `TokenStore`, the runtime
starts (domains warmed, scenario engine restored, `player_login` event fired),
and the state flips to `'signed-in'`.
## Environments
A project has two environments, `staging` and `prod`. The SDK key you pass as
`RudderClientOptions.projectKey` belongs to one of them, so the environment is
resolved at login and carried inside the access and refresh tokens; nothing in
the client API takes an environment argument, and a player created in one
environment is invisible in the other. Content released only to `staging` is
empty for a `prod` key and vice versa.
Tokens issued before SDK 2.0.0 carry no environment claim and are rejected with
401. The transport's refresh then fails, clears the token store and emits
`'signed-out'` — log the player in again.
## Auth state
```ts
@@ -2,7 +2,7 @@
`BattlePassService` (source: `src/battlepass/BattlePassService.ts`).
Call-and-response access to battle pass endpoints. Battle pass state is tied to
a **scenario battle pass node**, so calls carry `scenarioId` + `nodeId` (plus
a **scenario battle pass node**, so calls carry `scenarioSlug` + `nodeId` (plus
`runId` for mutating calls). NOT observable — re-fetch progress explicitly
after a mutation.
@@ -14,7 +14,7 @@ you already know the scenario/node/run identifiers.
## Methods
```ts
getProgress(scenarioId: string, nodeId: string): Promise<GetBattlePassProgressResponse>
getProgress(scenarioSlug: string, nodeId: string): Promise<GetBattlePassProgressResponse>
addXp(request: AddBattlePassXpRequest): Promise<AddBattlePassXpResponse>
claimReward(request: ClaimBattlePassRewardRequest): Promise<ClaimBattlePassRewardResponse>
purchasePremium(request: PurchaseBattlePassPremiumRequest): Promise<PurchaseBattlePassPremiumResponse>
@@ -27,7 +27,7 @@ interface AddBattlePassXpRequest {
amount?: number;
nodeId?: string;
runId?: string;
scenarioId?: string;
scenarioSlug?: string;
source?: string; // configured XP source
}
interface AddBattlePassXpResponse {
@@ -41,7 +41,7 @@ interface ClaimBattlePassRewardRequest {
level?: number;
nodeId?: string;
runId?: string;
scenarioId?: string;
scenarioSlug?: string;
track?: 'free' | 'premium';
}
interface ClaimBattlePassRewardResponse {
@@ -62,7 +62,7 @@ interface PurchaseBattlePassPremiumRequest {
idempotencyKey?: string;
nodeId?: string;
runId?: string;
scenarioId?: string;
scenarioSlug?: string;
}
interface PurchaseBattlePassPremiumResponse {
error?: string;
@@ -80,5 +80,5 @@ interface PurchaseBattlePassPremiumResponse {
in the response and the typed error `code` (`RudderErrorCodes`).
- `purchasePremium` charges the player's wallet; idempotent; pass your own
`idempotencyKey` for safe retries.
- Prefer the `onBattlePass` effect session, which binds `scenarioId` /
- Prefer the `onBattlePass` effect session, which binds `scenarioSlug` /
`nodeId` / `runId` and posts scenario callbacks for you.
+6 -6
View File
@@ -11,15 +11,15 @@ Distinct from scenario quest nodes, which advance through
```ts
list(): Promise<Quest[]>
claim(questId: string): Promise<ClaimQuestResponse>
reportProgress(metric: string, amount: number): Promise<string[]> // ids of quests completed by this report
claim(questSlug: string): Promise<ClaimQuestResponse>
reportProgress(metric: string, amount: number): Promise<string[]> // slugs of quests completed by this report
```
## Types
```ts
interface Quest {
id?: string;
slug?: string; // stable across environments — what claim() takes
name?: string;
objectives?: QuestObjectiveProgress[];
rewards?: Reward[];
@@ -47,12 +47,12 @@ interface ClaimQuestResponse {
```ts
const quests = await client.quests.list();
for (const quest of quests) {
if (quest.status === 'completed' && quest.id) {
const res = await client.quests.claim(quest.id);
if (quest.status === 'completed' && quest.slug) {
const res = await client.quests.claim(quest.slug);
// res.success / res.alreadyClaimed / res.granted
}
}
const completedIds = await client.quests.reportProgress('kills', 1);
const completedSlugs = await client.quests.reportProgress('kills', 1);
```
- `claim` is idempotent server-side; check `success` / `alreadyClaimed` /
+4 -4
View File
@@ -122,7 +122,7 @@ counter response may carry the next `PendingEffect`.
}
```
These wrap `client.battlePass` with the run's `scenarioId`/`nodeId`/`runId`
These wrap `client.battlePass` with the run's `scenarioSlug`/`nodeId`/`runId`
already bound — prefer them over calling the service manually.
### `BattlePassLevelEffect` — a `battlepass_level` node (single claimable tier)
@@ -134,9 +134,9 @@ already bound — prefer them over calling the service manually.
### Run lifecycle effects
```ts
interface ScenarioCompletedEffect { readonly runId: string; readonly scenarioId: string }
interface ScenarioCompletedEffect { readonly runId: string; readonly scenarioSlug: string }
interface ScenarioFailedEffect {
readonly runId: string; readonly scenarioId: string; readonly nodeId: string;
readonly runId: string; readonly scenarioSlug: string; readonly nodeId: string;
readonly error: Error;
}
```
@@ -154,7 +154,7 @@ not delivered to the client.
## Reliability notes
- Completion methods POST `/sdk/v1/scenarios/callback` with
`{scenarioId, runId, nodeId, handle}` using the handles `output`,
`{scenarioSlug, runId, nodeId, handle}` using the handles `output`,
`onPurchase`, `onDecline`, `onEnd`, `onClaim`, `onComplete`, `onLevelUp`,
`onPremiumPurchase`.
- Transient callback failures (network error, 5xx) leave the effect active
+10 -8
View File
@@ -13,7 +13,7 @@ client.stores.onChange(cb);
await client.stores.load() / .reload();
// Direct purchase (bypassing handles):
await client.stores.purchase(storeSlug, offerId, options?);
await client.stores.purchase(storeSlug, offerSlug, options?);
```
## Handles
@@ -28,7 +28,8 @@ class ShopHandle {
}
class OfferHandle {
readonly id: string;
readonly id: string; // authoring id, differs between staging and prod
readonly slug: string; // stable across environments — what purchases use
readonly name?: string;
readonly price?: OfferPrice; // { amount?: number; currency?: string }
readonly contents: OfferContent[]; // { amount?: number; itemId?: string }[]
@@ -48,15 +49,15 @@ interface PurchaseOfferResponse {
## Purchase semantics
- `stores.purchase(storeSlug, offerId, options?)` and `offer.buy(options?)` are
the same executor; handles just bind the slug/id.
- `stores.purchase(storeSlug, offerSlug, options?)` and `offer.buy(options?)` are
the same executor; handles just bind the store slug and the offer slug.
- When `options.idempotencyKey` is omitted the SDK generates
`crypto.randomUUID()` per call — safe retries require passing your own key.
- On success the executor invalidates `player`, `inventory`, and `stores`, so
subscribers observe fresh wallet/inventory/store data immediately.
- Check `response.success` / `response.error` — a failed purchase is a resolved
response, not necessarily a thrown error.
- Purchase metrics (`purchase.offer:<offerId>`, `purchase.item:<itemId>`) are
- Purchase metrics (`purchase.offer:<offerSlug>`, `purchase.item:<itemId>`) are
reported to quests automatically server-side — do not report them manually
(see `reference/quests.md`).
@@ -71,10 +72,11 @@ interface Store {
}
interface Offer {
contents?: OfferContent[]; createdAt?: string; id?: string;
maxPurchases?: number; name?: string; price?: OfferPrice; updatedAt?: string;
maxPurchases?: number; name?: string; price?: OfferPrice; slug?: string;
updatedAt?: string;
}
```
`ShopHandle`/`OfferHandle` throw a plain `Error` if constructed from a store
without `slug` / an offer without `id` — in practice the domain only builds
handles from server data that has both.
without `slug` / an offer without `id` or `slug` — in practice the domain only
builds handles from server data that has all of them.
+3 -3
View File
@@ -2,7 +2,7 @@
* BattlePassService — call-and-response access to the battle pass endpoints.
*
* 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
* observable domain; re-fetch progress explicitly after a mutation.
*/
@@ -23,8 +23,8 @@ export class BattlePassService {
constructor(private readonly ctx: RudderContext) {}
/** Reads current progress (xp, level, premium, claimed tiers) for a node. */
getProgress(scenarioId: string, nodeId: string): Promise<GetBattlePassProgressResponse> {
return api.getBattlePassProgress(this.ctx, { scenarioId, nodeId });
getProgress(scenarioSlug: string, nodeId: string): Promise<GetBattlePassProgressResponse> {
return api.getBattlePassProgress(this.ctx, { scenarioSlug, nodeId });
}
/** 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[]> {
readonly syncKey = 'stores';
/** Shared purchase executor: `stores.purchase(slug, offerId, opts)`. */
/** Shared purchase executor: `stores.purchase(slug, offerSlug, opts)`. */
readonly purchase: PurchaseOffer;
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.
const purchase: PurchaseOffer = async (
storeSlug: string,
offerId: string,
offerSlug: string,
options: BuyOptions = {},
): Promise<PurchaseOfferResponse> => {
const response = await api.purchaseOffer(
ctx,
{ storeSlug, offerId },
{ storeSlug, offerSlug },
{
storeSlug,
offerId,
offerSlug,
idempotencyKey: options.idempotencyKey ?? crypto.randomUUID(),
},
);
+2 -2
View File
@@ -40,12 +40,12 @@ export interface WaitEffect {
export interface ScenarioCompletedEffect {
readonly runId: string;
readonly scenarioId: string;
readonly scenarioSlug: string;
}
export interface ScenarioFailedEffect {
readonly runId: string;
readonly scenarioId: string;
readonly scenarioSlug: string;
readonly nodeId: string;
readonly error: Error;
}
+2 -2
View File
@@ -163,10 +163,10 @@ export const api = {
purchaseOffer: (
t: Transport,
path: { storeSlug: string; offerId: string },
path: { storeSlug: string; offerSlug: string },
body: PurchaseOfferRequest,
): 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: (
t: Transport,
+4 -4
View File
@@ -6,7 +6,7 @@ export interface AddBattlePassXpRequest {
"amount"?: number;
"nodeId"?: string;
"runId"?: string;
"scenarioId"?: string;
"scenarioSlug"?: string;
"source"?: string;
}
@@ -21,7 +21,7 @@ export interface ClaimBattlePassRewardRequest {
"level"?: number;
"nodeId"?: string;
"runId"?: string;
"scenarioId"?: string;
"scenarioSlug"?: string;
"track"?: "free" | "premium";
}
@@ -39,7 +39,7 @@ export interface ClaimedTier {
export interface GetBattlePassProgressRequest {
"nodeId"?: string;
"scenarioId"?: string;
"scenarioSlug"?: string;
}
export interface GetBattlePassProgressResponse {
@@ -53,7 +53,7 @@ export interface PurchaseBattlePassPremiumRequest {
"idempotencyKey"?: string;
"nodeId"?: string;
"runId"?: string;
"scenarioId"?: string;
"scenarioSlug"?: string;
}
export interface PurchaseBattlePassPremiumResponse {
+3 -3
View File
@@ -3,7 +3,7 @@
import type { Reward } from './common.js';
export interface ClaimQuestRequest {
"questId"?: string;
"questSlug"?: string;
}
export interface ClaimQuestResponse {
@@ -18,10 +18,10 @@ export interface ListQuestsResponse {
}
export interface Quest {
"id"?: string;
"name"?: string;
"objectives"?: QuestObjectiveProgress[];
"rewards"?: Reward[];
"slug"?: string;
"status"?: "active" | "claimed" | "completed";
}
@@ -39,6 +39,6 @@ export interface ReportQuestProgressRequest {
}
export interface ReportQuestProgressResponse {
"completedQuestIds"?: string[];
"completedQuestSlugs"?: string[];
}
+3 -3
View File
@@ -4,7 +4,7 @@ export interface HandleScenarioCallbackRequest {
"handle"?: string;
"nodeId"?: string;
"runId"?: string;
"scenarioId"?: string;
"scenarioSlug"?: string;
}
export interface HandleScenarioCallbackResponse {
@@ -19,7 +19,7 @@ export interface PendingEffect {
"data": { [key: string]: unknown };
"nodeId": string;
"runId": string;
"scenarioId": string;
"scenarioSlug": string;
"type": string;
"waitDeadline"?: string;
}
@@ -37,7 +37,7 @@ export interface UpdateScenarioCounterRequest {
"counterKey"?: string;
"nodeId"?: string;
"runId"?: string;
"scenarioId"?: string;
"scenarioSlug"?: string;
}
export interface UpdateScenarioCounterResponse {
+2 -1
View File
@@ -12,6 +12,7 @@ export interface Offer {
"maxPurchases"?: number;
"name"?: string;
"price"?: OfferPrice;
"slug"?: string;
"updatedAt"?: string;
}
@@ -27,7 +28,7 @@ export interface OfferPrice {
export interface PurchaseOfferRequest {
"idempotencyKey"?: string;
"offerId"?: string;
"offerSlug"?: string;
"storeSlug"?: string;
}
+2 -2
View File
@@ -9,8 +9,8 @@
*/
/** Metric for purchasing a store offer; auto-reported on purchase. */
export function purchaseOffer(offerId: string): string {
return `purchase.offer:${offerId}`;
export function purchaseOffer(offerSlug: string): string {
return `purchase.offer:${offerSlug}`;
}
/** 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). */
claim(questId: string): Promise<ClaimQuestResponse> {
return api.claimQuest(this.ctx, { questId });
claim(questSlug: string): Promise<ClaimQuestResponse> {
return api.claimQuest(this.ctx, { questSlug });
}
/** Reports metric progress; returns ids of quests completed by this report. */
async reportProgress(metric: string, amount: number): Promise<string[]> {
const response = await api.reportQuestProgress(this.ctx, { metric, amount });
return response.completedQuestIds ?? [];
return response.completedQuestSlugs ?? [];
}
}
+20 -18
View File
@@ -22,7 +22,7 @@ const JITTER_RATIO = 0.2;
type ActiveRecord = {
runId: string;
nodeId: string;
scenarioId: string;
scenarioSlug: string;
storePurchased?: boolean;
lastPurchase?: PurchaseOfferResponse;
};
@@ -40,7 +40,9 @@ function isTransientHttpError(err: unknown): boolean {
function isDroppedRunError(err: unknown): boolean {
if (!(err instanceof RudderHttpError)) return false;
return (
err.code === RudderErrorCodes.unknownRun || err.code === RudderErrorCodes.runExpired
err.code === RudderErrorCodes.unknownRun ||
err.code === RudderErrorCodes.runExpired ||
err.code === RudderErrorCodes.runNotActive
);
}
@@ -175,7 +177,7 @@ export class ScenarioService {
for (const [key, record] of [...this.active.entries()]) {
if (incomingKeys.has(key)) continue;
this.deactivate(key);
this.emitCompletedIfIdle(record.runId, record.scenarioId);
this.emitCompletedIfIdle(record.runId, record.scenarioSlug);
}
const after = new Set(this.active.keys());
@@ -204,7 +206,7 @@ export class ScenarioService {
const record: ActiveRecord = {
runId: pending.runId,
nodeId: pending.nodeId,
scenarioId: pending.scenarioId,
scenarioSlug: pending.scenarioSlug,
};
this.active.set(key, record);
this.completedRuns.delete(pending.runId);
@@ -287,16 +289,16 @@ export class ScenarioService {
}
private dispatchBattlePass(record: ActiveRecord): void {
const { scenarioId, nodeId, runId } = record;
const { scenarioSlug, nodeId, runId } = record;
this.ctx.effects.emitBattlePass({
getProgress: () => this.battlePass.getProgress(scenarioId, nodeId),
getProgress: () => this.battlePass.getProgress(scenarioSlug, nodeId),
addXp: (source, amount) =>
this.battlePass.addXp({ scenarioId, nodeId, source, amount, runId }),
this.battlePass.addXp({ scenarioSlug, nodeId, source, amount, runId }),
claimReward: (level, track) =>
this.battlePass.claimReward({ scenarioId, nodeId, level, track, runId }),
this.battlePass.claimReward({ scenarioSlug, nodeId, level, track, runId }),
purchasePremium: async () => {
const response = await this.battlePass.purchasePremium({
scenarioId,
scenarioSlug,
nodeId,
idempotencyKey: crypto.randomUUID(),
runId,
@@ -344,7 +346,7 @@ export class ScenarioService {
): Promise<void> {
try {
const response = await api.updateScenarioCounter(this.ctx, {
scenarioId: record.scenarioId,
scenarioSlug: record.scenarioSlug,
nodeId: record.nodeId,
counterKey: objectiveId,
amount,
@@ -360,7 +362,7 @@ export class ScenarioService {
if (response.effect) {
this.ingest([response.effect]);
} else {
this.emitCompletedIfIdle(record.runId, record.scenarioId);
this.emitCompletedIfIdle(record.runId, record.scenarioSlug);
}
} catch {
}
@@ -372,7 +374,7 @@ export class ScenarioService {
if (this.droppedRuns.has(record.runId)) return;
try {
const response = await api.handleScenarioCallback(this.ctx, {
scenarioId: record.scenarioId,
scenarioSlug: record.scenarioSlug,
nodeId: record.nodeId,
handle,
runId: record.runId,
@@ -384,7 +386,7 @@ export class ScenarioService {
if (response?.effect) {
this.ingest([response.effect]);
} else {
this.emitCompletedIfIdle(record.runId, record.scenarioId);
this.emitCompletedIfIdle(record.runId, record.scenarioSlug);
}
} catch (err) {
if (isTransientHttpError(err)) return;
@@ -423,10 +425,10 @@ export class ScenarioService {
private dropRun(runId: string, nodeId: string, error: Error): void {
this.droppedRuns.add(runId);
let scenarioId = '';
let scenarioSlug = '';
for (const [key, record] of [...this.active.entries()]) {
if (record.runId !== runId) continue;
if (!scenarioId) scenarioId = record.scenarioId;
if (!scenarioSlug) scenarioSlug = record.scenarioSlug;
this.deactivate(key);
}
console.warn(
@@ -434,19 +436,19 @@ export class ScenarioService {
);
this.ctx.effects.emitScenarioFailed({
runId,
scenarioId,
scenarioSlug,
nodeId,
error,
});
}
private emitCompletedIfIdle(runId: string, scenarioId: string): void {
private emitCompletedIfIdle(runId: string, scenarioSlug: string): void {
if (this.droppedRuns.has(runId)) return;
if (this.completedRuns.has(runId)) return;
for (const record of this.active.values()) {
if (record.runId === runId) return;
}
this.completedRuns.add(runId);
this.ctx.effects.emitScenarioCompleted({ runId, scenarioId });
this.ctx.effects.emitScenarioCompleted({ runId, scenarioSlug });
}
}
-16
View File
@@ -23,22 +23,6 @@ export class RemoteConfigState<
if (!config || config.value == null) return defaultValue;
return parseValue(config.value, config.valueType, defaultValue);
}
/**
* Applies a scenario remote_config_override patch to the current snapshot.
*
* @internal
*/
applyOverride(key: string, value: string, valueType = 'json'): void {
const next = new Map(this.data ?? []);
next.set(key, {
key,
value,
valueType,
active: true,
} as RemoteConfig);
this.set(next);
}
}
function parseValue<T>(
+7 -2
View File
@@ -16,12 +16,13 @@ export interface BuyOptions {
*/
export type PurchaseOffer = (
storeSlug: string,
offerId: string,
offerSlug: string,
options?: BuyOptions,
) => Promise<PurchaseOfferResponse>;
export class OfferHandle {
public readonly id: string;
public readonly slug: string;
public readonly name?: string;
public readonly price?: OfferPrice;
public readonly contents: OfferContent[];
@@ -35,7 +36,11 @@ export class OfferHandle {
if (!offer.id) {
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.slug = offer.slug;
this.name = offer.name;
this.price = offer.price;
this.contents = offer.contents ?? [];
@@ -43,7 +48,7 @@ export class OfferHandle {
}
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({
effects: [{
runId: 'run-1',
scenarioId: 'scenario-1',
scenarioSlug: 'scenario-1',
nodeId: 'start',
type: 'notification',
data: { message: 'Welcome!' },
+6 -6
View File
@@ -88,7 +88,7 @@ describe('ScenarioService', () => {
});
}
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({});
});
@@ -203,7 +203,7 @@ describe('ScenarioService', () => {
return url.includes('/sdk/v1/scenarios/callback');
});
expect(JSON.parse(callbackCall![1]!.body as string)).toEqual({
scenarioId: 'scenario-1',
scenarioSlug: 'scenario-1',
nodeId: 'n1',
handle: 'output',
runId: 'run-1',
@@ -227,7 +227,7 @@ describe('ScenarioService', () => {
expect(onCompleted).toHaveBeenCalledOnce();
expect(onCompleted.mock.calls[0][0]).toEqual({
runId: 'run-1',
scenarioId: 'scenario-1',
scenarioSlug: 'scenario-1',
});
expect(scenarios.isRunning).toBe(false);
});
@@ -359,10 +359,10 @@ describe('ScenarioService', () => {
return jsonResponse({
name: '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({});
@@ -387,7 +387,7 @@ describe('ScenarioService', () => {
return jsonResponse({ effects: [effect('store', { storeSlug: '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({});
});
+13
View File
@@ -74,6 +74,19 @@ export function adminApi(artifact: SeedArtifact = loadArtifact()): AdminClient {
return api;
}
/** Credits a player's wallet through the admin batch endpoint. */
export async function grantCurrency(
artifact: SeedArtifact,
playerId: string,
currencyCode: string,
amount: number,
): Promise<void> {
await adminApi(artifact).post(
`/platform/v1/projects/${artifact.projectId}/players/wallet/adjust?environment=${artifact.environment}`,
{ items: [{ playerId, currencyCode, amount, reason: 'e2e grant' }] },
);
}
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
/** Retries a read until ok(result) holds — absorbs release/cache reload lag. */
+77 -55
View File
@@ -1,11 +1,10 @@
// Production seed: registers an operator, creates a project, configures staging
// content (items, remote configs, leaderboard, store with free+paid offers, a
// content (counter, item, remote configs, leaderboard, store with free+paid offers, a
// global quest, a scenario), and promotes it to the prod snapshot the runtime SDK serves.
// Excludes realtime & analytics.
//
// The scenario flow uses the SDK runtime's exact node `data` keys and handle names —
// the game's plan builder copies node.data verbatim into the execution plan
// (see liveops-game/shared/fsm/plan_builder.go), so what we store is what the SDK runs.
// The scenario flow uses the server engine's node types, `data` keys and handle names —
// the server walks the graph itself (see liveops-backend/internal/domain/scenario/engine/planbuilder
// and .../fsm), so what we store is what the server runs.
import { createAdminClient, type AdminClient } from './adminClient.js';
import type { SeedArtifact } from './types.js';
@@ -20,6 +19,7 @@ interface ReleaseResponse {
interface QuestResponse {
id: string;
slug: string;
name: string;
status: string;
objectives?: Array<{ id: string; metric: string; target: number }>;
@@ -29,9 +29,10 @@ interface QuestResponse {
interface StoreResponse {
id: string;
name: string;
data?: { slug?: string };
slug: string;
offers?: Array<{
id: string;
slug: string;
name: string;
contents?: Array<{ itemId: string; amount: number }>;
}>;
@@ -110,31 +111,43 @@ export async function runSeed(
api.setToken(auth.accessToken);
log(`registered ${adminEmail}`);
// 2. Project.
const project = await api.post<{ id: string; key: string; name: string }>(
'/platform/v1/projects',
{ name: `e2e-${ts}` },
);
// 2. Project. Creating one provisions the staging + prod environments, each with
// its own SDK key — that key is what the runtime SDK sends as projectKey.
const project = await api.post<{ id: string; name: string }>('/platform/v1/projects', {
name: `e2e-${ts}`,
});
const projPath = `/platform/v1/projects/${project.id}`;
log(`project ${project.id} key=${project.key}`);
// 3. Staging environment (idempotent — may already exist).
try {
await api.post(`${projPath}/environments`, { projectId: project.id, name: authoringEnv });
} catch {
// already exists / not required — content POSTs below are the real test
const environments = await api.get<Array<{ name: string; sdkKey: string }>>(
`${projPath}/environments`,
);
const projectKey = environments.find((e) => e.name === runtimeEnv)?.sdkKey;
if (!projectKey) {
throw new Error(`project ${project.id} has no ${runtimeEnv} environment sdk key`);
}
log(`project ${project.id} ${runtimeEnv}Key=${projectKey}`);
// 3. Soft currency counter — offer prices are validated against counter slugs, and
// the purchase debit resolves the currency through the catalog counter cache.
const currency = 'soft';
await api.post(`${projPath}/counters${q}`, {
projectId: project.id,
environment: authoringEnv,
name: 'Soft Currency',
slug: currency,
kind: 'currency',
});
// 4. Item (granted as a paid offer's contents).
const item = await api.post<{ id: string; name: string }>(`${projPath}/items${q}`, {
projectId: project.id,
environment: authoringEnv,
name: 'Gold Coin',
tags: ['currency'],
slug: 'gold-coin',
});
// 5. Remote configs — one per value type. RCs are live per-env flags served
// straight from the outbox/cache, so they go directly into the runtime env.
// straight from the cache and are not copied by a promote, so they go directly
// into the runtime env.
const remoteConfigs: Array<{ key: string; value: string; valueType: string }> = [
{ key: 'max_energy', value: '100', valueType: 'int' },
{ key: 'spawn_rate', value: '1.5', valueType: 'float' },
@@ -164,43 +177,44 @@ export async function runSeed(
});
// 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 (liveops-game shop cache), and
// the CreateStore API has no slug field — so we set it via data.
// Stores and offers carry an authored slug that survives promotes.
const storeSlug = 'starter-shop';
const store = await api.post<{ id: string; offers: Array<{ id: string; name: string }> }>(
`${projPath}/stores${q}`,
{
const paidOfferSlug = 'gold-pack';
const store = await api.post<StoreResponse>(`${projPath}/stores${q}`, {
projectId: project.id,
environment: authoringEnv,
slug: storeSlug,
name: 'Starter Shop',
description: 'E2E shop',
data: { slug: storeSlug },
offers: [
{ name: 'Welcome Gift', contents: [{ itemId: item.id, amount: 10 }], price: { currency: 'soft', amount: 0 } },
{
slug: 'welcome-gift',
name: 'Welcome Gift',
contents: [{ itemId: item.id, amount: 10 }],
price: { currency, amount: 0 },
},
{
slug: paidOfferSlug,
name: 'Gold Pack',
contents: [{ itemId: item.id, amount: 50 }],
price: { currency: 'soft', amount: 100 },
price: { currency, amount: 100 },
maxPurchases: 2,
},
],
},
);
});
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.
const questName = 'Buy Gold Pack Quest';
const questObjectiveId = 'buy-gold-pack';
const questTarget = 1;
const questMetric = `purchase.offer:${paidOffer.id}`;
const questSlug = 'buy-gold-pack-quest';
const questMetric = `purchase.offer:${paidOfferSlug}`;
const questRewardAmount = 7;
const quest = await api.post<QuestResponse>(`${projPath}/quests${q}`, {
projectId: project.id,
environment: authoringEnv,
slug: questSlug,
name: questName,
objectives: [{ id: questObjectiveId, metric: questMetric, target: questTarget }],
rewards: [{ itemId: item.id, amount: questRewardAmount }],
@@ -210,24 +224,26 @@ export async function runSeed(
// 9. Scenario (must be live now: startAt <= now <= endAt).
const startAt = new Date(ts - 3600_000).toISOString();
const endAt = new Date(ts + 365 * 24 * 3600_000).toISOString();
const scenarioEvent = 'session_start';
const scenarioSlug = 'onboarding';
const scenario = await api.post<{ id: string }>(`${projPath}/scenarios${q}`, {
projectId: project.id,
environment: authoringEnv,
slug: scenarioSlug,
name: 'Onboarding',
description: 'e2e onboarding',
startAt,
endAt,
});
// 10. Scenario flow (trigger -> notification -> rc override -> store -> boundary -> notification).
await api.put(`${projPath}/scenarios/${scenario.id}`, {
// 10. Scenario flow (trigger -> notification -> rc override -> store -> condition -> notification).
await api.put(`${projPath}/scenarios/${scenario.id}?environment=${authoringEnv}`, {
projectId: project.id,
scenarioId: scenario.id,
flow: buildFlow(storeSlug),
});
log(`scenario ${scenario.id} flow set`);
// 11. Promote staging content to prod (the runtime serves prod/latest.json).
// 11. Promote staging content to prod (the runtime serves the prod snapshot).
const release = await api.post<ReleaseResponse>(`${projPath}/releases/promote`, {
projectId: project.id,
fromEnvironment: authoringEnv,
@@ -235,14 +251,15 @@ export async function runSeed(
});
await pollRelease(api, projPath, runtimeEnv, release.id, log);
// 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 prodStore = prodStores.find((s) => s.data?.slug === storeSlug || s.name === 'Starter Shop');
const prodStore = prodStores.find((s) => s.slug === storeSlug);
if (!prodStore) {
throw new Error(`promoted store ${storeSlug} was not found in ${runtimeEnv}`);
}
const prodPaidOffer = prodStore.offers?.find((o) => o.name === 'Gold Pack');
if (!prodPaidOffer?.id) {
throw new Error('promoted paid offer did not return an id');
const prodPaidOffer = prodStore.offers?.find((o) => o.slug === paidOfferSlug);
if (!prodPaidOffer?.slug) {
throw new Error('promoted paid offer did not return a slug');
}
const prodRewardItemId = prodPaidOffer.contents?.[0]?.itemId;
if (!prodRewardItemId) {
@@ -250,26 +267,31 @@ export async function runSeed(
}
const prodQuests = await api.get<QuestResponse[]>(`${projPath}/quests${prodQ}`);
const prodQuest = prodQuests.find((q) => q.name === questName);
if (!prodQuest?.id) {
throw new Error(`promoted quest ${questName} was not found in ${runtimeEnv}`);
const prodQuest = prodQuests.find((qq) => qq.slug === questSlug);
if (!prodQuest?.slug) {
throw new Error(`promoted quest ${questSlug} was not found in ${runtimeEnv}`);
}
const prodQuestMetric = prodQuest.objectives?.find((o) => o.id === questObjectiveId)?.metric;
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;
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`);
}
// 12. Settle: let the game service hot-reload caches from the new snapshot.
await sleep(3000);
const prodScenarios = await api.get<Array<{ id: string; slug: string; name: string }>>(
`${projPath}/scenarios${prodQ}`,
);
const prodScenario = prodScenarios.find((s) => s.slug === scenarioSlug);
if (!prodScenario?.slug) {
throw new Error(`promoted scenario ${scenarioSlug} was not found in ${runtimeEnv}`);
}
return {
baseUrl,
projectId: project.id,
projectKey: project.key,
projectKey,
adminToken: auth.accessToken,
adminEmail,
environment: runtimeEnv,
@@ -280,14 +302,14 @@ export async function runSeed(
slug: storeSlug,
freeOfferName: 'Welcome Gift',
paidOfferName: 'Gold Pack',
paidOfferId: prodPaidOffer.id,
paidPrice: { currency: 'soft', amount: 100 },
paidOfferSlug: prodPaidOffer.slug,
paidPrice: { currency, amount: 100 },
paidMaxPurchases: 2,
grantedItemName: 'Gold Coin',
paidGrantAmount: 50,
},
quest: {
id: prodQuest.id,
slug: prodQuest.slug,
name: questName,
objectiveId: questObjectiveId,
metric: prodQuestMetric,
@@ -305,7 +327,7 @@ export async function runSeed(
shopLayout: { cols: 3 },
spawnRateOverride: 3.0,
},
scenario: { id: scenario.id, event: 'session_start' },
scenario: { slug: prodScenario.slug, event: scenarioEvent },
};
}
+3 -3
View File
@@ -16,14 +16,14 @@ export interface SeedArtifact {
slug: string;
freeOfferName: string;
paidOfferName: string;
paidOfferId: string;
paidOfferSlug: string;
paidPrice: { currency: string; amount: number };
paidMaxPurchases: number;
grantedItemName: string;
paidGrantAmount: number;
};
quest: {
id: string;
slug: string;
name: string;
objectiveId: string;
metric: string;
@@ -42,5 +42,5 @@ export interface SeedArtifact {
/** spawn_rate value the scenario's remote_config_override applies. */
spawnRateOverride: number;
};
scenario: { id: string; event: string };
scenario: { slug: string; event: string };
}
+5 -6
View File
@@ -15,12 +15,11 @@ describe('e2e-prod: health', () => {
expect(body.status).toBe('ok');
});
it('content + game gRPC backends are serving', async () => {
// /readyz may be 503 if analytics is not_serving (out of scope here); assert the
// backends this suite actually uses are serving.
it('GET /readyz reports every backing store ready', async () => {
const res = await fetch(`${baseUrl}/readyz`);
const body = (await res.json()) as { grpc?: Record<string, string> };
expect(body.grpc?.content).toBe('serving');
expect(body.grpc?.game).toBe('serving');
expect(res.ok).toBe(true);
const body = (await res.json()) as { status?: string; checks?: Record<string, string> };
expect(body.status).toBe('ready');
expect(body.checks).toMatchObject({ db: 'ok', redis: 'ok', s3: 'ok' });
});
});
+4 -8
View File
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeAll } from 'vitest';
import { adminApi, freshPlayer, loadArtifact, withReadRetry } from './_setup/harness.js';
import { adminApi, freshPlayer, grantCurrency, loadArtifact, withReadRetry } from './_setup/harness.js';
import type { SeedArtifact } from './_setup/types.js';
import type { RudderClient } from '../../src/client/RudderClient.js';
import type { OfferHandle } from '../../src/state/shops.js';
@@ -48,12 +48,8 @@ describe('e2e-prod: purchase (wallet/inventory/limits)', () => {
const price = artifact.store.paidPrice.amount;
const offer = await resolvePaidOffer(client);
// 1. Grant currency via the new admin endpoint.
await api.post(`/platform/v1/projects/${artifact.projectId}/players/${playerId}/wallet/adjust`, {
currencyCode: currency,
amount: 1000,
reason: 'e2e grant',
});
// 1. Grant currency via the admin endpoint.
await grantCurrency(artifact, playerId, currency, 1000);
const granted = await withReadRetry(
() => balance(client, currency),
(b) => b === 1000,
@@ -68,7 +64,7 @@ describe('e2e-prod: purchase (wallet/inventory/limits)', () => {
// 3. Inventory credited (verified via admin player details).
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);
expect(Number(inv?.amount)).toBe(artifact.store.paidGrantAmount);
+6 -8
View File
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeAll, vi } from 'vitest';
import { adminApi, loadArtifact, makeProdClient } from './_setup/harness.js';
import { grantCurrency, loadArtifact, makeProdClient } from './_setup/harness.js';
import type { SeedArtifact } from './_setup/types.js';
import type { NotificationEffect, StoreOfferEffect } from '../../src/index.js';
import { getScenarioRuntime } from '../../src/client/RudderClient.js';
@@ -31,13 +31,11 @@ describe('e2e-prod: scenarios', () => {
// The boundary buy uses the paid offer — fund the wallet so the purchase succeeds.
const playerId = (await client.player.reload()).player!.id!;
await adminApi(artifact).post(
`/platform/v1/projects/${artifact.projectId}/players/${playerId}/wallet/adjust`,
{
currencyCode: artifact.store.paidPrice.currency,
amount: artifact.store.paidPrice.amount,
reason: 'e2e scenario funding',
},
await grantCurrency(
artifact,
playerId,
artifact.store.paidPrice.currency,
artifact.store.paidPrice.amount,
);
// First notification dispatched.
+8 -8
View File
@@ -24,7 +24,7 @@ describe('E2E: boundary nodes (server-side continuation)', () => {
stores: [{
slug: 'starter',
name: 'Starter',
offers: [{ id: 'pack_1', name: 'Starter Pack' }],
offers: [{ id: 'pack_1', slug: 'pack-1', name: 'Starter Pack' }],
}],
});
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 () => {
const offerEffect = effect('store', { storeSlug: 'starter', message: 'Buy the starter pack!' }, {
scenarioId: 'offer_flow',
scenarioSlug: 'offer_flow',
runId: 'offer_flow-run',
nodeId: 'offer',
});
const reward = effect('notification', { message: 'Reward granted: 500 gems!' }, {
scenarioId: 'offer_flow',
scenarioSlug: 'offer_flow',
runId: 'offer_flow-run',
nodeId: 'reward',
});
@@ -52,7 +52,7 @@ describe('E2E: boundary nodes (server-side continuation)', () => {
let offer: StoreOfferEffect | undefined;
client.effects.onStoreOffer((e) => { offer = e; });
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;
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');
expect(callback?.body).toEqual({
scenarioId: 'offer_flow',
scenarioSlug: 'offer_flow',
nodeId: 'offer',
handle: 'onPurchase',
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 () => {
const offerEffect = effect('store', { storeSlug: 'starter', message: 'Buy the starter pack!' }, {
scenarioId: 'offer_flow',
scenarioSlug: 'offer_flow',
runId: 'offer_flow-run',
nodeId: 'offer',
});
const consolation = effect('notification', { message: 'Maybe next time!' }, {
scenarioId: 'offer_flow',
scenarioSlug: 'offer_flow',
runId: 'offer_flow-run',
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 () => {
const offerEffect = effect('store', { storeSlug: 'starter' }, {
scenarioId: 'offer_flow',
scenarioSlug: 'offer_flow',
runId: 'offer_flow-run',
nodeId: 'offer',
});
+8 -8
View File
@@ -18,29 +18,29 @@ function makeClient(): RudderClient {
function offerScenario(now: number) {
const waitIntro = effect('wait', {}, {
scenarioId: 'offer_flow',
scenarioSlug: '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',
const offer = effect('store', { storeSlug: 'starter', offerSlug: 'pack-1', message: 'Limited starter pack!' }, {
scenarioSlug: 'offer_flow',
runId: 'offer_flow-run',
nodeId: 'offer',
});
const waitReminder = effect('wait', {}, {
scenarioId: 'offer_flow',
scenarioSlug: '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',
scenarioSlug: 'offer_flow',
runId: 'offer_flow-run',
nodeId: 'reminder',
});
const thanks = effect('notification', { message: 'Thanks for your purchase!' }, {
scenarioId: 'offer_flow',
scenarioSlug: 'offer_flow',
runId: 'offer_flow-run',
nodeId: 'thanks',
});
@@ -58,7 +58,7 @@ describe('E2E: player offer journey', () => {
stores: [{
slug: 'starter',
name: 'Starter',
offers: [{ id: 'pack_1', name: 'Starter Pack' }],
offers: [{ id: 'pack_1', slug: 'pack-1', name: 'Starter Pack' }],
}],
});
gateway.install();
@@ -139,7 +139,7 @@ describe('E2E: player offer journey', () => {
expect(purchase.success).toBe(true);
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);
+3 -3
View File
@@ -24,17 +24,17 @@ describe('E2E: pending effects resume after a new login', () => {
stubDeterministicUuid();
vi.useFakeTimers();
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 wait = effect('wait', {}, {
scenarioId: 'offer_flow',
scenarioSlug: 'offer_flow',
runId: 'offer_flow-run',
nodeId: 'wait_intro',
waitDeadline: new Date(now + MINUTE).toISOString(),
});
const offer = effect('store', { message: 'Limited starter pack!', storeSlug: 'starter' }, {
scenarioId: 'offer_flow',
scenarioSlug: 'offer_flow',
runId: 'offer_flow-run',
nodeId: 'offer',
});
+7 -7
View File
@@ -33,8 +33,8 @@ export interface FakeGatewayState {
export interface FakeGateway {
state: FakeGatewayState;
recorded: RecordedRequest[];
purchases: Array<{ storeSlug: string; offerId: string; idempotencyKey: string; authToken: string | null }>;
questClaims: Array<{ questId: string; authToken: string | null }>;
purchases: Array<{ storeSlug: string; offerSlug: string; idempotencyKey: string; authToken: string | null }>;
questClaims: Array<{ questSlug: string; authToken: string | null }>;
install(): void;
onEvent(event: string, ...effects: PendingEffect[]): 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 };
purchases.push({
storeSlug: decodeURIComponent(purchaseMatch[1]),
offerId: decodeURIComponent(purchaseMatch[2]),
offerSlug: decodeURIComponent(purchaseMatch[2]),
idempotencyKey: b.idempotencyKey ?? '',
authToken: req.authToken,
});
@@ -192,10 +192,10 @@ export function createFakeGateway(seed?: Partial<FakeGatewayState>): FakeGateway
return json(state.quests);
}
if (method === 'POST' && path === '/sdk/v1/quests/claim') {
const b = body as { questId?: string };
const questId = b.questId ?? '';
questClaims.push({ questId, authToken: req.authToken });
return json(state.questClaims.get(questId) ?? { success: false, error: 'not found' });
const b = body as { questSlug?: string };
const questSlug = b.questSlug ?? '';
questClaims.push({ questSlug, authToken: req.authToken });
return json(state.questClaims.get(questSlug) ?? { success: false, error: 'not found' });
}
if (path === '/sdk/v1/storage') {
+1 -1
View File
@@ -7,7 +7,7 @@ export function effect(
): PendingEffect {
return {
runId: 'run-1',
scenarioId: 'scenario-1',
scenarioSlug: 'scenario-1',
nodeId: `${type}-1`,
type,
data,