92 lines
4.8 KiB
TypeScript
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("百人赛活动状态正在更新,请重试");
|
|
}
|