844 lines
25 KiB
TypeScript
844 lines
25 KiB
TypeScript
import cloud from '@lafjs/cloud'
|
||
|
||
export default async function (ctx: FunctionContext) {
|
||
console.log('Hello World')
|
||
return { data: 'hi, laf' }
|
||
}
|
||
export type JungleRewardType = "coin" | "freeze" | "hammer" | "magic_wand" | "infinite_health";
|
||
|
||
export interface JungleRewardConfig {
|
||
type: JungleRewardType;
|
||
count: number;
|
||
itemId?: number;
|
||
}
|
||
|
||
export interface JungleTierConfig {
|
||
id: number;
|
||
price: number;
|
||
isPaid: boolean;
|
||
productId: string;
|
||
priceTemporary?: boolean;
|
||
rewards: JungleRewardConfig[];
|
||
}
|
||
|
||
export interface JungleTreasureConfig {
|
||
activityId: string;
|
||
version: number;
|
||
totalTiers: number;
|
||
tiers: JungleTierConfig[];
|
||
}
|
||
|
||
export type JungleActivityType = "personal" | "calendar";
|
||
|
||
export interface JungleCalendarConfig {
|
||
enabled: boolean;
|
||
dayOfMonth: number;
|
||
startHour: number;
|
||
startMinute: number;
|
||
timezoneOffsetMinutes: number;
|
||
durationMs: number;
|
||
testUserIds: readonly string[];
|
||
}
|
||
|
||
export interface JungleCalendarPeriod {
|
||
key: string;
|
||
startsAt: number;
|
||
expiresAt: number;
|
||
}
|
||
|
||
/** 0=付费档未解锁,1=已解锁未领取,2=已领取。 */
|
||
export type JungleTierState = 0 | 1 | 2;
|
||
|
||
/** 仅服务端存储,paidOrderNos 不返回客户端。 */
|
||
export interface JungleStoredState {
|
||
activityId: string;
|
||
configVersion: number;
|
||
activityType: JungleActivityType;
|
||
periodKey: string;
|
||
startsAt: number;
|
||
firstTriggered: boolean;
|
||
lastCalendarPeriodKey: string;
|
||
tierStates: JungleTierState[];
|
||
paidOrderNos: Record<string, string>;
|
||
revision: number;
|
||
expiresAt: number;
|
||
updatedAt: number;
|
||
}
|
||
|
||
export interface JungleUnlockResult {
|
||
ok: boolean;
|
||
changed: boolean;
|
||
tierId?: number;
|
||
state?: JungleStoredState;
|
||
message?: string;
|
||
}
|
||
|
||
export interface JungleClaimResult {
|
||
ok: boolean;
|
||
tierId?: number;
|
||
paidOrderNo?: string;
|
||
state?: JungleStoredState;
|
||
message?: string;
|
||
}
|
||
|
||
export type JungleRewardTotals = Record<JungleRewardType, number>;
|
||
|
||
export interface JungleExpiredSettlementResult {
|
||
ok: boolean;
|
||
claimedTierIds: number[];
|
||
rewards: JungleRewardTotals;
|
||
state?: JungleStoredState;
|
||
message?: string;
|
||
}
|
||
|
||
export interface JungleActivityResolution {
|
||
available: boolean;
|
||
changed: boolean;
|
||
state?: JungleStoredState;
|
||
reason: string;
|
||
}
|
||
|
||
export interface JungleActivityResolutionOptions {
|
||
allowCalendarStart?: boolean;
|
||
calendarConfig?: JungleCalendarConfig;
|
||
calendarUserId?: unknown;
|
||
}
|
||
|
||
export const JUNGLE_TREASURE_ACTIVITY_ID = "jungle_treasure_v1";
|
||
export const JUNGLE_TREASURE_CONFIG_VERSION = 2;
|
||
export const JUNGLE_TREASURE_TOTAL_TIERS = 51;
|
||
export const JUNGLE_TREASURE_DURATION_MS = 7 * 24 * 60 * 60 * 1000;
|
||
/** 全服周期正式配置:每月 28 日 00:00(北京时间)开始。 */
|
||
export const JUNGLE_TREASURE_CALENDAR_CONFIG: JungleCalendarConfig = {
|
||
enabled: true,
|
||
dayOfMonth: 28,
|
||
startHour: 0,
|
||
startMinute: 0,
|
||
timezoneOffsetMinutes: 8 * 60,
|
||
durationMs: JUNGLE_TREASURE_DURATION_MS,
|
||
testUserIds: [],
|
||
};
|
||
|
||
/** 后端礼包配置入口。修改数值时只调整此函数中的档位数据。 */
|
||
export function createJungleTreasureConfig(): JungleTreasureConfig {
|
||
const tiers: JungleTierConfig[] = [
|
||
createTier(1, 0, [{ type: "coin", count: 100 }]),
|
||
createTier(2, 0, [{ type: "hammer", count: 1 }]),
|
||
createTier(3, 0, [
|
||
{ type: "coin", count: 200 },
|
||
{ type: "freeze", count: 1 },
|
||
{ type: "magic_wand", count: 1 },
|
||
]),
|
||
createTier(4, 3, [{ type: "coin", count: 700 }], "jungle_treasure_1"),
|
||
createTier(5, 0, [{ type: "hammer", count: 1 }]),
|
||
createTier(6, 0, [{ type: "infinite_health", count: 900 }]),
|
||
createTier(7, 0, [
|
||
{ type: "coin", count: 200 },
|
||
{ type: "magic_wand", count: 1 },
|
||
]),
|
||
createTier(8, 0, [{ type: "coin", count: 100 }]),
|
||
createTier(9, 0, [{ type: "freeze", count: 1 }]),
|
||
createTier(10, 6, [
|
||
{ type: "coin", count: 1500 },
|
||
{ type: "freeze", count: 1 },
|
||
{ type: "magic_wand", count: 1 },
|
||
], "jungle_treasure_2"),
|
||
createTier(11, 0, [{ type: "infinite_health", count: 900 }]),
|
||
createTier(12, 0, [{ type: "hammer", count: 1 }]),
|
||
createTier(13, 0, [{ type: "coin", count: 500 }]),
|
||
createTier(14, 0, [{ type: "hammer", count: 1 }]),
|
||
createTier(15, 0, [{ type: "magic_wand", count: 1 }]),
|
||
createTier(16, 9, [
|
||
{ type: "coin", count: 2000 },
|
||
{ type: "freeze", count: 1 },
|
||
{ type: "magic_wand", count: 1 },
|
||
], "jungle_treasure_3"),
|
||
createTier(17, 0, [{ type: "infinite_health", count: 900 }]),
|
||
createTier(18, 0, [
|
||
{ type: "hammer", count: 2 },
|
||
{ type: "freeze", count: 2 },
|
||
{ type: "magic_wand", count: 2 },
|
||
]),
|
||
createTier(19, 0, [{ type: "coin", count: 200 }]),
|
||
createTier(20, 0, [{ type: "freeze", count: 1 }]),
|
||
createTier(21, 0, [{ type: "magic_wand", count: 1 }]),
|
||
createTier(22, 15, [
|
||
{ type: "coin", count: 3000 },
|
||
{ type: "freeze", count: 3 },
|
||
{ type: "magic_wand", count: 3 },
|
||
], "jungle_treasure_4"),
|
||
createTier(23, 0, [
|
||
{ type: "hammer", count: 2 },
|
||
{ type: "freeze", count: 1 },
|
||
]),
|
||
createTier(24, 0, [
|
||
{ type: "infinite_health", count: 1800 },
|
||
{ type: "freeze", count: 2 },
|
||
]),
|
||
createTier(25, 0, [{ type: "magic_wand", count: 2 }]),
|
||
createTier(26, 0, [{ type: "hammer", count: 1 }]),
|
||
createTier(27, 0, [{ type: "magic_wand", count: 1 }]),
|
||
createTier(28, 24, [
|
||
{ type: "coin", count: 5000 },
|
||
{ type: "hammer", count: 2 },
|
||
], "jungle_treasure_5"),
|
||
createTier(29, 0, [
|
||
{ type: "hammer", count: 2 },
|
||
{ type: "freeze", count: 1 },
|
||
{ type: "magic_wand", count: 1 },
|
||
]),
|
||
createTier(30, 0, [
|
||
{ type: "freeze", count: 4 },
|
||
{ type: "magic_wand", count: 4 },
|
||
]),
|
||
createTier(31, 0, [
|
||
{ type: "infinite_health", count: 1800 },
|
||
{ type: "freeze", count: 2 },
|
||
]),
|
||
createTier(32, 0, [{ type: "magic_wand", count: 2 }]),
|
||
createTier(33, 0, [
|
||
{ type: "freeze", count: 1 },
|
||
{ type: "magic_wand", count: 1 },
|
||
]),
|
||
createTier(34, 42, [
|
||
{ type: "coin", count: 10000 },
|
||
{ type: "hammer", count: 3 },
|
||
{ type: "freeze", count: 5 },
|
||
], "jungle_treasure_6"),
|
||
createTier(35, 0, [
|
||
{ type: "coin", count: 3000 },
|
||
{ type: "freeze", count: 2 },
|
||
{ type: "magic_wand", count: 2 },
|
||
]),
|
||
createTier(36, 0, [
|
||
{ type: "infinite_health", count: 3600 },
|
||
{ type: "hammer", count: 2 },
|
||
]),
|
||
createTier(37, 0, [
|
||
{ type: "coin", count: 2000 },
|
||
{ type: "magic_wand", count: 5 },
|
||
]),
|
||
createTier(38, 0, [{ type: "freeze", count: 3 }]),
|
||
createTier(39, 0, [{ type: "magic_wand", count: 3 }]),
|
||
createTier(40, 68, [
|
||
{ type: "coin", count: 21000 },
|
||
{ type: "hammer", count: 10 },
|
||
], "jungle_treasure_7"),
|
||
createTier(41, 0, [
|
||
{ type: "infinite_health", count: 7200 },
|
||
{ type: "freeze", count: 15 },
|
||
]),
|
||
createTier(42, 0, [{ type: "magic_wand", count: 15 }]),
|
||
createTier(43, 0, [{ type: "coin", count: 2500 }]),
|
||
createTier(44, 0, [{ type: "freeze", count: 5 }]),
|
||
createTier(45, 0, [{ type: "magic_wand", count: 5 }]),
|
||
createTier(46, 98, [
|
||
{ type: "coin", count: 35000 },
|
||
{ type: "freeze", count: 15 },
|
||
{ type: "magic_wand", count: 15 },
|
||
], "jungle_treasure_8"),
|
||
createTier(47, 0, [
|
||
{ type: "infinite_health", count: 14400 },
|
||
{ type: "hammer", count: 10 },
|
||
]),
|
||
createTier(48, 0, [{ type: "hammer", count: 5 }]),
|
||
createTier(49, 0, [
|
||
{ type: "freeze", count: 10 },
|
||
{ type: "magic_wand", count: 5 },
|
||
]),
|
||
createTier(50, 0, [{ type: "coin", count: 2500 }]),
|
||
createTier(51, 0, [
|
||
{ type: "freeze", count: 5 },
|
||
{ type: "magic_wand", count: 10 },
|
||
]),
|
||
];
|
||
|
||
validateTierOrder(tiers);
|
||
return {
|
||
activityId: JUNGLE_TREASURE_ACTIVITY_ID,
|
||
version: JUNGLE_TREASURE_CONFIG_VERSION,
|
||
totalTiers: JUNGLE_TREASURE_TOTAL_TIERS,
|
||
tiers,
|
||
};
|
||
}
|
||
|
||
export const JUNGLE_TREASURE_CONFIG = createJungleTreasureConfig();
|
||
|
||
export function createDefaultJungleState(now = Date.now()): JungleStoredState {
|
||
return createStoredState(
|
||
JUNGLE_TREASURE_CONFIG.tiers.map((tier) => tier.isPaid ? 0 : 1),
|
||
{},
|
||
0,
|
||
now + JUNGLE_TREASURE_DURATION_MS,
|
||
now,
|
||
{
|
||
activityType: "personal",
|
||
periodKey: "",
|
||
startsAt: now,
|
||
firstTriggered: true,
|
||
lastCalendarPeriodKey: "",
|
||
},
|
||
);
|
||
}
|
||
|
||
/** 只处理当前 JSON 字符串状态;无状态时创建默认 7 天数据。 */
|
||
export function normalizeStoredJungleState(raw: unknown, now = Date.now()): JungleStoredState {
|
||
const value = parseUnknownValue(raw);
|
||
const defaultState = createDefaultJungleState(now);
|
||
if (!value || Array.isArray(value) || !Array.isArray(value.tierStates)) {
|
||
return defaultState;
|
||
}
|
||
|
||
const expiresAt = toPositiveTimestamp(value.expiresAt);
|
||
if (!expiresAt) {
|
||
return defaultState;
|
||
}
|
||
|
||
const tierStates = [...defaultState.tierStates];
|
||
for (let index = 0; index < JUNGLE_TREASURE_TOTAL_TIERS; index++) {
|
||
const configuredTier = JUNGLE_TREASURE_CONFIG.tiers[index];
|
||
const state = toTierState(value.tierStates[index]);
|
||
if (state !== null) {
|
||
tierStates[index] = configuredTier.isPaid ? state : (state === 2 ? 2 : 1);
|
||
}
|
||
else {
|
||
tierStates[index] = defaultState.tierStates[index];
|
||
}
|
||
}
|
||
|
||
return createStoredState(
|
||
tierStates,
|
||
normalizePaidOrderNos(value),
|
||
toNonNegativeInteger(value.revision),
|
||
expiresAt,
|
||
toNonNegativeInteger(value.updatedAt) || now,
|
||
{
|
||
activityType: value.activityType === "calendar" ? "calendar" : "personal",
|
||
periodKey: normalizePeriodKey(value.periodKey),
|
||
startsAt: toPositiveTimestamp(value.startsAt)
|
||
|| Math.max(1, expiresAt - JUNGLE_TREASURE_DURATION_MS),
|
||
// 任何已有 Jungle 状态都视为首次独立活动已经触发。
|
||
firstTriggered: true,
|
||
lastCalendarPeriodKey: normalizePeriodKey(value.lastCalendarPeriodKey),
|
||
},
|
||
);
|
||
}
|
||
|
||
export function isCurrentJungleState(raw: unknown): boolean {
|
||
const value = parseUnknownValue(raw);
|
||
return Boolean(
|
||
value
|
||
&& !Array.isArray(value)
|
||
&& value.activityId === JUNGLE_TREASURE_ACTIVITY_ID
|
||
&& value.configVersion === JUNGLE_TREASURE_CONFIG_VERSION
|
||
&& (value.activityType === "personal" || value.activityType === "calendar")
|
||
&& typeof value.periodKey === "string"
|
||
&& toPositiveTimestamp(value.startsAt) > 0
|
||
&& value.firstTriggered === true
|
||
&& typeof value.lastCalendarPeriodKey === "string"
|
||
&& Array.isArray(value.tierStates)
|
||
&& value.tierStates.length === JUNGLE_TREASURE_TOTAL_TIERS
|
||
&& value.tierStates.every((item: unknown) => toTierState(item) !== null)
|
||
&& toPositiveTimestamp(value.expiresAt) > 0
|
||
);
|
||
}
|
||
|
||
/** 用户表中按 JSON 字符串保存,避免 Laf 数据面板展开 51 项状态数组。 */
|
||
export function serializeJungleState(state: JungleStoredState): string {
|
||
return JSON.stringify(state);
|
||
}
|
||
|
||
export function getJunglePaidTierByProductId(productId: unknown): JungleTierConfig | null {
|
||
const normalizedProductId = String(productId || "").trim();
|
||
if (!normalizedProductId) {
|
||
return null;
|
||
}
|
||
return JUNGLE_TREASURE_CONFIG.tiers.find(
|
||
(tier) => tier.isPaid && tier.productId === normalizedProductId,
|
||
) || null;
|
||
}
|
||
|
||
/** 返回当前正在进行的月历窗口;窗口外返回 null。 */
|
||
export function getCurrentJungleCalendarPeriod(
|
||
now = Date.now(),
|
||
config: JungleCalendarConfig = JUNGLE_TREASURE_CALENDAR_CONFIG,
|
||
): JungleCalendarPeriod | null {
|
||
if (!isValidJungleCalendarConfig(config) || !Number.isFinite(now) || now <= 0) {
|
||
return null;
|
||
}
|
||
|
||
const offsetMs = config.timezoneOffsetMinutes * 60 * 1000;
|
||
const localNow = new Date(now + offsetMs);
|
||
let year = localNow.getUTCFullYear();
|
||
let month = localNow.getUTCMonth();
|
||
let startsAt = Date.UTC(
|
||
year,
|
||
month,
|
||
config.dayOfMonth,
|
||
config.startHour,
|
||
config.startMinute,
|
||
) - offsetMs;
|
||
if (now < startsAt) {
|
||
month--;
|
||
if (month < 0) {
|
||
month = 11;
|
||
year--;
|
||
}
|
||
startsAt = Date.UTC(
|
||
year,
|
||
month,
|
||
config.dayOfMonth,
|
||
config.startHour,
|
||
config.startMinute,
|
||
) - offsetMs;
|
||
}
|
||
|
||
const expiresAt = startsAt + config.durationMs;
|
||
if (now < startsAt || now >= expiresAt) {
|
||
return null;
|
||
}
|
||
return {
|
||
key: `${year}-${String(month + 1).padStart(2, "0")}`,
|
||
startsAt,
|
||
expiresAt,
|
||
};
|
||
}
|
||
|
||
export function isValidJungleCalendarConfig(
|
||
config: JungleCalendarConfig = JUNGLE_TREASURE_CALENDAR_CONFIG,
|
||
): boolean {
|
||
return Boolean(
|
||
config
|
||
&& typeof config.enabled === "boolean"
|
||
&& Number.isInteger(config.dayOfMonth)
|
||
&& config.dayOfMonth >= 1
|
||
&& config.dayOfMonth <= 28
|
||
&& Number.isInteger(config.startHour)
|
||
&& config.startHour >= 0
|
||
&& config.startHour <= 23
|
||
&& Number.isInteger(config.startMinute)
|
||
&& config.startMinute >= 0
|
||
&& config.startMinute <= 59
|
||
&& Number.isInteger(config.timezoneOffsetMinutes)
|
||
&& Math.abs(config.timezoneOffsetMinutes) <= 14 * 60
|
||
&& Number.isFinite(config.durationMs)
|
||
&& config.durationMs > 0
|
||
&& Array.isArray(config.testUserIds)
|
||
&& config.testUserIds.every((uid) => typeof uid === "string" && Boolean(uid.trim()))
|
||
);
|
||
}
|
||
|
||
/** 全服开关关闭时,仅后端代码白名单中的用户可开启月历活动。 */
|
||
export function isJungleCalendarEnabledForUser(
|
||
userId: unknown,
|
||
config: JungleCalendarConfig = JUNGLE_TREASURE_CALENDAR_CONFIG,
|
||
): boolean {
|
||
if (!isValidJungleCalendarConfig(config)) {
|
||
return false;
|
||
}
|
||
if (config.enabled) {
|
||
return true;
|
||
}
|
||
const normalizedUserId = String(userId || "").trim();
|
||
return Boolean(
|
||
normalizedUserId
|
||
&& config.testUserIds.some((uid) => uid.trim() === normalizedUserId),
|
||
);
|
||
}
|
||
|
||
/** C 方案:只统计第一处未解锁付费档之前尚未领取的档位。 */
|
||
export function hasBlockingJungleClaims(raw: unknown, now = Date.now()): boolean {
|
||
const state = normalizeStoredJungleState(raw, now);
|
||
for (const tierState of state.tierStates) {
|
||
if (tierState === 0) {
|
||
break;
|
||
}
|
||
if (tierState === 1) {
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/** 到期结算只领取第一处未解锁付费档之前的 state=1 档位。 */
|
||
export function settleExpiredJungleTiers(
|
||
raw: unknown,
|
||
now = Date.now(),
|
||
): JungleExpiredSettlementResult {
|
||
const state = normalizeStoredJungleState(raw, now);
|
||
const rewards = createEmptyRewardTotals();
|
||
if (now < state.expiresAt) {
|
||
return { ok: false, claimedTierIds: [], rewards, message: "Jungle 活动尚未结束" };
|
||
}
|
||
|
||
const tierStates = [...state.tierStates];
|
||
const claimedTierIds: number[] = [];
|
||
for (let index = 0; index < tierStates.length; index++) {
|
||
const tierState = tierStates[index];
|
||
if (tierState === 0) {
|
||
break;
|
||
}
|
||
if (tierState !== 1) {
|
||
continue;
|
||
}
|
||
tierStates[index] = 2;
|
||
claimedTierIds.push(index + 1);
|
||
for (const reward of JUNGLE_TREASURE_CONFIG.tiers[index].rewards) {
|
||
rewards[reward.type] += reward.count;
|
||
}
|
||
}
|
||
|
||
if (claimedTierIds.length === 0) {
|
||
return { ok: false, claimedTierIds, rewards, message: "当前没有可结算的 Jungle 奖励" };
|
||
}
|
||
|
||
return {
|
||
ok: true,
|
||
claimedTierIds,
|
||
rewards,
|
||
state: createStoredState(
|
||
tierStates,
|
||
state.paidOrderNos,
|
||
state.revision + 1,
|
||
state.expiresAt,
|
||
now,
|
||
getStoredStateMetadata(state),
|
||
),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* read 调用时解析玩家应看到的活动。无历史状态优先创建首次独立活动;
|
||
* 已有状态结束后,只有月历窗口内且边界时未被旧活动阻挡才创建统一周期。
|
||
*/
|
||
export function resolveJungleActivity(
|
||
raw: unknown,
|
||
now = Date.now(),
|
||
options: JungleActivityResolutionOptions = {},
|
||
): JungleActivityResolution {
|
||
const config = options.calendarConfig || JUNGLE_TREASURE_CALENDAR_CONFIG;
|
||
const allowCalendarStart = options.allowCalendarStart !== false;
|
||
if (!hasStoredJungleValue(raw)) {
|
||
return {
|
||
available: true,
|
||
changed: true,
|
||
state: createDefaultJungleState(now),
|
||
reason: "personal_started",
|
||
};
|
||
}
|
||
|
||
let state = normalizeStoredJungleState(raw, now);
|
||
let changed = !isCurrentJungleState(raw) || typeof raw !== "string";
|
||
if (now < state.expiresAt) {
|
||
return { available: true, changed, state, reason: "active" };
|
||
}
|
||
|
||
const period = getCurrentJungleCalendarPeriod(now, config);
|
||
if (hasBlockingJungleClaims(state, now)) {
|
||
if (period && state.lastCalendarPeriodKey !== period.key) {
|
||
state = markCalendarPeriodHandled(state, period.key, now);
|
||
changed = true;
|
||
}
|
||
return { available: true, changed, state, reason: "claim_only" };
|
||
}
|
||
|
||
if (!isValidJungleCalendarConfig(config)) {
|
||
return { available: false, changed, state, reason: "invalid_calendar_config" };
|
||
}
|
||
if (!period) {
|
||
return { available: false, changed, state, reason: "outside_calendar_window" };
|
||
}
|
||
if (state.lastCalendarPeriodKey === period.key) {
|
||
return { available: false, changed, state, reason: "calendar_period_handled" };
|
||
}
|
||
if (state.expiresAt > period.startsAt) {
|
||
state = markCalendarPeriodHandled(state, period.key, now);
|
||
return { available: false, changed: true, state, reason: "blocked_at_calendar_start" };
|
||
}
|
||
if (!isJungleCalendarEnabledForUser(options.calendarUserId, config)) {
|
||
state = markCalendarPeriodHandled(state, period.key, now);
|
||
return { available: false, changed: true, state, reason: "calendar_disabled" };
|
||
}
|
||
if (!allowCalendarStart) {
|
||
return { available: false, changed, state, reason: "calendar_start_not_allowed" };
|
||
}
|
||
|
||
return {
|
||
available: true,
|
||
changed: true,
|
||
state: createCalendarJungleState(period, state, now),
|
||
reason: "calendar_started",
|
||
};
|
||
}
|
||
|
||
/** Jungle 下单只校验订单创建瞬间是否仍处于活动倒计时内。 */
|
||
export function isJungleActivityPurchasable(raw: unknown, now = Date.now()): boolean {
|
||
const value = parseUnknownValue(raw);
|
||
if (!value || Array.isArray(value) || !Array.isArray(value.tierStates)) {
|
||
return false;
|
||
}
|
||
const state = normalizeStoredJungleState(value, now);
|
||
return now >= state.startsAt && now < state.expiresAt;
|
||
}
|
||
|
||
export function unlockJungleTierByProductId(
|
||
raw: unknown,
|
||
productId: unknown,
|
||
outTradeNo = "",
|
||
now = Date.now(),
|
||
): JungleUnlockResult {
|
||
const tier = getJunglePaidTierByProductId(productId);
|
||
if (!tier) {
|
||
return { ok: false, changed: false, message: "未找到对应的 Jungle 付费档位" };
|
||
}
|
||
|
||
// Laf 可能暂时使用共享函数的旧类型缓存,这里按实际返回结构读取新字段。
|
||
const state: any = normalizeStoredJungleState(raw, now);
|
||
const stateExpiresAt = Number(state["expiresAt"]);
|
||
const tierStates = [...state.tierStates];
|
||
const paidOrderNos = { ...state.paidOrderNos };
|
||
let changed = false;
|
||
|
||
if (tierStates[tier.id - 1] === 0) {
|
||
tierStates[tier.id - 1] = 1;
|
||
changed = true;
|
||
}
|
||
if (outTradeNo && !paidOrderNos[String(tier.id)]) {
|
||
paidOrderNos[String(tier.id)] = outTradeNo;
|
||
changed = true;
|
||
}
|
||
|
||
return {
|
||
ok: true,
|
||
changed,
|
||
tierId: tier.id,
|
||
state: changed
|
||
? createStoredState(
|
||
tierStates,
|
||
paidOrderNos,
|
||
state.revision + 1,
|
||
stateExpiresAt,
|
||
now,
|
||
getStoredStateMetadata(state),
|
||
)
|
||
: state,
|
||
};
|
||
}
|
||
|
||
/** 活动中保持原单档规则;活动过期后禁止越过第一处未解锁付费档。 */
|
||
export function claimJungleTier(raw: unknown, rawTierId: unknown, now = Date.now()): JungleClaimResult {
|
||
const tierId = Number(rawTierId);
|
||
if (!Number.isInteger(tierId) || tierId < 1 || tierId > JUNGLE_TREASURE_TOTAL_TIERS) {
|
||
return { ok: false, message: "礼包档位编号不合法" };
|
||
}
|
||
|
||
const state: any = normalizeStoredJungleState(raw, now);
|
||
const stateExpiresAt = Number(state["expiresAt"]);
|
||
const currentState = state.tierStates[tierId - 1];
|
||
if (currentState === 0) {
|
||
return { ok: false, tierId, message: "该付费档位尚未解锁" };
|
||
}
|
||
if (currentState === 2) {
|
||
return { ok: false, tierId, message: "该档位已经领取" };
|
||
}
|
||
if (now >= stateExpiresAt) {
|
||
const firstLockedIndex = state.tierStates.indexOf(0);
|
||
if (firstLockedIndex >= 0 && tierId - 1 > firstLockedIndex) {
|
||
return { ok: false, tierId, message: "活动已结束,该档位位于未解锁付费档之后" };
|
||
}
|
||
}
|
||
|
||
const tierStates = [...state.tierStates];
|
||
tierStates[tierId - 1] = 2;
|
||
return {
|
||
ok: true,
|
||
tierId,
|
||
paidOrderNo: state.paidOrderNos[String(tierId)] || "",
|
||
state: createStoredState(
|
||
tierStates,
|
||
state.paidOrderNos,
|
||
state.revision + 1,
|
||
stateExpiresAt,
|
||
now,
|
||
getStoredStateMetadata(state),
|
||
),
|
||
};
|
||
}
|
||
|
||
function createTier(
|
||
id: number,
|
||
price: number,
|
||
rewards: JungleRewardConfig[],
|
||
productId = "",
|
||
priceTemporary = false,
|
||
): JungleTierConfig {
|
||
const isPaid = price > 0;
|
||
if (isPaid && !productId) {
|
||
throw new Error(`[JungleTreasureConfig] 第 ${id} 档为付费档,必须配置 productId`);
|
||
}
|
||
if (!rewards.length || rewards.length > 5) {
|
||
throw new Error(`[JungleTreasureConfig] 第 ${id} 档奖励数量必须为 1 至 5`);
|
||
}
|
||
return {
|
||
id,
|
||
price,
|
||
isPaid,
|
||
productId: isPaid ? productId : "",
|
||
...(priceTemporary ? { priceTemporary: true } : {}),
|
||
rewards,
|
||
};
|
||
}
|
||
|
||
function validateTierOrder(tiers: JungleTierConfig[]): void {
|
||
if (tiers.length !== JUNGLE_TREASURE_TOTAL_TIERS) {
|
||
throw new Error(`[JungleTreasureConfig] 应配置 ${JUNGLE_TREASURE_TOTAL_TIERS} 档,实际为 ${tiers.length} 档`);
|
||
}
|
||
tiers.forEach((tier, index) => {
|
||
if (tier.id !== index + 1) {
|
||
throw new Error(`[JungleTreasureConfig] 第 ${index + 1} 个配置的 id 必须为 ${index + 1}`);
|
||
}
|
||
});
|
||
}
|
||
|
||
function createStoredState(
|
||
tierStates: JungleTierState[],
|
||
paidOrderNos: Record<string, string>,
|
||
revision: number,
|
||
expiresAt: number,
|
||
updatedAt: number,
|
||
metadata?: Partial<Pick<
|
||
JungleStoredState,
|
||
"activityType" | "periodKey" | "startsAt" | "firstTriggered" | "lastCalendarPeriodKey"
|
||
>>,
|
||
): JungleStoredState {
|
||
return {
|
||
activityId: JUNGLE_TREASURE_ACTIVITY_ID,
|
||
configVersion: JUNGLE_TREASURE_CONFIG_VERSION,
|
||
activityType: metadata?.activityType === "calendar" ? "calendar" : "personal",
|
||
periodKey: normalizePeriodKey(metadata?.periodKey),
|
||
startsAt: toPositiveTimestamp(metadata?.startsAt)
|
||
|| Math.max(1, expiresAt - JUNGLE_TREASURE_DURATION_MS),
|
||
firstTriggered: metadata?.firstTriggered !== false,
|
||
lastCalendarPeriodKey: normalizePeriodKey(metadata?.lastCalendarPeriodKey),
|
||
tierStates,
|
||
paidOrderNos: { ...paidOrderNos },
|
||
revision,
|
||
expiresAt,
|
||
updatedAt,
|
||
};
|
||
}
|
||
|
||
function createEmptyRewardTotals(): JungleRewardTotals {
|
||
return {
|
||
coin: 0,
|
||
freeze: 0,
|
||
hammer: 0,
|
||
magic_wand: 0,
|
||
infinite_health: 0,
|
||
};
|
||
}
|
||
|
||
function createCalendarJungleState(
|
||
period: JungleCalendarPeriod,
|
||
previousState: JungleStoredState,
|
||
now: number,
|
||
): JungleStoredState {
|
||
return createStoredState(
|
||
JUNGLE_TREASURE_CONFIG.tiers.map((tier) => tier.isPaid ? 0 : 1),
|
||
{},
|
||
previousState.revision + 1,
|
||
period.expiresAt,
|
||
now,
|
||
{
|
||
activityType: "calendar",
|
||
periodKey: period.key,
|
||
startsAt: period.startsAt,
|
||
firstTriggered: true,
|
||
lastCalendarPeriodKey: period.key,
|
||
},
|
||
);
|
||
}
|
||
|
||
function markCalendarPeriodHandled(
|
||
state: JungleStoredState,
|
||
periodKey: string,
|
||
now: number,
|
||
): JungleStoredState {
|
||
return createStoredState(
|
||
[...state.tierStates],
|
||
state.paidOrderNos,
|
||
state.revision + 1,
|
||
state.expiresAt,
|
||
now,
|
||
{
|
||
...getStoredStateMetadata(state),
|
||
lastCalendarPeriodKey: periodKey,
|
||
},
|
||
);
|
||
}
|
||
|
||
function getStoredStateMetadata(state: JungleStoredState) {
|
||
return {
|
||
activityType: state.activityType,
|
||
periodKey: state.periodKey,
|
||
startsAt: state.startsAt,
|
||
firstTriggered: state.firstTriggered,
|
||
lastCalendarPeriodKey: state.lastCalendarPeriodKey,
|
||
};
|
||
}
|
||
|
||
function hasStoredJungleValue(raw: unknown): boolean {
|
||
return raw !== undefined && raw !== null && !(typeof raw === "string" && !raw.trim());
|
||
}
|
||
|
||
function normalizePaidOrderNos(value: any): Record<string, string> {
|
||
const result: Record<string, string> = {};
|
||
if (!value || Array.isArray(value) || !value.paidOrderNos || typeof value.paidOrderNos !== "object") {
|
||
return result;
|
||
}
|
||
for (const tier of JUNGLE_TREASURE_CONFIG.tiers) {
|
||
if (!tier.isPaid) {
|
||
continue;
|
||
}
|
||
const orderNo = String(value.paidOrderNos[String(tier.id)] || "").trim();
|
||
if (orderNo) {
|
||
result[String(tier.id)] = orderNo;
|
||
}
|
||
}
|
||
return result;
|
||
}
|
||
|
||
function parseUnknownValue(raw: unknown): any {
|
||
if (Array.isArray(raw) || (raw && typeof raw === "object")) {
|
||
return raw;
|
||
}
|
||
if (typeof raw !== "string" || !raw.trim()) {
|
||
return null;
|
||
}
|
||
try {
|
||
return JSON.parse(raw);
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function toTierState(value: unknown): JungleTierState | null {
|
||
const state = Number(value);
|
||
return state === 0 || state === 1 || state === 2 ? state : null;
|
||
}
|
||
|
||
function toNonNegativeInteger(value: unknown): number {
|
||
const numberValue = Number(value);
|
||
return Number.isInteger(numberValue) && numberValue >= 0 ? numberValue : 0;
|
||
}
|
||
|
||
function toPositiveTimestamp(value: unknown): number {
|
||
const numberValue = Number(value);
|
||
return Number.isFinite(numberValue) && numberValue > 0 ? Math.floor(numberValue) : 0;
|
||
}
|
||
|
||
function normalizePeriodKey(value: unknown): string {
|
||
const periodKey = String(value || "").trim();
|
||
return /^\d{4}-\d{2}$/.test(periodKey) ? periodKey : "";
|
||
}
|