server/laf-cloud/functions/activityConfig/list.ts

111 lines
5.5 KiB
TypeScript

import cloud from "@lafjs/cloud";
import Utils from "@/Utils";
import { openPeriodForUser, openingRuleForUser } from "@/cloudRise/periods";
import { calendar } from "@/goldMiner/config";
import { periodById, periodForPlayer } from "@/goldMiner/service";
import { testPeriods } from "@/goldMiner/testPeriods";
const LOOKAHEAD_MS = 7 * 24 * 60 * 60 * 1000;
const REFRESH_MS = 30 * 1000;
type ActivityInfo = {
activityId: string;
periodId: string;
configVersion: string;
startsAt: number;
endsAt: number;
unlockLevel: number;
phase: "active" | "upcoming";
purchaseEnabled?: boolean;
};
/** Only an explicit HomeScene request creates a period; listing is read only. */
async function cloudRiseActivities(now: number, _windowEndsAt: number, user: any) {
const period = openPeriodForUser(user, now);
const activities: ActivityInfo[] = period ? [{
activityId: "cloudRise", periodId: period.periodId, configVersion: period.configVersion,
startsAt: period.startsAt, endsAt: period.endsAt, unlockLevel: period.unlockLevel, phase: "active"
}] : [];
return { activities, openingRules: user ? [await openingRuleForUser(user, now)] : [] };
}
/** Reuse the gameplay calendar, version selection, legacy pins and purchase control. Read-only. */
async function goldMinerActivities(now: number, windowEndsAt: number, user: any) {
const result: ActivityInfo[] = [];
const temporary = (await testPeriods()).filter((p: any) => p.startsAt < windowEndsAt && p.endsAt > now);
for (const entry of temporary) {
const period = await periodForPlayer(await periodById(entry.periodId), user?._id?.toString());
if (!period) continue;
result.push({
activityId: "goldMiner", periodId: period.periodId, configVersion: period.configVersion,
startsAt: period.startsAt, endsAt: period.endsAt, unlockLevel: period.configSnapshot.unlockPassedLevel,
phase: period.startsAt <= now ? "active" : "upcoming", purchaseEnabled: period.purchaseEnabled
});
}
for (let dates = calendar(now); dates.startsAt < windowEndsAt; dates = calendar(dates.nextStartsAt)) {
if (dates.endsAt <= now) continue;
// Hide the regular current entry while gameplay gives an active temporary period precedence.
if (dates.startsAt <= now && temporary.some((p: any) => p.startsAt <= now)) continue;
const period = await periodForPlayer(await periodById(dates.periodId), user?._id?.toString());
if (!period) continue;
result.push({
activityId: "goldMiner", periodId: period.periodId, configVersion: period.configVersion,
startsAt: period.startsAt, endsAt: period.endsAt,
unlockLevel: period.configSnapshot.unlockPassedLevel,
phase: period.startsAt <= now ? "active" : "upcoming", purchaseEnabled: period.purchaseEnabled,
});
}
return { activities: result, openingRules: [] };
}
// Providers share gameplay period selection and never create player records.
const providers = [cloudRiseActivities, goldMinerActivities];
export async function activityList(now = Date.now(), user: any = null) {
const windowEndsAt = now + LOOKAHEAD_MS;
const results = await Promise.all(providers.map(provider => provider(now, windowEndsAt, user)));
const candidates = results.flatMap(result => result.activities);
const openingRules = results.flatMap(result => result.openingRules);
// Each activity exposes one period: current first, otherwise the earliest upcoming period.
// Overlapping current periods keep the existing latest-start precedence.
candidates.sort((a, b) => a.activityId < b.activityId ? -1 : a.activityId > b.activityId ? 1
: Number(b.startsAt <= now) - Number(a.startsAt <= now)
|| (a.startsAt <= now ? b.startsAt - a.startsAt : a.startsAt - b.startsAt)
|| (a.periodId < b.periodId ? -1 : a.periodId > b.periodId ? 1 : 0));
const selected = new Set<string>();
const activities = candidates.filter(activity => {
if (selected.has(activity.activityId)) return false;
selected.add(activity.activityId); return true;
});
let refreshAt = now + REFRESH_MS;
// A currently hidden test period may take precedence when it starts; refresh at that boundary too.
for (const activity of candidates) {
for (const boundary of [activity.startsAt, activity.endsAt]) {
if (boundary > now) refreshAt = Math.min(refreshAt, boundary);
}
}
return { schemaVersion: 1, serverNow: now, windowEndsAt, refreshAt, activities, openingRules };
}
/** Authenticated users receive their personal CloudRise window; anonymous callers see public activities only. */
export default async function (ctx: FunctionContext) {
try {
const body = ctx.body || {}, supplied = body.uid !== undefined;
let user: any = null;
if (supplied) {
const uid = typeof body.uid === "number" ? body.uid
: typeof body.uid === "string" && /^[1-9]\d*$/.test(body.uid) ? Number(body.uid) : NaN;
if (!Number.isSafeInteger(uid) || uid < 1) return { code: 0, msg: "玩家 uid 无效", data: null };
user = await (cloud.mongo.db as any).collection("users").findOne({ onlyId: uid });
if (!user?.token || !Utils.checkToken(body.token, user.token)) return { code: 0, msg: "token校验失败", data: null };
}
return { code: 1, msg: "ok", data: await activityList(Date.now(), user) };
}
catch (error) {
console.error("activityConfig/list query failed", error);
// A failed query must not look like a successful empty list and erase a client's cached entries.
return { code: 0, msg: "活动信息暂时无法获取,请稍后重试", data: null };
}
}