import type { ExecutionPlanNode } from '../../generated/common.js'; import type { PlanRun } from './types.js'; import type { BuyOptions, OfferHandle, ShopHandle } from '../../state/shops.js'; import type { PurchaseOfferResponse } from '../../generated/stores.js'; import type { BattlePassService } from '../../battlepass/BattlePassService.js'; import type { AddBattlePassXpResponse, ClaimBattlePassRewardResponse, GetBattlePassProgressResponse, PurchaseBattlePassPremiumResponse, } from '../../generated/battlepass.js'; /** Resolves a store handle by slug — satisfied by the stores domain. */ interface StoreResolver { getBySlug(slug: string): Promise; } // ---- ScenarioNodeContext ---- /** * Context object passed to each session. * Wraps the current run, plan node, and raw node data. * The session calls `complete(handle)` to advance the DAG. */ export class ScenarioNodeContext { constructor( public readonly run: PlanRun, public readonly node: ExecutionPlanNode, private readonly onComplete: (runId: string, nodeId: string, handle: string) => Promise, private readonly onProgress: (runId: string, nodeId: string, counterKey: string, amount: number) => Promise, ) {} /** Extracts a typed value from node data. */ get(key: string, defaultValue: T): T { const data = this.node.data as Record | undefined; const value = data?.[key]; if (value === undefined || value === null) return defaultValue; return value as unknown as T; } /** Full node data as a typed record. */ get data(): Record { return (this.node.data as Record) ?? {}; } /** Completes the current node with the given output handle. */ async complete(handle: string): Promise { await this.onComplete(this.run.runId, this.node.id!, handle); } /** Updates counter progress for server-managed scenario nodes. */ async updateProgress(counterKey: string, amount: number): Promise { await this.onProgress(this.run.runId, this.node.id!, counterKey, amount); } } // ---- NotificationSession ---- export class NotificationSession { public readonly title: string; public readonly message: string; constructor(private readonly context: ScenarioNodeContext) { this.title = context.get('title', ''); this.message = context.get('message', ''); } get(key: string, defaultValue: T): T { return this.context.get(key, defaultValue); } get data(): Record { return this.context.data; } get node(): ExecutionPlanNode { return this.context.node; } async complete(): Promise { await this.context.complete('output'); } } // ---- WaitSession ---- export class WaitSession { constructor( private readonly context: ScenarioNodeContext, public readonly deadlineUtc: Date, ) {} get(key: string, defaultValue: T): T { return this.context.get(key, defaultValue); } get data(): Record { return this.context.data; } get node(): ExecutionPlanNode { return this.context.node; } } // ---- StoreSession ---- export class StoreSession { private resolved = false; constructor( private readonly context: ScenarioNodeContext, private readonly stores: StoreResolver, ) {} get isResolved(): boolean { return this.resolved; } get(key: string, defaultValue: T): T { return this.context.get(key, defaultValue); } get data(): Record { return this.context.data; } get node(): ExecutionPlanNode { return this.context.node; } async getStore(): Promise { return this.stores.getBySlug(this.get('storeSlug', '')); } async buy( offer: OfferHandle, options?: BuyOptions, ): Promise { if (this.resolved) { return { success: false, error: 'store session already resolved' }; } const purchase = await offer.buy(options); if (purchase.success) { this.resolved = true; await this.context.complete('onPurchase'); } return purchase; } async decline(): Promise { if (this.resolved) return; this.resolved = true; await this.context.complete('onDecline'); } } // ---- LeaderboardSession ---- export class LeaderboardSession { private resolved = false; constructor(private readonly context: ScenarioNodeContext) {} get isResolved(): boolean { return this.resolved; } get(key: string, defaultValue: T): T { return this.context.get(key, defaultValue); } get data(): Record { return this.context.data; } get node(): ExecutionPlanNode { return this.context.node; } async end(): Promise { if (this.resolved) return; this.resolved = true; await this.context.complete('onEnd'); } async rewardClaimed(): Promise { if (this.resolved) return; this.resolved = true; await this.context.complete('onRewardClaimed'); } } // ---- QuestSession ---- /** * A scenario quest node. The game reports objective progress; the server * auto-completes the node (crossing `onComplete`, which grants rewards and * advances the run) once every objective is satisfied. */ export class QuestSession { constructor(private readonly context: ScenarioNodeContext) {} get name(): string { return this.context.get('name', ''); } get objectives(): Array> { return this.context.get('objectives', [] as Array>); } get(key: string, defaultValue: T): T { return this.context.get(key, defaultValue); } get data(): Record { return this.context.data; } get node(): ExecutionPlanNode { return this.context.node; } /** * Reports progress toward an objective. When the reported counter completes * every objective, the server signals completion and the node crosses * `onComplete` automatically. */ async reportProgress(objectiveId: string, amount = 1): Promise { await this.context.updateProgress(objectiveId, amount); } } // ---- BattlePassSession ---- /** * A scenario battlepass node. Exposes the battlepass operations (xp, premium, * progress) bound to this node's scenario/node/run ids, plus explicit boundary * crossings (`onLevelUp`, `onPremiumPurchase`) the game drives from its UI. The * server validates each crossing (e.g. onLevelUp requires the level be reached). */ export class BattlePassSession { constructor( private readonly context: ScenarioNodeContext, private readonly battlePass: BattlePassService, ) {} get data(): Record { return this.context.data; } get node(): ExecutionPlanNode { return this.context.node; } get(key: string, defaultValue: T): T { return this.context.get(key, defaultValue); } private ids(): { scenarioId: string; nodeId: string; runId: string } { return { scenarioId: this.context.run.scenarioId, nodeId: this.context.node.id!, runId: this.context.run.runId, }; } /** Current xp/level/premium/claimed-tiers for this node. */ getProgress(): Promise { const { scenarioId, nodeId } = this.ids(); return this.battlePass.getProgress(scenarioId, nodeId); } /** Credits XP from a configured source. */ addXp(source: string, amount: number): Promise { const { scenarioId, nodeId, runId } = this.ids(); return this.battlePass.addXp({ scenarioId, nodeId, source, amount, runId }); } /** Claims a tier reward at a reached level. */ claimReward(level: number, track?: string): Promise { const { scenarioId, nodeId, runId } = this.ids(); return this.battlePass.claimReward({ scenarioId, nodeId, level, track, runId }); } /** Purchases premium, then crosses `onPremiumPurchase` on success. */ async purchasePremium(): Promise { const { scenarioId, nodeId, runId } = this.ids(); const response = await this.battlePass.purchasePremium({ scenarioId, nodeId, idempotencyKey: crypto.randomUUID(), runId, }); if (response.success) { await this.context.complete('onPremiumPurchase'); } return response; } /** Crosses `onLevelUp` (server validates the node's level was reached). */ async levelUp(): Promise { await this.context.complete('onLevelUp'); } /** Ends the battlepass node via `onComplete`. */ async end(): Promise { await this.context.complete('onComplete'); } } // ---- BattlePassLevelSession ---- /** * A scenario battlepass_level node — a single claimable tier. `claim()` crosses * `onComplete`, which the server accepts only once the player has reached the * node's configured level. */ export class BattlePassLevelSession { constructor(private readonly context: ScenarioNodeContext) {} get level(): number { return this.context.get('levelNumber', 0); } get data(): Record { return this.context.data; } get node(): ExecutionPlanNode { return this.context.node; } get(key: string, defaultValue: T): T { return this.context.get(key, defaultValue); } /** Claims this tier; crosses `onComplete` (server checks level reached). */ async claim(): Promise { await this.context.complete('onComplete'); } }