Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7271874cac | |||
| a7401b534a | |||
| e863cc3bf7 |
@@ -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
|
||||||
|
|||||||
Generated
+2
-2
@@ -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
@@ -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/"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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` /
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,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[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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 ?? [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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!' },
|
||||||
|
|||||||
@@ -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({});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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 },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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' });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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') {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user