feat: modules and resources domains, drop removed domains, regenerated models
This commit is contained in:
@@ -7,7 +7,6 @@
|
||||
*/
|
||||
|
||||
import type { RudderContext } from '../core/context.js';
|
||||
import type { RemoteConfigShape } from '../state/RemoteConfigState.js';
|
||||
import { getOrCreateDeviceId } from '../device/DeviceId.js';
|
||||
import { api } from '../generated/api.js';
|
||||
import type {
|
||||
@@ -72,7 +71,7 @@ export interface LinkWithCustomOptions {
|
||||
provider?: string;
|
||||
}
|
||||
|
||||
export class AuthService<TConfig extends RemoteConfigShape = RemoteConfigShape> {
|
||||
export class AuthService {
|
||||
private readonly listeners = new Set<AuthStateListener>();
|
||||
private state: AuthState;
|
||||
|
||||
@@ -118,6 +117,8 @@ export class AuthService<TConfig extends RemoteConfigShape = RemoteConfigShape>
|
||||
region,
|
||||
language,
|
||||
nickname,
|
||||
platform: 'web',
|
||||
appVersion: this.ctx.options.appVersion,
|
||||
});
|
||||
|
||||
this.ctx.options.tokenStore.saveTokens(
|
||||
@@ -145,6 +146,8 @@ export class AuthService<TConfig extends RemoteConfigShape = RemoteConfigShape>
|
||||
region,
|
||||
language,
|
||||
nickname,
|
||||
platform: 'web',
|
||||
appVersion: this.ctx.options.appVersion,
|
||||
});
|
||||
|
||||
this.ctx.options.tokenStore.saveTokens(
|
||||
@@ -169,6 +172,8 @@ export class AuthService<TConfig extends RemoteConfigShape = RemoteConfigShape>
|
||||
region,
|
||||
language,
|
||||
nickname,
|
||||
platform: 'web',
|
||||
appVersion: this.ctx.options.appVersion,
|
||||
});
|
||||
|
||||
this.ctx.options.tokenStore.saveTokens(
|
||||
@@ -193,6 +198,8 @@ export class AuthService<TConfig extends RemoteConfigShape = RemoteConfigShape>
|
||||
region,
|
||||
language,
|
||||
nickname,
|
||||
platform: 'web',
|
||||
appVersion: this.ctx.options.appVersion,
|
||||
});
|
||||
|
||||
this.ctx.options.tokenStore.saveTokens(
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
/**
|
||||
* BattlePassService — call-and-response access to the battle pass endpoints.
|
||||
*
|
||||
* Battle pass state is tied to a scenario battle pass node, so every call
|
||||
* carries a `scenarioSlug` + `nodeId` (and a `runId` for the mutating calls).
|
||||
* Battle pass has no sync-revision key, so this is a plain service, not an
|
||||
* observable domain; re-fetch progress explicitly after a mutation.
|
||||
*/
|
||||
|
||||
import type { RudderContext } from '../core/context.js';
|
||||
import { api } from '../generated/api.js';
|
||||
import type {
|
||||
AddBattlePassXpRequest,
|
||||
AddBattlePassXpResponse,
|
||||
ClaimBattlePassRewardRequest,
|
||||
ClaimBattlePassRewardResponse,
|
||||
PurchaseBattlePassPremiumRequest,
|
||||
PurchaseBattlePassPremiumResponse,
|
||||
GetBattlePassProgressResponse,
|
||||
} from '../generated/battlepass.js';
|
||||
|
||||
export class BattlePassService {
|
||||
constructor(private readonly ctx: RudderContext) {}
|
||||
|
||||
/** Reads current progress (xp, level, premium, claimed tiers) for a node. */
|
||||
getProgress(scenarioSlug: string, nodeId: string): Promise<GetBattlePassProgressResponse> {
|
||||
return api.getBattlePassProgress(this.ctx, { scenarioSlug, nodeId });
|
||||
}
|
||||
|
||||
/** Credits XP from a configured source. Returns the new xp/level. */
|
||||
addXp(request: AddBattlePassXpRequest): Promise<AddBattlePassXpResponse> {
|
||||
return api.addBattlePassXp(this.ctx, request);
|
||||
}
|
||||
|
||||
/** Claims a tier reward at a reached level (idempotent server-side). */
|
||||
claimReward(request: ClaimBattlePassRewardRequest): Promise<ClaimBattlePassRewardResponse> {
|
||||
return api.claimBattlePassReward(this.ctx, request);
|
||||
}
|
||||
|
||||
/** Purchases the premium track (charges the wallet, idempotent). */
|
||||
purchasePremium(
|
||||
request: PurchaseBattlePassPremiumRequest,
|
||||
): Promise<PurchaseBattlePassPremiumResponse> {
|
||||
return api.purchaseBattlePassPremium(this.ctx, request);
|
||||
}
|
||||
}
|
||||
+20
-112
@@ -1,16 +1,12 @@
|
||||
/**
|
||||
* RudderClient — the main entry point for the LiveOps Web SDK.
|
||||
*
|
||||
* Composes the observable domains, the scenario runtime, and the core HTTP
|
||||
* transport that every service uses to reach the LiveOps API gateway.
|
||||
* Composes the kernel domains (auth, player, catalog, storage, modules,
|
||||
* resources) and the HTTP transport every service uses to reach the API.
|
||||
*
|
||||
* The client builds a {@link RudderContext} first and injects it into every
|
||||
* domain and service, so they depend on that narrow surface rather than on the
|
||||
* client (which keeps the module graph acyclic). Cross-domain wiring (the
|
||||
* inventory↔catalog link, purchase invalidation) is done here at construction.
|
||||
*
|
||||
* The scenario effects client is loaded lazily on first use — a client that
|
||||
* never logs in never pays for the scenario machinery.
|
||||
* domain, so they depend on that narrow surface rather than on the client
|
||||
* (which keeps the module graph acyclic).
|
||||
*/
|
||||
|
||||
import type {
|
||||
@@ -22,45 +18,32 @@ import type { RudderContext } from '../core/context.js';
|
||||
import { request, type RequestContext } from '../transport/request.js';
|
||||
import { createDefaultTokenStore } from '../token/TokenStore.js';
|
||||
import { AuthService } from '../auth/AuthService.js';
|
||||
import { LeaderboardsService } from '../leaderboards/LeaderboardsService.js';
|
||||
import { BattlePassService } from '../battlepass/BattlePassService.js';
|
||||
import { QuestsService } from '../quests/QuestsService.js';
|
||||
import type { ScenarioService } from '../scenario/ScenarioService.js';
|
||||
import { EffectsCenter, type Effects } from '../effects/EffectsCenter.js';
|
||||
import { SyncEngine } from '../state/SyncEngine.js';
|
||||
import type { RemoteConfigShape } from '../state/RemoteConfigState.js';
|
||||
import { PlayerDomain } from '../domains/PlayerDomain.js';
|
||||
import { CatalogDomain } from '../domains/CatalogDomain.js';
|
||||
import { InventoryDomain } from '../domains/InventoryDomain.js';
|
||||
import { ConfigDomain } from '../domains/ConfigDomain.js';
|
||||
import { StorageDomain } from '../domains/StorageDomain.js';
|
||||
import { ProjectStorageDomain } from '../domains/ProjectStorageDomain.js';
|
||||
import { StoresDomain } from '../domains/StoresDomain.js';
|
||||
import { ModulesDomain } from '../domains/ModulesDomain.js';
|
||||
import { ResourcesDomain, type ResourceHandle } from '../domains/ResourcesDomain.js';
|
||||
|
||||
type ManagedDomain = { invalidate(): void; reset(): void };
|
||||
|
||||
export class RudderClient<TConfig extends RemoteConfigShape = RemoteConfigShape> {
|
||||
export class RudderClient {
|
||||
public readonly options: ResolvedRudderClientOptions;
|
||||
|
||||
public readonly auth: AuthService<TConfig>;
|
||||
public readonly leaderboards: LeaderboardsService;
|
||||
public readonly battlePass: BattlePassService;
|
||||
public readonly quests: QuestsService;
|
||||
public readonly auth: AuthService;
|
||||
public readonly effects: Effects;
|
||||
|
||||
public readonly player: PlayerDomain;
|
||||
public readonly catalog: CatalogDomain;
|
||||
public readonly inventory: InventoryDomain;
|
||||
public readonly remoteConfig: ConfigDomain<TConfig>;
|
||||
public readonly storage: StorageDomain;
|
||||
public readonly projectStorage: ProjectStorageDomain;
|
||||
public readonly stores: StoresDomain;
|
||||
public readonly modules: ModulesDomain;
|
||||
private readonly resourcesDomain: ResourcesDomain;
|
||||
|
||||
private readonly effectsCenter: EffectsCenter;
|
||||
private scenarioRuntime: ScenarioService | null = null;
|
||||
private scenarioRuntimePromise: Promise<ScenarioService> | null = null;
|
||||
private readonly syncEngine: SyncEngine;
|
||||
private readonly loginEvent: string | null;
|
||||
private readonly ctx: RudderContext;
|
||||
private readonly transportContext: RequestContext;
|
||||
private runtimeStarted = false;
|
||||
@@ -78,13 +61,12 @@ export class RudderClient<TConfig extends RemoteConfigShape = RemoteConfigShape>
|
||||
}
|
||||
|
||||
this.options = { ...options, tokenStore: options.tokenStore ?? createDefaultTokenStore() };
|
||||
this.loginEvent =
|
||||
options.runtime?.loginEvent === undefined ? 'player_login' : options.runtime.loginEvent;
|
||||
|
||||
this.transportContext = {
|
||||
baseUrl: this.options.baseUrl,
|
||||
tokenStore: this.options.tokenStore,
|
||||
requestTimeoutMs: this.options.requestTimeoutMs,
|
||||
appVersion: this.options.appVersion,
|
||||
refreshState: {},
|
||||
onAuthFailure: () => {
|
||||
this.auth.notifySessionExpired();
|
||||
@@ -105,29 +87,18 @@ export class RudderClient<TConfig extends RemoteConfigShape = RemoteConfigShape>
|
||||
|
||||
this.player = new PlayerDomain(ctx);
|
||||
this.catalog = new CatalogDomain(ctx);
|
||||
this.inventory = new InventoryDomain(ctx, this.catalog);
|
||||
this.remoteConfig = new ConfigDomain<TConfig>(ctx);
|
||||
this.storage = new StorageDomain(ctx);
|
||||
this.projectStorage = new ProjectStorageDomain(ctx);
|
||||
this.stores = new StoresDomain(ctx, () => {
|
||||
this.player.invalidate();
|
||||
this.inventory.invalidate();
|
||||
this.stores.invalidate();
|
||||
});
|
||||
this.modules = new ModulesDomain(ctx);
|
||||
this.resourcesDomain = new ResourcesDomain(ctx);
|
||||
|
||||
this.leaderboards = new LeaderboardsService(ctx);
|
||||
this.battlePass = new BattlePassService(ctx);
|
||||
this.quests = new QuestsService(ctx);
|
||||
this.syncEngine = new SyncEngine(ctx, [
|
||||
this.player,
|
||||
this.catalog,
|
||||
this.inventory,
|
||||
this.remoteConfig,
|
||||
this.storage,
|
||||
this.projectStorage,
|
||||
this.stores,
|
||||
]);
|
||||
this.auth = new AuthService<TConfig>(
|
||||
this.auth = new AuthService(
|
||||
ctx,
|
||||
() => this.startRuntimeAfterLogin(),
|
||||
() => this.stopRuntimeAfterLogout(),
|
||||
@@ -136,81 +107,33 @@ export class RudderClient<TConfig extends RemoteConfigShape = RemoteConfigShape>
|
||||
|
||||
dispose(): void {
|
||||
this.syncEngine.stop();
|
||||
this.scenarioRuntime?.clear();
|
||||
this.resetAll();
|
||||
this.runtimeStarted = false;
|
||||
}
|
||||
|
||||
resources(slug: string): ResourceHandle {
|
||||
return this.resourcesDomain.of(slug);
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
async startRuntimeAfterLogin(): Promise<void> {
|
||||
const runtimePromise = this.ensureScenarioRuntime();
|
||||
if (this.runtimeStarted) {
|
||||
this.scenarioRuntime?.clear();
|
||||
this.invalidateAll();
|
||||
}
|
||||
this.runtimeStarted = true;
|
||||
await Promise.all([
|
||||
this.remoteConfig.load(),
|
||||
this.player.load(),
|
||||
this.stores.load(),
|
||||
this.catalog.load(),
|
||||
]);
|
||||
const runtime = await runtimePromise;
|
||||
await runtime.start();
|
||||
if (this.loginEvent) {
|
||||
await this.sendRuntimeEvent(runtime, this.loginEvent);
|
||||
}
|
||||
await Promise.all([this.player.load(), this.catalog.load()]);
|
||||
this.syncEngine.start();
|
||||
}
|
||||
|
||||
private ensureScenarioRuntime(): Promise<ScenarioService> {
|
||||
this.scenarioRuntimePromise ??= this.createScenarioRuntime();
|
||||
return this.scenarioRuntimePromise;
|
||||
}
|
||||
|
||||
private async createScenarioRuntime(): Promise<ScenarioService> {
|
||||
const { ScenarioService } = await import('../scenario/ScenarioService.js');
|
||||
this.scenarioRuntime = new ScenarioService(
|
||||
this.ctx,
|
||||
{
|
||||
player: this.player,
|
||||
inventory: this.inventory,
|
||||
stores: this.stores,
|
||||
},
|
||||
this.battlePass,
|
||||
);
|
||||
return this.scenarioRuntime;
|
||||
}
|
||||
|
||||
private async sendRuntimeEvent(
|
||||
runtime: ScenarioService,
|
||||
event: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await runtime.send(event);
|
||||
} catch (error) {
|
||||
console.warn(`RudderClient: scenario event "${event}" failed after login`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
stopRuntimeAfterLogout(): void {
|
||||
this.runtimeStarted = false;
|
||||
this.syncEngine.stop();
|
||||
this.scenarioRuntime?.clear();
|
||||
this.resetAll();
|
||||
}
|
||||
|
||||
private get managedDomains(): ManagedDomain[] {
|
||||
return [
|
||||
this.player,
|
||||
this.catalog,
|
||||
this.inventory,
|
||||
this.remoteConfig,
|
||||
this.storage,
|
||||
this.projectStorage,
|
||||
this.stores,
|
||||
];
|
||||
return [this.player, this.catalog, this.storage, this.projectStorage];
|
||||
}
|
||||
|
||||
private invalidateAll(): void {
|
||||
@@ -221,18 +144,3 @@ export class RudderClient<TConfig extends RemoteConfigShape = RemoteConfigShape>
|
||||
for (const domain of this.managedDomains) domain.reset();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test/internal access to the scenario runtime, which is deliberately not part
|
||||
* of the public client surface (it is driven through `client.effects`).
|
||||
* Lazily loads the scenario effects client on first call.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export function getScenarioRuntime<TConfig extends RemoteConfigShape = RemoteConfigShape>(
|
||||
client: RudderClient<TConfig>,
|
||||
): Promise<ScenarioService> {
|
||||
return (
|
||||
client as unknown as { ensureScenarioRuntime(): Promise<ScenarioService> }
|
||||
).ensureScenarioRuntime();
|
||||
}
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import type { TokenStore } from '../token/TokenStore.js';
|
||||
|
||||
export interface RudderRuntimeOptions {
|
||||
loginEvent?: string | null;
|
||||
}
|
||||
|
||||
export interface RudderClientOptions {
|
||||
/** Base URL of the LiveOps API gateway (e.g., "https://api.rudder.build"). */
|
||||
baseUrl: string;
|
||||
@@ -18,6 +14,12 @@ export interface RudderClientOptions {
|
||||
*/
|
||||
tokenStore?: TokenStore;
|
||||
|
||||
/**
|
||||
* Game build version sent as `appVersion` on every login and token refresh
|
||||
* (used by segments). Omitted when not set.
|
||||
*/
|
||||
appVersion?: string;
|
||||
|
||||
/** Per-request timeout in milliseconds (default: 10000). */
|
||||
requestTimeoutMs?: number;
|
||||
|
||||
@@ -29,9 +31,6 @@ export interface RudderClientOptions {
|
||||
* Defaults to `console.error`.
|
||||
*/
|
||||
onEffectError?: (error: unknown) => void;
|
||||
|
||||
/** Advanced runtime knobs (login event). */
|
||||
runtime?: RudderRuntimeOptions;
|
||||
}
|
||||
|
||||
/** Client options with all defaults resolved — what `client.options` exposes. */
|
||||
|
||||
@@ -3,15 +3,13 @@
|
||||
*
|
||||
* All errors extend `RudderError`. Network errors, HTTP errors, and
|
||||
* authentication errors have dedicated subclasses for granular handling.
|
||||
* The machine-readable `code` is the server-provided {@link RudderErrorCode}
|
||||
* when known, or any other string for unknown servers / SDK-local codes
|
||||
* The machine-readable `code` is the server-provided code (open-ended: platform
|
||||
* codes and module business error codes) or an SDK-local code
|
||||
* (e.g. `sdk/invalid-options` for client configuration errors).
|
||||
*/
|
||||
|
||||
import type { RudderErrorCode } from '../generated/errors.js';
|
||||
|
||||
/** Code carried by SDK errors: a known server code, or any other string. */
|
||||
export type RudderErrorCodeLike = RudderErrorCode | (string & {});
|
||||
/** Code carried by SDK errors: a server code or an SDK-local code. */
|
||||
export type RudderErrorCodeLike = string;
|
||||
|
||||
/** Code for client-side configuration validation failures. */
|
||||
export const SDK_ERROR_INVALID_OPTIONS = 'sdk/invalid-options';
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
import { SyncedState } from '../state/SyncedState.js';
|
||||
import type { RudderContext } from '../core/context.js';
|
||||
import { api } from '../generated/api.js';
|
||||
import type { CatalogItem } from '../generated/catalog.js';
|
||||
import type { CatalogCurrency } from '../generated/catalog.js';
|
||||
|
||||
/**
|
||||
* CatalogDomain — the item catalog, keyed by slug. Synced under revision
|
||||
* key `catalog`. Feeds the merged {@link InventoryDomain} view.
|
||||
* CatalogDomain — currency definitions, keyed by slug. Synced under revision
|
||||
* key `catalog`.
|
||||
*/
|
||||
export class CatalogDomain extends SyncedState<Map<string, CatalogItem>> {
|
||||
export class CatalogDomain extends SyncedState<Map<string, CatalogCurrency>> {
|
||||
readonly syncKey = 'catalog';
|
||||
|
||||
constructor(ctx: RudderContext) {
|
||||
super(async () => {
|
||||
const response = await api.listCatalogItems(ctx);
|
||||
const map = new Map<string, CatalogItem>();
|
||||
for (const item of response.items ?? []) {
|
||||
if (!item.slug) continue;
|
||||
map.set(item.slug, item);
|
||||
const response = await api.listCatalogCurrencies(ctx);
|
||||
const map = new Map<string, CatalogCurrency>();
|
||||
for (const currency of response.currencies ?? []) {
|
||||
if (!currency.slug) continue;
|
||||
map.set(currency.slug, currency);
|
||||
}
|
||||
return map;
|
||||
});
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import { RemoteConfigState, type RemoteConfigShape } from '../state/RemoteConfigState.js';
|
||||
import type { RudderContext } from '../core/context.js';
|
||||
import { api } from '../generated/api.js';
|
||||
import type { RemoteConfig } from '../generated/remote-config.js';
|
||||
|
||||
/**
|
||||
* ConfigDomain — typed remote configuration as an observable entity. Synced
|
||||
* under revision key `config`. Extends {@link RemoteConfigState}, which adds
|
||||
* typed `get()` and scenario override support.
|
||||
*/
|
||||
export class ConfigDomain<
|
||||
TConfig extends RemoteConfigShape = RemoteConfigShape,
|
||||
> extends RemoteConfigState<TConfig> {
|
||||
readonly syncKey = 'config';
|
||||
|
||||
constructor(ctx: RudderContext) {
|
||||
super(async () => {
|
||||
const response = await api.listSdkRemoteConfigs(ctx);
|
||||
const map = new Map<string, RemoteConfig>();
|
||||
if (response.configs) {
|
||||
for (const [key, config] of Object.entries(response.configs)) {
|
||||
// The server already filters inactive configs out (the game cache skips
|
||||
// !active) and may omit `active` on the wire, so keep anything that is
|
||||
// not explicitly inactive instead of dropping configs that carry no flag.
|
||||
if (config && config.active !== false) {
|
||||
map.set(config.key ?? key, config);
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import { SyncedState } from '../state/SyncedState.js';
|
||||
import type { RudderContext } from '../core/context.js';
|
||||
import { api } from '../generated/api.js';
|
||||
import { mergeInventory, type InventoryItem } from '../state/inventory.js';
|
||||
import type { CatalogDomain } from './CatalogDomain.js';
|
||||
|
||||
/**
|
||||
* InventoryDomain — owned items merged with their catalog entries. Synced
|
||||
* under revision key `inventory`; also refreshes when the catalog changes.
|
||||
*/
|
||||
export class InventoryDomain extends SyncedState<InventoryItem[]> {
|
||||
readonly syncKey = 'inventory';
|
||||
|
||||
constructor(ctx: RudderContext, catalog: CatalogDomain) {
|
||||
super(async () => {
|
||||
const [response, catalogItems] = await Promise.all([
|
||||
api.getInventory(ctx),
|
||||
catalog.load(),
|
||||
]);
|
||||
return mergeInventory(response.items ?? [], catalogItems);
|
||||
});
|
||||
|
||||
// Catalog changes flow into the merged inventory view.
|
||||
let previousCatalog = catalog.data;
|
||||
catalog.onChange((snapshot) => {
|
||||
if (snapshot.data === previousCatalog) return;
|
||||
previousCatalog = snapshot.data;
|
||||
this.invalidate();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { RudderContext } from '../core/context.js';
|
||||
import { api } from '../generated/api.js';
|
||||
import type { CallModuleFunctionClientResponse } from '../generated/modules.js';
|
||||
|
||||
export class ModulesDomain {
|
||||
constructor(private readonly ctx: RudderContext) {}
|
||||
|
||||
call(module: string, fn: string, args?: unknown): Promise<CallModuleFunctionClientResponse> {
|
||||
return api.callModuleFunction(this.ctx, { module, fn }, { args });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { RudderContext } from '../core/context.js';
|
||||
import { api } from '../generated/api.js';
|
||||
import type {
|
||||
CreateResourceRowRequest,
|
||||
ListResourceRowsResponse,
|
||||
ResourceRow,
|
||||
UpdateResourceRowRequest,
|
||||
} from '../generated/resources.js';
|
||||
|
||||
export class ResourceHandle {
|
||||
constructor(
|
||||
private readonly ctx: RudderContext,
|
||||
private readonly slug: string,
|
||||
) {}
|
||||
|
||||
list(): Promise<ListResourceRowsResponse> {
|
||||
return api.listResourceRows(this.ctx, { slug: this.slug });
|
||||
}
|
||||
|
||||
get(id: string): Promise<ResourceRow> {
|
||||
return api.getResourceRow(this.ctx, { slug: this.slug, id });
|
||||
}
|
||||
|
||||
create(body: CreateResourceRowRequest): Promise<ResourceRow> {
|
||||
return api.createResourceRow(this.ctx, { slug: this.slug }, body);
|
||||
}
|
||||
|
||||
update(id: string, body: UpdateResourceRowRequest): Promise<ResourceRow> {
|
||||
return api.updateResourceRow(this.ctx, { slug: this.slug, id }, body);
|
||||
}
|
||||
|
||||
delete(id: string): Promise<void> {
|
||||
return api.deleteResourceRow(this.ctx, { slug: this.slug, id });
|
||||
}
|
||||
}
|
||||
|
||||
export class ResourcesDomain {
|
||||
constructor(private readonly ctx: RudderContext) {}
|
||||
|
||||
of(slug: string): ResourceHandle {
|
||||
return new ResourceHandle(this.ctx, slug);
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import { SyncedState } from '../state/SyncedState.js';
|
||||
import type { RudderContext } from '../core/context.js';
|
||||
import { api } from '../generated/api.js';
|
||||
import { ShopHandle, type BuyOptions, type PurchaseOffer } from '../state/shops.js';
|
||||
import type { PurchaseOfferResponse } from '../generated/stores.js';
|
||||
|
||||
/**
|
||||
* StoresDomain — the observable list of stores, plus the purchase executor
|
||||
* that every offer/store handle shares. Synced under revision key `stores`.
|
||||
*
|
||||
* A successful purchase runs `onPurchase`, which the client wires to invalidate
|
||||
* the profile, inventory, and store list so subscribers observe fresh data.
|
||||
*/
|
||||
export class StoresDomain extends SyncedState<ShopHandle[]> {
|
||||
readonly syncKey = 'stores';
|
||||
|
||||
/** Shared purchase executor: `stores.purchase(slug, offerSlug, opts)`. */
|
||||
readonly purchase: PurchaseOffer;
|
||||
|
||||
private readonly ctx: RudderContext;
|
||||
|
||||
constructor(ctx: RudderContext, onPurchase: () => void) {
|
||||
// Built before super() so it captures params, not `this`; the store/offer
|
||||
// handles created in the loader all delegate to this one executor.
|
||||
const purchase: PurchaseOffer = async (
|
||||
storeSlug: string,
|
||||
offerSlug: string,
|
||||
options: BuyOptions = {},
|
||||
): Promise<PurchaseOfferResponse> => {
|
||||
const response = await api.purchaseOffer(
|
||||
ctx,
|
||||
{ storeSlug, offerSlug },
|
||||
{
|
||||
storeSlug,
|
||||
offerSlug,
|
||||
idempotencyKey: options.idempotencyKey ?? crypto.randomUUID(),
|
||||
},
|
||||
);
|
||||
onPurchase();
|
||||
return response;
|
||||
};
|
||||
|
||||
super(async () => {
|
||||
const response = await api.listSdkStores(ctx);
|
||||
return (response.stores ?? []).map((store) => new ShopHandle(purchase, store));
|
||||
});
|
||||
|
||||
this.ctx = ctx;
|
||||
this.purchase = purchase;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a store by slug (cache-first). Used by scenario store nodes.
|
||||
* @internal
|
||||
*/
|
||||
async getBySlug(slug: string): Promise<ShopHandle> {
|
||||
const cached = this.data?.find((store) => store.slug === slug);
|
||||
if (cached) return cached;
|
||||
const store = await api.getStore(this.ctx, { slug });
|
||||
return new ShopHandle(this.purchase, store);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,3 @@
|
||||
import type { PurchaseOfferResponse } from '../generated/stores.js';
|
||||
import type { BuyOptions, OfferHandle, ShopHandle } from '../state/shops.js';
|
||||
import type {
|
||||
AddBattlePassXpResponse,
|
||||
ClaimBattlePassRewardResponse,
|
||||
GetBattlePassProgressResponse,
|
||||
PurchaseBattlePassPremiumResponse,
|
||||
} from '../generated/battlepass.js';
|
||||
|
||||
export type EffectUnsubscribe = () => void;
|
||||
|
||||
export interface NotificationEffect {
|
||||
@@ -15,25 +6,6 @@ export interface NotificationEffect {
|
||||
done(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface StoreOfferEffect {
|
||||
readonly store: ShopHandle;
|
||||
readonly offers: readonly OfferHandle[];
|
||||
readonly message?: string;
|
||||
buy(offer: OfferHandle, options?: BuyOptions): Promise<PurchaseOfferResponse>;
|
||||
dismiss(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface LeaderboardEffect {
|
||||
end(): Promise<void>;
|
||||
claim(): Promise<void>;
|
||||
/** @deprecated Use {@link LeaderboardEffect.claim}. Removed in the next SDK version. */
|
||||
rewardClaimed(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface ConfigChangedEffect {
|
||||
readonly key: string;
|
||||
}
|
||||
|
||||
export interface WaitEffect {
|
||||
readonly deadlineUtc: Date;
|
||||
}
|
||||
@@ -50,52 +22,20 @@ export interface ScenarioFailedEffect {
|
||||
readonly error: Error;
|
||||
}
|
||||
|
||||
export interface QuestEffect {
|
||||
readonly name: string;
|
||||
readonly objectives: ReadonlyArray<Record<string, unknown>>;
|
||||
reportProgress(objectiveId: string, amount?: number): Promise<void>;
|
||||
}
|
||||
|
||||
export interface BattlePassEffect {
|
||||
getProgress(): Promise<GetBattlePassProgressResponse>;
|
||||
addXp(source: string, amount: number): Promise<AddBattlePassXpResponse>;
|
||||
claimReward(level: number, track?: 'free' | 'premium'): Promise<ClaimBattlePassRewardResponse>;
|
||||
purchasePremium(): Promise<PurchaseBattlePassPremiumResponse>;
|
||||
levelUp(): Promise<void>;
|
||||
end(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface BattlePassLevelEffect {
|
||||
readonly level: number;
|
||||
claim(): Promise<void>;
|
||||
}
|
||||
|
||||
type EffectHandler<TEffect> = (effect: TEffect) => void | Promise<void>;
|
||||
|
||||
export interface Effects {
|
||||
onNotification(handler: EffectHandler<NotificationEffect>): EffectUnsubscribe;
|
||||
onStoreOffer(handler: EffectHandler<StoreOfferEffect>): EffectUnsubscribe;
|
||||
onLeaderboard(handler: EffectHandler<LeaderboardEffect>): EffectUnsubscribe;
|
||||
onConfigChanged(handler: EffectHandler<ConfigChangedEffect>): EffectUnsubscribe;
|
||||
onWait(handler: EffectHandler<WaitEffect>): EffectUnsubscribe;
|
||||
onScenarioCompleted(handler: EffectHandler<ScenarioCompletedEffect>): EffectUnsubscribe;
|
||||
onScenarioFailed(handler: EffectHandler<ScenarioFailedEffect>): EffectUnsubscribe;
|
||||
onQuest(handler: EffectHandler<QuestEffect>): EffectUnsubscribe;
|
||||
onBattlePass(handler: EffectHandler<BattlePassEffect>): EffectUnsubscribe;
|
||||
onBattlePassLevel(handler: EffectHandler<BattlePassLevelEffect>): EffectUnsubscribe;
|
||||
}
|
||||
|
||||
export class EffectsCenter implements Effects {
|
||||
private readonly notificationHandlers = new Set<EffectHandler<NotificationEffect>>();
|
||||
private readonly storeOfferHandlers = new Set<EffectHandler<StoreOfferEffect>>();
|
||||
private readonly leaderboardHandlers = new Set<EffectHandler<LeaderboardEffect>>();
|
||||
private readonly configChangedHandlers = new Set<EffectHandler<ConfigChangedEffect>>();
|
||||
private readonly waitHandlers = new Set<EffectHandler<WaitEffect>>();
|
||||
private readonly scenarioCompletedHandlers = new Set<EffectHandler<ScenarioCompletedEffect>>();
|
||||
private readonly scenarioFailedHandlers = new Set<EffectHandler<ScenarioFailedEffect>>();
|
||||
private readonly questHandlers = new Set<EffectHandler<QuestEffect>>();
|
||||
private readonly battlePassHandlers = new Set<EffectHandler<BattlePassEffect>>();
|
||||
private readonly battlePassLevelHandlers = new Set<EffectHandler<BattlePassLevelEffect>>();
|
||||
|
||||
constructor(
|
||||
private readonly onError: (error: unknown) => void = (error) =>
|
||||
@@ -106,18 +46,6 @@ export class EffectsCenter implements Effects {
|
||||
return this.addHandler(this.notificationHandlers, handler);
|
||||
}
|
||||
|
||||
onStoreOffer(handler: EffectHandler<StoreOfferEffect>): EffectUnsubscribe {
|
||||
return this.addHandler(this.storeOfferHandlers, handler);
|
||||
}
|
||||
|
||||
onLeaderboard(handler: EffectHandler<LeaderboardEffect>): EffectUnsubscribe {
|
||||
return this.addHandler(this.leaderboardHandlers, handler);
|
||||
}
|
||||
|
||||
onConfigChanged(handler: EffectHandler<ConfigChangedEffect>): EffectUnsubscribe {
|
||||
return this.addHandler(this.configChangedHandlers, handler);
|
||||
}
|
||||
|
||||
onWait(handler: EffectHandler<WaitEffect>): EffectUnsubscribe {
|
||||
return this.addHandler(this.waitHandlers, handler);
|
||||
}
|
||||
@@ -130,40 +58,11 @@ export class EffectsCenter implements Effects {
|
||||
return this.addHandler(this.scenarioFailedHandlers, handler);
|
||||
}
|
||||
|
||||
onQuest(handler: EffectHandler<QuestEffect>): EffectUnsubscribe {
|
||||
return this.addHandler(this.questHandlers, handler);
|
||||
}
|
||||
|
||||
onBattlePass(handler: EffectHandler<BattlePassEffect>): EffectUnsubscribe {
|
||||
return this.addHandler(this.battlePassHandlers, handler);
|
||||
}
|
||||
|
||||
onBattlePassLevel(handler: EffectHandler<BattlePassLevelEffect>): EffectUnsubscribe {
|
||||
return this.addHandler(this.battlePassLevelHandlers, handler);
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
emitNotification(effect: NotificationEffect): void {
|
||||
this.emit(this.notificationHandlers, effect);
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
emitStoreOffer(effect: StoreOfferEffect | Promise<StoreOfferEffect>): void {
|
||||
Promise.resolve(effect)
|
||||
.then((value) => this.emit(this.storeOfferHandlers, value))
|
||||
.catch((error) => this.onError(error));
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
emitLeaderboard(effect: LeaderboardEffect): void {
|
||||
this.emit(this.leaderboardHandlers, effect);
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
emitConfigChanged(key: string): void {
|
||||
this.emit(this.configChangedHandlers, { key });
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
emitWait(effect: WaitEffect): void {
|
||||
this.emit(this.waitHandlers, effect);
|
||||
@@ -179,21 +78,6 @@ export class EffectsCenter implements Effects {
|
||||
this.emit(this.scenarioFailedHandlers, effect);
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
emitQuest(effect: QuestEffect): void {
|
||||
this.emit(this.questHandlers, effect);
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
emitBattlePass(effect: BattlePassEffect): void {
|
||||
this.emit(this.battlePassHandlers, effect);
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
emitBattlePassLevel(effect: BattlePassLevelEffect): void {
|
||||
this.emit(this.battlePassLevelHandlers, effect);
|
||||
}
|
||||
|
||||
private addHandler<TEffect>(
|
||||
handlers: Set<EffectHandler<TEffect>>,
|
||||
handler: EffectHandler<TEffect>,
|
||||
|
||||
+33
-111
@@ -1,17 +1,12 @@
|
||||
// Code generated by apigen. DO NOT EDIT.
|
||||
|
||||
import type { LinkViaAppleRequest, LinkViaCustomRequest, LinkViaGoogleRequest, LoginViaAppleRequest, LoginViaAppleResponse, LoginViaCustomRequest, LoginViaCustomResponse, LoginViaDeviceRequest, LoginViaDeviceResponse, LoginViaGoogleRequest, LoginViaGoogleResponse, RefreshAccessTokenRequest, RefreshAccessTokenResponse, UnlinkIdentityRequest } from './auth.js';
|
||||
import type { AddBattlePassXpRequest, AddBattlePassXpResponse, ClaimBattlePassRewardRequest, ClaimBattlePassRewardResponse, GetBattlePassProgressRequest, GetBattlePassProgressResponse, PurchaseBattlePassPremiumRequest, PurchaseBattlePassPremiumResponse } from './battlepass.js';
|
||||
import type { ListCatalogItemsResponse } from './catalog.js';
|
||||
import type { GetInventoryResponse } from './inventory.js';
|
||||
import type { GetRankingResponse, SubmitScoreRequest } from './leaderboards.js';
|
||||
import type { ListCatalogCurrenciesResponse } from './catalog.js';
|
||||
import type { CallModuleFunctionClientResponse, CallModuleFunctionRequest } from './modules.js';
|
||||
import type { PlayerProfile } from './player.js';
|
||||
import type { GetProjectStorageResponse, UpdateProjectStorageRequest } from './project-storage.js';
|
||||
import type { ClaimQuestRequest, ClaimQuestResponse, ListQuestsResponse, ReportQuestProgressRequest, ReportQuestProgressResponse } from './quests.js';
|
||||
import type { ListRemoteConfigsResponse, RemoteConfig } from './remote-config.js';
|
||||
import type { HandleScenarioCallbackRequest, HandleScenarioCallbackResponse, ListPendingScenarioEffectsResponse, TriggerScenarioRequest, TriggerScenarioResponse, UpdateScenarioCounterRequest, UpdateScenarioCounterResponse } from './scenarios.js';
|
||||
import type { CreateResourceRowRequest, ListResourceRowsResponse, ResourceRow, UpdateResourceRowRequest } from './resources.js';
|
||||
import type { GetStorageResponse, UpdateStorageRequest } from './storage.js';
|
||||
import type { ListStoresResponse, PurchaseOfferRequest, PurchaseOfferResponse, Store } from './stores.js';
|
||||
|
||||
/** The minimal transport contract the generated API functions call through. */
|
||||
export interface Transport {
|
||||
@@ -36,23 +31,25 @@ function qs(
|
||||
|
||||
/** Typed request functions, one per SDK operation. */
|
||||
export const api = {
|
||||
addBattlePassXp: (
|
||||
callModuleFunction: (
|
||||
t: Transport,
|
||||
body: AddBattlePassXpRequest,
|
||||
): Promise<AddBattlePassXpResponse> =>
|
||||
t.request<AddBattlePassXpResponse>('POST', '/sdk/v1/battlepass/xp', body),
|
||||
path: { module: string; fn: string },
|
||||
body: CallModuleFunctionRequest,
|
||||
): Promise<CallModuleFunctionClientResponse> =>
|
||||
t.request<CallModuleFunctionClientResponse>('POST', `/sdk/v1/modules/${enc(path.module)}/${enc(path.fn)}`, body),
|
||||
|
||||
claimBattlePassReward: (
|
||||
createResourceRow: (
|
||||
t: Transport,
|
||||
body: ClaimBattlePassRewardRequest,
|
||||
): Promise<ClaimBattlePassRewardResponse> =>
|
||||
t.request<ClaimBattlePassRewardResponse>('POST', '/sdk/v1/battlepass/claim', body),
|
||||
path: { slug: string },
|
||||
body: CreateResourceRowRequest,
|
||||
): Promise<ResourceRow> =>
|
||||
t.request<ResourceRow>('POST', `/sdk/v1/resources/${enc(path.slug)}`, body),
|
||||
|
||||
claimQuest: (
|
||||
deleteResourceRow: (
|
||||
t: Transport,
|
||||
body: ClaimQuestRequest,
|
||||
): Promise<ClaimQuestResponse> =>
|
||||
t.request<ClaimQuestResponse>('POST', '/sdk/v1/quests/claim', body),
|
||||
path: { slug: string; id: string },
|
||||
): Promise<void> =>
|
||||
t.request<void>('DELETE', `/sdk/v1/resources/${enc(path.slug)}/${enc(path.id)}`),
|
||||
|
||||
deleteStorage: (
|
||||
t: Transport,
|
||||
@@ -60,17 +57,6 @@ export const api = {
|
||||
): Promise<void> =>
|
||||
t.request<void>('DELETE', `/sdk/v1/storage${qs([['type', query?.type]])}`),
|
||||
|
||||
getBattlePassProgress: (
|
||||
t: Transport,
|
||||
body: GetBattlePassProgressRequest,
|
||||
): Promise<GetBattlePassProgressResponse> =>
|
||||
t.request<GetBattlePassProgressResponse>('POST', '/sdk/v1/battlepass/progress', body),
|
||||
|
||||
getInventory: (
|
||||
t: Transport,
|
||||
): Promise<GetInventoryResponse> =>
|
||||
t.request<GetInventoryResponse>('GET', '/sdk/v1/inventory'),
|
||||
|
||||
getPlayerInformation: (
|
||||
t: Transport,
|
||||
): Promise<PlayerProfile> =>
|
||||
@@ -82,18 +68,11 @@ export const api = {
|
||||
): Promise<GetProjectStorageResponse> =>
|
||||
t.request<GetProjectStorageResponse>('GET', `/sdk/v1/project-storage${qs([['types', query?.types], ['limit', query?.limit], ['cursor', query?.cursor]])}`),
|
||||
|
||||
getRanking: (
|
||||
getResourceRow: (
|
||||
t: Transport,
|
||||
path: { slug: string },
|
||||
query?: { limit?: number },
|
||||
): Promise<GetRankingResponse> =>
|
||||
t.request<GetRankingResponse>('GET', `/sdk/v1/leaderboards/${enc(path.slug)}/ranking${qs([['limit', query?.limit]])}`),
|
||||
|
||||
getRemoteConfig: (
|
||||
t: Transport,
|
||||
path: { key: string },
|
||||
): Promise<RemoteConfig> =>
|
||||
t.request<RemoteConfig>('GET', `/sdk/v1/remote-configs/${enc(path.key)}`),
|
||||
path: { slug: string; id: string },
|
||||
): Promise<ResourceRow> =>
|
||||
t.request<ResourceRow>('GET', `/sdk/v1/resources/${enc(path.slug)}/${enc(path.id)}`),
|
||||
|
||||
getRevisions: (
|
||||
t: Transport,
|
||||
@@ -106,18 +85,6 @@ export const api = {
|
||||
): Promise<GetStorageResponse> =>
|
||||
t.request<GetStorageResponse>('GET', `/sdk/v1/storage${qs([['types', query?.types], ['limit', query?.limit], ['cursor', query?.cursor]])}`),
|
||||
|
||||
getStore: (
|
||||
t: Transport,
|
||||
path: { slug: string },
|
||||
): Promise<Store> =>
|
||||
t.request<Store>('GET', `/sdk/v1/stores/${enc(path.slug)}`),
|
||||
|
||||
handleScenarioCallback: (
|
||||
t: Transport,
|
||||
body: HandleScenarioCallbackRequest,
|
||||
): Promise<HandleScenarioCallbackResponse> =>
|
||||
t.request<HandleScenarioCallbackResponse>('POST', '/sdk/v1/scenarios/callback', body),
|
||||
|
||||
linkViaApple: (
|
||||
t: Transport,
|
||||
body: LinkViaAppleRequest,
|
||||
@@ -136,30 +103,16 @@ export const api = {
|
||||
): Promise<void> =>
|
||||
t.request<void>('POST', '/sdk/v1/authorization/google/link', body),
|
||||
|
||||
listCatalogItems: (
|
||||
listCatalogCurrencies: (
|
||||
t: Transport,
|
||||
): Promise<ListCatalogItemsResponse> =>
|
||||
t.request<ListCatalogItemsResponse>('GET', '/sdk/v1/catalog'),
|
||||
): Promise<ListCatalogCurrenciesResponse> =>
|
||||
t.request<ListCatalogCurrenciesResponse>('GET', '/sdk/v1/catalog'),
|
||||
|
||||
listPendingScenarioEffects: (
|
||||
listResourceRows: (
|
||||
t: Transport,
|
||||
): Promise<ListPendingScenarioEffectsResponse> =>
|
||||
t.request<ListPendingScenarioEffectsResponse>('GET', '/sdk/v1/scenarios/pending'),
|
||||
|
||||
listQuests: (
|
||||
t: Transport,
|
||||
): Promise<ListQuestsResponse> =>
|
||||
t.request<ListQuestsResponse>('POST', '/sdk/v1/quests/list'),
|
||||
|
||||
listSdkRemoteConfigs: (
|
||||
t: Transport,
|
||||
): Promise<ListRemoteConfigsResponse> =>
|
||||
t.request<ListRemoteConfigsResponse>('GET', '/sdk/v1/remote-configs'),
|
||||
|
||||
listSdkStores: (
|
||||
t: Transport,
|
||||
): Promise<ListStoresResponse> =>
|
||||
t.request<ListStoresResponse>('GET', '/sdk/v1/stores'),
|
||||
path: { slug: string },
|
||||
): Promise<ListResourceRowsResponse> =>
|
||||
t.request<ListResourceRowsResponse>('GET', `/sdk/v1/resources/${enc(path.slug)}`),
|
||||
|
||||
loginViaApple: (
|
||||
t: Transport,
|
||||
@@ -185,44 +138,12 @@ export const api = {
|
||||
): Promise<LoginViaGoogleResponse> =>
|
||||
t.request<LoginViaGoogleResponse>('POST', '/sdk/v1/authorization/google', body),
|
||||
|
||||
purchaseBattlePassPremium: (
|
||||
t: Transport,
|
||||
body: PurchaseBattlePassPremiumRequest,
|
||||
): Promise<PurchaseBattlePassPremiumResponse> =>
|
||||
t.request<PurchaseBattlePassPremiumResponse>('POST', '/sdk/v1/battlepass/premium', body),
|
||||
|
||||
purchaseOffer: (
|
||||
t: Transport,
|
||||
path: { storeSlug: string; offerSlug: string },
|
||||
body: PurchaseOfferRequest,
|
||||
): Promise<PurchaseOfferResponse> =>
|
||||
t.request<PurchaseOfferResponse>('POST', `/sdk/v1/stores/${enc(path.storeSlug)}/offers/${enc(path.offerSlug)}/purchase`, body),
|
||||
|
||||
refreshAccessToken: (
|
||||
t: Transport,
|
||||
body: RefreshAccessTokenRequest,
|
||||
): Promise<RefreshAccessTokenResponse> =>
|
||||
t.request<RefreshAccessTokenResponse>('POST', '/sdk/v1/authorization/refresh', body),
|
||||
|
||||
reportQuestProgress: (
|
||||
t: Transport,
|
||||
body: ReportQuestProgressRequest,
|
||||
): Promise<ReportQuestProgressResponse> =>
|
||||
t.request<ReportQuestProgressResponse>('POST', '/sdk/v1/quests/progress', body),
|
||||
|
||||
submitScore: (
|
||||
t: Transport,
|
||||
path: { slug: string },
|
||||
body: SubmitScoreRequest,
|
||||
): Promise<void> =>
|
||||
t.request<void>('POST', `/sdk/v1/leaderboards/${enc(path.slug)}/submit-score`, body),
|
||||
|
||||
triggerScenario: (
|
||||
t: Transport,
|
||||
body: TriggerScenarioRequest,
|
||||
): Promise<TriggerScenarioResponse> =>
|
||||
t.request<TriggerScenarioResponse>('POST', '/sdk/v1/scenarios/trigger', body),
|
||||
|
||||
unlinkIdentity: (
|
||||
t: Transport,
|
||||
body: UnlinkIdentityRequest,
|
||||
@@ -235,11 +156,12 @@ export const api = {
|
||||
): Promise<void> =>
|
||||
t.request<void>('PUT', '/sdk/v1/project-storage', body),
|
||||
|
||||
updateScenarioCounter: (
|
||||
updateResourceRow: (
|
||||
t: Transport,
|
||||
body: UpdateScenarioCounterRequest,
|
||||
): Promise<UpdateScenarioCounterResponse> =>
|
||||
t.request<UpdateScenarioCounterResponse>('POST', '/sdk/v1/scenarios/counter', body),
|
||||
path: { slug: string; id: string },
|
||||
body: UpdateResourceRowRequest,
|
||||
): Promise<ResourceRow> =>
|
||||
t.request<ResourceRow>('PATCH', `/sdk/v1/resources/${enc(path.slug)}/${enc(path.id)}`, body),
|
||||
|
||||
updateStorage: (
|
||||
t: Transport,
|
||||
|
||||
@@ -14,10 +14,12 @@ export interface LinkViaGoogleRequest {
|
||||
}
|
||||
|
||||
export interface LoginViaAppleRequest {
|
||||
"appVersion"?: string;
|
||||
"idToken": string;
|
||||
"key": string;
|
||||
"language": string;
|
||||
"nickname"?: string;
|
||||
"platform"?: string;
|
||||
"region": string;
|
||||
}
|
||||
|
||||
@@ -27,10 +29,12 @@ export interface LoginViaAppleResponse {
|
||||
}
|
||||
|
||||
export interface LoginViaCustomRequest {
|
||||
"appVersion"?: string;
|
||||
"customData"?: { [key: string]: unknown };
|
||||
"key"?: string;
|
||||
"language"?: string;
|
||||
"nickname"?: string;
|
||||
"platform"?: string;
|
||||
"provider"?: string;
|
||||
"region"?: string;
|
||||
}
|
||||
@@ -41,10 +45,12 @@ export interface LoginViaCustomResponse {
|
||||
}
|
||||
|
||||
export interface LoginViaDeviceRequest {
|
||||
"appVersion"?: string;
|
||||
"deviceId"?: string;
|
||||
"key"?: string;
|
||||
"language"?: string;
|
||||
"nickname"?: string;
|
||||
"platform"?: string;
|
||||
"region"?: string;
|
||||
}
|
||||
|
||||
@@ -54,10 +60,12 @@ export interface LoginViaDeviceResponse {
|
||||
}
|
||||
|
||||
export interface LoginViaGoogleRequest {
|
||||
"appVersion"?: string;
|
||||
"idToken": string;
|
||||
"key": string;
|
||||
"language": string;
|
||||
"nickname"?: string;
|
||||
"platform"?: string;
|
||||
"region": string;
|
||||
}
|
||||
|
||||
@@ -67,6 +75,8 @@ export interface LoginViaGoogleResponse {
|
||||
}
|
||||
|
||||
export interface RefreshAccessTokenRequest {
|
||||
"appVersion"?: string;
|
||||
"platform"?: string;
|
||||
"refreshToken"?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
// Code generated by apigen. DO NOT EDIT.
|
||||
|
||||
import type { Reward } from './common.js';
|
||||
|
||||
export interface AddBattlePassXpRequest {
|
||||
"amount"?: number;
|
||||
"nodeId"?: string;
|
||||
"runId"?: string;
|
||||
"scenarioSlug"?: string;
|
||||
"source"?: string;
|
||||
}
|
||||
|
||||
export interface AddBattlePassXpResponse {
|
||||
"level"?: number;
|
||||
"leveledUp"?: boolean;
|
||||
"maxLevel"?: boolean;
|
||||
"xp"?: number;
|
||||
}
|
||||
|
||||
export interface ClaimBattlePassRewardRequest {
|
||||
"level"?: number;
|
||||
"nodeId"?: string;
|
||||
"runId"?: string;
|
||||
"scenarioSlug"?: string;
|
||||
"track"?: "free" | "premium";
|
||||
}
|
||||
|
||||
export interface ClaimBattlePassRewardResponse {
|
||||
"alreadyClaimed"?: boolean;
|
||||
"error"?: string;
|
||||
"granted"?: Reward[];
|
||||
"success"?: boolean;
|
||||
}
|
||||
|
||||
export interface ClaimedTier {
|
||||
"level"?: number;
|
||||
"track"?: string;
|
||||
}
|
||||
|
||||
export interface GetBattlePassProgressRequest {
|
||||
"nodeId"?: string;
|
||||
"scenarioSlug"?: string;
|
||||
}
|
||||
|
||||
export interface GetBattlePassProgressResponse {
|
||||
"claimedTiers"?: ClaimedTier[];
|
||||
"level"?: number;
|
||||
"premiumOwned"?: boolean;
|
||||
"xp"?: number;
|
||||
}
|
||||
|
||||
export interface PurchaseBattlePassPremiumRequest {
|
||||
"idempotencyKey"?: string;
|
||||
"nodeId"?: string;
|
||||
"runId"?: string;
|
||||
"scenarioSlug"?: string;
|
||||
}
|
||||
|
||||
export interface PurchaseBattlePassPremiumResponse {
|
||||
"error"?: string;
|
||||
"success"?: boolean;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
// Code generated by apigen. DO NOT EDIT.
|
||||
|
||||
export interface CatalogItem {
|
||||
export interface CatalogCurrency {
|
||||
"data"?: { [key: string]: unknown };
|
||||
"icon"?: string;
|
||||
"name"?: string;
|
||||
"properties"?: { [key: string]: unknown };
|
||||
"slug"?: string;
|
||||
"tags"?: string[];
|
||||
}
|
||||
|
||||
export interface ListCatalogItemsResponse {
|
||||
"items"?: CatalogItem[];
|
||||
export interface ListCatalogCurrenciesResponse {
|
||||
"currencies"?: CatalogCurrency[];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
// Code generated by apigen. DO NOT EDIT.
|
||||
|
||||
import type { ResourceRowUsage } from './resources.js';
|
||||
|
||||
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"?: "forbidden" | "row_referenced";
|
||||
"error"?: string;
|
||||
"index"?: number;
|
||||
"path"?: string;
|
||||
"playerId"?: string;
|
||||
"requestId"?: string;
|
||||
}
|
||||
|
||||
export interface Reward {
|
||||
"amount"?: number;
|
||||
"currency"?: string;
|
||||
"itemId"?: string;
|
||||
"usages"?: ResourceRowUsage[];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,8 @@
|
||||
// Code generated by apigen. DO NOT EDIT.
|
||||
|
||||
export type RudderErrorCode = "early_completion" | "forbidden" | "level_not_reached" | "node_not_active" | "objectives_incomplete" | "run_expired" | "run_not_active" | "scenario_not_active" | "unknown_run";
|
||||
export type RudderErrorCode = "forbidden" | "row_referenced";
|
||||
|
||||
export const RudderErrorCodes = {
|
||||
earlyCompletion: "early_completion",
|
||||
forbidden: "forbidden",
|
||||
levelNotReached: "level_not_reached",
|
||||
nodeNotActive: "node_not_active",
|
||||
objectivesIncomplete: "objectives_incomplete",
|
||||
runExpired: "run_expired",
|
||||
runNotActive: "run_not_active",
|
||||
scenarioNotActive: "scenario_not_active",
|
||||
unknownRun: "unknown_run",
|
||||
rowReferenced: "row_referenced",
|
||||
} as const;
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
// Code generated by apigen. DO NOT EDIT.
|
||||
|
||||
export * from './auth.js';
|
||||
export * from './battlepass.js';
|
||||
export * from './catalog.js';
|
||||
export * from './common.js';
|
||||
export * from './inventory.js';
|
||||
export * from './leaderboards.js';
|
||||
export * from './modules.js';
|
||||
export * from './player.js';
|
||||
export * from './project-storage.js';
|
||||
export * from './quests.js';
|
||||
export * from './remote-config.js';
|
||||
export * from './scenarios.js';
|
||||
export * from './resources.js';
|
||||
export * from './storage.js';
|
||||
export * from './stores.js';
|
||||
export * from './api.js';
|
||||
export * from './errors.js';
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
// Code generated by apigen. DO NOT EDIT.
|
||||
|
||||
export interface GetInventoryResponse {
|
||||
"items"?: PlayerInventoryItem[];
|
||||
}
|
||||
|
||||
export interface PlayerInventoryItem {
|
||||
"amount"?: number;
|
||||
"nameOverride"?: string;
|
||||
"propertiesOverride"?: { [key: string]: unknown };
|
||||
"slug"?: string;
|
||||
"updatedAt"?: string;
|
||||
}
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
// Code generated by apigen. DO NOT EDIT.
|
||||
|
||||
export interface GetRankingResponse {
|
||||
"entries"?: RankEntry[];
|
||||
"total"?: number;
|
||||
}
|
||||
|
||||
export interface RankEntry {
|
||||
"playerId"?: string;
|
||||
"playerName"?: string;
|
||||
"rank"?: number;
|
||||
"score"?: number;
|
||||
}
|
||||
|
||||
export interface SubmitScoreRequest {
|
||||
"score"?: number;
|
||||
"slug"?: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
// Code generated by apigen. DO NOT EDIT.
|
||||
|
||||
export interface CallModuleFunctionClientResponse {
|
||||
"result"?: unknown;
|
||||
}
|
||||
|
||||
export interface CallModuleFunctionRequest {
|
||||
"args"?: unknown;
|
||||
"dryRun"?: boolean;
|
||||
}
|
||||
|
||||
export interface ModuleErrorResponse {
|
||||
"code": string;
|
||||
"error": string;
|
||||
"requestId"?: string;
|
||||
}
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
// Code generated by apigen. DO NOT EDIT.
|
||||
|
||||
import type { Reward } from './common.js';
|
||||
|
||||
export interface ClaimQuestRequest {
|
||||
"questSlug"?: string;
|
||||
}
|
||||
|
||||
export interface ClaimQuestResponse {
|
||||
"alreadyClaimed"?: boolean;
|
||||
"error"?: string;
|
||||
"granted"?: Reward[];
|
||||
"success"?: boolean;
|
||||
}
|
||||
|
||||
export interface ListQuestsResponse {
|
||||
"quests"?: Quest[];
|
||||
}
|
||||
|
||||
export interface Quest {
|
||||
"name"?: string;
|
||||
"objectives"?: QuestObjectiveProgress[];
|
||||
"rewards"?: Reward[];
|
||||
"slug"?: string;
|
||||
"status"?: "active" | "claimed" | "completed";
|
||||
}
|
||||
|
||||
export interface QuestObjectiveProgress {
|
||||
"completed"?: boolean;
|
||||
"current"?: number;
|
||||
"metric"?: string;
|
||||
"objectiveId"?: string;
|
||||
"target"?: number;
|
||||
}
|
||||
|
||||
export interface ReportQuestProgressRequest {
|
||||
"amount"?: number;
|
||||
"metric"?: string;
|
||||
}
|
||||
|
||||
export interface ReportQuestProgressResponse {
|
||||
"completedQuestSlugs"?: string[];
|
||||
}
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
// Code generated by apigen. DO NOT EDIT.
|
||||
|
||||
export interface ListRemoteConfigsResponse {
|
||||
"configs"?: { [key: string]: RemoteConfig };
|
||||
}
|
||||
|
||||
export interface RemoteConfig {
|
||||
"active"?: boolean;
|
||||
"createdAt"?: string;
|
||||
"description"?: string;
|
||||
"environment"?: string;
|
||||
"id"?: string;
|
||||
"key"?: string;
|
||||
"projectId"?: string;
|
||||
"updatedAt"?: string;
|
||||
"value"?: string;
|
||||
"valueType"?: "bool" | "float" | "int" | "json" | "string";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
// Code generated by apigen. DO NOT EDIT.
|
||||
|
||||
export interface CreateResourceRowRequest {
|
||||
"data"?: { [key: string]: unknown };
|
||||
"id"?: string;
|
||||
}
|
||||
|
||||
export interface ListResourceRowsResponse {
|
||||
"items"?: ResourceRow[];
|
||||
"total"?: number;
|
||||
}
|
||||
|
||||
export interface ResourceRow {
|
||||
"createdAt"?: string;
|
||||
"data"?: { [key: string]: unknown };
|
||||
"id"?: string;
|
||||
"playerId"?: string;
|
||||
"rank"?: number;
|
||||
"updatedAt"?: string;
|
||||
"version"?: number;
|
||||
}
|
||||
|
||||
export interface ResourceRowUsage {
|
||||
"definitionSlug"?: string;
|
||||
"field"?: string;
|
||||
"moduleSlug"?: string;
|
||||
"rowId"?: string;
|
||||
}
|
||||
|
||||
export interface UpdateResourceRowRequest {
|
||||
"data"?: { [key: string]: unknown };
|
||||
"version"?: number;
|
||||
}
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
// Code generated by apigen. DO NOT EDIT.
|
||||
|
||||
export interface HandleScenarioCallbackRequest {
|
||||
"handle"?: string;
|
||||
"nodeId"?: string;
|
||||
"runId"?: string;
|
||||
"scenarioSlug"?: string;
|
||||
}
|
||||
|
||||
export interface HandleScenarioCallbackResponse {
|
||||
"effect"?: PendingEffect;
|
||||
}
|
||||
|
||||
export interface ListPendingScenarioEffectsResponse {
|
||||
"effects": PendingEffect[];
|
||||
}
|
||||
|
||||
export interface PendingEffect {
|
||||
"data": { [key: string]: unknown };
|
||||
"nodeId": string;
|
||||
"runId": string;
|
||||
"scenarioSlug": string;
|
||||
"type": string;
|
||||
"waitDeadline"?: string;
|
||||
}
|
||||
|
||||
export interface TriggerScenarioRequest {
|
||||
"event"?: string;
|
||||
}
|
||||
|
||||
export interface TriggerScenarioResponse {
|
||||
"effects": PendingEffect[];
|
||||
}
|
||||
|
||||
export interface UpdateScenarioCounterRequest {
|
||||
"amount"?: number;
|
||||
"counterKey"?: string;
|
||||
"nodeId"?: string;
|
||||
"runId"?: string;
|
||||
"scenarioSlug"?: string;
|
||||
}
|
||||
|
||||
export interface UpdateScenarioCounterResponse {
|
||||
"completed"?: boolean;
|
||||
"effect"?: PendingEffect;
|
||||
}
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
// Code generated by apigen. DO NOT EDIT.
|
||||
|
||||
export interface ListStoresResponse {
|
||||
"stores"?: Store[];
|
||||
"total"?: number;
|
||||
}
|
||||
|
||||
export interface Offer {
|
||||
"contents"?: OfferContent[];
|
||||
"createdAt"?: string;
|
||||
"id"?: string;
|
||||
"maxPurchases"?: number;
|
||||
"name"?: string;
|
||||
"price"?: OfferPrice;
|
||||
"slug"?: string;
|
||||
"updatedAt"?: string;
|
||||
}
|
||||
|
||||
export interface OfferContent {
|
||||
"amount"?: number;
|
||||
"itemId"?: string;
|
||||
}
|
||||
|
||||
export interface OfferPrice {
|
||||
"amount"?: number;
|
||||
"currency"?: string;
|
||||
}
|
||||
|
||||
export interface PurchaseOfferRequest {
|
||||
"idempotencyKey"?: string;
|
||||
"offerSlug"?: string;
|
||||
"storeSlug"?: string;
|
||||
}
|
||||
|
||||
export interface PurchaseOfferResponse {
|
||||
"error"?: string;
|
||||
"purchaseId"?: string;
|
||||
"success"?: boolean;
|
||||
}
|
||||
|
||||
export interface Store {
|
||||
"createdAt"?: string;
|
||||
"data"?: { [key: string]: unknown };
|
||||
"description"?: string;
|
||||
"environment"?: string;
|
||||
"id"?: string;
|
||||
"name"?: string;
|
||||
"offers"?: Offer[];
|
||||
"projectId"?: string;
|
||||
"scenarioId"?: string;
|
||||
"slug"?: string;
|
||||
"status"?: string;
|
||||
"updatedAt"?: string;
|
||||
}
|
||||
|
||||
+13
-49
@@ -16,8 +16,7 @@ export type { RudderErrorCodeLike } from './client/RudderError.js';
|
||||
export type { TokenStore } from './token/TokenStore.js';
|
||||
export { createDefaultTokenStore, createLocalStorageTokenStore } from './token/TokenStore.js';
|
||||
|
||||
// Services — types only; instances live on the client
|
||||
// (client.auth, client.leaderboards, client.battlePass, client.quests).
|
||||
// Services — types only; instances live on the client (client.auth).
|
||||
export type {
|
||||
AuthService,
|
||||
AuthState,
|
||||
@@ -28,10 +27,6 @@ export type {
|
||||
LoginWithDeviceOptions,
|
||||
LoginWithGoogleOptions,
|
||||
} from './auth/AuthService.js';
|
||||
export type { LeaderboardsService, LeaderboardHandle } from './leaderboards/LeaderboardsService.js';
|
||||
export type { BattlePassService } from './battlepass/BattlePassService.js';
|
||||
export type { QuestsService } from './quests/QuestsService.js';
|
||||
export * as QuestMetrics from './quests/QuestMetrics.js';
|
||||
|
||||
// Observable state primitives
|
||||
export { SyncedState } from './state/SyncedState.js';
|
||||
@@ -40,74 +35,43 @@ export type {
|
||||
SyncedStatus,
|
||||
SyncedListener,
|
||||
} from './state/SyncedState.js';
|
||||
export { RemoteConfigState } from './state/RemoteConfigState.js';
|
||||
export type { RemoteConfigShape } from './state/RemoteConfigState.js';
|
||||
export { ShopHandle, OfferHandle } from './state/shops.js';
|
||||
export type { BuyOptions } from './state/shops.js';
|
||||
export type { InventoryItem } from './state/inventory.js';
|
||||
|
||||
// Domains — types only; instances live on the client
|
||||
// (client.player, client.stores, client.remoteConfig, …).
|
||||
// (client.player, client.modules, client.resources(slug), …).
|
||||
export type { PlayerDomain } from './domains/PlayerDomain.js';
|
||||
export type { CatalogDomain } from './domains/CatalogDomain.js';
|
||||
export type { InventoryDomain } from './domains/InventoryDomain.js';
|
||||
export type { ConfigDomain } from './domains/ConfigDomain.js';
|
||||
export type { ModulesDomain } from './domains/ModulesDomain.js';
|
||||
export type { ResourcesDomain, ResourceHandle } from './domains/ResourcesDomain.js';
|
||||
export type { StorageDomain } from './domains/StorageDomain.js';
|
||||
export type { ProjectStorageDomain } from './domains/ProjectStorageDomain.js';
|
||||
export type { StoresDomain } from './domains/StoresDomain.js';
|
||||
|
||||
// Effects
|
||||
export type {
|
||||
ConfigChangedEffect,
|
||||
Effects,
|
||||
EffectUnsubscribe,
|
||||
LeaderboardEffect,
|
||||
NotificationEffect,
|
||||
StoreOfferEffect,
|
||||
WaitEffect,
|
||||
ScenarioCompletedEffect,
|
||||
ScenarioFailedEffect,
|
||||
QuestEffect,
|
||||
BattlePassEffect,
|
||||
BattlePassLevelEffect,
|
||||
} from './effects/EffectsCenter.js';
|
||||
|
||||
// Generated wire types — the shapes returned by the observable state and
|
||||
// handles, so consumers can name them instead of re-declaring mirrors.
|
||||
export type { Player, PlayerProfile, PlayerProfileIdentity, Wallet } from './generated/player.js';
|
||||
export type { CatalogItem } from './generated/catalog.js';
|
||||
export type { PlayerInventoryItem } from './generated/inventory.js';
|
||||
export type {
|
||||
Store,
|
||||
Offer,
|
||||
OfferContent,
|
||||
OfferPrice,
|
||||
PurchaseOfferResponse,
|
||||
} from './generated/stores.js';
|
||||
export type { RankEntry } from './generated/leaderboards.js';
|
||||
export type { CatalogCurrency } from './generated/catalog.js';
|
||||
export type { StorageItem, GetStorageResponse } from './generated/storage.js';
|
||||
export type {
|
||||
ProjectStorageItem,
|
||||
GetProjectStorageResponse,
|
||||
ProjectStorageUpdateItem,
|
||||
} from './generated/project-storage.js';
|
||||
export type { RemoteConfig } from './generated/remote-config.js';
|
||||
export type { Reward } from './generated/common.js';
|
||||
export type {
|
||||
Quest,
|
||||
QuestObjectiveProgress,
|
||||
ClaimQuestResponse,
|
||||
} from './generated/quests.js';
|
||||
CallModuleFunctionRequest,
|
||||
CallModuleFunctionClientResponse,
|
||||
} from './generated/modules.js';
|
||||
export type {
|
||||
ClaimedTier,
|
||||
AddBattlePassXpRequest,
|
||||
AddBattlePassXpResponse,
|
||||
ClaimBattlePassRewardRequest,
|
||||
ClaimBattlePassRewardResponse,
|
||||
PurchaseBattlePassPremiumRequest,
|
||||
PurchaseBattlePassPremiumResponse,
|
||||
GetBattlePassProgressRequest,
|
||||
GetBattlePassProgressResponse,
|
||||
} from './generated/battlepass.js';
|
||||
export type { RudderErrorCode } from './generated/errors.js';
|
||||
export { RudderErrorCodes } from './generated/errors.js';
|
||||
ResourceRow,
|
||||
ListResourceRowsResponse,
|
||||
CreateResourceRowRequest,
|
||||
UpdateResourceRowRequest,
|
||||
} from './generated/resources.js';
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import type { RudderContext } from '../core/context.js';
|
||||
import { api } from '../generated/api.js';
|
||||
import type { RankEntry } from '../generated/leaderboards.js';
|
||||
|
||||
/**
|
||||
* A cached handle for a specific leaderboard.
|
||||
*
|
||||
* Created by `LeaderboardsService.findBySlug()` — subsequent calls with
|
||||
* the same slug return the same handle, avoiding redundant fetches.
|
||||
*/
|
||||
export class LeaderboardHandle {
|
||||
private entries: RankEntry[] = [];
|
||||
|
||||
constructor(
|
||||
public readonly slug: string,
|
||||
private readonly ctx: RudderContext,
|
||||
) {}
|
||||
|
||||
getEntries(): readonly RankEntry[] {
|
||||
return this.entries;
|
||||
}
|
||||
|
||||
async submit(score: number): Promise<void> {
|
||||
return api.submitScore(this.ctx, { slug: this.slug }, { slug: this.slug, score });
|
||||
}
|
||||
|
||||
async list(limit = 100): Promise<readonly RankEntry[]> {
|
||||
const response = await api.getRanking(
|
||||
this.ctx,
|
||||
{ slug: this.slug },
|
||||
{ limit: limit > 0 ? limit : undefined },
|
||||
);
|
||||
this.entries = response.entries ?? [];
|
||||
return this.entries;
|
||||
}
|
||||
}
|
||||
|
||||
export class LeaderboardsService {
|
||||
private readonly cache = new Map<string, LeaderboardHandle>();
|
||||
|
||||
constructor(private readonly ctx: RudderContext) {}
|
||||
|
||||
/** Returns a cached handle for the given leaderboard slug. */
|
||||
findBySlug(slug: string): LeaderboardHandle {
|
||||
let handle = this.cache.get(slug);
|
||||
if (!handle) {
|
||||
handle = new LeaderboardHandle(slug, this.ctx);
|
||||
this.cache.set(slug, handle);
|
||||
}
|
||||
return handle;
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
/**
|
||||
* 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(offerSlug: string): string {
|
||||
return `purchase.offer:${offerSlug}`;
|
||||
}
|
||||
|
||||
/** Metric for purchasing a catalog item; auto-reported on purchase. */
|
||||
export function purchaseItem(itemId: string): string {
|
||||
return `purchase.item:${itemId}`;
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
/**
|
||||
* QuestsService — call-and-response access to the global quest endpoints.
|
||||
*
|
||||
* These are the player's global quests (list + claim), distinct from scenario
|
||||
* quest nodes (which advance via {@link QuestSession}). Global quests have no
|
||||
* sync-revision key, so this is a plain service; re-list after a claim.
|
||||
*/
|
||||
|
||||
import type { RudderContext } from '../core/context.js';
|
||||
import { api } from '../generated/api.js';
|
||||
import type { Quest, ClaimQuestResponse } from '../generated/quests.js';
|
||||
|
||||
export class QuestsService {
|
||||
constructor(private readonly ctx: RudderContext) {}
|
||||
|
||||
/** Lists the player's quests with per-objective progress and rewards. */
|
||||
async list(): Promise<Quest[]> {
|
||||
const response = await api.listQuests(this.ctx);
|
||||
return response.quests ?? [];
|
||||
}
|
||||
|
||||
/** Claims a completed quest's rewards (idempotent server-side). */
|
||||
claim(questSlug: string): Promise<ClaimQuestResponse> {
|
||||
return api.claimQuest(this.ctx, { questSlug });
|
||||
}
|
||||
|
||||
/** Reports metric progress; returns ids of quests completed by this report. */
|
||||
async reportProgress(metric: string, amount: number): Promise<string[]> {
|
||||
const response = await api.reportQuestProgress(this.ctx, { metric, amount });
|
||||
return response.completedQuestSlugs ?? [];
|
||||
}
|
||||
}
|
||||
@@ -1,454 +0,0 @@
|
||||
import type { RudderContext } from '../core/context.js';
|
||||
import type { ShopHandle, OfferHandle, BuyOptions } from '../state/shops.js';
|
||||
import type { PurchaseOfferResponse } from '../generated/stores.js';
|
||||
import { api } from '../generated/api.js';
|
||||
import type { PendingEffect, TriggerScenarioResponse } from '../generated/scenarios.js';
|
||||
import { RudderErrorCodes } from '../generated/errors.js';
|
||||
import {
|
||||
RudderNetworkError,
|
||||
RudderHttpError,
|
||||
} from '../client/RudderError.js';
|
||||
import type { BattlePassService } from '../battlepass/BattlePassService.js';
|
||||
|
||||
export interface ScenarioDomains {
|
||||
readonly player: { invalidate(): void };
|
||||
readonly inventory: { invalidate(): void };
|
||||
readonly stores: { getBySlug(slug: string): Promise<ShopHandle> };
|
||||
}
|
||||
|
||||
const DEFAULT_PENDING_INTERVAL_MS = 30_000;
|
||||
const JITTER_RATIO = 0.2;
|
||||
|
||||
type ActiveRecord = {
|
||||
runId: string;
|
||||
nodeId: string;
|
||||
scenarioSlug: string;
|
||||
storePurchased?: boolean;
|
||||
lastPurchase?: PurchaseOfferResponse;
|
||||
};
|
||||
|
||||
function effectKey(runId: string, nodeId: string): string {
|
||||
return `${runId}:${nodeId}`;
|
||||
}
|
||||
|
||||
function isTransientHttpError(err: unknown): boolean {
|
||||
if (err instanceof RudderNetworkError) return true;
|
||||
if (err instanceof RudderHttpError && err.status >= 500) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function isDroppedRunError(err: unknown): boolean {
|
||||
if (!(err instanceof RudderHttpError)) return false;
|
||||
return (
|
||||
err.code === RudderErrorCodes.unknownRun ||
|
||||
err.code === RudderErrorCodes.runExpired ||
|
||||
err.code === RudderErrorCodes.runNotActive
|
||||
);
|
||||
}
|
||||
|
||||
function asString(value: unknown, fallback = ''): string {
|
||||
return typeof value === 'string' ? value : fallback;
|
||||
}
|
||||
|
||||
function asObjectives(value: unknown): Array<Record<string, unknown>> {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.filter(
|
||||
(item): item is Record<string, unknown> =>
|
||||
item !== null && typeof item === 'object' && !Array.isArray(item),
|
||||
);
|
||||
}
|
||||
|
||||
function asLevel(value: unknown): number {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : 0;
|
||||
}
|
||||
|
||||
export class ScenarioService {
|
||||
private readonly active = new Map<string, ActiveRecord>();
|
||||
private readonly waitTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
private readonly droppedRuns = new Set<string>();
|
||||
private readonly completedRuns = new Set<string>();
|
||||
private heartbeatTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
private running = false;
|
||||
private polling = false;
|
||||
private pollQueued = false;
|
||||
|
||||
constructor(
|
||||
private readonly ctx: RudderContext,
|
||||
private readonly domains: ScenarioDomains,
|
||||
private readonly battlePass: BattlePassService,
|
||||
private readonly intervalMs: number = DEFAULT_PENDING_INTERVAL_MS,
|
||||
) {}
|
||||
|
||||
get isRunning(): boolean {
|
||||
return this.active.size > 0;
|
||||
}
|
||||
|
||||
async send(eventName: string): Promise<TriggerScenarioResponse> {
|
||||
const response = await api.triggerScenario(this.ctx, { event: eventName });
|
||||
this.domains.player.invalidate();
|
||||
this.domains.inventory.invalidate();
|
||||
this.ingest(response.effects ?? []);
|
||||
return response;
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this.running) return;
|
||||
this.running = true;
|
||||
if (typeof document !== 'undefined') {
|
||||
document.addEventListener('visibilitychange', this.onVisibilityChange);
|
||||
}
|
||||
await this.pollPending();
|
||||
this.scheduleHeartbeat();
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.running = false;
|
||||
this.pollQueued = false;
|
||||
if (this.heartbeatTimer) {
|
||||
clearTimeout(this.heartbeatTimer);
|
||||
this.heartbeatTimer = undefined;
|
||||
}
|
||||
for (const timer of this.waitTimers.values()) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
this.waitTimers.clear();
|
||||
this.active.clear();
|
||||
this.droppedRuns.clear();
|
||||
this.completedRuns.clear();
|
||||
if (typeof document !== 'undefined') {
|
||||
document.removeEventListener('visibilitychange', this.onVisibilityChange);
|
||||
}
|
||||
}
|
||||
|
||||
private readonly onVisibilityChange = (): void => {
|
||||
if (this.running && typeof document !== 'undefined' && !document.hidden) {
|
||||
void this.heartbeatTick();
|
||||
}
|
||||
};
|
||||
|
||||
private scheduleHeartbeat(): void {
|
||||
if (!this.running) return;
|
||||
if (this.heartbeatTimer) clearTimeout(this.heartbeatTimer);
|
||||
const jitter = 1 + (Math.random() * 2 - 1) * JITTER_RATIO;
|
||||
this.heartbeatTimer = setTimeout(() => {
|
||||
void this.heartbeatTick();
|
||||
}, this.intervalMs * jitter);
|
||||
}
|
||||
|
||||
private async heartbeatTick(): Promise<void> {
|
||||
if (!this.running) return;
|
||||
if (typeof document !== 'undefined' && document.hidden) {
|
||||
this.scheduleHeartbeat();
|
||||
return;
|
||||
}
|
||||
await this.pollPending();
|
||||
this.scheduleHeartbeat();
|
||||
}
|
||||
|
||||
private async pollPending(): Promise<void> {
|
||||
if (this.polling) {
|
||||
this.pollQueued = true;
|
||||
return;
|
||||
}
|
||||
this.polling = true;
|
||||
try {
|
||||
do {
|
||||
this.pollQueued = false;
|
||||
try {
|
||||
const response = await api.listPendingScenarioEffects(this.ctx);
|
||||
this.reconcile(response.effects ?? []);
|
||||
} catch {
|
||||
}
|
||||
} while (this.pollQueued);
|
||||
} finally {
|
||||
this.polling = false;
|
||||
}
|
||||
}
|
||||
|
||||
private reconcile(effects: PendingEffect[]): void {
|
||||
const incoming = effects.filter((effect) => !this.droppedRuns.has(effect.runId));
|
||||
const incomingKeys = new Set(
|
||||
incoming.map((effect) => effectKey(effect.runId, effect.nodeId)),
|
||||
);
|
||||
const before = new Set(this.active.keys());
|
||||
|
||||
this.ingest(incoming);
|
||||
|
||||
for (const [key, record] of [...this.active.entries()]) {
|
||||
if (incomingKeys.has(key)) continue;
|
||||
this.deactivate(key);
|
||||
this.emitCompletedIfIdle(record.runId, record.scenarioSlug);
|
||||
}
|
||||
|
||||
const after = new Set(this.active.keys());
|
||||
if (before.size !== after.size || [...before].some((key) => !after.has(key))) {
|
||||
this.domains.player.invalidate();
|
||||
this.domains.inventory.invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
private ingest(effects: PendingEffect[]): void {
|
||||
for (const effect of effects) {
|
||||
this.ingestOne(effect);
|
||||
}
|
||||
}
|
||||
|
||||
private ingestOne(pending: PendingEffect): void {
|
||||
if (this.droppedRuns.has(pending.runId)) return;
|
||||
const key = effectKey(pending.runId, pending.nodeId);
|
||||
if (this.active.has(key)) {
|
||||
if (pending.waitDeadline && !this.waitTimers.has(key)) {
|
||||
this.scheduleWait(key, pending.waitDeadline);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const record: ActiveRecord = {
|
||||
runId: pending.runId,
|
||||
nodeId: pending.nodeId,
|
||||
scenarioSlug: pending.scenarioSlug,
|
||||
};
|
||||
this.active.set(key, record);
|
||||
this.completedRuns.delete(pending.runId);
|
||||
if (pending.waitDeadline) {
|
||||
this.scheduleWait(key, pending.waitDeadline);
|
||||
}
|
||||
this.dispatch(pending, record);
|
||||
}
|
||||
|
||||
private dispatch(pending: PendingEffect, record: ActiveRecord): void {
|
||||
switch (pending.type) {
|
||||
case 'notification':
|
||||
this.ctx.effects.emitNotification({
|
||||
title: asString(pending.data.title),
|
||||
message: asString(pending.data.message),
|
||||
done: () => this.complete(record, 'output'),
|
||||
});
|
||||
break;
|
||||
case 'store':
|
||||
this.dispatchStore(pending, record);
|
||||
break;
|
||||
case 'wait':
|
||||
this.ctx.effects.emitWait({
|
||||
deadlineUtc: pending.waitDeadline
|
||||
? new Date(pending.waitDeadline)
|
||||
: new Date(),
|
||||
});
|
||||
break;
|
||||
case 'leaderboard': {
|
||||
const end = () => this.complete(record, 'onEnd');
|
||||
const claim = () => this.complete(record, 'onClaim');
|
||||
this.ctx.effects.emitLeaderboard({
|
||||
end,
|
||||
claim,
|
||||
rewardClaimed: claim,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'quest':
|
||||
this.ctx.effects.emitQuest({
|
||||
name: asString(pending.data.name),
|
||||
objectives: asObjectives(pending.data.objectives),
|
||||
reportProgress: (objectiveId, amount) =>
|
||||
this.reportQuestProgress(record, objectiveId, amount),
|
||||
});
|
||||
break;
|
||||
case 'battlepass':
|
||||
this.dispatchBattlePass(record);
|
||||
break;
|
||||
case 'battlepass_level':
|
||||
this.ctx.effects.emitBattlePassLevel({
|
||||
level: asLevel(pending.data.levelNumber),
|
||||
claim: () => this.complete(record, 'onComplete'),
|
||||
});
|
||||
break;
|
||||
default:
|
||||
console.warn(
|
||||
`[Rudder] Unsupported scenario node type '${pending.type}' (${pending.nodeId})`,
|
||||
);
|
||||
this.dropRun(
|
||||
record.runId,
|
||||
record.nodeId,
|
||||
new Error(`Unsupported scenario node type '${pending.type}'`),
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private dispatchStore(pending: PendingEffect, record: ActiveRecord): void {
|
||||
const slug = asString(pending.data.storeSlug);
|
||||
this.ctx.effects.emitStoreOffer(
|
||||
this.domains.stores.getBySlug(slug).then((store) => ({
|
||||
store,
|
||||
offers: store.offers,
|
||||
message: typeof pending.data.message === 'string' ? pending.data.message : undefined,
|
||||
buy: (offer, options) => this.buyStore(record, offer, options),
|
||||
dismiss: () => this.dismissStore(record),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
private dispatchBattlePass(record: ActiveRecord): void {
|
||||
const { scenarioSlug, nodeId, runId } = record;
|
||||
this.ctx.effects.emitBattlePass({
|
||||
getProgress: () => this.battlePass.getProgress(scenarioSlug, nodeId),
|
||||
addXp: (source, amount) =>
|
||||
this.battlePass.addXp({ scenarioSlug, nodeId, source, amount, runId }),
|
||||
claimReward: (level, track) =>
|
||||
this.battlePass.claimReward({ scenarioSlug, nodeId, level, track, runId }),
|
||||
purchasePremium: async () => {
|
||||
const response = await this.battlePass.purchasePremium({
|
||||
scenarioSlug,
|
||||
nodeId,
|
||||
idempotencyKey: crypto.randomUUID(),
|
||||
runId,
|
||||
});
|
||||
if (response.success) {
|
||||
await this.complete(record, 'onPremiumPurchase');
|
||||
}
|
||||
return response;
|
||||
},
|
||||
levelUp: () => this.complete(record, 'onLevelUp'),
|
||||
end: () => this.complete(record, 'onComplete'),
|
||||
});
|
||||
}
|
||||
|
||||
private async buyStore(
|
||||
record: ActiveRecord,
|
||||
offer: OfferHandle,
|
||||
options?: BuyOptions,
|
||||
): Promise<PurchaseOfferResponse> {
|
||||
const key = effectKey(record.runId, record.nodeId);
|
||||
if (!this.active.has(key)) {
|
||||
return { success: false, error: 'store session already resolved' };
|
||||
}
|
||||
if (!record.storePurchased) {
|
||||
const purchase = await offer.buy(options);
|
||||
if (!purchase.success) return purchase;
|
||||
record.storePurchased = true;
|
||||
record.lastPurchase = purchase;
|
||||
}
|
||||
await this.complete(record, 'onPurchase');
|
||||
return record.lastPurchase ?? { success: true };
|
||||
}
|
||||
|
||||
private async dismissStore(record: ActiveRecord): Promise<void> {
|
||||
const key = effectKey(record.runId, record.nodeId);
|
||||
if (!this.active.has(key)) return;
|
||||
if (record.storePurchased) return;
|
||||
await this.complete(record, 'onDecline');
|
||||
}
|
||||
|
||||
private async reportQuestProgress(
|
||||
record: ActiveRecord,
|
||||
objectiveId: string,
|
||||
amount = 1,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const response = await api.updateScenarioCounter(this.ctx, {
|
||||
scenarioSlug: record.scenarioSlug,
|
||||
nodeId: record.nodeId,
|
||||
counterKey: objectiveId,
|
||||
amount,
|
||||
runId: record.runId,
|
||||
});
|
||||
if (!response?.completed) return;
|
||||
this.domains.player.invalidate();
|
||||
this.domains.inventory.invalidate();
|
||||
const key = effectKey(record.runId, record.nodeId);
|
||||
if (this.active.has(key)) {
|
||||
this.deactivate(key);
|
||||
}
|
||||
if (response.effect) {
|
||||
this.ingest([response.effect]);
|
||||
} else {
|
||||
this.emitCompletedIfIdle(record.runId, record.scenarioSlug);
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
private async complete(record: ActiveRecord, handle: string): Promise<void> {
|
||||
const key = effectKey(record.runId, record.nodeId);
|
||||
if (!this.active.has(key)) return;
|
||||
if (this.droppedRuns.has(record.runId)) return;
|
||||
try {
|
||||
const response = await api.handleScenarioCallback(this.ctx, {
|
||||
scenarioSlug: record.scenarioSlug,
|
||||
nodeId: record.nodeId,
|
||||
handle,
|
||||
runId: record.runId,
|
||||
});
|
||||
this.domains.player.invalidate();
|
||||
this.domains.inventory.invalidate();
|
||||
if (!this.active.has(key)) return;
|
||||
this.deactivate(key);
|
||||
if (response?.effect) {
|
||||
this.ingest([response.effect]);
|
||||
} else {
|
||||
this.emitCompletedIfIdle(record.runId, record.scenarioSlug);
|
||||
}
|
||||
} catch (err) {
|
||||
if (isTransientHttpError(err)) return;
|
||||
if (isDroppedRunError(err)) {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
this.dropRun(record.runId, record.nodeId, error);
|
||||
throw err;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleWait(key: string, waitDeadline: string): void {
|
||||
const deadline = Date.parse(waitDeadline);
|
||||
if (Number.isNaN(deadline)) return;
|
||||
const remaining = Math.max(0, deadline - Date.now());
|
||||
const existing = this.waitTimers.get(key);
|
||||
if (existing) clearTimeout(existing);
|
||||
this.waitTimers.set(
|
||||
key,
|
||||
setTimeout(() => {
|
||||
this.waitTimers.delete(key);
|
||||
void this.pollPending();
|
||||
}, remaining),
|
||||
);
|
||||
}
|
||||
|
||||
private deactivate(key: string): void {
|
||||
const timer = this.waitTimers.get(key);
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
this.waitTimers.delete(key);
|
||||
}
|
||||
this.active.delete(key);
|
||||
}
|
||||
|
||||
private dropRun(runId: string, nodeId: string, error: Error): void {
|
||||
this.droppedRuns.add(runId);
|
||||
let scenarioSlug = '';
|
||||
for (const [key, record] of [...this.active.entries()]) {
|
||||
if (record.runId !== runId) continue;
|
||||
if (!scenarioSlug) scenarioSlug = record.scenarioSlug;
|
||||
this.deactivate(key);
|
||||
}
|
||||
console.warn(
|
||||
`[Rudder] Scenario run ${runId} failed at node ${nodeId}: ${error.message}`,
|
||||
);
|
||||
this.ctx.effects.emitScenarioFailed({
|
||||
runId,
|
||||
scenarioSlug,
|
||||
nodeId,
|
||||
error,
|
||||
});
|
||||
}
|
||||
|
||||
private emitCompletedIfIdle(runId: string, scenarioSlug: string): void {
|
||||
if (this.droppedRuns.has(runId)) return;
|
||||
if (this.completedRuns.has(runId)) return;
|
||||
for (const record of this.active.values()) {
|
||||
if (record.runId === runId) return;
|
||||
}
|
||||
this.completedRuns.add(runId);
|
||||
this.ctx.effects.emitScenarioCompleted({ runId, scenarioSlug });
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import type { RemoteConfig } from '../generated/remote-config.js';
|
||||
import { SyncedState } from './SyncedState.js';
|
||||
|
||||
export type RemoteConfigShape = Record<string, unknown>;
|
||||
type RemoteConfigKey<TConfig extends RemoteConfigShape> = Extract<keyof TConfig, string>;
|
||||
|
||||
/**
|
||||
* RemoteConfigState — typed remote configuration as an observable entity.
|
||||
*
|
||||
* `get()` parses values synchronously from the loaded snapshot based on their
|
||||
* declared `valueType` and returns the default value while not loaded.
|
||||
*/
|
||||
export class RemoteConfigState<
|
||||
TConfig extends RemoteConfigShape = RemoteConfigShape,
|
||||
> extends SyncedState<Map<string, RemoteConfig>> {
|
||||
get<TKey extends RemoteConfigKey<TConfig>>(key: TKey): TConfig[TKey] | undefined;
|
||||
get<TKey extends RemoteConfigKey<TConfig>>(key: TKey, defaultValue: TConfig[TKey]): TConfig[TKey];
|
||||
get<TKey extends RemoteConfigKey<TConfig>>(
|
||||
key: TKey,
|
||||
defaultValue?: TConfig[TKey],
|
||||
): TConfig[TKey] | undefined {
|
||||
const config = this.data?.get(key);
|
||||
if (!config || config.value == null) return defaultValue;
|
||||
return parseValue(config.value, config.valueType, defaultValue);
|
||||
}
|
||||
}
|
||||
|
||||
function parseValue<T>(
|
||||
value: string,
|
||||
valueType: string | undefined,
|
||||
defaultValue: T | undefined,
|
||||
): T | undefined {
|
||||
try {
|
||||
switch ((valueType ?? '').toLowerCase()) {
|
||||
case 'int':
|
||||
case 'integer':
|
||||
return Number.parseInt(value, 10) as unknown as T;
|
||||
case 'float':
|
||||
case 'double':
|
||||
case 'number':
|
||||
return Number.parseFloat(value) as unknown as T;
|
||||
case 'bool':
|
||||
case 'boolean':
|
||||
return (value === 'true') as unknown as T;
|
||||
case 'json':
|
||||
case 'object':
|
||||
return JSON.parse(value) as T;
|
||||
default:
|
||||
return value as unknown as T;
|
||||
}
|
||||
} catch {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import type { CatalogItem } from '../generated/catalog.js';
|
||||
import type { PlayerInventoryItem } from '../generated/inventory.js';
|
||||
|
||||
/** A fully-resolved owned item: player inventory row merged with its catalog entry. */
|
||||
export interface InventoryItem {
|
||||
slug: string;
|
||||
amount: number;
|
||||
name: string;
|
||||
properties: Record<string, unknown>;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
/** @internal Merges owned inventory rows with their catalog entries. */
|
||||
export function mergeInventory(
|
||||
owned: PlayerInventoryItem[],
|
||||
catalog: Map<string, CatalogItem>,
|
||||
): InventoryItem[] {
|
||||
return owned.map((item) => {
|
||||
const slug = item.slug ?? '';
|
||||
const entry = catalog.get(slug);
|
||||
return {
|
||||
slug,
|
||||
amount: item.amount ?? 0,
|
||||
name: item.nameOverride || entry?.name || slug,
|
||||
properties: { ...(entry?.properties ?? {}), ...(item.propertiesOverride ?? {}) },
|
||||
tags: entry?.tags ?? [],
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import type {
|
||||
Offer,
|
||||
OfferContent,
|
||||
OfferPrice,
|
||||
Store,
|
||||
PurchaseOfferResponse,
|
||||
} from '../generated/stores.js';
|
||||
|
||||
export interface BuyOptions {
|
||||
idempotencyKey?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire-level purchase executor provided by the stores domain
|
||||
* (`StoresDomain.purchase`).
|
||||
*/
|
||||
export type PurchaseOffer = (
|
||||
storeSlug: string,
|
||||
offerSlug: string,
|
||||
options?: BuyOptions,
|
||||
) => Promise<PurchaseOfferResponse>;
|
||||
|
||||
export class OfferHandle {
|
||||
public readonly id: string;
|
||||
public readonly slug: string;
|
||||
public readonly name?: string;
|
||||
public readonly price?: OfferPrice;
|
||||
public readonly contents: OfferContent[];
|
||||
public readonly maxPurchases?: number;
|
||||
|
||||
constructor(
|
||||
private readonly purchase: PurchaseOffer,
|
||||
private readonly storeSlug: string,
|
||||
private readonly offer: Offer,
|
||||
) {
|
||||
if (!offer.id) {
|
||||
throw new Error('Rudder Stores: offer.id is required');
|
||||
}
|
||||
if (!offer.slug) {
|
||||
throw new Error('Rudder Stores: offer.slug is required');
|
||||
}
|
||||
this.id = offer.id;
|
||||
this.slug = offer.slug;
|
||||
this.name = offer.name;
|
||||
this.price = offer.price;
|
||||
this.contents = offer.contents ?? [];
|
||||
this.maxPurchases = offer.maxPurchases;
|
||||
}
|
||||
|
||||
async buy(options: BuyOptions = {}): Promise<PurchaseOfferResponse> {
|
||||
return this.purchase(this.storeSlug, this.slug, options);
|
||||
}
|
||||
}
|
||||
|
||||
export class ShopHandle {
|
||||
public readonly slug: string;
|
||||
public readonly name?: string;
|
||||
public readonly description?: string;
|
||||
public readonly data?: { [key: string]: unknown };
|
||||
public readonly offers: OfferHandle[];
|
||||
|
||||
constructor(
|
||||
purchase: PurchaseOffer,
|
||||
store: Store,
|
||||
) {
|
||||
if (!store.slug) {
|
||||
throw new Error('Rudder Stores: store.slug is required');
|
||||
}
|
||||
this.slug = store.slug;
|
||||
this.name = store.name;
|
||||
this.description = store.description;
|
||||
this.data = store.data;
|
||||
this.offers = (store.offers ?? []).map(
|
||||
(offer) => new OfferHandle(purchase, this.slug, offer),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,8 @@ export interface RequestContext {
|
||||
baseUrl: string;
|
||||
tokenStore: TokenStore;
|
||||
requestTimeoutMs?: number;
|
||||
/** Sent with `platform: 'web'` on token refresh; omitted when not set. */
|
||||
appVersion?: string;
|
||||
/** Single-flight refresh state shared by all requests of one client. */
|
||||
refreshState?: { pending?: Promise<boolean> };
|
||||
/** Called when the refresh flow fails — lets the client stop its runtime. */
|
||||
@@ -168,7 +170,11 @@ async function doRefreshTokens(ctx: RequestContext): Promise<boolean> {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ refreshToken } satisfies RefreshAccessTokenRequest),
|
||||
body: JSON.stringify({
|
||||
refreshToken,
|
||||
platform: 'web',
|
||||
appVersion: ctx.appVersion,
|
||||
} satisfies RefreshAccessTokenRequest),
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) return false;
|
||||
|
||||
Reference in New Issue
Block a user