Slug-based scenarios, quests and offers per the environments contract; regenerated models; e2e seed passes environment to admin mirrors
Claude-Session: https://claude.ai/code/session_01SMCvdwDmuxoaqGgvGBLk1V
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
* BattlePassService — call-and-response access to the battle pass endpoints.
|
||||
*
|
||||
* Battle pass state is tied to a scenario battle pass node, so every call
|
||||
* carries a `scenarioId` + `nodeId` (and a `runId` for the mutating calls).
|
||||
* carries a `scenarioSlug` + `nodeId` (and a `runId` for the mutating calls).
|
||||
* Battle pass has no sync-revision key, so this is a plain service, not an
|
||||
* observable domain; re-fetch progress explicitly after a mutation.
|
||||
*/
|
||||
@@ -23,8 +23,8 @@ export class BattlePassService {
|
||||
constructor(private readonly ctx: RudderContext) {}
|
||||
|
||||
/** Reads current progress (xp, level, premium, claimed tiers) for a node. */
|
||||
getProgress(scenarioId: string, nodeId: string): Promise<GetBattlePassProgressResponse> {
|
||||
return api.getBattlePassProgress(this.ctx, { scenarioId, nodeId });
|
||||
getProgress(scenarioSlug: string, nodeId: string): Promise<GetBattlePassProgressResponse> {
|
||||
return api.getBattlePassProgress(this.ctx, { scenarioSlug, nodeId });
|
||||
}
|
||||
|
||||
/** Credits XP from a configured source. Returns the new xp/level. */
|
||||
|
||||
@@ -14,7 +14,7 @@ import type { PurchaseOfferResponse } from '../generated/stores.js';
|
||||
export class StoresDomain extends SyncedState<ShopHandle[]> {
|
||||
readonly syncKey = 'stores';
|
||||
|
||||
/** Shared purchase executor: `stores.purchase(slug, offerId, opts)`. */
|
||||
/** Shared purchase executor: `stores.purchase(slug, offerSlug, opts)`. */
|
||||
readonly purchase: PurchaseOffer;
|
||||
|
||||
private readonly ctx: RudderContext;
|
||||
@@ -24,15 +24,15 @@ export class StoresDomain extends SyncedState<ShopHandle[]> {
|
||||
// handles created in the loader all delegate to this one executor.
|
||||
const purchase: PurchaseOffer = async (
|
||||
storeSlug: string,
|
||||
offerId: string,
|
||||
offerSlug: string,
|
||||
options: BuyOptions = {},
|
||||
): Promise<PurchaseOfferResponse> => {
|
||||
const response = await api.purchaseOffer(
|
||||
ctx,
|
||||
{ storeSlug, offerId },
|
||||
{ storeSlug, offerSlug },
|
||||
{
|
||||
storeSlug,
|
||||
offerId,
|
||||
offerSlug,
|
||||
idempotencyKey: options.idempotencyKey ?? crypto.randomUUID(),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -40,12 +40,12 @@ export interface WaitEffect {
|
||||
|
||||
export interface ScenarioCompletedEffect {
|
||||
readonly runId: string;
|
||||
readonly scenarioId: string;
|
||||
readonly scenarioSlug: string;
|
||||
}
|
||||
|
||||
export interface ScenarioFailedEffect {
|
||||
readonly runId: string;
|
||||
readonly scenarioId: string;
|
||||
readonly scenarioSlug: string;
|
||||
readonly nodeId: string;
|
||||
readonly error: Error;
|
||||
}
|
||||
|
||||
@@ -163,10 +163,10 @@ export const api = {
|
||||
|
||||
purchaseOffer: (
|
||||
t: Transport,
|
||||
path: { storeSlug: string; offerId: string },
|
||||
path: { storeSlug: string; offerSlug: string },
|
||||
body: PurchaseOfferRequest,
|
||||
): Promise<PurchaseOfferResponse> =>
|
||||
t.request<PurchaseOfferResponse>('POST', `/sdk/v1/stores/${enc(path.storeSlug)}/offers/${enc(path.offerId)}/purchase`, body),
|
||||
t.request<PurchaseOfferResponse>('POST', `/sdk/v1/stores/${enc(path.storeSlug)}/offers/${enc(path.offerSlug)}/purchase`, body),
|
||||
|
||||
refreshAccessToken: (
|
||||
t: Transport,
|
||||
|
||||
@@ -6,7 +6,7 @@ export interface AddBattlePassXpRequest {
|
||||
"amount"?: number;
|
||||
"nodeId"?: string;
|
||||
"runId"?: string;
|
||||
"scenarioId"?: string;
|
||||
"scenarioSlug"?: string;
|
||||
"source"?: string;
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ export interface ClaimBattlePassRewardRequest {
|
||||
"level"?: number;
|
||||
"nodeId"?: string;
|
||||
"runId"?: string;
|
||||
"scenarioId"?: string;
|
||||
"scenarioSlug"?: string;
|
||||
"track"?: "free" | "premium";
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ export interface ClaimedTier {
|
||||
|
||||
export interface GetBattlePassProgressRequest {
|
||||
"nodeId"?: string;
|
||||
"scenarioId"?: string;
|
||||
"scenarioSlug"?: string;
|
||||
}
|
||||
|
||||
export interface GetBattlePassProgressResponse {
|
||||
@@ -53,7 +53,7 @@ export interface PurchaseBattlePassPremiumRequest {
|
||||
"idempotencyKey"?: string;
|
||||
"nodeId"?: string;
|
||||
"runId"?: string;
|
||||
"scenarioId"?: string;
|
||||
"scenarioSlug"?: string;
|
||||
}
|
||||
|
||||
export interface PurchaseBattlePassPremiumResponse {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import type { Reward } from './common.js';
|
||||
|
||||
export interface ClaimQuestRequest {
|
||||
"questId"?: string;
|
||||
"questSlug"?: string;
|
||||
}
|
||||
|
||||
export interface ClaimQuestResponse {
|
||||
@@ -18,10 +18,10 @@ export interface ListQuestsResponse {
|
||||
}
|
||||
|
||||
export interface Quest {
|
||||
"id"?: string;
|
||||
"name"?: string;
|
||||
"objectives"?: QuestObjectiveProgress[];
|
||||
"rewards"?: Reward[];
|
||||
"slug"?: string;
|
||||
"status"?: "active" | "claimed" | "completed";
|
||||
}
|
||||
|
||||
@@ -39,6 +39,6 @@ export interface ReportQuestProgressRequest {
|
||||
}
|
||||
|
||||
export interface ReportQuestProgressResponse {
|
||||
"completedQuestIds"?: string[];
|
||||
"completedQuestSlugs"?: string[];
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ export interface HandleScenarioCallbackRequest {
|
||||
"handle"?: string;
|
||||
"nodeId"?: string;
|
||||
"runId"?: string;
|
||||
"scenarioId"?: string;
|
||||
"scenarioSlug"?: string;
|
||||
}
|
||||
|
||||
export interface HandleScenarioCallbackResponse {
|
||||
@@ -19,7 +19,7 @@ export interface PendingEffect {
|
||||
"data": { [key: string]: unknown };
|
||||
"nodeId": string;
|
||||
"runId": string;
|
||||
"scenarioId": string;
|
||||
"scenarioSlug": string;
|
||||
"type": string;
|
||||
"waitDeadline"?: string;
|
||||
}
|
||||
@@ -37,7 +37,7 @@ export interface UpdateScenarioCounterRequest {
|
||||
"counterKey"?: string;
|
||||
"nodeId"?: string;
|
||||
"runId"?: string;
|
||||
"scenarioId"?: string;
|
||||
"scenarioSlug"?: string;
|
||||
}
|
||||
|
||||
export interface UpdateScenarioCounterResponse {
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface Offer {
|
||||
"maxPurchases"?: number;
|
||||
"name"?: string;
|
||||
"price"?: OfferPrice;
|
||||
"slug"?: string;
|
||||
"updatedAt"?: string;
|
||||
}
|
||||
|
||||
@@ -27,7 +28,7 @@ export interface OfferPrice {
|
||||
|
||||
export interface PurchaseOfferRequest {
|
||||
"idempotencyKey"?: string;
|
||||
"offerId"?: string;
|
||||
"offerSlug"?: string;
|
||||
"storeSlug"?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
*/
|
||||
|
||||
/** Metric for purchasing a store offer; auto-reported on purchase. */
|
||||
export function purchaseOffer(offerId: string): string {
|
||||
return `purchase.offer:${offerId}`;
|
||||
export function purchaseOffer(offerSlug: string): string {
|
||||
return `purchase.offer:${offerSlug}`;
|
||||
}
|
||||
|
||||
/** Metric for purchasing a catalog item; auto-reported on purchase. */
|
||||
|
||||
@@ -20,13 +20,13 @@ export class QuestsService {
|
||||
}
|
||||
|
||||
/** Claims a completed quest's rewards (idempotent server-side). */
|
||||
claim(questId: string): Promise<ClaimQuestResponse> {
|
||||
return api.claimQuest(this.ctx, { questId });
|
||||
claim(questSlug: string): Promise<ClaimQuestResponse> {
|
||||
return api.claimQuest(this.ctx, { questSlug });
|
||||
}
|
||||
|
||||
/** Reports metric progress; returns ids of quests completed by this report. */
|
||||
async reportProgress(metric: string, amount: number): Promise<string[]> {
|
||||
const response = await api.reportQuestProgress(this.ctx, { metric, amount });
|
||||
return response.completedQuestIds ?? [];
|
||||
return response.completedQuestSlugs ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ const JITTER_RATIO = 0.2;
|
||||
type ActiveRecord = {
|
||||
runId: string;
|
||||
nodeId: string;
|
||||
scenarioId: string;
|
||||
scenarioSlug: string;
|
||||
storePurchased?: boolean;
|
||||
lastPurchase?: PurchaseOfferResponse;
|
||||
};
|
||||
@@ -177,7 +177,7 @@ export class ScenarioService {
|
||||
for (const [key, record] of [...this.active.entries()]) {
|
||||
if (incomingKeys.has(key)) continue;
|
||||
this.deactivate(key);
|
||||
this.emitCompletedIfIdle(record.runId, record.scenarioId);
|
||||
this.emitCompletedIfIdle(record.runId, record.scenarioSlug);
|
||||
}
|
||||
|
||||
const after = new Set(this.active.keys());
|
||||
@@ -206,7 +206,7 @@ export class ScenarioService {
|
||||
const record: ActiveRecord = {
|
||||
runId: pending.runId,
|
||||
nodeId: pending.nodeId,
|
||||
scenarioId: pending.scenarioId,
|
||||
scenarioSlug: pending.scenarioSlug,
|
||||
};
|
||||
this.active.set(key, record);
|
||||
this.completedRuns.delete(pending.runId);
|
||||
@@ -289,16 +289,16 @@ export class ScenarioService {
|
||||
}
|
||||
|
||||
private dispatchBattlePass(record: ActiveRecord): void {
|
||||
const { scenarioId, nodeId, runId } = record;
|
||||
const { scenarioSlug, nodeId, runId } = record;
|
||||
this.ctx.effects.emitBattlePass({
|
||||
getProgress: () => this.battlePass.getProgress(scenarioId, nodeId),
|
||||
getProgress: () => this.battlePass.getProgress(scenarioSlug, nodeId),
|
||||
addXp: (source, amount) =>
|
||||
this.battlePass.addXp({ scenarioId, nodeId, source, amount, runId }),
|
||||
this.battlePass.addXp({ scenarioSlug, nodeId, source, amount, runId }),
|
||||
claimReward: (level, track) =>
|
||||
this.battlePass.claimReward({ scenarioId, nodeId, level, track, runId }),
|
||||
this.battlePass.claimReward({ scenarioSlug, nodeId, level, track, runId }),
|
||||
purchasePremium: async () => {
|
||||
const response = await this.battlePass.purchasePremium({
|
||||
scenarioId,
|
||||
scenarioSlug,
|
||||
nodeId,
|
||||
idempotencyKey: crypto.randomUUID(),
|
||||
runId,
|
||||
@@ -346,7 +346,7 @@ export class ScenarioService {
|
||||
): Promise<void> {
|
||||
try {
|
||||
const response = await api.updateScenarioCounter(this.ctx, {
|
||||
scenarioId: record.scenarioId,
|
||||
scenarioSlug: record.scenarioSlug,
|
||||
nodeId: record.nodeId,
|
||||
counterKey: objectiveId,
|
||||
amount,
|
||||
@@ -362,7 +362,7 @@ export class ScenarioService {
|
||||
if (response.effect) {
|
||||
this.ingest([response.effect]);
|
||||
} else {
|
||||
this.emitCompletedIfIdle(record.runId, record.scenarioId);
|
||||
this.emitCompletedIfIdle(record.runId, record.scenarioSlug);
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
@@ -374,7 +374,7 @@ export class ScenarioService {
|
||||
if (this.droppedRuns.has(record.runId)) return;
|
||||
try {
|
||||
const response = await api.handleScenarioCallback(this.ctx, {
|
||||
scenarioId: record.scenarioId,
|
||||
scenarioSlug: record.scenarioSlug,
|
||||
nodeId: record.nodeId,
|
||||
handle,
|
||||
runId: record.runId,
|
||||
@@ -386,7 +386,7 @@ export class ScenarioService {
|
||||
if (response?.effect) {
|
||||
this.ingest([response.effect]);
|
||||
} else {
|
||||
this.emitCompletedIfIdle(record.runId, record.scenarioId);
|
||||
this.emitCompletedIfIdle(record.runId, record.scenarioSlug);
|
||||
}
|
||||
} catch (err) {
|
||||
if (isTransientHttpError(err)) return;
|
||||
@@ -425,10 +425,10 @@ export class ScenarioService {
|
||||
|
||||
private dropRun(runId: string, nodeId: string, error: Error): void {
|
||||
this.droppedRuns.add(runId);
|
||||
let scenarioId = '';
|
||||
let scenarioSlug = '';
|
||||
for (const [key, record] of [...this.active.entries()]) {
|
||||
if (record.runId !== runId) continue;
|
||||
if (!scenarioId) scenarioId = record.scenarioId;
|
||||
if (!scenarioSlug) scenarioSlug = record.scenarioSlug;
|
||||
this.deactivate(key);
|
||||
}
|
||||
console.warn(
|
||||
@@ -436,19 +436,19 @@ export class ScenarioService {
|
||||
);
|
||||
this.ctx.effects.emitScenarioFailed({
|
||||
runId,
|
||||
scenarioId,
|
||||
scenarioSlug,
|
||||
nodeId,
|
||||
error,
|
||||
});
|
||||
}
|
||||
|
||||
private emitCompletedIfIdle(runId: string, scenarioId: string): void {
|
||||
private emitCompletedIfIdle(runId: string, scenarioSlug: string): void {
|
||||
if (this.droppedRuns.has(runId)) return;
|
||||
if (this.completedRuns.has(runId)) return;
|
||||
for (const record of this.active.values()) {
|
||||
if (record.runId === runId) return;
|
||||
}
|
||||
this.completedRuns.add(runId);
|
||||
this.ctx.effects.emitScenarioCompleted({ runId, scenarioId });
|
||||
this.ctx.effects.emitScenarioCompleted({ runId, scenarioSlug });
|
||||
}
|
||||
}
|
||||
|
||||
+7
-2
@@ -16,12 +16,13 @@ export interface BuyOptions {
|
||||
*/
|
||||
export type PurchaseOffer = (
|
||||
storeSlug: string,
|
||||
offerId: string,
|
||||
offerSlug: string,
|
||||
options?: BuyOptions,
|
||||
) => Promise<PurchaseOfferResponse>;
|
||||
|
||||
export class OfferHandle {
|
||||
public readonly id: string;
|
||||
public readonly slug: string;
|
||||
public readonly name?: string;
|
||||
public readonly price?: OfferPrice;
|
||||
public readonly contents: OfferContent[];
|
||||
@@ -35,7 +36,11 @@ export class OfferHandle {
|
||||
if (!offer.id) {
|
||||
throw new Error('Rudder Stores: offer.id is required');
|
||||
}
|
||||
if (!offer.slug) {
|
||||
throw new Error('Rudder Stores: offer.slug is required');
|
||||
}
|
||||
this.id = offer.id;
|
||||
this.slug = offer.slug;
|
||||
this.name = offer.name;
|
||||
this.price = offer.price;
|
||||
this.contents = offer.contents ?? [];
|
||||
@@ -43,7 +48,7 @@ export class OfferHandle {
|
||||
}
|
||||
|
||||
async buy(options: BuyOptions = {}): Promise<PurchaseOfferResponse> {
|
||||
return this.purchase(this.storeSlug, this.id, options);
|
||||
return this.purchase(this.storeSlug, this.slug, options);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user