/** Coin Madness: manual, sequential stages. All dates are UTC epoch milliseconds. */ export const PRODUCT = 'coin_madness'; export interface CoinConfig { enabled: boolean; approved: boolean; periodId: string; startsAt: number; endsAt: number; minLevel: number; priceCents: number; title: string; stages: Array<{ wins: number; coins: number }>; } export interface CoinState { config: CoinConfig; paidAt: number; orderNo: string; reservedOrder: string; stage: number; progress: number; lastLevel: number; settled: boolean; } export function parse(value: any): T | null { try { return typeof value === 'string' ? JSON.parse(value) : value || null; } catch { return null; } } export function validConfig(raw: any): raw is CoinConfig { const integer = (n: any, min: number) => Number.isSafeInteger(n) && n >= min; return !!raw && raw.enabled === true && raw.approved === true && typeof raw.periodId === 'string' && /^[a-zA-Z0-9_-]{1,64}$/.test(raw.periodId) && integer(raw.startsAt, 1) && integer(raw.endsAt, raw.startsAt + 1) && integer(raw.minLevel, 0) && integer(raw.priceCents, 1) && typeof raw.title === 'string' && raw.title.length > 0 && raw.title.length <= 40 && Array.isArray(raw.stages) && raw.stages.length > 0 && raw.stages.length <= 50 && raw.stages.every((s: any) => integer(s.wins, 1) && integer(s.coins, 1)) && Number.isSafeInteger(raw.stages.reduce((n: number, s: any) => n + s.coins, 0)); } export function createState(config: CoinConfig, level: number): CoinState { return { config: JSON.parse(JSON.stringify(config)), paidAt: 0, orderNo: '', reservedOrder: '', stage: 0, progress: 0, lastLevel: level, settled: false }; } export function active(state: CoinState, now: number): boolean { return !!state && now >= state.config.startsAt && now < state.config.endsAt && !state.settled; } export function win(state: CoinState, level: number, now: number, isVictory: boolean, infinite: boolean): CoinState { const next: CoinState = JSON.parse(JSON.stringify(state)); if (!Number.isSafeInteger(level) || level <= next.lastLevel) return next; const singleNewLevel = level === next.lastLevel + 1; next.lastLevel = level; // Includes waiting-for-claim wins and syncs: never bank them. const target = next.config.stages[next.stage]; if (active(next, now) && next.paidAt > 0 && now >= next.paidAt && target && isVictory && !infinite && singleNewLevel && next.progress < target.wins) next.progress++; return next; } export function reward(state: CoinState, now: number, expired: boolean): { state: CoinState; coins: number } { const next: CoinState = JSON.parse(JSON.stringify(state)); if (expired ? now < next.config.endsAt : !active(next, now)) throw new Error('活动时间不允许此操作'); const target = next.config.stages[next.stage]; const coins = next.paidAt > 0 && !next.settled && target && next.progress >= target.wins ? target.coins : 0; if (!expired && !coins) throw new Error('当前阶段尚未达标'); if (coins) { next.stage++; next.progress = 0; } if (expired) next.settled = true; return { state: next, coins }; }