server/laf-cloud/functions/cloudRise/periods.ts
2026-09-24 17:59:52 +08:00

92 lines
4.8 KiB
TypeScript

import cloud from "@lafjs/cloud";
import * as ConfigStore from "@/activityConfig/store";
import { randomBytes } from "crypto";
const users = (): any => (cloud.mongo.db as any).collection("users");
const terminal = (run: any): boolean => !!run && ["completed", "failed", "expired"].includes(run.status);
const stateOf = (user: any): any => typeof user.cloudRiseState === "string"
? JSON.parse(user.cloudRiseState || "{}") : user.cloudRiseState || {};
/** Read only: a legacy active challenge retains its existing deadline through the rollout. */
export function periodForUser(user: any): any {
if (!user) return null;
if (user.cloudRisePeriod) return user.cloudRisePeriod;
const run = stateOf(user).run;
if (!run || terminal(run)) return null;
return { periodId: run.periodId, configVersion: "legacy", startsAt: run.startedAt, endsAt: run.expiresAt,
durationHours: run.durationHours || 24, unlockLevel: 1,
pools: run.pools || [10000, 15000, 20000] };
}
export function openPeriodForUser(user: any, now: number): any {
const period = periodForUser(user);
if (!period || !(period.startsAt <= now && now < period.endsAt)) return null;
const run = stateOf(user).run;
return run?.periodId === period.periodId && terminal(run) ? null : period;
}
/** Fractional hours are a server-side test-only setting; client flags cannot enable them. */
export function validDurationHours(hours: any): boolean {
return Number.isFinite(hours) && hours > 0
&& (Number.isSafeInteger(hours) || process.env.PAYMENT_APP_ENV === "test")
&& Number.isSafeInteger(hours * 3600000) && hours * 3600000 >= 1;
}
/** A published version is now a template; old global signup end times do not close personal periods. */
export async function templateAt(now: number): Promise<any> {
const row = await ConfigStore.latestEnabled("cloudRise", now);
if (!row) return null;
const config = row.config, durationHours = config?.durationHours ?? 24;
const pools = config?.pools ?? [10000, 15000, 20000];
if (!config || !Number.isSafeInteger(config.unlockLevel) || config.unlockLevel < 1
|| !validDurationHours(durationHours)
|| !Number.isSafeInteger(now + durationHours * 3600000)
|| !Array.isArray(pools) || pools.length !== 3 || !pools.every((p: any) => Number.isSafeInteger(p) && p > 0)) {
throw new Error("百人赛活动模板无效");
}
return { configVersion: row.configVersion, durationHours, unlockLevel: config.unlockLevel, pools: [...pools] };
}
export const COOLDOWN_MS = 12 * 3600000;
/** Expiry uses the original deadline, even when the server notices it hours later. */
export function lastEndedAt(user: any, now: number): number | null {
const period = periodForUser(user), run = stateOf(user).run;
const deadline = Number(period?.endsAt ?? run?.expiresAt);
if (terminal(run) && (!period || run.periodId === period.periodId)) {
if (run.status !== "expired") {
const ended = Number(run.stages?.[run.stages.length - 1]?.endedAt);
if (Number.isFinite(ended) && ended > 0) return Number.isFinite(deadline) ? Math.min(ended, deadline) : ended;
}
return Number.isFinite(deadline) && deadline > 0 ? deadline : null;
}
return Number.isFinite(deadline) && deadline > 0 && deadline <= now ? deadline : null;
}
/** Read only: HomeScene receives the facts needed to decide whether to request opening. */
export async function openingRuleForUser(user: any, now: number): Promise<any> {
const template = await templateAt(now);
return { activityId: "cloudRise", enabled: !!template, unlockLevel: template?.unlockLevel ?? 0,
cooldownMs: COOLDOWN_MS, lastEndedAt: lastEndedAt(user, now), hasOpenPeriod: !!openPeriodForUser(user, now) };
}
/** Called only by an explicit HomeScene request; login, progress reports and queries never create periods. */
export async function openForUser(onlyId: number, now = Date.now()): Promise<any> {
for (let retry = 0; retry < 5; retry++) {
const user = await users().findOne({ onlyId });
if (!user) throw new Error("玩家不存在");
const existing = openPeriodForUser(user, now);
if (existing) return existing; // Idempotent retries, including two devices entering HomeScene together.
const template = await templateAt(now);
if (!template) return null; // A valid enabled template is needed to construct the period.
const period = { ...template, periodId: "cloudRise:" + onlyId + ":" + randomBytes(12).toString("hex"),
startsAt: now, endsAt: now + template.durationHours * 3600000 };
const result = await users().updateOne({ onlyId,
cloudRisePeriod: user.cloudRisePeriod === undefined ? { $exists: false } : user.cloudRisePeriod,
cloudRiseState: user.cloudRiseState === undefined ? { $exists: false } : user.cloudRiseState,
}, { $set: { cloudRisePeriod: period } });
if (result.modifiedCount === 1) return period;
}
throw new Error("百人赛活动状态正在更新,请重试");
}