2026-08-12 14:02:25 +03:00
|
|
|
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<ShopHandle>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---- 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<void>,
|
|
|
|
|
private readonly onProgress: (runId: string, nodeId: string, counterKey: string, amount: number) => Promise<void>,
|
|
|
|
|
) {}
|
|
|
|
|
|
|
|
|
|
/** Extracts a typed value from node data. */
|
|
|
|
|
get<T>(key: string, defaultValue: T): T {
|
|
|
|
|
const data = this.node.data as Record<string, unknown> | 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<string, unknown> {
|
|
|
|
|
return (this.node.data as Record<string, unknown>) ?? {};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Completes the current node with the given output handle. */
|
|
|
|
|
async complete(handle: string): Promise<void> {
|
|
|
|
|
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<void> {
|
|
|
|
|
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<T>(key: string, defaultValue: T): T {
|
|
|
|
|
return this.context.get(key, defaultValue);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
get data(): Record<string, unknown> {
|
|
|
|
|
return this.context.data;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
get node(): ExecutionPlanNode {
|
|
|
|
|
return this.context.node;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async complete(): Promise<void> {
|
|
|
|
|
await this.context.complete('output');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---- WaitSession ----
|
|
|
|
|
|
|
|
|
|
export class WaitSession {
|
|
|
|
|
constructor(
|
|
|
|
|
private readonly context: ScenarioNodeContext,
|
|
|
|
|
public readonly deadlineUtc: Date,
|
|
|
|
|
) {}
|
|
|
|
|
|
|
|
|
|
get<T>(key: string, defaultValue: T): T {
|
|
|
|
|
return this.context.get(key, defaultValue);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
get data(): Record<string, unknown> {
|
|
|
|
|
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<T>(key: string, defaultValue: T): T {
|
|
|
|
|
return this.context.get(key, defaultValue);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
get data(): Record<string, unknown> {
|
|
|
|
|
return this.context.data;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
get node(): ExecutionPlanNode {
|
|
|
|
|
return this.context.node;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async getStore(): Promise<ShopHandle> {
|
|
|
|
|
return this.stores.getBySlug(this.get('storeSlug', ''));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async buy(
|
|
|
|
|
offer: OfferHandle,
|
|
|
|
|
options?: BuyOptions,
|
|
|
|
|
): Promise<PurchaseOfferResponse> {
|
|
|
|
|
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<void> {
|
|
|
|
|
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<T>(key: string, defaultValue: T): T {
|
|
|
|
|
return this.context.get(key, defaultValue);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
get data(): Record<string, unknown> {
|
|
|
|
|
return this.context.data;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
get node(): ExecutionPlanNode {
|
|
|
|
|
return this.context.node;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async end(): Promise<void> {
|
|
|
|
|
if (this.resolved) return;
|
|
|
|
|
this.resolved = true;
|
|
|
|
|
await this.context.complete('onEnd');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async rewardClaimed(): Promise<void> {
|
|
|
|
|
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<Record<string, unknown>> {
|
|
|
|
|
return this.context.get('objectives', [] as Array<Record<string, unknown>>);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
get<T>(key: string, defaultValue: T): T {
|
|
|
|
|
return this.context.get(key, defaultValue);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
get data(): Record<string, unknown> {
|
|
|
|
|
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<void> {
|
|
|
|
|
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<string, unknown> {
|
|
|
|
|
return this.context.data;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
get node(): ExecutionPlanNode {
|
|
|
|
|
return this.context.node;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
get<T>(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<GetBattlePassProgressResponse> {
|
|
|
|
|
const { scenarioId, nodeId } = this.ids();
|
|
|
|
|
return this.battlePass.getProgress(scenarioId, nodeId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Credits XP from a configured source. */
|
|
|
|
|
addXp(source: string, amount: number): Promise<AddBattlePassXpResponse> {
|
|
|
|
|
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<ClaimBattlePassRewardResponse> {
|
|
|
|
|
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<PurchaseBattlePassPremiumResponse> {
|
|
|
|
|
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<void> {
|
|
|
|
|
await this.context.complete('onLevelUp');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Ends the battlepass node via `onComplete`. */
|
|
|
|
|
async end(): Promise<void> {
|
|
|
|
|
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 {
|
2026-08-19 17:48:55 +03:00
|
|
|
return this.context.get('levelNumber', 0);
|
2026-08-12 14:02:25 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
get data(): Record<string, unknown> {
|
|
|
|
|
return this.context.data;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
get node(): ExecutionPlanNode {
|
|
|
|
|
return this.context.node;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
get<T>(key: string, defaultValue: T): T {
|
|
|
|
|
return this.context.get(key, defaultValue);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Claims this tier; crosses `onComplete` (server checks level reached). */
|
|
|
|
|
async claim(): Promise<void> {
|
|
|
|
|
await this.context.complete('onComplete');
|
|
|
|
|
}
|
|
|
|
|
}
|