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 # 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 ## 1.0.0
- Scenario execution moved server-side. The SDK no longer walks a local DAG - 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", "name": "@rudder/js-sdk",
"version": "1.0.0", "version": "2.0.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@rudder/js-sdk", "name": "@rudder/js-sdk",
"version": "1.0.0", "version": "2.0.0",
"devDependencies": { "devDependencies": {
"@types/node": "^26.1.1", "@types/node": "^26.1.1",
"jsdom": "^29.1.1", "jsdom": "^29.1.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@rudder/js-sdk", "name": "@rudder/js-sdk",
"version": "1.0.0", "version": "2.0.0",
"publishConfig": { "publishConfig": {
"registry": "https://hub.rudder.build/api/packages/rudder/npm/" "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>` | | Player profile + wallets | `client.player` | observable `SyncedState<PlayerProfile>` |
| Inventory (catalog-merged) | `client.inventory` | observable `SyncedState<InventoryItem[]>` | | Inventory (catalog-merged) | `client.inventory` | observable `SyncedState<InventoryItem[]>` |
| Catalog | `client.catalog` | observable `SyncedState<Map<string, CatalogItem>>` | | 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 | | Remote config | `client.remoteConfig` + `.get(key, default?)` | observable, typed |
| Player storage | `client.storage` + `.save(items)` / `.delete(type)` | observable + mutations | | Player storage | `client.storage` + `.save(items)` / `.delete(type)` | observable + mutations |
| Project storage | `client.projectStorage` + `.save(items)` | observable + mutation | | 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), starts (domains warmed, scenario engine restored, `player_login` event fired),
and the state flips to `'signed-in'`. 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 ## Auth state
```ts ```ts
@@ -2,7 +2,7 @@
`BattlePassService` (source: `src/battlepass/BattlePassService.ts`). `BattlePassService` (source: `src/battlepass/BattlePassService.ts`).
Call-and-response access to battle pass endpoints. Battle pass state is tied to 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 `runId` for mutating calls). NOT observable — re-fetch progress explicitly
after a mutation. after a mutation.
@@ -14,7 +14,7 @@ you already know the scenario/node/run identifiers.
## Methods ## Methods
```ts ```ts
getProgress(scenarioId: string, nodeId: string): Promise<GetBattlePassProgressResponse> getProgress(scenarioSlug: string, nodeId: string): Promise<GetBattlePassProgressResponse>
addXp(request: AddBattlePassXpRequest): Promise<AddBattlePassXpResponse> addXp(request: AddBattlePassXpRequest): Promise<AddBattlePassXpResponse>
claimReward(request: ClaimBattlePassRewardRequest): Promise<ClaimBattlePassRewardResponse> claimReward(request: ClaimBattlePassRewardRequest): Promise<ClaimBattlePassRewardResponse>
purchasePremium(request: PurchaseBattlePassPremiumRequest): Promise<PurchaseBattlePassPremiumResponse> purchasePremium(request: PurchaseBattlePassPremiumRequest): Promise<PurchaseBattlePassPremiumResponse>
@@ -27,7 +27,7 @@ interface AddBattlePassXpRequest {
amount?: number; amount?: number;
nodeId?: string; nodeId?: string;
runId?: string; runId?: string;
scenarioId?: string; scenarioSlug?: string;
source?: string; // configured XP source source?: string; // configured XP source
} }
interface AddBattlePassXpResponse { interface AddBattlePassXpResponse {
@@ -41,7 +41,7 @@ interface ClaimBattlePassRewardRequest {
level?: number; level?: number;
nodeId?: string; nodeId?: string;
runId?: string; runId?: string;
scenarioId?: string; scenarioSlug?: string;
track?: 'free' | 'premium'; track?: 'free' | 'premium';
} }
interface ClaimBattlePassRewardResponse { interface ClaimBattlePassRewardResponse {
@@ -62,7 +62,7 @@ interface PurchaseBattlePassPremiumRequest {
idempotencyKey?: string; idempotencyKey?: string;
nodeId?: string; nodeId?: string;
runId?: string; runId?: string;
scenarioId?: string; scenarioSlug?: string;
} }
interface PurchaseBattlePassPremiumResponse { interface PurchaseBattlePassPremiumResponse {
error?: string; error?: string;
@@ -80,5 +80,5 @@ interface PurchaseBattlePassPremiumResponse {
in the response and the typed error `code` (`RudderErrorCodes`). in the response and the typed error `code` (`RudderErrorCodes`).
- `purchasePremium` charges the player's wallet; idempotent; pass your own - `purchasePremium` charges the player's wallet; idempotent; pass your own
`idempotencyKey` for safe retries. `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. `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 ```ts
list(): Promise<Quest[]> list(): Promise<Quest[]>
claim(questId: string): Promise<ClaimQuestResponse> claim(questSlug: string): Promise<ClaimQuestResponse>
reportProgress(metric: string, amount: number): Promise<string[]> // ids of quests completed by this report reportProgress(metric: string, amount: number): Promise<string[]> // slugs of quests completed by this report
``` ```
## Types ## Types
```ts ```ts
interface Quest { interface Quest {
id?: string; slug?: string; // stable across environments — what claim() takes
name?: string; name?: string;
objectives?: QuestObjectiveProgress[]; objectives?: QuestObjectiveProgress[];
rewards?: Reward[]; rewards?: Reward[];
@@ -47,12 +47,12 @@ interface ClaimQuestResponse {
```ts ```ts
const quests = await client.quests.list(); const quests = await client.quests.list();
for (const quest of quests) { for (const quest of quests) {
if (quest.status === 'completed' && quest.id) { if (quest.status === 'completed' && quest.slug) {
const res = await client.quests.claim(quest.id); const res = await client.quests.claim(quest.slug);
// res.success / res.alreadyClaimed / res.granted // 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` / - `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. already bound — prefer them over calling the service manually.
### `BattlePassLevelEffect` — a `battlepass_level` node (single claimable tier) ### `BattlePassLevelEffect` — a `battlepass_level` node (single claimable tier)
@@ -134,9 +134,9 @@ already bound — prefer them over calling the service manually.
### Run lifecycle effects ### Run lifecycle effects
```ts ```ts
interface ScenarioCompletedEffect { readonly runId: string; readonly scenarioId: string } interface ScenarioCompletedEffect { readonly runId: string; readonly scenarioSlug: string }
interface ScenarioFailedEffect { interface ScenarioFailedEffect {
readonly runId: string; readonly scenarioId: string; readonly nodeId: string; readonly runId: string; readonly scenarioSlug: string; readonly nodeId: string;
readonly error: Error; readonly error: Error;
} }
``` ```
@@ -154,7 +154,7 @@ not delivered to the client.
## Reliability notes ## Reliability notes
- Completion methods POST `/sdk/v1/scenarios/callback` with - 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`, `onPurchase`, `onDecline`, `onEnd`, `onClaim`, `onComplete`, `onLevelUp`,
`onPremiumPurchase`. `onPremiumPurchase`.
- Transient callback failures (network error, 5xx) leave the effect active - 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(); await client.stores.load() / .reload();
// Direct purchase (bypassing handles): // Direct purchase (bypassing handles):
await client.stores.purchase(storeSlug, offerId, options?); await client.stores.purchase(storeSlug, offerSlug, options?);
``` ```
## Handles ## Handles
@@ -28,7 +28,8 @@ class ShopHandle {
} }
class OfferHandle { 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 name?: string;
readonly price?: OfferPrice; // { amount?: number; currency?: string } readonly price?: OfferPrice; // { amount?: number; currency?: string }
readonly contents: OfferContent[]; // { amount?: number; itemId?: string }[] readonly contents: OfferContent[]; // { amount?: number; itemId?: string }[]
@@ -48,15 +49,15 @@ interface PurchaseOfferResponse {
## Purchase semantics ## Purchase semantics
- `stores.purchase(storeSlug, offerId, options?)` and `offer.buy(options?)` are - `stores.purchase(storeSlug, offerSlug, options?)` and `offer.buy(options?)` are
the same executor; handles just bind the slug/id. the same executor; handles just bind the store slug and the offer slug.
- When `options.idempotencyKey` is omitted the SDK generates - When `options.idempotencyKey` is omitted the SDK generates
`crypto.randomUUID()` per call — safe retries require passing your own key. `crypto.randomUUID()` per call — safe retries require passing your own key.
- On success the executor invalidates `player`, `inventory`, and `stores`, so - On success the executor invalidates `player`, `inventory`, and `stores`, so
subscribers observe fresh wallet/inventory/store data immediately. subscribers observe fresh wallet/inventory/store data immediately.
- Check `response.success` / `response.error` — a failed purchase is a resolved - Check `response.success` / `response.error` — a failed purchase is a resolved
response, not necessarily a thrown error. 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 reported to quests automatically server-side — do not report them manually
(see `reference/quests.md`). (see `reference/quests.md`).
@@ -71,10 +72,11 @@ interface Store {
} }
interface Offer { interface Offer {
contents?: OfferContent[]; createdAt?: string; id?: string; 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 `ShopHandle`/`OfferHandle` throw a plain `Error` if constructed from a store
without `slug` / an offer without `id` — in practice the domain only builds without `slug` / an offer without `id` or `slug` — in practice the domain only
handles from server data that has both. 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. * 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 ?? [];
} }
} }
+20 -18
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;
}; };
@@ -40,7 +40,9 @@ function isTransientHttpError(err: unknown): boolean {
function isDroppedRunError(err: unknown): boolean { function isDroppedRunError(err: unknown): boolean {
if (!(err instanceof RudderHttpError)) return false; if (!(err instanceof RudderHttpError)) return false;
return ( 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()]) { 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());
@@ -204,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);
@@ -287,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,
@@ -344,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,
@@ -360,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 {
} }
@@ -372,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,
@@ -384,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;
@@ -423,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(
@@ -434,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 });
} }
} }
-16
View File
@@ -23,22 +23,6 @@ export class RemoteConfigState<
if (!config || config.value == null) return defaultValue; if (!config || config.value == null) return defaultValue;
return parseValue(config.value, config.valueType, 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>( function parseValue<T>(
+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({});
}); });
+13
View File
@@ -74,6 +74,19 @@ export function adminApi(artifact: SeedArtifact = loadArtifact()): AdminClient {
return api; 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)); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
/** Retries a read until ok(result) holds — absorbs release/cache reload lag. */ /** 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 // 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. // 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 scenario flow uses the server engine's node types, `data` keys and handle names —
// the game's plan builder copies node.data verbatim into the execution plan // the server walks the graph itself (see liveops-backend/internal/domain/scenario/engine/planbuilder
// (see liveops-game/shared/fsm/plan_builder.go), so what we store is what the SDK runs. // and .../fsm), so what we store is what the server runs.
import { createAdminClient, type AdminClient } from './adminClient.js'; import { createAdminClient, type AdminClient } from './adminClient.js';
import type { SeedArtifact } from './types.js'; import type { SeedArtifact } from './types.js';
@@ -20,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 }>;
@@ -29,9 +29,10 @@ interface QuestResponse {
interface StoreResponse { interface StoreResponse {
id: string; id: string;
name: string; name: string;
data?: { slug?: string }; 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 }>;
}>; }>;
@@ -110,31 +111,43 @@ export async function runSeed(
api.setToken(auth.accessToken); api.setToken(auth.accessToken);
log(`registered ${adminEmail}`); log(`registered ${adminEmail}`);
// 2. Project. // 2. Project. Creating one provisions the staging + prod environments, each with
const project = await api.post<{ id: string; key: string; name: string }>( // its own SDK key — that key is what the runtime SDK sends as projectKey.
'/platform/v1/projects', const project = await api.post<{ id: string; name: string }>('/platform/v1/projects', {
{ name: `e2e-${ts}` }, name: `e2e-${ts}`,
); });
const projPath = `/platform/v1/projects/${project.id}`; const projPath = `/platform/v1/projects/${project.id}`;
log(`project ${project.id} key=${project.key}`); const environments = await api.get<Array<{ name: string; sdkKey: string }>>(
`${projPath}/environments`,
// 3. Staging environment (idempotent — may already exist). );
try { const projectKey = environments.find((e) => e.name === runtimeEnv)?.sdkKey;
await api.post(`${projPath}/environments`, { projectId: project.id, name: authoringEnv }); if (!projectKey) {
} catch { throw new Error(`project ${project.id} has no ${runtimeEnv} environment sdk key`);
// already exists / not required — content POSTs below are the real test
} }
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). // 4. Item (granted as a paid offer's contents).
const item = await api.post<{ id: string; name: string }>(`${projPath}/items${q}`, { const item = await api.post<{ id: string; name: string }>(`${projPath}/items${q}`, {
projectId: project.id, projectId: project.id,
environment: authoringEnv, environment: authoringEnv,
name: 'Gold Coin', name: 'Gold Coin',
tags: ['currency'], slug: 'gold-coin',
}); });
// 5. Remote configs — one per value type. RCs are live per-env flags served // 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 }> = [ const remoteConfigs: Array<{ key: string; value: string; valueType: string }> = [
{ key: 'max_energy', value: '100', valueType: 'int' }, { key: 'max_energy', value: '100', valueType: 'int' },
{ key: 'spawn_rate', value: '1.5', valueType: 'float' }, { 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. // 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 // Stores and offers carry an authored slug that survives promotes.
// the CreateStore API has no slug field — so we set it via data.
const storeSlug = 'starter-shop'; const storeSlug = 'starter-shop';
const store = await api.post<{ id: string; offers: Array<{ id: string; name: string }> }>( const paidOfferSlug = 'gold-pack';
`${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: 'soft', 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: 'soft', amount: 100 }, price: { currency, amount: 100 },
maxPurchases: 2, maxPurchases: 2,
}, },
], ],
}, });
);
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 }],
@@ -210,24 +224,26 @@ export async function runSeed(
// 9. Scenario (must be live now: startAt <= now <= endAt). // 9. Scenario (must be live now: startAt <= now <= endAt).
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 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,
endAt, endAt,
}); });
// 10. Scenario flow (trigger -> notification -> rc override -> store -> boundary -> 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`);
// 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`, { const release = await api.post<ReleaseResponse>(`${projPath}/releases/promote`, {
projectId: project.id, projectId: project.id,
fromEnvironment: authoringEnv, fromEnvironment: authoringEnv,
@@ -235,14 +251,15 @@ export async function runSeed(
}); });
await pollRelease(api, projPath, runtimeEnv, release.id, log); 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 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) {
@@ -250,26 +267,31 @@ 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`);
} }
// 12. Settle: let the game service hot-reload caches from the new snapshot. const prodScenarios = await api.get<Array<{ id: string; slug: string; name: string }>>(
await sleep(3000); `${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 { return {
baseUrl, baseUrl,
projectId: project.id, projectId: project.id,
projectKey: project.key, projectKey,
adminToken: auth.accessToken, adminToken: auth.accessToken,
adminEmail, adminEmail,
environment: runtimeEnv, environment: runtimeEnv,
@@ -280,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: 'soft', 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,
@@ -305,7 +327,7 @@ export async function runSeed(
shopLayout: { cols: 3 }, shopLayout: { cols: 3 },
spawnRateOverride: 3.0, 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; 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 };
} }
+5 -6
View File
@@ -15,12 +15,11 @@ describe('e2e-prod: health', () => {
expect(body.status).toBe('ok'); expect(body.status).toBe('ok');
}); });
it('content + game gRPC backends are serving', async () => { it('GET /readyz reports every backing store ready', async () => {
// /readyz may be 503 if analytics is not_serving (out of scope here); assert the
// backends this suite actually uses are serving.
const res = await fetch(`${baseUrl}/readyz`); const res = await fetch(`${baseUrl}/readyz`);
const body = (await res.json()) as { grpc?: Record<string, string> }; expect(res.ok).toBe(true);
expect(body.grpc?.content).toBe('serving'); const body = (await res.json()) as { status?: string; checks?: Record<string, string> };
expect(body.grpc?.game).toBe('serving'); 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 { 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 { SeedArtifact } from './_setup/types.js';
import type { RudderClient } from '../../src/client/RudderClient.js'; import type { RudderClient } from '../../src/client/RudderClient.js';
import type { OfferHandle } from '../../src/state/shops.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 price = artifact.store.paidPrice.amount;
const offer = await resolvePaidOffer(client); const offer = await resolvePaidOffer(client);
// 1. Grant currency via the new admin endpoint. // 1. Grant currency via the admin endpoint.
await api.post(`/platform/v1/projects/${artifact.projectId}/players/${playerId}/wallet/adjust`, { await grantCurrency(artifact, playerId, currency, 1000);
currencyCode: currency,
amount: 1000,
reason: 'e2e grant',
});
const granted = await withReadRetry( const granted = await withReadRetry(
() => balance(client, currency), () => balance(client, currency),
(b) => b === 1000, (b) => b === 1000,
@@ -68,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);
+6 -8
View File
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeAll, vi } from 'vitest'; 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 { SeedArtifact } from './_setup/types.js';
import type { NotificationEffect, StoreOfferEffect } from '../../src/index.js'; import type { NotificationEffect, StoreOfferEffect } from '../../src/index.js';
import { getScenarioRuntime } from '../../src/client/RudderClient.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. // The boundary buy uses the paid offer — fund the wallet so the purchase succeeds.
const playerId = (await client.player.reload()).player!.id!; const playerId = (await client.player.reload()).player!.id!;
await adminApi(artifact).post( await grantCurrency(
`/platform/v1/projects/${artifact.projectId}/players/${playerId}/wallet/adjust`, artifact,
{ playerId,
currencyCode: artifact.store.paidPrice.currency, artifact.store.paidPrice.currency,
amount: artifact.store.paidPrice.amount, artifact.store.paidPrice.amount,
reason: 'e2e scenario funding',
},
); );
// First notification dispatched. // First notification dispatched.
+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,