8 Commits

Author SHA1 Message Date
edmand46 04e3565412 0.6.0: LeaderboardSession.claim, rank_not_eligible is recoverable
CI / publish (push) Successful in 27s
CI / check (push) Successful in 59s
onClaim replaces onRewardClaimed. rewardClaimed() is a deprecated alias removed in the next version.
2026-08-29 11:33:01 +03:00
edmand46 19a296ab9a ci: publish with NPM_TOKEN PAT (job GITEA_TOKEN cannot write org packages)
CI / publish (push) Successful in 29s
CI / check (push) Successful in 58s
2026-08-28 18:42:59 +03:00
edmand46 a72989402e 0.5.1: custom quest metrics no-op, doc fix
CI / publish (push) Failing after 31s
CI / check (push) Successful in 1m5s
2026-08-28 17:38:02 +03:00
edmand46 3556c0b035 ci: grant packages:write to publish job (GITEA_TOKEN 401 on npm publish) 2026-08-28 17:38:02 +03:00
edmand46 fff971ad15 0.5.0: typecheck against regenerated models
CI / publish (push) Failing after 26s
CI / check (push) Successful in 1m4s
listQuests no longer takes a body, battle-pass track is a union, scenario
run status has no unknown_run. This ships loginWithCustom and quest
metric helpers that never reached the registry after the v0.4.0 publish
job failed.
2026-08-28 15:18:31 +03:00
edmand46 e6128e54bd sdk: loginWithCustom facade + regenerated models
CI / check (push) Failing after 22s
CI / publish (push) Has been skipped
2026-08-28 13:36:09 +03:00
edmand46 68287d212e quests: QuestMetrics helpers (purchaseOffer/purchaseItem), README quests section
CI / check (push) Successful in 59s
CI / publish (push) Has been skipped
2026-08-25 16:25:32 +03:00
edmand46 1013f2395f e2e-prod: platform wallet adjust route, fund scenario player, RCs seeded directly in prod env
CI / check (push) Successful in 58s
CI / publish (push) Has been skipped
2026-08-20 11:35:21 +03:00
23 changed files with 221 additions and 56 deletions
+4 -1
View File
@@ -35,6 +35,9 @@ jobs:
if: startsWith(github.ref, 'refs/tags/v') if: startsWith(github.ref, 'refs/tags/v')
needs: check needs: check
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -55,4 +58,4 @@ jobs:
- name: Publish - name: Publish
run: npm publish run: npm publish
env: env:
NODE_AUTH_TOKEN: ${{ secrets.GITEA_TOKEN }} NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+27
View File
@@ -1,5 +1,32 @@
# Changelog # Changelog
## 0.6.0
- `LeaderboardSession.claim()` / effect `claim()` completes the leaderboard
node with handle `onClaim`. The server matches live rank to an authored
place and continues from that place (Grant Reward and/or In-App Message).
- `rewardClaimed()` is a deprecated alias for `claim()` and will be removed
in the next SDK version.
- `rank_not_eligible` on Claim leaves the session open (retry Claim or End);
it no longer fails the run.
## 0.5.1
- `QuestMetrics` / `reportProgress`: custom free-text metrics no longer
progress quests. Report a released catalog counter slug instead.
`purchaseOffer` / `purchaseItem` helpers are unchanged (server-side shop
fan-out).
## 0.5.0
- `client.auth.loginWithCustom({ customData, region?, language?, nickname? })`
— custom webhook auth, same token + runtime start as `loginWithDevice`.
- `QuestMetrics.purchaseOffer` / `purchaseItem` helpers for the shop purchase
metric format.
- Typecheck against regenerated models: `listQuests` no longer takes a body,
battle-pass `track` is `'free' | 'premium'`, scenario run status has no
`unknown_run`.
## 0.4.0 ## 0.4.0
- Fixed `BattlePassLevelSession.level` reading node data key `level` — the - Fixed `BattlePassLevelSession.level` reading node data key `level` — the
+33 -2
View File
@@ -52,7 +52,7 @@ client.dispose();
| Area | Access | | Area | Access |
|---|---| |---|---|
| Auth | `client.auth.loginWithDevice()`, `client.auth.logout()`, `client.auth.isAuthenticated`, `client.auth.onAuthStateChange(cb)` | | Auth | `client.auth.loginWithDevice()`, `client.auth.loginWithCustom({ customData })`, `client.auth.logout()`, `client.auth.isAuthenticated`, `client.auth.onAuthStateChange(cb)` |
| Player / wallets | `client.player` (observable) | | Player / wallets | `client.player` (observable) |
| Inventory | `client.inventory` (observable, catalog-merged) | | Inventory | `client.inventory` (observable, catalog-merged) |
| Catalog | `client.catalog` (observable) | | Catalog | `client.catalog` (observable) |
@@ -61,7 +61,7 @@ client.dispose();
| Storage | `client.storage` (observable) + `.save(items)` / `.delete(type)` | | Storage | `client.storage` (observable) + `.save(items)` / `.delete(type)` |
| Leaderboards | `client.leaderboards.findBySlug(slug)``handle.submit(score)` / `handle.list(limit?)` | | Leaderboards | `client.leaderboards.findBySlug(slug)``handle.submit(score)` / `handle.list(limit?)` |
| Battle pass | `client.battlePass` (getProgress / addXp / claimReward / purchasePremium) | | Battle pass | `client.battlePass` (getProgress / addXp / claimReward / purchasePremium) |
| Quests | `client.quests.list()` / `client.quests.claim(id)` | | Quests | `client.quests.list()` / `client.quests.claim(id)` / `client.quests.reportProgress(metric, amount)` |
| Scenario effects | `client.effects.on*` | | Scenario effects | `client.effects.on*` |
Type the remote config for `client.remoteConfig`: Type the remote config for `client.remoteConfig`:
@@ -75,6 +75,37 @@ const client = new RudderClient<GameConfig>({ /* … */ });
client.remoteConfig.get('player_speed', 200); // number client.remoteConfig.get('player_speed', 200); // number
``` ```
## Quests
`client.quests` covers the player's global quests — list with per-objective
progress, claim, and metric reports. These are distinct from scenario quest
nodes, which advance through the `onQuest` effect's `QuestSession`. Global
quests have no live sync: re-list after a claim or report.
```ts
const quests = await client.quests.list();
for (const quest of quests) {
if (quest.status === 'completed' && quest.id) {
await client.quests.claim(quest.id);
}
}
// Custom metrics advance matching objectives server-side; the call returns
// the ids of quests completed by this report.
const completedIds = await client.quests.reportProgress('kills', 1);
```
Purchase metrics are reported automatically when a purchase goes through
`client.stores`; the `QuestMetrics` helpers name the format so quest configs
and client code agree on it:
```ts
import { QuestMetrics } from '@rudder/js-sdk';
QuestMetrics.purchaseOffer('starter-pack'); // "purchase.offer:starter-pack"
QuestMetrics.purchaseItem('moonberry'); // "purchase.item:moonberry"
```
## Scenario effects ## Scenario effects
The scenario runtime is not exposed directly; scenario nodes surface through The scenario runtime is not exposed directly; scenario nodes surface through
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "@rudder/js-sdk", "name": "@rudder/js-sdk",
"version": "0.1.0", "version": "0.5.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@rudder/js-sdk", "name": "@rudder/js-sdk",
"version": "0.1.0", "version": "0.5.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": "0.4.0", "version": "0.6.0",
"publishConfig": { "publishConfig": {
"registry": "https://hub.rudder.build/api/packages/rudder/npm/" "registry": "https://hub.rudder.build/api/packages/rudder/npm/"
}, },
+41 -4
View File
@@ -1,15 +1,15 @@
/** /**
* AuthService — device-based player authentication. * AuthService — player authentication.
* *
* Handles login via device ID (the primary auth flow for game clients), * Handles login via device ID (the primary auth flow for game clients) or a
* logout (token clearing), and auth state observation. * custom backend webhook, logout (token clearing), and auth state observation.
*/ */
import type { RudderContext } from '../core/context.js'; import type { RudderContext } from '../core/context.js';
import type { RemoteConfigShape } from '../state/RemoteConfigState.js'; import type { RemoteConfigShape } from '../state/RemoteConfigState.js';
import { getOrCreateDeviceId } from '../device/DeviceId.js'; import { getOrCreateDeviceId } from '../device/DeviceId.js';
import { api } from '../generated/api.js'; import { api } from '../generated/api.js';
import type { LoginViaDeviceResponse } from '../generated/auth.js'; import type { LoginViaCustomResponse, LoginViaDeviceResponse } from '../generated/auth.js';
export type AuthState = 'signed-in' | 'signed-out'; export type AuthState = 'signed-in' | 'signed-out';
@@ -24,6 +24,17 @@ export interface LoginWithDeviceOptions {
nickname?: string; nickname?: string;
} }
export interface LoginWithCustomOptions {
/** Arbitrary payload forwarded to the developer's custom auth webhook. */
customData: Record<string, unknown>;
/** Player's region code (default: "global"). */
region?: string;
/** Player's language code (default: "en"). */
language?: string;
/** Optional player nickname; omitted from the request when not set. */
nickname?: string;
}
export class AuthService<TConfig extends RemoteConfigShape = RemoteConfigShape> { export class AuthService<TConfig extends RemoteConfigShape = RemoteConfigShape> {
private readonly listeners = new Set<AuthStateListener>(); private readonly listeners = new Set<AuthStateListener>();
private state: AuthState; private state: AuthState;
@@ -81,6 +92,32 @@ export class AuthService<TConfig extends RemoteConfigShape = RemoteConfigShape>
return response; return response;
} }
/**
* Authenticates via the project's custom authorization webhook and returns
* access + refresh tokens.
*
* The payload is forwarded to the developer's backend; on success, tokens are
* saved to the client's TokenStore.
*/
async loginWithCustom(options: LoginWithCustomOptions): Promise<LoginViaCustomResponse> {
const { customData, region = 'global', language = 'en', nickname } = options;
const response = await api.loginViaCustom(this.ctx, {
key: this.ctx.options.projectKey,
customData,
region,
language,
nickname,
});
this.ctx.options.tokenStore.saveTokens(
response.accessToken ?? '',
response.refreshToken ?? '',
);
await this.startRuntime();
this.setState('signed-in');
return response;
}
/** Clears all stored tokens (logout). */ /** Clears all stored tokens (logout). */
logout(): void { logout(): void {
this.ctx.options.tokenStore.clear(); this.ctx.options.tokenStore.clear();
+5 -2
View File
@@ -35,6 +35,8 @@ export interface StoreOfferEffect {
export interface LeaderboardEffect { export interface LeaderboardEffect {
end(): Promise<void>; end(): Promise<void>;
claim(): Promise<void>;
/** @deprecated Use {@link LeaderboardEffect.claim}. Removed in the next SDK version. */
rewardClaimed(): Promise<void>; rewardClaimed(): Promise<void>;
} }
@@ -79,7 +81,7 @@ export interface QuestEffect {
export interface BattlePassEffect { export interface BattlePassEffect {
getProgress(): Promise<GetBattlePassProgressResponse>; getProgress(): Promise<GetBattlePassProgressResponse>;
addXp(source: string, amount: number): Promise<AddBattlePassXpResponse>; addXp(source: string, amount: number): Promise<AddBattlePassXpResponse>;
claimReward(level: number, track?: string): Promise<ClaimBattlePassRewardResponse>; claimReward(level: number, track?: 'free' | 'premium'): Promise<ClaimBattlePassRewardResponse>;
purchasePremium(): Promise<PurchaseBattlePassPremiumResponse>; purchasePremium(): Promise<PurchaseBattlePassPremiumResponse>;
levelUp(): Promise<void>; levelUp(): Promise<void>;
end(): Promise<void>; end(): Promise<void>;
@@ -191,7 +193,8 @@ export class EffectsCenter implements Effects {
emitLeaderboard(session: LeaderboardSession): void { emitLeaderboard(session: LeaderboardSession): void {
this.emit(this.leaderboardHandlers, { this.emit(this.leaderboardHandlers, {
end: () => session.end(), end: () => session.end(),
rewardClaimed: () => session.rewardClaimed(), claim: () => session.claim(),
rewardClaimed: () => session.claim(),
}); });
} }
+9 -4
View File
@@ -1,13 +1,13 @@
// Code generated by apigen. DO NOT EDIT. // Code generated by apigen. DO NOT EDIT.
import type { LoginViaDeviceRequest, LoginViaDeviceResponse, RefreshAccessTokenRequest, RefreshAccessTokenResponse } from './auth.js'; import type { LoginViaCustomRequest, LoginViaCustomResponse, LoginViaDeviceRequest, LoginViaDeviceResponse, RefreshAccessTokenRequest, RefreshAccessTokenResponse } from './auth.js';
import type { AddBattlePassXpRequest, AddBattlePassXpResponse, ClaimBattlePassRewardRequest, ClaimBattlePassRewardResponse, GetBattlePassProgressRequest, GetBattlePassProgressResponse, PurchaseBattlePassPremiumRequest, PurchaseBattlePassPremiumResponse } from './battlepass.js'; import type { AddBattlePassXpRequest, AddBattlePassXpResponse, ClaimBattlePassRewardRequest, ClaimBattlePassRewardResponse, GetBattlePassProgressRequest, GetBattlePassProgressResponse, PurchaseBattlePassPremiumRequest, PurchaseBattlePassPremiumResponse } from './battlepass.js';
import type { ListCatalogItemsResponse } from './catalog.js'; import type { ListCatalogItemsResponse } from './catalog.js';
import type { GetInventoryResponse } from './inventory.js'; import type { GetInventoryResponse } from './inventory.js';
import type { GetRankingResponse, SubmitScoreRequest } from './leaderboards.js'; import type { GetRankingResponse, SubmitScoreRequest } from './leaderboards.js';
import type { PlayerProfile } from './player.js'; import type { PlayerProfile } from './player.js';
import type { GetProjectStorageResponse, UpdateProjectStorageRequest } from './project-storage.js'; import type { GetProjectStorageResponse, UpdateProjectStorageRequest } from './project-storage.js';
import type { ClaimQuestRequest, ClaimQuestResponse, ListQuestsRequest, ListQuestsResponse, ReportQuestProgressRequest, ReportQuestProgressResponse } from './quests.js'; import type { ClaimQuestRequest, ClaimQuestResponse, ListQuestsResponse, ReportQuestProgressRequest, ReportQuestProgressResponse } from './quests.js';
import type { ListRemoteConfigsResponse, RemoteConfig } from './remote-config.js'; import type { ListRemoteConfigsResponse, RemoteConfig } from './remote-config.js';
import type { GetScenarioRunRequest, GetScenarioRunResponse, HandleScenarioCallbackRequest, HandleScenarioCallbackResponse, TriggerScenarioRequest, TriggerScenarioResponse, UpdateScenarioCounterRequest, UpdateScenarioCounterResponse } from './scenarios.js'; import type { GetScenarioRunRequest, GetScenarioRunResponse, HandleScenarioCallbackRequest, HandleScenarioCallbackResponse, TriggerScenarioRequest, TriggerScenarioResponse, UpdateScenarioCounterRequest, UpdateScenarioCounterResponse } from './scenarios.js';
import type { GetStorageResponse, UpdateStorageRequest } from './storage.js'; import type { GetStorageResponse, UpdateStorageRequest } from './storage.js';
@@ -131,9 +131,8 @@ export const api = {
listQuests: ( listQuests: (
t: Transport, t: Transport,
body: ListQuestsRequest,
): Promise<ListQuestsResponse> => ): Promise<ListQuestsResponse> =>
t.request<ListQuestsResponse>('POST', '/sdk/v1/quests/list', body), t.request<ListQuestsResponse>('POST', '/sdk/v1/quests/list'),
listSdkRemoteConfigs: ( listSdkRemoteConfigs: (
t: Transport, t: Transport,
@@ -145,6 +144,12 @@ export const api = {
): Promise<ListStoresResponse> => ): Promise<ListStoresResponse> =>
t.request<ListStoresResponse>('GET', '/sdk/v1/stores'), t.request<ListStoresResponse>('GET', '/sdk/v1/stores'),
loginViaCustom: (
t: Transport,
body: LoginViaCustomRequest,
): Promise<LoginViaCustomResponse> =>
t.request<LoginViaCustomResponse>('POST', '/sdk/v1/authorization/custom', body),
loginViaDevice: ( loginViaDevice: (
t: Transport, t: Transport,
body: LoginViaDeviceRequest, body: LoginViaDeviceRequest,
+13
View File
@@ -1,5 +1,18 @@
// Code generated by apigen. DO NOT EDIT. // Code generated by apigen. DO NOT EDIT.
export interface LoginViaCustomRequest {
"customData"?: { [key: string]: unknown };
"key"?: string;
"language"?: string;
"nickname"?: string;
"region"?: string;
}
export interface LoginViaCustomResponse {
"accessToken"?: string;
"refreshToken"?: string;
}
export interface LoginViaDeviceRequest { export interface LoginViaDeviceRequest {
"deviceId"?: string; "deviceId"?: string;
"key"?: string; "key"?: string;
+3 -9
View File
@@ -1,6 +1,6 @@
// Code generated by apigen. DO NOT EDIT. // Code generated by apigen. DO NOT EDIT.
import type { ExecutionPlan } from './common.js'; import type { ExecutionPlan, Reward } from './common.js';
export interface AddBattlePassXpRequest { export interface AddBattlePassXpRequest {
"amount"?: number; "amount"?: number;
@@ -18,24 +18,18 @@ export interface AddBattlePassXpResponse {
"xp"?: number; "xp"?: number;
} }
export interface BattlePassReward {
"amount"?: number;
"currency"?: string;
"itemId"?: string;
}
export interface ClaimBattlePassRewardRequest { export interface ClaimBattlePassRewardRequest {
"level"?: number; "level"?: number;
"nodeId"?: string; "nodeId"?: string;
"runId"?: string; "runId"?: string;
"scenarioId"?: string; "scenarioId"?: string;
"track"?: string; "track"?: "free" | "premium";
} }
export interface ClaimBattlePassRewardResponse { export interface ClaimBattlePassRewardResponse {
"alreadyClaimed"?: boolean; "alreadyClaimed"?: boolean;
"error"?: string; "error"?: string;
"granted"?: BattlePassReward[]; "granted"?: Reward[];
"success"?: boolean; "success"?: boolean;
} }
+8
View File
@@ -13,6 +13,8 @@ export interface BoundaryNode {
export interface ErrorResponse { export interface ErrorResponse {
"code"?: "early_completion" | "forbidden" | "level_not_reached" | "node_not_active" | "objectives_incomplete" | "run_expired" | "run_not_active" | "scenario_not_active" | "unknown_run"; "code"?: "early_completion" | "forbidden" | "level_not_reached" | "node_not_active" | "objectives_incomplete" | "run_expired" | "run_not_active" | "scenario_not_active" | "unknown_run";
"error"?: string; "error"?: string;
"index"?: number;
"playerId"?: string;
"requestId"?: string; "requestId"?: string;
} }
@@ -42,3 +44,9 @@ export interface PlanEdge {
"targetHandle"?: string; "targetHandle"?: string;
} }
export interface Reward {
"amount"?: number;
"currency"?: string;
"itemId"?: string;
}
+5 -12
View File
@@ -1,5 +1,7 @@
// Code generated by apigen. DO NOT EDIT. // Code generated by apigen. DO NOT EDIT.
import type { Reward } from './common.js';
export interface ClaimQuestRequest { export interface ClaimQuestRequest {
"questId"?: string; "questId"?: string;
} }
@@ -7,13 +9,10 @@ export interface ClaimQuestRequest {
export interface ClaimQuestResponse { export interface ClaimQuestResponse {
"alreadyClaimed"?: boolean; "alreadyClaimed"?: boolean;
"error"?: string; "error"?: string;
"granted"?: QuestReward[]; "granted"?: Reward[];
"success"?: boolean; "success"?: boolean;
} }
export interface ListQuestsRequest {
}
export interface ListQuestsResponse { export interface ListQuestsResponse {
"quests"?: Quest[]; "quests"?: Quest[];
} }
@@ -22,8 +21,8 @@ export interface Quest {
"id"?: string; "id"?: string;
"name"?: string; "name"?: string;
"objectives"?: QuestObjectiveProgress[]; "objectives"?: QuestObjectiveProgress[];
"rewards"?: QuestReward[]; "rewards"?: Reward[];
"status"?: string; "status"?: "active" | "claimed" | "completed";
} }
export interface QuestObjectiveProgress { export interface QuestObjectiveProgress {
@@ -34,12 +33,6 @@ export interface QuestObjectiveProgress {
"target"?: number; "target"?: number;
} }
export interface QuestReward {
"amount"?: number;
"currency"?: string;
"itemId"?: string;
}
export interface ReportQuestProgressRequest { export interface ReportQuestProgressRequest {
"amount"?: number; "amount"?: number;
"metric"?: string; "metric"?: string;
+1 -1
View File
@@ -14,6 +14,6 @@ export interface RemoteConfig {
"projectId"?: string; "projectId"?: string;
"updatedAt"?: string; "updatedAt"?: string;
"value"?: string; "value"?: string;
"valueType"?: string; "valueType"?: "bool" | "float" | "int" | "json" | "string";
} }
+1 -1
View File
@@ -9,7 +9,7 @@ export interface GetScenarioRunRequest {
export interface GetScenarioRunResponse { export interface GetScenarioRunResponse {
"plan"?: ExecutionPlan; "plan"?: ExecutionPlan;
"runId"?: string; "runId"?: string;
"status"?: string; "status"?: "active" | "completed" | "expired";
} }
export interface HandleScenarioCallbackRequest { export interface HandleScenarioCallbackRequest {
+3 -2
View File
@@ -22,11 +22,13 @@ export type {
AuthService, AuthService,
AuthState, AuthState,
AuthStateListener, AuthStateListener,
LoginWithCustomOptions,
LoginWithDeviceOptions, LoginWithDeviceOptions,
} from './auth/AuthService.js'; } from './auth/AuthService.js';
export type { LeaderboardsService, LeaderboardHandle } from './leaderboards/LeaderboardsService.js'; export type { LeaderboardsService, LeaderboardHandle } from './leaderboards/LeaderboardsService.js';
export type { BattlePassService } from './battlepass/BattlePassService.js'; export type { BattlePassService } from './battlepass/BattlePassService.js';
export type { QuestsService } from './quests/QuestsService.js'; export type { QuestsService } from './quests/QuestsService.js';
export * as QuestMetrics from './quests/QuestMetrics.js';
// Observable state primitives // Observable state primitives
export { SyncedState } from './state/SyncedState.js'; export { SyncedState } from './state/SyncedState.js';
@@ -87,14 +89,13 @@ export type {
ProjectStorageUpdateItem, ProjectStorageUpdateItem,
} from './generated/project-storage.js'; } from './generated/project-storage.js';
export type { RemoteConfig } from './generated/remote-config.js'; export type { RemoteConfig } from './generated/remote-config.js';
export type { Reward } from './generated/common.js';
export type { export type {
Quest, Quest,
QuestObjectiveProgress, QuestObjectiveProgress,
QuestReward,
ClaimQuestResponse, ClaimQuestResponse,
} from './generated/quests.js'; } from './generated/quests.js';
export type { export type {
BattlePassReward,
ClaimedTier, ClaimedTier,
AddBattlePassXpRequest, AddBattlePassXpRequest,
AddBattlePassXpResponse, AddBattlePassXpResponse,
+19
View File
@@ -0,0 +1,19 @@
/**
* QuestMetrics — builders for the quest metric strings known to the platform.
*
* Purchase metrics are reported automatically server-side by the shop
* purchase fan-out, so these helpers exist mainly to name the format
* instead of hardcoding it. Catalog counter slugs are reported via
* {@link QuestsService.reportProgress}. Custom free-text metrics no longer
* progress quests — they no-op at runtime.
*/
/** Metric for purchasing a store offer; auto-reported on purchase. */
export function purchaseOffer(offerId: string): string {
return `purchase.offer:${offerId}`;
}
/** Metric for purchasing a catalog item; auto-reported on purchase. */
export function purchaseItem(itemId: string): string {
return `purchase.item:${itemId}`;
}
+1 -1
View File
@@ -15,7 +15,7 @@ export class QuestsService {
/** Lists the player's quests with per-objective progress and rewards. */ /** Lists the player's quests with per-objective progress and rewards. */
async list(): Promise<Quest[]> { async list(): Promise<Quest[]> {
const response = await api.listQuests(this.ctx, {}); const response = await api.listQuests(this.ctx);
return response.quests ?? []; return response.quests ?? [];
} }
+12 -2
View File
@@ -61,6 +61,10 @@ function isTransientHttpError(err: unknown): boolean {
return false; return false;
} }
function isRankNotEligible(err: unknown): boolean {
return err instanceof RudderHttpError && err.code === 'rank_not_eligible';
}
export class ScenarioService<TConfig extends RemoteConfigShape = RemoteConfigShape> { export class ScenarioService<TConfig extends RemoteConfigShape = RemoteConfigShape> {
private readonly runs = new Map<string, RuntimeRun>(); private readonly runs = new Map<string, RuntimeRun>();
@@ -136,8 +140,9 @@ export class ScenarioService<TConfig extends RemoteConfigShape = RemoteConfigSha
try { try {
const response = await api.getScenarioRun(this.ctx, { runId: saved.runId }); const response = await api.getScenarioRun(this.ctx, { runId: saved.runId });
const status: string | undefined = response?.status;
if (response?.status === 'unknown_run' || response?.status === 'expired') { if (status === 'unknown_run' || status === 'expired') {
continue; continue;
} }
@@ -433,6 +438,10 @@ export class ScenarioService<TConfig extends RemoteConfigShape = RemoteConfigSha
await this.persist(); await this.persist();
} catch (err) { } catch (err) {
run.pendingTransitions--; run.pendingTransitions--;
if (isRankNotEligible(err)) {
await this.persist();
throw err;
}
this.failRun( this.failRun(
run, run,
nodeId, nodeId,
@@ -466,8 +475,9 @@ export class ScenarioService<TConfig extends RemoteConfigShape = RemoteConfigSha
let reconciled = false; let reconciled = false;
try { try {
const reconcile = await api.getScenarioRun(this.ctx, { runId: run.runId }); const reconcile = await api.getScenarioRun(this.ctx, { runId: run.runId });
const reconcileStatus: string | undefined = reconcile?.status;
if (reconcile?.status === 'unknown_run' || reconcile?.status === 'expired') { if (reconcileStatus === 'unknown_run' || reconcileStatus === 'expired') {
this.runs.delete(run.runId); this.runs.delete(run.runId);
await this.persist(); await this.persist();
reconciled = true; reconciled = true;
+9 -4
View File
@@ -180,14 +180,19 @@ export class LeaderboardSession {
async end(): Promise<void> { async end(): Promise<void> {
if (this.resolved) return; if (this.resolved) return;
this.resolved = true;
await this.context.complete('onEnd'); await this.context.complete('onEnd');
this.resolved = true;
} }
async rewardClaimed(): Promise<void> { async claim(): Promise<void> {
if (this.resolved) return; if (this.resolved) return;
await this.context.complete('onClaim');
this.resolved = true; this.resolved = true;
await this.context.complete('onRewardClaimed'); }
/** @deprecated Use {@link LeaderboardSession.claim}. Removed in the next SDK version. */
async rewardClaimed(): Promise<void> {
return this.claim();
} }
} }
@@ -278,7 +283,7 @@ export class BattlePassSession {
} }
/** Claims a tier reward at a reached level. */ /** Claims a tier reward at a reached level. */
claimReward(level: number, track?: string): Promise<ClaimBattlePassRewardResponse> { claimReward(level: number, track?: 'free' | 'premium'): Promise<ClaimBattlePassRewardResponse> {
const { scenarioId, nodeId, runId } = this.ids(); const { scenarioId, nodeId, runId } = this.ids();
return this.battlePass.claimReward({ scenarioId, nodeId, level, track, runId }); return this.battlePass.claimReward({ scenarioId, nodeId, level, track, runId });
} }
+9 -4
View File
@@ -133,7 +133,8 @@ export async function runSeed(
tags: ['currency'], tags: ['currency'],
}); });
// 5. Remote configs — one per value type. // 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.
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' },
@@ -142,9 +143,9 @@ export async function runSeed(
{ key: 'shop_layout', value: '{"cols":3}', valueType: 'json' }, { key: 'shop_layout', value: '{"cols":3}', valueType: 'json' },
]; ];
for (const rc of remoteConfigs) { for (const rc of remoteConfigs) {
await api.post(`${projPath}/remote-configs${q}`, { await api.post(`${projPath}/remote-configs${prodQ}`, {
projectId: project.id, projectId: project.id,
environment: authoringEnv, environment: runtimeEnv,
...rc, ...rc,
}); });
} }
@@ -227,7 +228,11 @@ export async function runSeed(
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 prod/latest.json).
const release = await api.post<ReleaseResponse>(`${projPath}/releases/promote`); const release = await api.post<ReleaseResponse>(`${projPath}/releases/promote`, {
projectId: project.id,
fromEnvironment: authoringEnv,
toEnvironment: runtimeEnv,
});
await pollRelease(api, projPath, runtimeEnv, release.id, log); await pollRelease(api, projPath, runtimeEnv, release.id, log);
const prodStores = await api.get<StoreResponse[]>(`${projPath}/stores${prodQ}`); const prodStores = await api.get<StoreResponse[]>(`${projPath}/stores${prodQ}`);
+2 -2
View File
@@ -49,7 +49,7 @@ describe('e2e-prod: purchase (wallet/inventory/limits)', () => {
const offer = await resolvePaidOffer(client); const offer = await resolvePaidOffer(client);
// 1. Grant currency via the new admin endpoint. // 1. Grant currency via the new admin endpoint.
await api.post(`/game/v1/projects/${artifact.projectId}/players/${playerId}/wallet/adjust`, { await api.post(`/platform/v1/projects/${artifact.projectId}/players/${playerId}/wallet/adjust`, {
currencyCode: currency, currencyCode: currency,
amount: 1000, amount: 1000,
reason: 'e2e grant', reason: 'e2e grant',
@@ -68,7 +68,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>(
`/game/v1/projects/${artifact.projectId}/players/${playerId}`, `/platform/v1/projects/${artifact.projectId}/players/${playerId}`,
); );
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);
+12 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeAll, vi } from 'vitest'; import { describe, it, expect, beforeAll, vi } from 'vitest';
import { loadArtifact, makeProdClient } from './_setup/harness.js'; import { adminApi, 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';
@@ -30,6 +30,17 @@ describe('e2e-prod: scenarios', () => {
}; };
await client.auth.loginWithDevice({ region: 'en', language: 'en' }); await client.auth.loginWithDevice({ region: 'en', language: 'en' });
// The boundary buy uses the paid offer — fund the wallet so the purchase succeeds.
const playerId = (await client.player.reload()).player!.id!;
await adminApi(artifact).post(
`/platform/v1/projects/${artifact.projectId}/players/${playerId}/wallet/adjust`,
{
currencyCode: artifact.store.paidPrice.currency,
amount: artifact.store.paidPrice.amount,
reason: 'e2e scenario funding',
},
);
// First notification dispatched. // First notification dispatched.
await vi.waitFor(() => expect(notifications.length).toBe(1), { timeout: 10_000 }); await vi.waitFor(() => expect(notifications.length).toBe(1), { timeout: 10_000 });
await notifications[0].done(); await notifications[0].done();
+1 -1
View File
@@ -16,7 +16,7 @@ interface GameRemoteConfig extends Record<string, unknown> {
missing_declared: string; missing_declared: string;
} }
function cfg(key: string, value: string, valueType: string, active = true): RemoteConfig { function cfg(key: string, value: string, valueType: NonNullable<RemoteConfig['valueType']>, active = true): RemoteConfig {
return { key, value, valueType, active }; return { key, value, valueType, active };
} }