diff --git a/assets/Script/JiaZai.ts b/assets/Script/JiaZai.ts index c91a33f1..b5038c4d 100644 --- a/assets/Script/JiaZai.ts +++ b/assets/Script/JiaZai.ts @@ -18,7 +18,7 @@ import { MiniGameSdk } from "./Sdk/MiniGameSdk"; import { LQCollideSystem } from "./lq_collide_system/lq_collide_system"; import AudioManager from "./module/Music/AudioManager"; import Utils from "./module/Pay/Utils"; -import { applyStarterPackStatus, starterPackRemaining, starterPackTimeText, starterPackDay, starterPackPopupKey, starterPackReward } from "./module/Config/StarterPack"; +import { applyStarterPackStatus, starterPackRemaining, starterPackTimeText, starterPackPopupKey, starterPackReward, starterPackFailureKey, starterPackNow } from "./module/Config/StarterPack"; import { initProvinceLocator } from "./module/Position/GetPosition"; import LoadingCatAnimation from "./LoadingCatAnimation"; import BlockAssetManager from "./module/Tool/BlockAssetManager"; @@ -2659,7 +2659,7 @@ export default class JiaZai extends cc.Component { || order.itemid == "gold_4" || order.itemid == "gold_5" || order.itemid == "gold_6" || order.itemid == "unlimited_health_bundle_10" || order.itemid == "unlimited_health_bundle_20" || order.itemid == "unlimited_health_bundle_30" || order.itemid == "month_Card" || /^reborn_Gift(?:_[2-5])?$/.test(order.itemid) - || order.itemid == "starter_pack" || order.itemid == "battlepass" || !!getNewShopProduct(order.itemid) + || /^starter_pack(?:_[2-5])?$/.test(order.itemid) || order.itemid == "battlepass" || !!getNewShopProduct(order.itemid) ) { if (canUpdateHome()) this.openLoad(); // console.log("补发名称:", order.itemid); @@ -2691,13 +2691,8 @@ export default class JiaZai extends cc.Component { else if (/^reborn_Gift(?:_[2-5])?$/.test(order.itemid)) { coinTemp = 1000; } - else if (order.itemid == "starter_pack") { - coinTemp = starterPackReward().coin; - cc.fx.GameConfig.GM_INFO.starterPackPurchased = true; - if (canUpdateHome()) { - this.newbieGift = false; - this.startStarter_pack(); - } + else if (/^starter_pack(?:_[2-5])?$/.test(order.itemid)) { + coinTemp = starterPackReward(order.starterPackOffer || res.starterPackOffer).coin; } else if (order.itemid == "battlepass") { // 通行证订单 this.reissuePaaCheck(); @@ -2705,7 +2700,12 @@ export default class JiaZai extends cc.Component { let title = "充值补发奖励金币:" + coinTemp; // MiniGameSdk.API.showToast(title); if (order.itemid != "month_Card") - cc.fx.GameTool.shopBuy(productId, true, productId === "starter_pack" || /^reborn_Gift(?:_[2-5])?$/.test(productId) || !!getNewShopProduct(productId) ? { outTradeNo: order.outTradeNo } : null); + cc.fx.GameTool.shopBuy(productId, true, /^starter_pack(?:_[2-5])?$/.test(productId) || /^reborn_Gift(?:_[2-5])?$/.test(productId) || !!getNewShopProduct(productId) ? { + outTradeNo: order.outTradeNo, + starterPackOffer: order.starterPackOffer || res.starterPackOffer, + starterPackBuyRound: order.starterPackBuyRound || res.starterPackBuyRound, + } : null); + if (/^starter_pack(?:_[2-5])?$/.test(productId) && canUpdateHome()) this.startStarter_pack(); // console.log("充值成功获得金币"); let price = parseInt(order.goodsPrice) || 0; // const dataSuccess = { @@ -2927,9 +2927,8 @@ export default class JiaZai extends cc.Component { } this.actionpNode.active = true; this.actionpNode.getComponent("NewbieGift").init(first); - // Count only a displayed popup, including the initial activation and - // manual opens. Failed loads must not consume today's reminder. - cc.fx.StorageMessage.setStorage(starterPackPopupKey(), starterPackDay()); + // Only an actually displayed automatic popup consumes this round's reminder. + cc.fx.GameTool.recordStarterPackShown(!event && customEventData === undefined); this.newbieGift = false; cc.fx.GameTool.shushu_Track("interface_exposure", { interface_id: "NewbieGift", reason: event ? "click" : "auto", @@ -3848,7 +3847,7 @@ export default class JiaZai extends cc.Component { this.newbieGift = false; this.setStarterPackHomeButtonVisible(false); this.stopStarter_pack(); - cc.fx.GameTool.tryReactivateStarterPack("low_coin"); + cc.fx.GameTool.tryReactivateStarterPack("resources"); } }; this.schedule(this.scheduleCallback3, 1); @@ -4090,14 +4089,14 @@ export default class JiaZai extends cc.Component { // 功能:检测是否需要弹出新手礼包 // 何时调用:在onLoad中调用 // 触发条件:关卡达到15级后检测 - // 主要功能:使用缓存控制每天只弹一次 + // 主要功能:资源触发时每轮最多自动弹一次 // ============================================ - onStarterPackReactivated() { + onStarterPackReactivated(automatic: boolean = true) { if (!this.isHomeRuntimeAlive() || starterPackRemaining() <= 0) return; this.setStarterPackHomeButtonVisible(true); - this.newbieGift = true; + this.newbieGift = automatic; this.startStarter_pack(); - this.openStarter_pack(); + if (automatic) this.openStarter_pack(); } checkStarter_pack() { @@ -4120,15 +4119,17 @@ export default class JiaZai extends cc.Component { const available = starterPackRemaining() > 0; this.setStarterPackHomeButtonVisible(available); this.startStarter_pack(); - this.newbieGift = available && cc.fx.StorageMessage.getStorage(starterPackPopupKey()) !== starterPackDay(); + const info = cc.fx.GameConfig.GM_INFO; + const needsResources = Number.isFinite(info.coin) && info.coin < 500 + || info.hp === 0 && Number.isFinite(info.userPowerTime) && info.userPowerTime <= starterPackNow() / 1000 + || [info.freezeAmount, info.hammerAmount, info.magicAmount].every(value => Number.isFinite(value) && value >= 0 && value <= 3) + || Number(cc.fx.StorageMessage.getStorage(starterPackFailureKey())) >= 4; + this.newbieGift = available && needsResources && !cc.fx.StorageMessage.getStorage(starterPackPopupKey()) + && (!info.starterPackRound || info.starterPackAutoShownRound !== info.starterPackRound); this.winStreakShow = !this.newbieGift; if (this.newbieGift) this.openStarter_pack(); }; - Utils.getStarter_pack(res => { - if (res && res.code === 1 && Number(res.data.starter_pack) === 0 && Number(res.data.starter_packState) !== 1) { - Utils.setStarter_pack(apply); - } else apply(res); - }); + Utils.getStarter_pack(apply); } // ============================================ diff --git a/assets/Script/SceneManager.ts b/assets/Script/SceneManager.ts index 173e76cf..7df8acad 100644 --- a/assets/Script/SceneManager.ts +++ b/assets/Script/SceneManager.ts @@ -1917,6 +1917,7 @@ export default class SceneManager extends cc.Component { root.active = true; root.once("starter-pack-closed", restore); root.getComponent("NewbieGift").init(!existing); + cc.fx.GameTool.recordStarterPackShown(entryPoint !== "shop_page"); cc.fx.GameTool.shushu_Track("interface_exposure", { interface_id: "NewbieGift", reason: entryPoint === "shop_page" ? "click" : "auto", is_first_exposure: !existing, entry_point: entryPoint, diff --git a/assets/Script/module/Config/GameConfig.ts b/assets/Script/module/Config/GameConfig.ts index 9bf62e36..72f7be5c 100644 --- a/assets/Script/module/Config/GameConfig.ts +++ b/assets/Script/module/Config/GameConfig.ts @@ -581,6 +581,9 @@ export class GameConfig { starter_packTime: number; // 新手礼包截止时间,秒 starterPackServerOffset: number; // 服务端时间与本地时间差,毫秒 starterPackPurchased: boolean; + starterPackRound: number; + starterPackOffer: any; + starterPackAutoShownRound: number; starterPackLastShownAt: number; // 最近一次实际弹出时间,毫秒 starterPackReactivatedDeadline: number; // 本次会话已确认的新周期截止时间,毫秒 wxFriend: boolean; //微信好友信息授权 @@ -796,6 +799,9 @@ export class GameConfig { starter_packTime: 0, // 新手礼包截止时间 starterPackServerOffset: 0, starterPackPurchased: false, + starterPackRound: 0, + starterPackOffer: null, + starterPackAutoShownRound: 0, starterPackLastShownAt: 0, starterPackReactivatedDeadline: 0, wxFriend: false, //微信好友信息授权 diff --git a/assets/Script/module/Config/StarterPack.ts b/assets/Script/module/Config/StarterPack.ts index 540895b8..f760363e 100644 --- a/assets/Script/module/Config/StarterPack.ts +++ b/assets/Script/module/Config/StarterPack.ts @@ -1,5 +1,6 @@ -/** Purchases and login compensation use the same client-owned reward. */ -export function starterPackReward() { +/** Use the order's offer for delivery; missing snapshots retain legacy rewards. */ +export function starterPackReward(offer?: any) { + if (offer?.reward) return { ...offer.reward }; return { coin: 3000, hammer: 3, freeze: 3, magic_wand: 3, infinite_health: 30 * 60 }; } @@ -21,6 +22,17 @@ export function starterPackTimeText(seconds: number): string { export function applyStarterPackStatus(data: any): void { const info = cc.fx.GameConfig.GM_INFO; const expiry = Number(data.starter_pack) || 0; + const round = Number(data.starterPackRound || data.starterPackOffer?.round) || 0; + if (round && round < (info.starterPackRound || 0)) return; + if (round > (info.starterPackRound || 0)) { + info.starterPackPurchased = false; + info.starterPackReactivatedDeadline = 0; + info.starterPackRound = round; + } + if (data.starterPackOffer && (!info.starterPackOffer || round > Number(info.starterPackOffer.round))) { + info.starterPackOffer = data.starterPackOffer; + } + info.starterPackAutoShownRound = Math.max(info.starterPackAutoShownRound || 0, Number(data.starterPackAutoShownRound) || 0); if (data.reactivated) info.starterPackReactivatedDeadline = Math.max(info.starterPackReactivatedDeadline || 0, expiry); // An activity read started before reactivation may arrive after its response. info.starter_packTime = Math.max(expiry, info.starterPackReactivatedDeadline || 0) / 1000; @@ -37,7 +49,8 @@ export function starterPackDay(): string { } export function starterPackPopupKey(): string { - return 'Starter_pack_' + cc.fx.GameConfig.GM_INFO.uid; + const info = cc.fx.GameConfig.GM_INFO; + return 'Starter_pack_' + info.uid + '_' + (info.starterPackRound || info.starter_packTime); } export function starterPackFailureKey(): string { diff --git a/assets/Script/module/Pay/Utils.ts b/assets/Script/module/Pay/Utils.ts index cd2be797..95ac6630 100644 --- a/assets/Script/module/Pay/Utils.ts +++ b/assets/Script/module/Pay/Utils.ts @@ -95,7 +95,8 @@ export default class Utils { //#region 道具直购接口 /**道具直购接口*/ - static buyProp(id, count, price, systemType, productId, callBack, passEnd?: string) { + static buyProp(id, count, price, systemType, productId, callBack, passEnd?: string, starterPackRound?: number) { + if (!/^starter_pack(?:_[2-5])?$/.test(productId)) cc.fx.GameTool.starterPackQueuedReason = "purchase"; let postName = "wx/orderPaySig"; if (systemType == "ios") { postName = "wx/iosorderPaySig"; @@ -103,7 +104,8 @@ export default class Utils { console.log("请求uid:" + Utils.uid, "请求id:" + id, "请求数量:" + count, "请求价格:" + price, "请求链接:" + postName); Utils.POST(postName, { uid: Utils.uid, itemid: id, itemCount: count, itemPrice: price, - ...(passEnd ? { passVersion: 2, passEnd } : {}) + ...(passEnd ? { passVersion: 2, passEnd } : {}), + ...(starterPackRound ? { starterPackRound } : {}) }, res => { if (res.code == 1) { Utils.outTradeNo = res.data.outTradeNo; @@ -247,6 +249,7 @@ export default class Utils { //#region ios支付 /**跳转客服,ios支付*/ static GoKEFu(iosPayInfo, callBack) { + if (!/^starter_pack(?:_[2-5])?$/.test(iosPayInfo.payment_name)) cc.fx.GameTool.starterPackQueuedReason = "purchase"; console.log("创建新的订单,清空老订单"); var order = `wcx_` + Math.round(Math.random() * 10 ** 13) + Date.now(); if (iosPayInfo.payment_name == "month_Card") { @@ -257,7 +260,7 @@ export default class Utils { cc.fx.GameConfig.GM_INFO.iosReviveOrder = null; cc.fx.GameConfig.GM_INFO.iosReviveOrder = order; } - else if (iosPayInfo.payment_name == "starter_pack") { + else if (/^starter_pack(?:_[2-5])?$/.test(iosPayInfo.payment_name)) { cc.fx.GameConfig.GM_INFO.iosStarterOrder = null; cc.fx.GameConfig.GM_INFO.iosStarterOrder = order; } @@ -280,6 +283,7 @@ export default class Utils { count: iosPayInfo.payment_count, price: iosPayInfo.price,//价格单位是分 ...(iosPayInfo.passEnd ? { passEnd: iosPayInfo.passEnd, passVersion: 2 } : {}), + ...(iosPayInfo.starterPackRound ? { starterPackRound: iosPayInfo.starterPackRound } : {}), } const shushu_data = { outTradeNo: order, @@ -1177,9 +1181,9 @@ export default class Utils { }, callBack); } - static markStarterPackShown(expiry: number) { + static markStarterPackShown(expiry: number, automatic: boolean = false) { Utils.POST("limitedTimeEvent", { - uid: cc.fx.GameConfig.GM_INFO.uid, event: "starter_pack", action: "shown", expiry, + uid: cc.fx.GameConfig.GM_INFO.uid, event: "starter_pack", action: "shown", expiry, automatic, }, () => { }); } @@ -1210,7 +1214,7 @@ export default class Utils { } } //触发新手礼包事件 - static setStarter_pack(callBack) { + static setStarter_pack(callBack, reason?: string) { //@ts-ignore if ((typeof wx !== 'undefined' && wx !== null) || (typeof tt !== 'undefined' && tt !== null)) { let uid = cc.fx.StorageMessage.getStorage("uid"); @@ -1222,6 +1226,7 @@ export default class Utils { uid: cc.fx.GameConfig.GM_INFO.uid, action: 'save', event: "starter_pack", + reason, level: cc.fx.GameConfig.GM_INFO.level, } Utils.POST("limitedTimeEvent", setData, res => { console.log("触发新手礼包事件:", res); diff --git a/assets/Script/module/Tool/GameTool.ts b/assets/Script/module/Tool/GameTool.ts index e39435b6..ad1a7a1d 100644 --- a/assets/Script/module/Tool/GameTool.ts +++ b/assets/Script/module/Tool/GameTool.ts @@ -7,9 +7,10 @@ import { MiniGameSdk } from "../../Sdk/MiniGameSdk"; import Utils from "../Pay/Utils"; import { getNewShopProduct } from "../Config/ShopCatalog"; import BlockAssetManager from "./BlockAssetManager"; +import HomePopupQueue from "../../../home_popup_queue/HomePopupQueue"; import { starterPackReward, applyStarterPackStatus, starterPackRemaining, starterPackNow, - starterPackDay, starterPackPopupKey, starterPackFailureKey, starterPackRecordResult + starterPackPopupKey, starterPackFailureKey, starterPackRecordResult } from "../Config/StarterPack"; //@ts-ignore //最大工具类 各种公共方法,以及处理上传,获取后端接口数据 @@ -801,6 +802,7 @@ var GameTool = { if (callback) { callback(data); } + if (data?.code === 1) this.tryReactivateStarterPack("resources"); }) } }, @@ -886,6 +888,7 @@ var GameTool = { cc.fx.GameTool.shushu_Track("shop_buy", buyData); callback(); + this.tryReactivateStarterPack("purchase"); } }, @@ -1000,6 +1003,7 @@ var GameTool = { //商城购买 shopBuy(productId, compensate, orderContext: any = null) { + if (/^starter_pack_[2-5]$/.test(productId) && !orderContext?.starterPackOffer?.reward) return false; const newProduct = getNewShopProduct(productId); const newOrderKey = newProduct && orderContext?.outTradeNo ? "ShopBGranted_" + cc.fx.GameConfig.GM_INFO.uid + "_" + orderContext.outTradeNo : ""; @@ -1007,7 +1011,7 @@ var GameTool = { const rebornOrderKey = /^reborn_Gift(?:_[2-5])?$/.test(productId) && orderContext?.outTradeNo ? "RebornGiftGranted_" + cc.fx.GameConfig.GM_INFO.uid + "_" + orderContext.outTradeNo : ""; if (rebornOrderKey && cc.fx.StorageMessage.getStorage(rebornOrderKey)) return false; - const starterOrderKey = productId === "starter_pack" && orderContext?.outTradeNo + const starterOrderKey = /^starter_pack(?:_[2-5])?$/.test(productId) && orderContext?.outTradeNo ? "StarterPackGranted_" + cc.fx.GameConfig.GM_INFO.uid + "_" + orderContext.outTradeNo : ""; if (starterOrderKey && cc.fx.StorageMessage.getStorage(starterOrderKey)) return; let coin = 0; @@ -1295,16 +1299,20 @@ var GameTool = { if (rebornOrderKey) cc.fx.StorageMessage.setStorage(rebornOrderKey, true); if (orderContext?.onRebornGiftGranted) orderContext.onRebornGiftGranted(); break; - case "starter_pack": { - const reward = starterPackReward(); + case "starter_pack": + case "starter_pack_2": + case "starter_pack_3": + case "starter_pack_4": + case "starter_pack_5": { + const reward = starterPackReward(orderContext?.starterPackOffer); coin = reward.coin; - price = 300; + price = orderContext?.starterPackOffer?.priceFen || 300; cc.fx.GameTool.changeCoin(coin); this.getShopProp({ freeze: reward.freeze, hammer: reward.hammer, magic_wand: reward.magic_wand, price }, compensate); - this.setUserPowerTime(reward.infinite_health, "starter_pack", compensate); + this.setUserPowerTime(reward.infinite_health, productId, compensate); rewardData = [ { type: "coin", count: coin }, { type: "freeze", count: reward.freeze }, @@ -1312,7 +1320,9 @@ var GameTool = { { type: "magic", count: reward.magic_wand }, { type: "infinite_health", count: reward.infinite_health }, ]; - cc.fx.GameConfig.GM_INFO.starterPackPurchased = true; + if ((Number(orderContext?.starterPackBuyRound) || 1) === (cc.fx.GameConfig.GM_INFO.starterPackRound || 1)) { + cc.fx.GameConfig.GM_INFO.starterPackPurchased = true; + } if (starterOrderKey) cc.fx.StorageMessage.setStorage(starterOrderKey, true); break; } @@ -1352,6 +1362,7 @@ var GameTool = { } cc.fx.GameTool.shushu_Track("shop_buy", buyData); MiniGameSdk.API.shushu_SetSuperProperties(null, true); + if (!compensate && !/^starter_pack(?:_[2-5])?$/.test(productId)) this.tryReactivateStarterPack("purchase"); }, getPassCheckPorp(porpId, num, compensate, getType?) { @@ -1883,13 +1894,16 @@ var GameTool = { let dateStr = cc.fx.StorageMessage.getStorage("starter_pack"); }, - recordStarterPackShown() { + recordStarterPackShown(automatic: boolean = false) { const info = cc.fx.GameConfig.GM_INFO; if (starterPackRemaining() <= 0) return; info.starterPackLastShownAt = starterPackNow(); cc.fx.StorageMessage.setStorage("StarterPackLastShown_" + info.uid, info.starterPackLastShownAt); - cc.fx.StorageMessage.setStorage(starterPackPopupKey(), starterPackDay()); - Utils.markStarterPackShown(Math.round(info.starter_packTime * 1000)); + if (automatic) { + info.starterPackAutoShownRound = info.starterPackRound; + cc.fx.StorageMessage.setStorage(starterPackPopupKey(), true); + } + Utils.markStarterPackShown(Math.round(info.starter_packTime * 1000), automatic); }, recordStarterPackLevelResult(won: boolean) { @@ -1900,21 +1914,45 @@ var GameTool = { tryReactivateStarterPack(reason: string) { const info = cc.fx.GameConfig.GM_INFO; - if (!info.uid || info.level < 15 || !info.canIos || info.starterPackPurchased - || !(info.starter_packTime > 0) || starterPackRemaining() > 0) return; + if (!info.uid || info.level < 15 || !info.canIos || starterPackRemaining() > 0) return; + if (reason === "resources") { + reason = this.starterPackQueuedReason || (Number.isFinite(info.coin) && info.coin < 500 ? "low_coin" + : info.hp === 0 && Number.isFinite(info.userPowerTime) && info.userPowerTime <= starterPackNow() / 1000 ? "low_health" + : [info.freezeAmount, info.hammerAmount, info.magicAmount].every(value => Number.isFinite(value) && value >= 0 && value <= 3) ? "low_props" + : Number(cc.fx.StorageMessage.getStorage(starterPackFailureKey())) >= 4 ? "four_failures" : ""); + } + if (!reason) return; + if (["shop", "level_purchase", "purchase"].includes(this.starterPackQueuedReason)) reason = this.starterPackQueuedReason; if (reason === "low_coin" && !(info.coin < 500)) return; + const expiry = info.starter_packTime * 1000; + if (expiry > 0 && starterPackNow() < Math.floor((expiry + 8 * 3600000) / 86400000) * 86400000 - 8 * 3600000 + 2 * 86400000) return; + const canvas = cc.find("Canvas"); + const home = canvas && canvas.getComponent(JiaZai); + const shop = canvas && canvas.getChildByName("shop"); + if (canvas?.getChildByName("Loading")?.activeInHierarchy || shop?.getChildByName("Loading")?.activeInHierarchy) { + this.starterPackQueuedReason = reason; + return; + } + if (cc.fx.GameConfig.isGameplayEntryPreparing() || (!home && !shop?.activeInHierarchy)) { + this.starterPackQueuedReason = reason; + return; + } + if (home && (["RewardNode", "getcard", "monthlyCardNode", "actionpNode", "winStreakNode", "passCheckNode", "passIntroNode", "jungleTreasureNode"] + .some(key => home[key]?.activeInHierarchy) + || ["zhuanchang", "Transfer", "CloudRisePanel"].some(name => canvas.getChildByName(name)?.activeInHierarchy))) { + this.starterPackQueuedReason = reason; + HomePopupQueue.get(canvas).enqueue("starterPackActivation", 40, done => { this.tryReactivateStarterPack(reason); done(); }); + return; + } if (this.starterPackReactivationUid === info.uid) { if (reason !== "low_coin") this.starterPackQueuedReason = reason; return; } - const lastShown = Math.max(info.starterPackLastShownAt || 0, - Number(cc.fx.StorageMessage.getStorage("StarterPackLastShown_" + info.uid)) || 0); - if (lastShown > 0 && starterPackNow() - lastShown <= 48 * 3600000) return; const uid = info.uid; this.starterPackReactivationUid = uid; this.starterPackQueuedReason = ""; Utils.reactivateStarter_pack(reason, Number(cc.fx.StorageMessage.getStorage(starterPackFailureKey())) || 0, - lastShown, res => { + 0, res => { const queued = this.starterPackReactivationUid === uid ? this.starterPackQueuedReason : ""; if (this.starterPackReactivationUid === uid) { this.starterPackReactivationUid = ""; @@ -1926,10 +1964,8 @@ var GameTool = { if (queued) this.tryReactivateStarterPack(queued); return; } - const canvas = cc.find("Canvas"); - const home = canvas && canvas.getComponent(JiaZai); if (home) { - home.onStarterPackReactivated(); + home.onStarterPackReactivated(!["shop", "level_purchase", "purchase"].includes(reason)); } }); }, diff --git a/assets/action_bundle/script/NewbieGift.ts b/assets/action_bundle/script/NewbieGift.ts index 049829b3..da2baf99 100644 --- a/assets/action_bundle/script/NewbieGift.ts +++ b/assets/action_bundle/script/NewbieGift.ts @@ -2,7 +2,7 @@ import JiaZai from "../../Script/JiaZai"; import LoadingCatAnimation from "../../Script/LoadingCatAnimation"; import Utils from "../../Script/module/Pay/Utils"; import { MiniGameSdk } from "../../Script/Sdk/MiniGameSdk"; -import { applyStarterPackStatus, starterPackRemaining, starterPackTimeText } from "../../Script/module/Config/StarterPack"; +import { applyStarterPackStatus, starterPackRemaining, starterPackTimeText, starterPackReward } from "../../Script/module/Config/StarterPack"; const { ccclass, property } = cc._decorator; declare const wx: any; @@ -19,9 +19,13 @@ export default class NewbieGift extends cc.Component { private onShowListener: () => void = null; private pendingOrder = ""; private confirmedOrder = ""; + private orderContext: any = null; + private offer: any = null; init(first: boolean) { this.node.opacity = 255; + this.offer = cc.fx.GameConfig.GM_INFO.starterPackOffer; + this.refreshOffer(); this.closeConfirmBox(); if (!this.paying) this.closeLoad(); const title = cc.find("ConfirmBox/title", this.node); @@ -35,7 +39,41 @@ export default class NewbieGift extends cc.Component { wx.onShow(this.onShowListener); } this.onShow(); - cc.fx.GameTool.recordStarterPackShown(); + } + + private refreshOffer() { + const price = Number(this.offer?.priceFen) || 300; + for (const value of [3, 6, 12, 18, 30]) cc.find("btnContainer/" + value, this.node).active = value * 100 === price; + const reward = starterPackReward(this.offer); + const bundle = cc.assetManager.getBundle("action_bundle"); + bundle.load("img/newbieGift/newbieGiftFont", cc.SpriteAtlas, (error, atlas: cc.SpriteAtlas) => { + if (error || !cc.isValid(this)) return; + for (const [path, amount] of [["daoju1/chuiziCount", reward.hammer], ["daoju2/chuiziCount", reward.freeze], ["daoju3/chuiziCount", reward.magic_wand]]) { + cc.find(String(path), this.node).getComponent(cc.Sprite).spriteFrame = atlas.getSpriteFrame(String(amount)); + } + const minutes = String(reward.infinite_health / 60); + const first = cc.find("tiliContainer/tiliCount1", this.node), second = cc.find("tiliContainer/tiliCount2", this.node); + first.getComponent(cc.Sprite).spriteFrame = atlas.getSpriteFrame(minutes[minutes.length - 2]); + second.getComponent(cc.Sprite).spriteFrame = atlas.getSpriteFrame(minutes[minutes.length - 1]); + let hundreds = first.parent.getChildByName("tiliHundreds"); + if (minutes.length === 3 && !hundreds) { + hundreds = cc.instantiate(first); + hundreds.name = "tiliHundreds"; + hundreds.x = first.x - (second.x - first.x); + first.parent.addChild(hundreds); + } + if (hundreds) { + hundreds.active = minutes.length === 3; + hundreds.getComponent(cc.Sprite).spriteFrame = atlas.getSpriteFrame(minutes[0]); + } + }); + cc.assetManager.getBundle("publicUI").load("font", cc.SpriteAtlas, (error, atlas: cc.SpriteAtlas) => { + if (error || !cc.isValid(this)) return; + const digits = String(reward.coin); + cc.find("goldContainer", this.node).children.filter(node => node.name === "New Sprite").forEach((node, index) => { + node.getComponent(cc.Sprite).spriteFrame = atlas.getSpriteFrame("scoin_" + digits[index]); + }); + }); } refreshCountdown() { @@ -72,10 +110,20 @@ export default class NewbieGift extends cc.Component { server: version === "开发版" || version === "体验版" ? Utils.testHttpip : Utils.httpip, }); const done = (data) => { + if (/^starter_pack_[2-5]$/.test(data?.data?.itemid || data?.data?.payment_name || "") && !data.data.starterPackOffer?.reward) { + this.polling = false; + this.paying = false; + this.openConfirmBox(); + this.refreshCountdown(); + return; + } + if (data?.data && /^starter_pack(?:_[2-5])?$/.test(data.data.itemid || data.data.payment_name || "")) { + this.orderContext = data.data; + } if (directPayment && data?.code === 1 && data.data?.pay_state === 2) { this.confirmedOrder = order; this.finishOrder(order); - } else if (!directPayment && data?.code === 1 && data.data?.payment_name === "starter_pack") { + } else if (!directPayment && data?.code === 1 && /^starter_pack(?:_[2-5])?$/.test(data.data?.payment_name)) { // The original customer-service API already completes the order. this.completeOrder(order); } else if ((directPayment && data?.code === 1 && data.data?.pay_state === 1) @@ -135,7 +183,9 @@ export default class NewbieGift extends cc.Component { this.iosOldPay(); return; } - Utils.buyProp("starter_pack", 1, 300, systemType, "starter_pack", result => { + const productId = this.offer?.productId || "starter_pack"; + const price = this.offer?.priceFen || 300; + Utils.buyProp(productId, 1, price, systemType, productId, result => { if (!result || result.err) { if (result && result.errCode === 16) { this.iosOldPay(); @@ -151,7 +201,7 @@ export default class NewbieGift extends cc.Component { this.pendingOrder = result.outTradeNo; this.logPayment("微信支付回调成功,等待服务端确认", this.pendingOrder); this.onShow(); - }); + }, undefined, this.offer?.round); }); } @@ -168,6 +218,7 @@ export default class NewbieGift extends cc.Component { this.refreshCountdown(); return; } + if (res.starterPackOffer) this.orderContext = res; this.completeOrder(order); }, order); } @@ -176,15 +227,18 @@ export default class NewbieGift extends cc.Component { if (grant) { this.logPayment("开始前端发奖", order); // The legacy acknowledgement returns data: "ok", not order metadata. - cc.fx.GameTool.shopBuy("starter_pack", false, { outTradeNo: order }); + const productId = this.orderContext?.itemid || this.orderContext?.payment_name || "starter_pack"; + cc.fx.GameTool.shopBuy(productId, false, { ...this.orderContext, outTradeNo: order }); this.logPayment("前端发奖调用完成", order); - MiniGameSdk.API.yinli_Pay(300, order, "购买金币道具:starter_pack"); + MiniGameSdk.API.yinli_Pay(this.orderContext?.starterPackOffer?.priceFen || 300, order, "购买金币道具:" + productId); MiniGameSdk.API.showToast("充值成功"); } this.pendingOrder = ""; this.confirmedOrder = ""; cc.fx.GameConfig.GM_INFO.iosStarterOrder = ""; - cc.fx.GameConfig.GM_INFO.starterPackPurchased = true; + const info = cc.fx.GameConfig.GM_INFO; + if ((Number(this.orderContext?.starterPackBuyRound) || 1) === (info.starterPackRound || 1)) info.starterPackPurchased = true; + this.orderContext = null; this.polling = false; this.paying = false; this.buyStarter_pack(); @@ -218,13 +272,14 @@ export default class NewbieGift extends cc.Component { MiniGameSdk.API.showToast(message); cc.fx.GameTool.shushu_Track("payment_fail", { outTradeNo: this.pendingOrder || cc.fx.GameConfig.GM_INFO.iosStarterOrder || "", - pay_amount: 300, payment_name: "starter_pack", payment_num: 1, + pay_amount: this.offer?.priceFen || 300, payment_name: this.offer?.productId || "starter_pack", payment_num: 1, fail_reason: message, }); } iosOldPay() { - Utils.GoKEFu({ price: 300, payment_name: "starter_pack", payment_count: 1 }, result => { + Utils.GoKEFu({ price: this.offer?.priceFen || 300, payment_name: this.offer?.productId || "starter_pack", payment_count: 1, + starterPackRound: this.offer?.round }, result => { this.paying = false; this.closeLoad(); this.refreshCountdown(); diff --git a/docs/starter-pack-v2.md b/docs/starter-pack-v2.md index 048c74c8..276c93cd 100644 --- a/docs/starter-pack-v2.md +++ b/docs/starter-pack-v2.md @@ -1,3 +1,31 @@ +# 当前接入:五档幸运礼包(2026-09-30) + +下方历史记录中的固定 3 元、48 小时、每日提醒和已购不重开已被本节取代。前后端代码均位于当前 MatchMaster 仓库,后端为 `server/laf-cloud/functions`。 + +使用现有 `newbieGift.prefab` 的 3/6/12/18/30 价格节点;不修改预制体或原布局。`NewbieGift` 根据本轮服务端报价切换价格和原数字图集,180 分钟补充第三位数字。奖励为: + +| 价格 | 商品 | 金币 | 三种道具各 | 无限体力 | 时长 | +| --- | --- | ---: | ---: | ---: | ---: | +| 3 元 | starter_pack | 3000 | 1 | 30 分钟 | 12 小时 | +| 6 元 | starter_pack_2 | 3000 | 1 | 30 分钟 | 6 小时 | +| 12 元 | starter_pack_3 | 4500 | 3 | 45 分钟 | 6 小时 | +| 18 元 | starter_pack_4 | 5000 | 5 | 60 分钟 | 6 小时 | +| 30 元 | starter_pack_5 | 8000 | 8 | 180 分钟 | 6 小时 | + +后端以 `pay_user` 决定首购身份,付费档位按昨日画像 `max(d,0.7A)` 选择,异常新轮固定 6 元。前端接收 `starterPackOffer`,同一轮价格、奖励和倒计时保持不变;仅更高轮号更新报价并清除旧轮已购状态。 + +等级 15 是硬条件。资源不足(金币小于 500、无无限体力且普通体力为 0、三个道具各不超过 3)、当前关卡四败、商城和购买意向等并行触发。玩法、支付和领奖中延后到安全界面;购买意向只恢复入口,资源提醒每轮自动一次。手动打开不消耗自动提醒次数。广告、免费领取、登录补偿不作为购买行为触发;礼包自身购买不递归触发新轮。 + +再次开放按北京时间原截止日期加 2 天:9 月 23 日 02:00 到期,9 月 25 日 00:00 起才可再触发。提前购买不改变原截止日期。到达冷却时间本身不启动倒计时。 + +支付继续使用原 `Utils.buyProp` / iOS 客服、查单、`getOrderReward` 和 `GameTool.shopBuy`。仅扩展商品 ID、报价与订单快照传递。领取仍识别 `data="ok"`,普通和登录补单都按对应订单快照发奖;旧单无快照时发原奖励(3000/各 3/1800 秒),跨轮旧单不关闭新礼包。订单号本地去重、无限体力叠加、支付超时和重试流程保留。 + +详细规则、后端部署顺序和模拟测试见 [后端说明](../server/laf-cloud/starterPack.README.md)。执行 `node --test tools/test-starter-pack.cjs` 验证五档显示、原接口兼容、旧单发奖、跨轮隔离及触发时间。未部署或完成真机支付验收;仍保留原并发下单和客户端发奖边界。 + +--- + +# 历史记录(以下旧规则已由上文取代) + # 新手礼包逻辑接入 沿用用户搭建的 `assets/action_bundle/prefab/newbieGift.prefab`,本次代码不创建、删除或重新布局 UI 节点。 diff --git a/server/laf-cloud/functions/limitedTimeEvent.ts b/server/laf-cloud/functions/limitedTimeEvent.ts index eedc1545..ed82a29a 100644 --- a/server/laf-cloud/functions/limitedTimeEvent.ts +++ b/server/laf-cloud/functions/limitedTimeEvent.ts @@ -1,6 +1,7 @@ import cloud from '@lafjs/cloud' const db = cloud.database(); import Utils from "@/Utils"; +import { PAYMENT_PROFILE_RULE } from "@/paymentProfile"; export default async function (ctx: FunctionContext) { const action = ctx.body.action; const uid = ctx.body.uid; @@ -35,24 +36,30 @@ export default async function (ctx: FunctionContext) { _id: uid, starter_pack: res.data.starter_pack, starter_packState: res.data.starter_packState ?? null, starterPackLastShownAt: res.data.starterPackLastShownAt ?? null, - }).update({ starterPackLastShownAt: Math.max(now, Number(res.data.starterPackLastShownAt) || 0) }); + }).update({ + starterPackLastShownAt: Math.max(now, Number(res.data.starterPackLastShownAt) || 0), + ...(ctx.body.automatic === true || ctx.body.automatic === 'true' + ? { starterPackAutoShownRound: starterPackRound(res.data) } : {}), + }); } res = await db.collection(dbname).where({ _id: uid }).getOne(); } const originalExpiry = Number(res.data.starter_pack) || 0; const now = Date.now(); - const options = action === "reactivate" ? ctx.body : null; - const user = await refreshStarterPack(res.data, action === "save", dbname, options); + const options = action === "reactivate" || action === "save" + ? { ...ctx.body, failureCount: Number(ctx.body.failureCount) } : null; + const user = await refreshStarterPack(res.data, !!options, dbname, options); return { code: 1, data: { starter_pack: Number(user.starter_pack) || 0, starter_packState: Number(user.starter_packState) || 0, starterPackVersion: Number(user.starterPackVersion) || 1, starterPackRound: starterPackRound(user), + starterPackOffer: user.starterPackOffer ?? null, serverTime: Date.now(), starterPackLastShownAt: Number(user.starterPackLastShownAt) || 0, - reactivated: action === "reactivate" && originalExpiry > 0 - && originalExpiry <= now && Number(user.starter_pack) > now, + starterPackAutoShownRound: Number(user.starterPackAutoShownRound) || 0, + reactivated: !!options && originalExpiry <= now && Number(user.starter_pack) > now, }, msg: "成功" }; } @@ -60,42 +67,92 @@ export default async function (ctx: FunctionContext) { } const DAY = 24 * 60 * 60 * 1000; -// starterPackVersion on the user is only a deadline-migration marker: -// 1 = legacy 24h, 2 = already using 48h. Orders do not use this field. +const BEIJING_OFFSET = 8 * 60 * 60 * 1000; +const STARTER_REWARD_VERSION = PAYMENT_PROFILE_RULE.starterPackRewardVersion; +const STARTER_TIERS = [ + { productId: 'starter_pack', priceFen: 300, coin: 3000, props: 1, minutes: 30 }, + { productId: 'starter_pack_2', priceFen: 600, coin: 3000, props: 1, minutes: 30 }, + { productId: 'starter_pack_3', priceFen: 1200, coin: 4500, props: 3, minutes: 45 }, + { productId: 'starter_pack_4', priceFen: 1800, coin: 5000, props: 5, minutes: 60 }, + { productId: 'starter_pack_5', priceFen: 3000, coin: 8000, props: 8, minutes: 180 }, +]; -// Upgrade only an unpurchased, still-live legacy offer, once. Its old deadline -// was trigger + 24h, so extending by 24h preserves the original trigger time. -export function starterPackPatch(user: any, now: number, trigger = false, options: any = null) { +// A live offer is immutable, including when payment identity or daily factors change. +export function starterPackPatch(user: any, now: number, trigger = false, options: any = null, profile: any = null) { const expiry = Number(user.starter_pack) || 0; - if (Number(user.starter_packState) === 1) return null; - if (expiry === 0) { - if (!trigger || Number(user.levelAmount) < 15 || !Number.isFinite(Number(user.levelAmount))) return null; - return { starter_pack: now + 2 * DAY, starter_packState: 0, starterPackVersion: 2, starterPackRound: 1 }; + if (expiry > now) { + if (user.starterPackOffer) return null; + // Preserve an existing legacy offer's original deadline and promised rewards. + return { + starterPackVersion: 3, starterPackRound: starterPackRound(user), + starterPackOffer: { + version: 3, legacy: true, round: starterPackRound(user), productId: 'starter_pack', priceFen: 300, + reward: { coin: 3000, hammer: 3, freeze: 3, magic_wand: 3, infinite_health: 1800 }, + startsAt: expiry - ((Number(user.starterPackVersion) || 1) < 2 ? DAY : 2 * DAY), expiresAt: expiry, + rewardVersion: 'starter_pack_legacy', selectionBasis: 'legacy', + }, + }; } - if (expiry > now && (Number(user.starterPackVersion) || 1) < 2) { - return { starter_pack: expiry + DAY, starterPackVersion: 2, starterPackRound: starterPackRound(user) }; + if (!trigger || !options || !Number.isFinite(Number(user.levelAmount)) || Number(user.levelAmount) < 15) return null; + if (expiry > 0 && now < Math.floor((expiry + BEIJING_OFFSET) / DAY) * DAY - BEIJING_OFFSET + 2 * DAY) return null; + const finite = (value: any) => value != null && Number.isFinite(Number(value)); + const eligible = ['shop', 'level_purchase', 'purchase'].includes(options.reason) + || (options.reason === 'low_coin' && finite(user.coinAmount) && Number(user.coinAmount) < 500) + || (options.reason === 'low_health' && finite(user.healthAmount) && Number(user.healthAmount) === 0 + && finite(user.userPowerTime) && Number(user.userPowerTime) <= now / 1000 + && !(Number(user.healthtimestamp) > 0 && now - Number(user.healthtimestamp) >= 30 * 60000)) + || (options.reason === 'low_props' && [user.freezeAmount, user.hammerAmount, user.magicAmount] + .every(value => finite(value) && Number(value) >= 0 && Number(value) <= 3)) + || (options.reason === 'four_failures' && Number(options.level) === Number(user.levelAmount) + && Number.isInteger(options.failureCount) && options.failureCount >= 4); + if (!eligible || (user.pay_user !== false && user.pay_user !== true)) return null; + + let tier = STARTER_TIERS[user.pay_user ? 1 : 0]; + let selectionBasis = user.pay_user ? 'fallback' : 'never_paid'; + if (user.pay_user === true) { + const midnight = Math.floor((now + BEIJING_OFFSET) / DAY) * DAY - BEIJING_OFFSET; + const rawRegistration = user.register_time instanceof Date ? user.register_time.getTime() : user.register_time; + const numericRegistration = typeof rawRegistration === 'number' || typeof rawRegistration === 'string' + ? Number(rawRegistration) : NaN; + const parsedRegistration = Number.isSafeInteger(numericRegistration) && numericRegistration > 0 + ? numericRegistration : typeof rawRegistration === 'string' && /T.*(?:Z|[+-]\d{2}:?\d{2})$/i.test(rawRegistration) + ? Date.parse(rawRegistration) : NaN; + const registration = Number.isSafeInteger(parsedRegistration) && parsedRegistration > 0 ? parsedRegistration : null; + const daily = profile?.spending?.daily_reference_fen; + const anchor = profile?.ticket_factors?.lucky?.anchor_fen; + if (profile?.data_valid === true && profile.amount_basis === 'confirmed_order_amount' + && profile.profile_rule_version === PAYMENT_PROFILE_RULE.version + && [midnight, midnight - DAY].includes(profile.profile_calculated_as_of) + && profile.openid === user.openid && profile.maintenance_identity?.pay_user === true + && profile.maintenance_identity?.register_time === registration + && Number.isFinite(daily) && daily >= 0 && Number.isFinite(anchor) && anchor >= 0) { + const reference = Math.max(daily, 0.7 * anchor); + tier = [...STARTER_TIERS.slice(1)].reverse().find(item => item.priceFen <= reference) || STARTER_TIERS[1]; + selectionBasis = 'profile'; + } } - if (expiry > 0 && expiry <= now && options - && Number.isFinite(Number(user.levelAmount)) && Number(user.levelAmount) >= 15) { - // Legacy records have no exact exposure time. Their expiry is a conservative - // fallback. A locally remembered exposure can only postpone reactivation. - const lastShown = Math.max(Number(user.starterPackLastShownAt) || expiry, - Math.min(now, Math.max(0, Number(options.lastShownAt) || 0))); - if (now - lastShown <= 2 * DAY) return null; - const eligible = options.reason === "shop" || options.reason === "level_purchase" - || (options.reason === "low_coin" && user.coinAmount != null - && Number.isFinite(Number(user.coinAmount)) && Number(user.coinAmount) < 500) - || (options.reason === "four_failures" && Number(options.level) === Number(user.levelAmount) - && Number.isInteger(options.failureCount) && options.failureCount >= 4); - if (eligible) return { starter_pack: now + 2 * DAY, starterPackVersion: 2, starterPackRound: starterPackRound(user) + 1 }; - } - return null; + const round = starterPackRound(user) + 1; + const expiresAt = now + (user.pay_user === false ? 12 : 6) * 3600000; + return { + starter_pack: expiresAt, starter_packState: 0, starterPackVersion: 3, starterPackRound: round, + starterPackOffer: { + version: 3, round, productId: tier.productId, priceFen: tier.priceFen, + reward: { coin: tier.coin, hammer: tier.props, freeze: tier.props, magic_wand: tier.props, infinite_health: tier.minutes * 60 }, + startsAt: now, expiresAt, rewardVersion: STARTER_REWARD_VERSION, selectionBasis, + }, + }; } export async function refreshStarterPack(user: any, trigger = false, collection = 'users', options: any = null) { const db = cloud.database(); for (let attempt = 0; attempt < 3; attempt++) { - const patch = starterPackPatch(user, Date.now(), trigger, options); + const now = Date.now(); + let profile: any = null; + if (trigger && user.pay_user === true && Number(user.starter_pack || 0) <= now) { + try { profile = (await db.collection('userRechargeStats').where({ _id: user._id }).getOne()).data; } + catch { /* Missing/unavailable daily factors use the agreed 6-yuan fallback. */ } + } + const patch = starterPackPatch(user, now, trigger, options, profile); if (!patch) return user; // Compare the stored values so concurrent reads cannot extend twice, and a // payment callback cannot have its purchased flag overwritten by activation. @@ -106,7 +163,15 @@ export async function refreshStarterPack(user: any, trigger = false, collection starterPackVersion: user.starterPackVersion ?? null, starterPackRound: user.starterPackRound ?? null, starterPackLastShownAt: user.starterPackLastShownAt ?? null, + pay_user: user.pay_user ?? null, + openid: user.openid ?? null, + register_time: user.register_time ?? null, + levelAmount: user.levelAmount ?? null, ...(options?.reason === "low_coin" ? { coinAmount: user.coinAmount ?? null } : {}), + ...(options?.reason === 'low_health' ? { healthAmount: user.healthAmount ?? null, + userPowerTime: user.userPowerTime ?? null, healthtimestamp: user.healthtimestamp ?? null } : {}), + ...(options?.reason === 'low_props' ? { freezeAmount: user.freezeAmount ?? null, + hammerAmount: user.hammerAmount ?? null, magicAmount: user.magicAmount ?? null } : {}), }).update(patch); const fresh = await db.collection(collection).where({ _id: user._id }).getOne(); if (!fresh.data) throw new Error('未获取到玩家数据'); @@ -118,21 +183,33 @@ export async function refreshStarterPack(user: any, trigger = false, collection // One round per activated offer, independent of daily popup impressions. export function starterPackRound(user: any): number { const round = Number(user?.starterPackRound); - return Number.isSafeInteger(round) && round > 0 ? round : Number(user?.starter_pack) > 0 ? 1 : 0; + return Number.isSafeInteger(round) && round > 0 ? round + : Number(user?.starter_pack) > 0 || Number(user?.starter_packState) === 1 ? 1 : 0; } export function starterPackOrderSnapshot(user: any, itemid: string) { - if (itemid !== 'starter_pack') return {}; + if (!/^starter_pack(?:_[2-5])?$/.test(itemid)) return {}; + if (!user || !Number.isFinite(Number(user.levelAmount)) || Number(user.levelAmount) < 15 + || Number(user.starter_packState) === 1 || !(Number(user.starter_pack) > Date.now())) { + throw new Error('本轮礼包未开放或已购买'); + } + const offer = user.starterPackOffer ?? starterPackPatch(user, Date.now())?.starterPackOffer; + if (!offer || offer.productId !== itemid || offer.expiresAt !== Number(user.starter_pack)) { + throw new Error('礼包档位已更新,请重新打开礼包'); + } return { + starterPackOffer: offer, starterPackBuyRound: Math.max(1, starterPackRound(user)), starterPackExpiresAt: Math.max(0, Number(user?.starter_pack) || 0), + rewardVersion: offer.rewardVersion, + activityType: 'lucky', }; } // Called only after server-side payment confirmation, never from client reward claims. // Keep a persisted snapshot so retries and later offer rounds cannot change the event. export async function reportRookieGift(order: any, user: any, paidAt: number) { - if (order?.itemid !== 'starter_pack' || !order.outTradeNo || user?.openid !== order.openid) return; + if (!/^starter_pack(?:_[2-5])?$/.test(order?.itemid || '') || !order.outTradeNo || user?.openid !== order.openid) return; try { const db = cloud.database(); if (!order.rookieGiftPaidAt) { diff --git a/server/laf-cloud/functions/login.ts b/server/laf-cloud/functions/login.ts index ef57e5d4..da130b53 100644 --- a/server/laf-cloud/functions/login.ts +++ b/server/laf-cloud/functions/login.ts @@ -109,6 +109,9 @@ export default async function (ctx: FunctionContext) { starter_packState: migratedUser?.starter_packState ?? 0, starterPackVersion: migratedUser?.starterPackVersion ?? 1, starterPackRound: migratedUser?.starterPackRound ?? 0, + ...(migratedUser?.starterPackOffer ? { starterPackOffer: migratedUser.starterPackOffer } : {}), + ...(migratedUser?.starterPackLastShownAt ? { starterPackLastShownAt: migratedUser.starterPackLastShownAt } : {}), + ...(migratedUser?.starterPackAutoShownRound ? { starterPackAutoShownRound: migratedUser.starterPackAutoShownRound } : {}), winStreak: migratedUser?.winStreak ?? 0, passCheck: migratedUser?.passCheck ?? null, endLevelNum: migratedUser?.endLevelNum ?? 0, @@ -477,19 +480,33 @@ async function getIosOrder(openid, ctx) { db.collection("users").where({ _id: res1.data._id }).update({ rebornGiftTime: Date.now(), rebornGiftCount: 0, pay_user: true }); break; case "starter_pack": - db.collection("users").where({ _id: res1.data._id }).update({ starter_packState: 1, pay_user: true }); + case "starter_pack_2": + case "starter_pack_3": + case "starter_pack_4": + case "starter_pack_5": { + const order = res.data[i], user = res1.data; + const legacyCycle = (!user.starterPackOffer || user.starterPackOffer.legacy === true) && Number(order.time) >= Number(user.starter_pack) - 2 * 86400000 + && Number(order.time) <= Number(user.starter_pack); + const expiry = order.starterPackExpiresAt ?? (legacyCycle ? user.starter_pack : null); + if (expiry != null && Number(expiry) === Number(user.starter_pack) + && (order.starterPackBuyRound == null || Number(order.starterPackBuyRound) === (Number(user.starterPackRound) || 1))) { + await db.collection("users").where({ _id: user._id, starter_pack: user.starter_pack, + starterPackRound: user.starterPackRound ?? null }).update({ starter_packState: 1 }); + } + await db.collection("users").where({ _id: user._id }).update({ pay_user: true }); break; + } default: db.collection("users").where({ _id: res1.data._id }).update({ pay_user: true }); break; } // 月卡补单确认成功后进入待客户端补发状态。 if (res.data[i].itemid === "month_Card" || jungleTier || /^battlepass(?:_30|_12)?$/.test(res.data[i].itemid) - || /^reborn_Gift(?:_[2-5])?$/.test(res.data[i].itemid)) { + || /^reborn_Gift(?:_[2-5])?$/.test(res.data[i].itemid) || /^starter_pack(?:_[2-5])?$/.test(res.data[i].itemid)) { await db.collection("order").where({ outTradeNo: res.data[i].outTradeNo }).update({ state: 1 }); } } else { - if (!res.data[i].passVersion && Date.now() - res.data[i].time > 3 * 3600000) { + if (!res.data[i].passVersion && !/^starter_pack(?:_[2-5])?$/.test(res.data[i].itemid) && Date.now() - res.data[i].time > 3 * 3600000) { let ret = await db.collection('order').where({ outTradeNo: res.data[i].outTradeNo }).remove({ multi: true }); } } diff --git a/server/laf-cloud/functions/paymentProfile.ts b/server/laf-cloud/functions/paymentProfile.ts index 28af188f..b6409b99 100644 --- a/server/laf-cloud/functions/paymentProfile.ts +++ b/server/laf-cloud/functions/paymentProfile.ts @@ -1,6 +1,6 @@ // Pure daily user features. Activity eligibility, offer selection and prices do not belong here. export const PAYMENT_PROFILE_RULE = { - version: 'payment_profile_v1_20260923', + version: 'payment_profile_v2_20260930', moneyThresholdsFen: [3000, 10000, 30000, 100000], frequencyDays: [2, 5, 10, 16], recencyDays: [7, 15, 30, 60], @@ -9,6 +9,7 @@ export const PAYMENT_PROFILE_RULE = { minimumObservationDays: 7, // Only the historical SKU/version window identified in the reference export. legacyBundle: { sku: 'unlimited_health_bundle_30', before: Date.parse('2026-09-22T00:00:00+08:00') }, + starterPackRewardVersion: 'starter_pack_v3_20260930', // Populate only after reward versions have been checked for comparability. comparableActivityVersions: { coin: [] as string[], lucky: [] as string[] }, }; @@ -36,6 +37,10 @@ function evidenceWeight(order: ProfileOrder, context: 'general' | 'coin' | 'luck if (order.sku === rule.legacyBundle.sku && order.paidAt < rule.legacyBundle.before) return 0.3; // First-purchase/subscription evidence must not be promoted by activity matching. if (sku === 'starter_pack' || sku === 'month_card' || /^battlepass(?:_|$)/.test(sku)) return 0.25; + if (/^starter_pack_[2-5]$/.test(sku)) { + return context === 'lucky' && order.activity === 'lucky' + && order.rewardVersion === rule.starterPackRewardVersion ? 1 : 0.25; + } if (context !== 'general' && order.activity === context && order.rewardVersion && rule.comparableActivityVersions[context].includes(order.rewardVersion)) return 1; if (sku === 'reborn_gift' || sku.startsWith('jungle_treasure_') || sku.startsWith('gold_miner')) return 0.5; diff --git a/server/laf-cloud/functions/wucaiMigration.ts b/server/laf-cloud/functions/wucaiMigration.ts index f481758a..374cf873 100644 --- a/server/laf-cloud/functions/wucaiMigration.ts +++ b/server/laf-cloud/functions/wucaiMigration.ts @@ -63,6 +63,9 @@ export function normalizeWucaiUser(source: any): any { starter_packState: numberOr(source?.starter_packState, 0), starterPackVersion: numberOr(source?.starterPackVersion, 1), starterPackRound: numberOr(source?.starterPackRound, 0), + starterPackOffer: cloneSerializable(source?.starterPackOffer ?? null), + starterPackLastShownAt: numberOr(source?.starterPackLastShownAt, 0), + starterPackAutoShownRound: numberOr(source?.starterPackAutoShownRound, 0), winStreak: numberOr(source?.winStreak, 0), passCheck: cloneSerializable(source?.passCheck ?? null), endLevelNum: numberOr(source?.endLevelNum, 0), diff --git a/server/laf-cloud/functions/wx/KeFuInfo.ts b/server/laf-cloud/functions/wx/KeFuInfo.ts index 23ad530a..cf253fc4 100644 --- a/server/laf-cloud/functions/wx/KeFuInfo.ts +++ b/server/laf-cloud/functions/wx/KeFuInfo.ts @@ -101,6 +101,8 @@ async function frontsign(data) { return signature } const propName = { + starter_pack: "幸运礼包3元", starter_pack_2: "幸运礼包6元", starter_pack_3: "幸运礼包12元", + starter_pack_4: "幸运礼包18元", starter_pack_5: "幸运礼包30元", new_gold_1: "新版金币包1", new_gold_2: "新版金币包2", new_gold_3: "新版金币包3", new_gold_4: "新版金币包4", new_gold_5: "新版金币包5", new_gold_6: "新版金币包6", new_unlimited_health_bundle_8: "新版组合礼包8元", new_unlimited_health_bundle_18: "新版组合礼包18元", @@ -184,8 +186,13 @@ async function PostImg(OpenId, outTradeNo, name, count, price, passInput: any = if (!token) { return { code: 0, data: null, msg: "token获取失败" }; } - const giftUser = name === "starter_pack" ? (await db.collection("users").where({ openid: OpenId }).getOne()).data : null; - const giftSnapshot = StarterPack.starterPackOrderSnapshot(giftUser, name); + const giftUser = /^starter_pack(?:_[2-5])?$/.test(name) ? (await db.collection("users").where({ openid: OpenId }).getOne()).data : null; + let giftSnapshot: any = {}; + try { + if (String(name).startsWith("starter_pack") && !/^starter_pack(?:_[2-5])?$/.test(name)) throw new Error("无效的礼包商品"); + giftSnapshot = StarterPack.starterPackOrderSnapshot(giftUser, name); + if (giftSnapshot.starterPackOffer) { count = 1; price = giftSnapshot.starterPackOffer.priceFen; } + } catch (error) { await PostMsg(OpenId, error.message); return; } const url = `https://api.weixin.qq.com/cgi-bin/message/custom/send?access_token=${token}`; const nonceStr = crypto.randomBytes(16).toString('hex'); const timestamp = Math.floor(Date.now() / 1000).toString(); diff --git a/server/laf-cloud/functions/wx/checkIos.ts b/server/laf-cloud/functions/wx/checkIos.ts index c2ff593c..22ecd7d0 100644 --- a/server/laf-cloud/functions/wx/checkIos.ts +++ b/server/laf-cloud/functions/wx/checkIos.ts @@ -27,6 +27,10 @@ export default async function (ctx: FunctionContext) { itemid: res.data.itemid, ...(res.data.starterPackBuyRound != null ? { starterPackBuyRound: res.data.starterPackBuyRound } : {}), ...(res.data.starterPackExpiresAt != null ? { starterPackExpiresAt: res.data.starterPackExpiresAt } : {}), + ...(res.data.starterPackOffer ? { + starterPackOffer: res.data.starterPackOffer, + activityType: res.data.activityType, rewardVersion: res.data.rewardVersion, + } : {}), itemCount: res.data.itemCount, goodsPrice: res.data.goodsPrice, time: res.data.time, diff --git a/server/laf-cloud/functions/wx/getOrderReward.ts b/server/laf-cloud/functions/wx/getOrderReward.ts index eb235169..af603c7d 100644 --- a/server/laf-cloud/functions/wx/getOrderReward.ts +++ b/server/laf-cloud/functions/wx/getOrderReward.ts @@ -30,5 +30,8 @@ export default async function (ctx: FunctionContext) { return { code: 0, data: null, msg: "充值尚未成功" }; } } - return { code: 1, data: "ok", msg: "成功" }; + return { code: 1, data: "ok", msg: "成功", ...(/^starter_pack(?:_[2-5])?$/.test(order.data.itemid) ? { + starterPackOffer: order.data.starterPackOffer, itemid: order.data.itemid, + starterPackBuyRound: order.data.starterPackBuyRound, + } : {}) }; } diff --git a/server/laf-cloud/functions/wx/getPayInfo.ts b/server/laf-cloud/functions/wx/getPayInfo.ts index 60cba1ce..8fdcd6b1 100644 --- a/server/laf-cloud/functions/wx/getPayInfo.ts +++ b/server/laf-cloud/functions/wx/getPayInfo.ts @@ -23,6 +23,10 @@ export default async function (ctx: FunctionContext) { if (res.data.openid !== user.data.openid) { return { code: 0, data: null, msg: "充值订单不属于当前玩家" }; } + const starterData = /^starter_pack(?:_[2-5])?$/.test(res.data.itemid) ? { + starterPackOffer: res.data.starterPackOffer, itemid: res.data.itemid, + starterPackBuyRound: res.data.starterPackBuyRound, + } : {}; if (res.data.state == 1 || res.data.state == 2) { // Retry only events already captured by the payment callback; do not backfill // historical completed orders using today's remaining time. @@ -31,7 +35,7 @@ export default async function (ctx: FunctionContext) { if (res.data.state == 1) { return { code: 1, data: { - pay_state: 2 + pay_state: 2, ...starterData }, msg: "成功" }; } else if (res.data.state == 2) { @@ -47,6 +51,7 @@ export default async function (ctx: FunctionContext) { return { code: 1, data: { pay_state: 1, + ...starterData, }, msg: "已领取奖励" } } else { diff --git a/server/laf-cloud/functions/wx/iosgetPayInfo.ts b/server/laf-cloud/functions/wx/iosgetPayInfo.ts index aacf9d8f..8cf0eaaa 100644 --- a/server/laf-cloud/functions/wx/iosgetPayInfo.ts +++ b/server/laf-cloud/functions/wx/iosgetPayInfo.ts @@ -30,11 +30,18 @@ export default async function (ctx: FunctionContext) { return { code: 0, data: null, msg: "未获取到充值信息" }; } let payment_name = ret.data?.itemid; + const starterGift = /^starter_pack(?:_[2-5])?$/.test(payment_name); + const starterData = starterGift ? { starterPackOffer: ret.data.starterPackOffer, + itemid: payment_name, starterPackBuyRound: ret.data.starterPackBuyRound } : {}; + if (starterGift) { + const owner = uid ? (await db.collection("users").where({ _id: uid }).getOne()).data : null; + if (!owner || owner.openid !== ret.data.openid) return { code: 0, data: null, msg: "充值订单不属于当前玩家" }; + } const rebornGift = /^reborn_Gift(?:_[2-5])?$/.test(payment_name); const rebornPending = rebornGift && Number(ctx.body.rebornGiftVersion) === 1; if (rebornGift && !uid) return { code: 0, data: null, msg: "未获取到玩家数据" }; if (isUpgradeOrder(ret.data) && ret.data.state === 2) return { code: 1, data: { payment_name, goodsPrice: ret.data.goodsPrice } }; - if (res && payment_name === "starter_pack") { + if (res && starterGift) { try { const giftUser = await db.collection("users").where({ openid: ret.data.openid }).getOne(); const paidAt = Date.parse(res.success_time); @@ -45,7 +52,7 @@ export default async function (ctx: FunctionContext) { } if (ret.data.state == 2 && (payment_name !== "month_Card" || ret.data.monthCardGranted === true)) { console.error("ios已经获取奖励"); - return { code: 0, data: null, msg: "已经获取到奖励" }; + return { code: 0, data: starterGift ? starterData : null, msg: "已经获取到奖励" }; } if (res) { let paidUser: any = null; @@ -84,8 +91,21 @@ export default async function (ctx: FunctionContext) { db.collection("users").where({ _id: uid }).update({ rebornGiftTime: Date.now(), rebornGiftCount: 0, pay_user: true }); break; case "starter_pack": - db.collection("users").where({ _id: uid }).update({ starter_packState: 1, pay_user: true }); + case "starter_pack_2": + case "starter_pack_3": + case "starter_pack_4": + case "starter_pack_5": { + const legacyCycle = (!res1.data.starterPackOffer || res1.data.starterPackOffer.legacy === true) && Number(ret.data.time) >= Number(res1.data.starter_pack) - 2 * 86400000 + && Number(ret.data.time) <= Number(res1.data.starter_pack); + const expiry = ret.data.starterPackExpiresAt ?? (legacyCycle ? res1.data.starter_pack : null); + if (expiry != null && Number(expiry) === Number(res1.data.starter_pack) + && (ret.data.starterPackBuyRound == null || Number(ret.data.starterPackBuyRound) === (Number(res1.data.starterPackRound) || 1))) { + await db.collection("users").where({ _id: uid, starter_pack: res1.data.starter_pack, + starterPackRound: res1.data.starterPackRound ?? null }).update({ starter_packState: 1 }); + } + await db.collection("users").where({ _id: uid }).update({ pay_user: true }); break; + } default: db.collection("users").where({ _id: uid }).update({ pay_user: true }); break; @@ -119,7 +139,7 @@ export default async function (ctx: FunctionContext) { console.error("ios Jungle 付费档位解锁失败,不影响原充值结果", error); } } - return { code: 1, data: { goodsPrice: ret.data?.goodsPrice, payment_name: payment_name }, msg: "充值成功" }; + return { code: 1, data: { goodsPrice: ret.data?.goodsPrice, payment_name: payment_name, ...starterData }, msg: "充值成功" }; } else { console.log("充值不成功:" + outTradeNo); return { code: 0, data: { goodsPrice: ret.data?.goodsPrice, payment_name: payment_name }, msg: "充值失败" }; diff --git a/server/laf-cloud/functions/wx/iosorderPaySig.ts b/server/laf-cloud/functions/wx/iosorderPaySig.ts index b6ff05aa..1f087656 100644 --- a/server/laf-cloud/functions/wx/iosorderPaySig.ts +++ b/server/laf-cloud/functions/wx/iosorderPaySig.ts @@ -42,6 +42,12 @@ export default async function (ctx: FunctionContext) { if (user.data == null) { return { code: 0, data: null, msg: "未获取到玩家数据" }; } + let starterFields: any = {}; + try { + if (String(itemid).startsWith("starter_pack") && !/^starter_pack(?:_[2-5])?$/.test(itemid)) throw new Error("无效的礼包商品"); + starterFields = StarterPack.starterPackOrderSnapshot(user.data, itemid); + if (starterFields.starterPackOffer) { itemCount = 1; itemPrice = starterFields.starterPackOffer.priceFen; } + } catch (error) { return { code: 0, data: null, msg: error.message }; } const jungleTier = JungleConfig.getJunglePaidTierByProductId(itemid); let passFields: any = {}; try { @@ -82,7 +88,7 @@ export default async function (ctx: FunctionContext) { openid: user.data.openid, outTradeNo: outTradeNo, paymentAppEnv, - ...StarterPack.starterPackOrderSnapshot(user.data, itemid), + ...starterFields, itemid: itemid, itemCount: itemCount, goodsPrice: Number(itemPrice), diff --git a/server/laf-cloud/functions/wx/orderPaySig.ts b/server/laf-cloud/functions/wx/orderPaySig.ts index 6ba60858..eb7ca732 100644 --- a/server/laf-cloud/functions/wx/orderPaySig.ts +++ b/server/laf-cloud/functions/wx/orderPaySig.ts @@ -42,6 +42,12 @@ export default async function (ctx: FunctionContext) { if (user.data == null) { return { code: 0, data: null, msg: "未获取到玩家数据" }; } + let starterFields: any = {}; + try { + if (String(itemid).startsWith("starter_pack") && !/^starter_pack(?:_[2-5])?$/.test(itemid)) throw new Error("无效的礼包商品"); + starterFields = StarterPack.starterPackOrderSnapshot(user.data, itemid); + if (starterFields.starterPackOffer) { itemCount = 1; itemPrice = starterFields.starterPackOffer.priceFen; } + } catch (error) { return { code: 0, data: null, msg: error.message }; } const jungleTier = JungleConfig.getJunglePaidTierByProductId(itemid); let passFields: any = {}; try { @@ -82,7 +88,7 @@ export default async function (ctx: FunctionContext) { openid: user.data.openid, outTradeNo: outTradeNo, paymentAppEnv, - ...StarterPack.starterPackOrderSnapshot(user.data, itemid), + ...starterFields, itemid: itemid, itemCount: itemCount, goodsPrice: Number(itemPrice), diff --git a/server/laf-cloud/functions/wx/payCallBack.ts b/server/laf-cloud/functions/wx/payCallBack.ts index 245f6c89..faf88339 100644 --- a/server/laf-cloud/functions/wx/payCallBack.ts +++ b/server/laf-cloud/functions/wx/payCallBack.ts @@ -111,8 +111,16 @@ export default async function (ctx: FunctionContext) { } } - if (order.itemid === "starter_pack") { - userUpdate.starter_packState = 1; + if (/^starter_pack(?:_[2-5])?$/.test(order.itemid)) { + // An earlier round may finish paying after a newer offer has opened. + const legacyCycle = (!user.starterPackOffer || user.starterPackOffer.legacy === true) && Number(order.time) >= Number(user.starter_pack) - 2 * 86400000 + && Number(order.time) <= Number(user.starter_pack); + const expiry = order.starterPackExpiresAt ?? (legacyCycle ? user.starter_pack : null); + if (expiry != null && Number(expiry) === Number(user.starter_pack) + && (order.starterPackBuyRound == null || Number(order.starterPackBuyRound) === (Number(user.starterPackRound) || 1))) { + await db.collection("users").where({ _id: user._id, starter_pack: user.starter_pack, + starterPackRound: user.starterPackRound ?? null }).update({ starter_packState: 1 }); + } } if (!user.pay_user) { userUpdate.pay_user = true; diff --git a/server/laf-cloud/starterPack.README.md b/server/laf-cloud/starterPack.README.md index 1d38773a..733720da 100644 --- a/server/laf-cloud/starterPack.README.md +++ b/server/laf-cloud/starterPack.README.md @@ -1,3 +1,63 @@ +# 当前规则:付费因子分档(2026-09-30) + +本节取代下方历史记录中的 48 小时期限、每日提醒、永久已购和统一奖励规则。实现位于本仓库的 `server/laf-cloud/functions`,继续扩展现有新手礼包支付、查询、领取和客户端发奖流程。 + +## 档位与报价 + +| 商品 ID | 价格 | 金币 | 冻结/锤子/魔法棒各 | 无限体力 | 倒计时 | +| --- | ---: | ---: | ---: | ---: | ---: | +| starter_pack | 3 元 | 3000 | 1 | 30 分钟 | 12 小时 | +| starter_pack_2 | 6 元 | 3000 | 1 | 30 分钟 | 6 小时 | +| starter_pack_3 | 12 元 | 4500 | 3 | 45 分钟 | 6 小时 | +| starter_pack_4 | 18 元 | 5000 | 5 | 60 分钟 | 6 小时 | +| starter_pack_5 | 30 元 | 8000 | 8 | 180 分钟 | 6 小时 | + +没有 `starter_pack_1`。`pay_user=false` 使用 3 元;`pay_user=true` 使用付费公式,缺失或非布尔身份不开新轮。上线前由业务校准 `pay_user`。 + +取 `userRechargeStats.spending.daily_reference_fen` 为 d,`ticket_factors.lucky.anchor_fen` 为 A,计算 `S=max(d,0.7A)`,在 600/1200/1800/3000 分中取不超过 S 的最高档,最低 600 分。公式不受历史成功价格、拒绝次数或降档机制影响。因子沿用 `rechargeStats` 最近 30 天订单的昨日画像;只接受当前规则版本、有效且身份一致的画像,截止时间须为今日北京时间零点或滞后一批。缺失、无效、过旧或读取异常时新轮固定 6 元。 + +`paymentProfile` 版本为 `payment_profile_v2_20260930`。3 元 SKU 仍使用 0.25 权重;新常规 SKU 在 lucky 因子、`activityType=lucky` 且已验证奖励版本 `starter_pack_v3_20260930` 时使用 1,其他上下文或未知版本仍为 0.25。 + +## 轮次与触发 + +- 等级至少 15。金币小于 500、普通体力为 0 且无限体力无效、三个道具各不超过 3、商城/局内购买意向、真实购买行为、当前关卡连续失败至少 4 次,任一满足即可。失败次数仍由客户端按账号和关卡计数,后端校验关卡和整数次数。 +- 新轮只在满足触发条件且可安全展示的界面开始计时。普通读取不启动。购买意向恢复入口,不强制弹窗;资源提醒每轮最多自动展示一次,玩法、支付及领奖过程延后处理。 +- 本轮 `starterPackOffer` 固定商品、价格、奖励、起止时间和轮号;期间身份或画像改变不会重选。 +- 下轮最早为原截止时间所属北京时间日期加 2 天的零点:9 月 23 日 02:00 到期,9 月 25 日 00:00 才能再触发。提前购买不改变这个截止时间。购买状态按轮次重置。 +- 旧有效礼包保留原截止时间和原奖励(3000 金币、三个道具各 3、无限体力 1800 秒);旧已购用户无可信截止时间时可在下一次合格触发进入新轮。 + +`limitedTimeEvent` 保留 read/save/reactivate/shown 动作;save/reactivate 需 reason。返回新增 `starterPackOffer` 和 `starterPackAutoShownRound`。shown 传本轮 expiry 和 automatic;支持客户端表单的字符串编码。`starterPackLastShownAt` 保留作为记录,不再决定冷却。 + +## 支付及兼容 + +现有下单接口仅对上述五个 SKU 校验当前有效轮次、未购买状态与商品匹配,并固定服务端报价和数量 1。订单保存 `starterPackOffer`、`starterPackBuyRound`、`starterPackExpiresAt`、`activityType`、`rewardVersion`,旧 iOS 转单继续携带这些字段。 + +新订单到期后不可创建;到期前的有效订单可继续原支付流程。回调只关闭所属轮次,不关闭后开的礼包。查单携带订单快照;`getOrderReward` 仍返回 `data="ok"`,礼包快照位于响应顶层。前端从该订单的快照发奖,缺快照旧单走原奖励,不使用当前轮报价替换旧单。登录和账号迁移保留轮次及报价。 + +保留原跨设备并发下单、客户端发奖及旧 iOS 响应中断的边界,未新增订单占位锁或服务端发奖事务。礼包待支付订单不再按原通用 3 小时期限清理,以免删除仍可能支付的旧单;关闭但未成功的礼包订单也会保留,不因此发奖。退款不在本次范围内。 + +`rookie_gift` 支付成功埋点扩展至五个 SKU,沿用订单级去重;轮次和剩余秒数取原订单快照。 + +## 发布与验证 + +本次只做本地代码修改和模拟验证,没有部署或真实支付。发布时先更新 `paymentProfile`,再更新依赖它的 `limitedTimeEvent`,随后更新现有 `wx/orderPaySig`、`wx/iosorderPaySig`、`wx/KeFuInfo`、`wx/checkIos`、`wx/payCallBack`、`wx/getPayInfo`、`wx/iosgetPayInfo`、`wx/getOrderReward`、`login`、`wucaiMigration`,最后发布配套客户端。无需新建云函数。新画像生成前,付费新轮会按 6 元兜底。 + +在项目根目录运行: + +```powershell +$env:TEST_WX_PAY_NOTIFY_URL = 'https://sor779u2w8.sealoshzh.site/wx/payCallBack' +node --test server/laf-cloud/tests/starter-pack.test.mjs server/laf-cloud/tests/payment-profile.test.mjs server/laf-cloud/tests/rookie-gift.test.mjs server/laf-cloud/tests/payment-routing.test.mjs server/laf-cloud/tests/login-ios-reborn.test.mjs +node --test tools/test-starter-pack.cjs +``` + +本次前端 51 项、后端专项 74 项通过;月卡附加回归经 TypeScript ESM 测试加载器运行,7 项通过。数据库、微信支付和数数均由测试模拟,真机显示及渠道支付需配套发布后验收。 + +扩展回归:`login-wucai-state` 为 33/34,通过修改前 HEAD 复现同一 CloudRise 失败;`recharge-stats` 为 24/39,HEAD 同样有 15 项失败,集中于旧版本断言和模拟库缺少 deleteMany。未改无关逻辑或测试。19 个修改的 TypeScript 文件与 HEAD 相比没有新增语法诊断;原 Utils.ts 的 getGacha 名称中不可见字符仍造成 21 条诊断,所以不宣称项目全量编译通过。 + +--- + +# 历史记录(以下旧规则已由上文取代) + # 幸运礼包:48 小时期限与重新激活 当前方案:后端负责活动有效期,奖励数量与实际发放由客户端负责。商品 ID 仍为 `starter_pack`。支付、查单、转单和领取接口恢复 `a1d3bfe` 之前的行为,不对新手礼包新增订单版本、奖励快照、价格固定或下单资格校验。 diff --git a/server/laf-cloud/tests/login-ios-reborn.test.mjs b/server/laf-cloud/tests/login-ios-reborn.test.mjs index 97a87726..4440a593 100644 --- a/server/laf-cloud/tests/login-ios-reborn.test.mjs +++ b/server/laf-cloud/tests/login-ios-reborn.test.mjs @@ -8,7 +8,7 @@ const source = await readFile(new URL('../functions/login.ts', import.meta.url), const recoverySource = source.slice(source.indexOf('async function getIosOrder('), source.indexOf('async function sead(')); const buildRecovery = new Function('db', 'getOrder', 'sead', 'getClientIp', 'JungleConfig', `return (${stripTypeScriptTypes(recoverySource)});`); -function fixture(itemid, extra = {}) { +function fixture(itemid, extra = {}, providerState = 'SUCCESS') { const user = { _id: 'user-1', openid: 'openid-1', rebornGiftTime: 0, rebornGiftCount: 3, pay_user: false }; const order = { outTradeNo: 'order-1', openid: user.openid, state: 0, type: 'ios', itemid, ...extra }; const providerCalls = []; @@ -26,7 +26,7 @@ function fixture(itemid, extra = {}) { } }; const recover = buildRecovery(db, async orderId => { providerCalls.push(orderId); - return { trade_state: 'SUCCESS' }; + return { trade_state: providerState }; }, () => {}, () => '', { getJunglePaidTierByProductId: () => null }); return { user, order, providerCalls, recover }; } @@ -54,3 +54,40 @@ test('disabled gold miner orders never enter legacy iOS recovery', async () => { assert.equal(order.state, 0); assert.equal(user.rebornGiftCount, 3); }); + +for (const itemid of ['starter_pack', 'starter_pack_2', 'starter_pack_3', 'starter_pack_4', 'starter_pack_5']) { + test(`login preserves lucky offer metadata and recovers ${itemid} through original delivery`, async () => { + const offer = { version: 3, round: 2, productId: itemid, expiresAt: Date.now() + 10000, + priceFen: 1200, reward: { coin: 4500, hammer: 3, freeze: 3, magic_wand: 3, infinite_health: 2700 } }; + const { user, order, recover } = fixture(itemid, { + starterPackOffer: offer, starterPackBuyRound: 2, starterPackExpiresAt: offer.expiresAt, + }); + Object.assign(user, { starterPackOffer: offer, starterPackRound: 2, starter_pack: offer.expiresAt }); + const pending = await recover(user.openid, {}); + assert.equal(user.starter_packState, 1); assert.equal(user.pay_user, true); assert.equal(order.state, 1); + assert.deepEqual(pending[0].starterPackOffer, offer); + assert.deepEqual(await recover(user.openid, {}), []); + }); +} + +test('login payment for an earlier lucky round never closes the current offer', async () => { + const { user, order, recover } = fixture('starter_pack_3', { starterPackBuyRound: 2, starterPackExpiresAt: 1000 }); + Object.assign(user, { starterPackRound: 3, starter_pack: 2000, starterPackOffer: { version: 3, round: 3 } }); + assert.equal((await recover(user.openid, {})).length, 1); + assert.equal(user.starter_packState, undefined); assert.equal(user.pay_user, true); assert.equal(order.state, 1); +}); + +test('login closes a matching migrated legacy cycle for an older order without an offer snapshot', async () => { + const { user, order, recover } = fixture('starter_pack', { time: Date.now() - 10000 }); + Object.assign(user, { starter_pack: Date.now() + 10000, starterPackRound: 2, starterPackOffer: { legacy: true } }); + assert.equal((await recover(user.openid, {})).length, 1); + assert.equal(order.state, 1); assert.equal(user.starter_packState, 1); + assert.equal(order.starterPackOffer, undefined); +}); + +test('login keeps unpaid lucky orders available past the old three-hour cleanup', async () => { + for (const itemid of ['starter_pack', 'starter_pack_5']) { + const { user, order, recover } = fixture(itemid, { time: Date.now() - 4 * 3600000 }, 'NOTPAY'); + assert.deepEqual(await recover(user.openid, {}), []); assert.equal(order.state, 0); + } +}); diff --git a/server/laf-cloud/tests/login-wucai-state.test.mjs b/server/laf-cloud/tests/login-wucai-state.test.mjs index a7368e1d..36608485 100644 --- a/server/laf-cloud/tests/login-wucai-state.test.mjs +++ b/server/laf-cloud/tests/login-wucai-state.test.mjs @@ -125,6 +125,9 @@ globalThis.fetch = async () => { }; registerHooks({ + load(url, context, next) { + return next(url, url.endsWith('.ts') ? { ...context, format: 'module-typescript' } : context); + }, resolve(specifier, context, nextResolve) { assert(!specifier.startsWith("@/goldMiner/") && !specifier.startsWith("@/dailyTask"), "CloudRise-only login must load without gold miner or daily task V2 modules"); @@ -581,8 +584,13 @@ test("legacy iaa parameter retains main branch users routing during the CloudRis }); test("new Wucai account migrates source data and returns type 0", async () => { + const starterPackOffer = { version: 3, round: 4, productId: 'starter_pack_3', priceFen: 1200, + startsAt: 1000, expiresAt: 2000, reward: { coin: 4500, hammer: 3, freeze: 3, magic_wand: 3, infinite_health: 2700 } }; reset([], { starterPackRound: 4, + starterPackOffer, + starterPackLastShownAt: 1500, + starterPackAutoShownRound: 4, levelAmount: 0, coinAmount: 100, freezeAmount: 0, @@ -601,6 +609,9 @@ test("new Wucai account migrates source data and returns type 0", async () => { assert.equal(state.sourceCalls, 1); assert.equal(state.users[0].wucai_type, 0); assert.equal(state.users[0].starterPackRound, 4); + assert.deepEqual(state.users[0].starterPackOffer, starterPackOffer); + assert.equal(state.users[0].starterPackLastShownAt, 1500); + assert.equal(state.users[0].starterPackAutoShownRound, 4); assert.equal(state.users[0].coinAmount, 100); assert.equal(state.users[0].freezeAmount, 0); assert.equal(state.users[0].pay_user, true); diff --git a/server/laf-cloud/tests/payment-profile.test.mjs b/server/laf-cloud/tests/payment-profile.test.mjs index 2ce96bad..49b0a952 100644 --- a/server/laf-cloud/tests/payment-profile.test.mjs +++ b/server/laf-cloud/tests/payment-profile.test.mjs @@ -1,6 +1,16 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { buildPaymentProfile, PAYMENT_PROFILE_RULE } from '../functions/paymentProfile.ts'; +import { readFileSync } from 'node:fs'; +import { registerHooks, stripTypeScriptTypes } from 'node:module'; + +const profileUrl = new URL('../functions/paymentProfile.ts', import.meta.url); +registerHooks({ load(url, context, next) { + if (url === profileUrl.href) return { + format: 'module', source: stripTypeScriptTypes(readFileSync(profileUrl, 'utf8')), shortCircuit: true, + }; + return next(url, context); +} }); +const { buildPaymentProfile, PAYMENT_PROFILE_RULE } = await import(profileUrl.href); const DAY = 86400000; const T = Date.parse('2026-09-23T00:00:00+08:00'); @@ -62,6 +72,50 @@ test('SKU weighting changes ticket evidence only; historical weighting ends at i assert.equal(pass.rfm.m30_fen, 12000); }); +test('confirmed paid starter tiers provide full lucky evidence while other contexts retain quarter weight', () => { + assert.equal(PAYMENT_PROFILE_RULE.version, 'payment_profile_v2_20260930'); + assert.equal(PAYMENT_PROFILE_RULE.starterPackRewardVersion, 'starter_pack_v3_20260930'); + for (const [sku, amount] of [['starter_pack_2', 600], ['starter_pack_3', 1200], + ['starter_pack_4', 1800], ['starter_pack_5', 3000]]) { + const result = profile(Array.from({ length: 4 }, (_, i) => payment(amount, i + 1, sku, { + activity: 'lucky', rewardVersion: PAYMENT_PROFILE_RULE.starterPackRewardVersion, + }))); + assert.equal(result.ticket_factors.lucky.anchor_fen, amount); + for (const context of ['general', 'coin']) { + assert.equal(result.ticket_factors[context].anchor_fen, 600 + 0.25 * (amount - 600)); + } + assert.equal(result.rfm.m30_fen, amount * 4); + assert.equal(result.spending.m30_fen, amount * 4); + } +}); + +test('paid starter tiers with missing or mismatched activity and reward versions retain quarter weight', () => { + for (const extra of [{}, { activity: 'lucky' }, + { rewardVersion: PAYMENT_PROFILE_RULE.starterPackRewardVersion }, + { activity: 'lucky', rewardVersion: 'older_reward' }, + { activity: 'coin', rewardVersion: PAYMENT_PROFILE_RULE.starterPackRewardVersion }]) { + for (const sku of ['starter_pack_2', 'starter_pack_3', 'starter_pack_4', 'starter_pack_5']) { + const result = profile(Array.from({ length: 4 }, (_, i) => payment(3000, i + 1, sku, extra))); + for (const context of ['general', 'coin', 'lucky']) { + assert.equal(result.ticket_factors[context].anchor_fen, 1200); + } + } + } +}); + +test('lucky starter reward metadata does not promote first-purchase or other product evidence', () => { + for (const [sku, amount, adjusted] of [['starter_pack', 300, 525], ['month_card', 3000, 1200], + ['battlepass_30', 3000, 1200], ['reborn_gift', 3000, 1800], + ['jungle_treasure_1', 3000, 1800], ['gold_miner_1', 3000, 1800], ['coin_pack', 3000, 3000]]) { + const result = profile(Array.from({ length: 4 }, (_, i) => payment(amount, i + 1, sku, { + activity: 'lucky', rewardVersion: PAYMENT_PROFILE_RULE.starterPackRewardVersion, + }))); + for (const context of ['general', 'coin', 'lucky']) { + assert.equal(result.ticket_factors[context].adjusted_median_fen, adjusted); + } + } +}); + test('new accounts use a minimum seven-day denominator; missing registration uses 30/15', () => { const result = profile([payment(7000, 1)], { registerTime: T - DAY }); assert.equal(result.spending.observed_days30, 7); diff --git a/server/laf-cloud/tests/payment-routing.test.mjs b/server/laf-cloud/tests/payment-routing.test.mjs index 21e91f35..063fad0e 100644 --- a/server/laf-cloud/tests/payment-routing.test.mjs +++ b/server/laf-cloud/tests/payment-routing.test.mjs @@ -35,6 +35,7 @@ registerHooks({ if ((specifier.startsWith("@/goldMiner") || specifier.startsWith("@/activityConfig/"))) return { url: new URL("../functions/" + specifier.slice(2) + ".ts", import.meta.url).href, shortCircuit: true }; if (specifier === '@lafjs/cloud') return { url: 'data:text/javascript,export default globalThis.__routingCloud', shortCircuit: true }; if (specifier === '@/limitedTimeEvent') return { url: new URL('../functions/limitedTimeEvent.ts', import.meta.url).href, shortCircuit: true }; + if (specifier === '@/paymentProfile') return { url: new URL('../functions/paymentProfile.ts', import.meta.url).href, shortCircuit: true }; if (specifier === '@/passCheckUpgrade') return { url: new URL('../functions/passCheckUpgrade.ts', import.meta.url).href, shortCircuit: true }; if (specifier === '@/Utils') return { url: new URL('../functions/Utils.ts', import.meta.url).href, shortCircuit: true }; if (specifier === '@/jungleConfig') return { url: new URL('../functions/jungleConfig.ts', import.meta.url).href, shortCircuit: true }; @@ -47,10 +48,13 @@ const { default: iosOrder } = await import('../functions/wx/iosorderPaySig.ts'); const key = 'synthetic-payment-test-key'; function reset(env = 'production') { process.env.PAYMENT_APP_ENV = env; process.env.WX_MIDAS_PAY_SIGN_KEY = key; - Object.assign(state, { orders: [], user: { _id: 'u', openid: 'o', session_key: 'session' }, reads: 0, writes: [], requests: [], reply: { status: 200, data: { ErrCode: 0 } } }); + Object.assign(state, { orders: [], user: { _id: 'u', openid: 'o', session_key: 'session', levelAmount: 15, + pay_user: false, starter_pack: Date.now() + 86400000, starterPackRound: 1, starterPackVersion: 2 }, + reads: 0, writes: [], requests: [], reply: { status: 200, data: { ErrCode: 0 } } }); } function order(id = 'wct_123', extra = {}) { - const result = { outTradeNo: id, openid: 'o', itemid: 'starter_pack', itemCount: 1, goodsPrice: 300, state: 0, paymentAppEnv: 'test', ...extra }; + const result = { outTradeNo: id, openid: 'o', itemid: 'starter_pack', itemCount: 1, goodsPrice: 300, + time: Date.now(), state: 0, paymentAppEnv: 'test', ...extra }; state.orders.push(result); return result; } function notification(id = 'wct_123', extra = {}) { diff --git a/server/laf-cloud/tests/rookie-gift.test.mjs b/server/laf-cloud/tests/rookie-gift.test.mjs index 6dd8397d..d481a572 100644 --- a/server/laf-cloud/tests/rookie-gift.test.mjs +++ b/server/laf-cloud/tests/rookie-gift.test.mjs @@ -44,6 +44,9 @@ globalThis.require = () => ({ }) }); registerHooks({ + load(url, context, next) { + return next(url, url.endsWith('.ts') ? { ...context, format: 'module-typescript' } : context); + }, resolve(specifier, context, next) { if (specifier === '@lafjs/cloud') return { url: 'data:text/javascript,export default globalThis.__rookieCloud', shortCircuit: true }; if (specifier.startsWith('@/')) return { url: new URL('../functions/' + specifier.slice(2) + '.ts', import.meta.url).href, shortCircuit: true }; @@ -65,7 +68,8 @@ function reset() { events.length = 0; shared.clear(); for (const key of Object.keys(tables)) tables[key] = []; process.env.PAYMENT_APP_ENV = 'production'; process.env.WX_MIDAS_PAY_SIGN_KEY = 'synthetic'; - const user = { _id: 'u', openid: 'o', distinctId: 'd', session_key: 's', starter_pack: NOW + 3599999, starterPackRound: 3, starterPackVersion: 2 }; + const user = { _id: 'u', openid: 'o', distinctId: 'd', session_key: 's', levelAmount: 15, pay_user: false, + starter_pack: NOW + 3599999, starterPackRound: 3, starterPackVersion: 2 }; tables.users.push(user); return user; } function order(user, extra = {}) { @@ -75,6 +79,107 @@ function order(user, extra = {}) { const ctx = body => ({ body, headers: {}, socket: { remoteAddress: '127.0.0.1' } }); const notification = () => ctx({ MiniGame: { Payload: JSON.stringify({ OutTradeNo: 'wcx_test' }) } }); +function setOffer(user, productId, priceFen, reward = { coin: 3000, hammer: 1, freeze: 1, magic_wand: 1, infinite_health: 1800 }) { + user.starterPackVersion = 3; + user.pay_user = productId !== 'starter_pack'; + user.starterPackOffer = { version: 3, round: user.starterPackRound, productId, priceFen, reward, + startsAt: NOW - 10000, expiresAt: user.starter_pack, rewardVersion: 'starter_pack_v3_20260930', selectionBasis: 'profile' }; + return user.starterPackOffer; +} + +test('each lucky tier uses the server offer price, quantity and reward snapshot on native payment', async () => { + for (const create of [androidOrder, nativeIosOrder]) { + for (const [productId, priceFen] of [['starter_pack', 300], ['starter_pack_2', 600], ['starter_pack_3', 1200], ['starter_pack_4', 1800], ['starter_pack_5', 3000]]) { + const user = reset(), offer = setOffer(user, productId, priceFen); + const result = await create(ctx({ uid: 'u', itemid: productId, itemPrice: 1, itemCount: 9 })); + assert.equal(result.code, 1); + const saved = tables.order[0]; + assert.equal(saved.goodsPrice, priceFen); assert.equal(saved.itemCount, 1); + assert.deepEqual(saved.starterPackOffer, offer); + assert.equal(saved.activityType, 'lucky'); assert.equal(saved.rewardVersion, offer.rewardVersion); + const signed = JSON.parse(result.data.signData); + assert.equal(signed.productId, productId); assert.equal(signed.goodsPrice, priceFen); assert.equal(signed.buyQuantity, 1); + } + } +}); + +test('lucky native creation rejects a mismatched tier, invalid suffix, expired or purchased offer', async () => { + for (const create of [androidOrder, nativeIosOrder]) for (const scenario of ['mismatch', 'suffix', 'expired', 'purchased']) { + const user = reset(); setOffer(user, 'starter_pack_3', 1200); + if (scenario === 'expired') { user.starter_pack = NOW; user.starterPackOffer.expiresAt = NOW; } + if (scenario === 'purchased') user.starter_packState = 1; + const itemid = scenario === 'mismatch' ? 'starter_pack_2' : scenario === 'suffix' ? 'starter_pack_1' : 'starter_pack_3'; + assert.equal((await create(ctx({ uid: 'u', itemid, itemPrice: 1200, itemCount: 1 }))).code, 0); + assert.equal(tables.order.length, 0); + } +}); + +test('lucky query and original reward acknowledgement return the purchased snapshot after expiry', async () => { + const user = reset(), offer = setOffer(user, 'starter_pack_4', 1800); + const row = { outTradeNo: 'wcx_test', openid: user.openid, itemid: offer.productId, goodsPrice: offer.priceFen, + itemCount: 1, state: 1, ...starterPackOrderSnapshot(user, offer.productId) }; + tables.order.push(row); now += 2 * DAY; + const result = await query(ctx({ uid: 'u', outTradeNo: row.outTradeNo })); + assert.equal(result.data.pay_state, 2); assert.equal(result.data.itemid, offer.productId); + assert.deepEqual(result.data.starterPackOffer, offer); + const claimed = await claimOrder(ctx({ uid: 'u', outTradeNo: row.outTradeNo })); + assert.equal(claimed.data, 'ok'); assert.deepEqual(claimed.starterPackOffer, offer); assert.equal(row.state, 2); + const done = await query(ctx({ uid: 'u', outTradeNo: row.outTradeNo })); + assert.equal(done.data.pay_state, 1); assert.deepEqual(done.data.starterPackOffer, offer); +}); + +test('lucky callback marks only its own round, while historical orders still confirm payment', async () => { + for (const older of [false, true]) { + const user = reset(), offer = setOffer(user, 'starter_pack_5', 3000); + const row = { outTradeNo: 'wcx_test', openid: 'o', itemid: offer.productId, goodsPrice: 3000, + itemCount: 1, time: NOW, state: 0, ...starterPackOrderSnapshot(user, offer.productId) }; + tables.order.push(row); + if (older) { user.starter_pack += DAY; user.starterPackRound++; setOffer(user, 'starter_pack_2', 600); } + assert.equal((await callback(notification())).ErrCode, 0); assert.equal(row.state, 1); + assert.equal(user.starter_packState === 1, !older); assert.equal(user.pay_user, true); + } + const user = reset(), row = order(user); + delete row.starterPackOffer; delete row.starterPackExpiresAt; delete row.starterPackBuyRound; + user.starterPackRound++; user.starter_pack += DAY; setOffer(user, 'starter_pack_2', 600); + assert.equal((await callback(notification())).ErrCode, 0); + assert.notEqual(user.starter_packState, 1); assert.equal(row.state, 1); + const result = await query(ctx({ uid: 'u', outTradeNo: row.outTradeNo })); + assert.equal(result.data.starterPackOffer, undefined); +}); + +test('legacy iOS lucky tiers preserve the locked offer through creation, transfer and paid query', async () => { + const user = reset(), offer = setOffer(user, 'starter_pack_3', 1200); + shared.set('wxaccess_token', 'synthetic'); provider = { prepay_id: 'mock-prepay' }; + await customerService(ctx({ FromUserName: 'o', SessionFrom: JSON.stringify({ tpye: 'ios', + outTradeNo: 'tier-ios', propName: offer.productId, count: 9, price: 1 }) })); + assert.equal(tables.iosOrder[0].goodsPrice, 1200); assert.equal(tables.iosOrder[0].itemCount, 1); + assert.deepEqual(tables.iosOrder[0].starterPackOffer, offer); + assert.equal(await transfer(ctx({ openid: 'o', time: user.iosTime, outTradeNo: 'tier-ios' })), true); + assert.deepEqual(tables.order[0].starterPackOffer, offer); + user.starter_pack += DAY; user.starterPackRound++; setOffer(user, 'starter_pack_5', 3000); + provider = { trade_state: 'SUCCESS', success_time: new Date(NOW).toISOString() }; + const paid = await iosQuery(ctx({ uid: 'u', outTradeNo: 'tier-ios' })); + assert.equal(paid.code, 1); assert.deepEqual(paid.data.starterPackOffer, offer); + assert.notEqual(user.starter_packState, 1); assert.equal(tables.order[0].state, 2); + const done = await iosQuery(ctx({ uid: 'u', outTradeNo: 'tier-ios' })); + assert.equal(done.msg, '已经获取到奖励'); assert.deepEqual(done.data.starterPackOffer, offer); +}); + +test('old orders without snapshots close only a matching migrated legacy offer', async () => { + for (const payment of ['native', 'ios']) { + const user = reset(), row = order(user); + delete row.starterPackOffer; delete row.starterPackExpiresAt; delete row.starterPackBuyRound; + user.starterPackOffer = { legacy: true, round: user.starterPackRound, expiresAt: user.starter_pack }; + if (payment === 'native') assert.equal((await callback(notification())).ErrCode, 0); + else { + provider = { trade_state: 'SUCCESS', success_time: new Date(NOW).toISOString() }; + assert.equal((await iosQuery(ctx({ uid: 'u', outTradeNo: row.outTradeNo }))).code, 1); + } + assert.equal(user.starter_packState, 1); assert.equal(user.pay_user, true); + assert.equal(row.starterPackOffer, undefined); + } +}); + test('native Android and iOS orders snapshot the server cycle, ignoring client analytics values', async () => { for (const create of [androidOrder, nativeIosOrder]) { const user = reset(); @@ -90,7 +195,7 @@ test('native callback reports the paid order cycle and integer seconds, not a la const user = reset(), row = order(user); user.starter_pack = NOW + 2 * DAY; user.starterPackRound = 4; assert.equal((await callback(notification())).ErrCode, 0); - assert.equal(row.state, 1); assert.equal(user.starter_packState, 1); + assert.equal(row.state, 1); assert.notEqual(user.starter_packState, 1); assert.equal(user.pay_user, true); assert.deepEqual(events[0].properties, { order_id: 'wcx_test', buy_round: 3, time_left: 3599 }); assert.equal(events[0].event, 'rookie_gift'); assert.equal(events[0].accountId, 'o'); assert.equal(events[0].distinctId, 'd'); assert.equal(events[0].firstCheckId, 'wcx_test'); diff --git a/server/laf-cloud/tests/starter-pack.test.mjs b/server/laf-cloud/tests/starter-pack.test.mjs index f780fa4c..dc458f9a 100644 --- a/server/laf-cloud/tests/starter-pack.test.mjs +++ b/server/laf-cloud/tests/starter-pack.test.mjs @@ -1,18 +1,22 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { registerHooks } from 'node:module'; +import { registerHooks, stripTypeScriptTypes } from 'node:module'; +import { readFileSync } from 'node:fs'; -const NOW = 1800000000000, DAY = 86400000; -let now = NOW, beforeUpdate = null; +const NOW = Date.parse('2026-09-30T12:00:00+08:00'), DAY = 86400000; +let now = NOW, beforeUpdate = null, failProfile = false; const realNow = Date.now; Date.now = () => now; test.after(() => { Date.now = realNow; }); -const tables = { users: [], usersAd: [] }; +const tables = { users: [], userRechargeStats: [] }; const copy = value => value == null ? value : structuredClone(value); const matches = (row, query) => Object.entries(query).every(([key, value]) => value === null ? row[key] == null : row[key] === value); globalThis.__starterCloud = { database: () => ({ collection: name => ({ where(query) { return { - async getOne() { return { data: copy(tables[name].find(row => matches(row, query))) }; }, + async getOne() { + if (name === 'userRechargeStats' && failProfile) throw new Error('profile unavailable'); + return { data: copy(tables[name].find(row => matches(row, query))) }; + }, async update(patch) { if (beforeUpdate) { const fn = beforeUpdate; beforeUpdate = null; fn(); } const rows = tables[name].filter(row => matches(row, query)); @@ -22,158 +26,236 @@ globalThis.__starterCloud = { }; } }) }), }; globalThis.__starterUtils = { checkToken: (got, expected) => got === expected }; -registerHooks({ resolve(specifier, context, next) { - if (specifier === '@lafjs/cloud') return { url: 'data:text/javascript,export default globalThis.__starterCloud', shortCircuit: true }; - if (specifier === '@/Utils') return { url: 'data:text/javascript,export default globalThis.__starterUtils', shortCircuit: true }; - if (specifier.startsWith('@/')) throw new Error('Unexpected deployment dependency: ' + specifier); - return next(specifier, context); -} }); -const { default: activity, refreshStarterPack } = await import('../functions/limitedTimeEvent.ts'); +registerHooks({ + resolve(specifier, context, next) { + if (specifier === '@lafjs/cloud') return { url: 'data:text/javascript,export default globalThis.__starterCloud', shortCircuit: true }; + if (specifier === '@/Utils') return { url: 'data:text/javascript,export default globalThis.__starterUtils', shortCircuit: true }; + if (specifier === '@/paymentProfile') return { url: new URL('../functions/paymentProfile.ts', import.meta.url).href, shortCircuit: true }; + if (specifier.startsWith('@/')) throw new Error('Unexpected dependency: ' + specifier); + return next(specifier, context); + }, + load(url, context, next) { + if (url.endsWith('.ts')) return { format: 'module', source: stripTypeScriptTypes(readFileSync(new URL(url), 'utf8')), shortCircuit: true }; + return next(url, context); + }, +}); +const { default: activity, starterPackPatch, starterPackOrderSnapshot } = await import('../functions/limitedTimeEvent.ts'); +const { PAYMENT_PROFILE_RULE } = await import('../functions/paymentProfile.ts'); function reset(patch = {}) { - now = NOW; beforeUpdate = null; + now = NOW; beforeUpdate = null; failProfile = false; for (const name of Object.keys(tables)) tables[name] = []; - tables.users.push({ _id: 'u1', token: 't1', levelAmount: 15, - starter_pack: 0, starter_packState: 0, ...patch }); + tables.users.push({ _id: 'u1', openid: 'o1', token: 't1', levelAmount: 15, pay_user: false, + register_time: NOW - 90 * DAY, starter_pack: 0, starter_packState: 0, ...patch }); return tables.users[0]; } -const ctx = extra => ({ body: { uid: 'u1', token: 't1', ...extra } }); -const read = () => activity(ctx({ action: 'read', event: 'starter_pack' })); -const save = () => activity(ctx({ action: 'save', event: 'starter_pack' })); -const reactivate = (reason, extra = {}) => activity(ctx({ action: 'reactivate', event: 'starter_pack', reason, ...extra })); -const expired = (extra = {}) => reset({starter_pack:NOW-DAY, starterPackVersion:2, - starterPackLastShownAt:NOW-2*DAY-1, coinAmount:499, ...extra}); +const ctx = extra => ({ body: { uid: 'u1', token: 't1', event: 'starter_pack', ...extra } }); +const read = () => activity(ctx({ action: 'read' })); +const trigger = (reason = 'shop', extra = {}) => activity(ctx({ action: 'reactivate', reason, ...extra })); +function profile(user, daily = 600, anchor = 600, patch = {}) { + const row = { _id: user._id, openid: user.openid, data_valid: true, amount_basis: 'confirmed_order_amount', + profile_rule_version: PAYMENT_PROFILE_RULE.version, + profile_calculated_as_of: Date.parse('2026-09-30T00:00:00+08:00'), + maintenance_identity: { pay_user: true, register_time: user.register_time }, + spending: { daily_reference_fen: daily }, ticket_factors: { lucky: { anchor_fen: anchor } }, ...patch }; + tables.userRechargeStats.push(row); return row; +} -test('activation starts 48 hours, read does not activate, repeated/concurrent save does not extend', async () => { +test('read or save without a reason cannot start; first unpaid offer lasts 12 hours', async () => { const user = reset(); - assert.equal((await read()).data.starter_pack, 0); - await Promise.all([save(), save()]); - assert.equal(user.starter_pack, NOW + 2 * DAY); - assert.equal(user.starterPackVersion, 2); - now += 5000; await save(); - assert.equal(user.starter_pack, NOW + 2 * DAY); + await read(); await activity(ctx({ action: 'save' })); + assert.equal(user.starter_pack, 0); + await Promise.all([trigger(), trigger('level_purchase')]); + assert.equal(user.starter_pack, NOW + DAY / 2); + assert.equal(user.starterPackRound, 1); + assert.equal(user.starterPackOffer.productId, 'starter_pack'); + assert.deepEqual(user.starterPackOffer.reward, { coin: 3000, hammer: 1, freeze: 1, magic_wand: 1, infinite_health: 1800 }); }); -test('legacy active user upgrades from original trigger exactly once, including concurrent reads', async () => { - const user = reset({ starter_pack: NOW + DAY / 2 }); - await Promise.all([read(), read()]); - assert.equal(user.starter_pack, NOW + 1.5 * DAY); - await read(); assert.equal(user.starter_pack, NOW + 1.5 * DAY); -}); -test('expired boundary and purchased users are never reactivated', async () => { - for (const patch of [{ starter_pack: NOW - 1 }, { starter_pack: NOW }, - { starter_pack: NOW + DAY, starter_packState: 1 }, { starter_packState: 1 }]) { - const user = reset(patch); const initial = copy(user); - await read(); await save(); assert.deepEqual(user, initial); + +test('formula thresholds select four paid products and exact rewards for six hours', async () => { + for (const [daily, id, price, coin, props, minutes] of [ + [0, 'starter_pack_2', 600, 3000, 1, 30], [1199, 'starter_pack_2', 600, 3000, 1, 30], + [1200, 'starter_pack_3', 1200, 4500, 3, 45], [1800, 'starter_pack_4', 1800, 5000, 5, 60], + [3000, 'starter_pack_5', 3000, 8000, 8, 180], [100000, 'starter_pack_5', 3000, 8000, 8, 180], + ]) { + const user = reset({ pay_user: true }); profile(user, daily); + await trigger(); + assert.equal(user.starter_pack, NOW + DAY / 4); + assert.equal(user.starterPackOffer.productId, id); + assert.equal(user.starterPackOffer.priceFen, price); + assert.deepEqual(user.starterPackOffer.reward, { coin, hammer: props, freeze: props, magic_wand: props, infinite_health: minutes * 60 }); } }); -test('missing level, low level, missing user and wrong token cannot activate', async () => { + +test('ticket reference is multiplied by 0.7 and history never overrides the formula', async () => { + const user = reset({ pay_user: true, lastStarterPackPrice: 3000 }); profile(user, 1000, 3000); + await trigger(); assert.equal(user.starterPackOffer.priceFen, 1800); + const second = reset({ pay_user: true, lastStarterPackPrice: 3000 }); profile(second, 1200, 600); + await trigger(); assert.equal(second.starterPackOffer.priceFen, 1200); +}); + +test('missing, invalid, stale, future, mismatched and unavailable paid profiles fall back to 6 yuan', async () => { + for (const patch of [null, { data_valid: false }, { profile_rule_version: 'old' }, + { profile_calculated_as_of: NOW - 3 * DAY }, { profile_calculated_as_of: NOW + DAY }, + { openid: 'someone-else' }, { maintenance_identity: { pay_user: false } }, + { amount_basis: 'unknown' }, { spending: { daily_reference_fen: null } }, + { ticket_factors: { lucky: { anchor_fen: NaN } } }]) { + const user = reset({ pay_user: true }); if (patch) profile(user, 100000, 100000, patch); + await trigger(); assert.equal(user.starterPackOffer.priceFen, 600); + assert.equal(user.starterPackOffer.selectionBasis, 'fallback'); + } + const user = reset({ pay_user: true }); failProfile = true; + await trigger(); assert.equal(user.starterPackOffer.priceFen, 600); +}); + +test('one daily batch of lag is allowed and pay_user determines first-purchase identity', async () => { + const paid = reset({ pay_user: true }); + profile(paid, 3000, 600, { profile_calculated_as_of: Date.parse('2026-09-29T00:00:00+08:00') }); + await trigger(); assert.equal(paid.starterPackOffer.priceFen, 3000); + const free = reset({ pay_user: false, vip_level: 5 }); profile(free, 100000, 100000); + await trigger(); assert.equal(free.starterPackOffer.priceFen, 300); + for (const pay_user of [undefined, null, 'false', 0]) { + const unknown = reset({ pay_user }); await trigger(); assert.equal(unknown.starter_pack, 0); + } +}); + +test('invalid registration timestamps use the same null identity as rechargeStats', async () => { + for (const register_time of [null, undefined, 0, '0', -1, '2026-09-01']) { + const user = reset({ pay_user: true, register_time }); + profile(user, 3000, 600, { maintenance_identity: { pay_user: true, register_time: null } }); + await trigger(); assert.equal(user.starterPackOffer.priceFen, 3000); + } +}); + +test('a displayed offer stays fixed when payment identity and daily factors change', async () => { + const user = reset(); await trigger(); const offer = copy(user.starterPackOffer); + user.pay_user = true; profile(user, 100000, 100000); now += 3600000; + await read(); await trigger(); + assert.deepEqual(user.starterPackOffer, offer); + assert.equal(starterPackOrderSnapshot(user, 'starter_pack').starterPackOffer.priceFen, 300); +}); + +test('legacy active offers retain their original deadline and old promised reward', async () => { + for (const version of [undefined, 1, 2]) { + const user = reset({ starter_pack: NOW + DAY, starterPackVersion: version }); + await Promise.all([read(), read()]); + assert.equal(user.starter_pack, NOW + DAY); + assert.equal(user.starterPackRound, 1); + assert.equal(user.starterPackOffer.legacy, true); + assert.equal(user.starterPackOffer.reward.hammer, 3); + assert.equal(user.starterPackOffer.reward.infinite_health, 1800); + const locked = copy(user); await read(); assert.deepEqual(user, locked); + } +}); + +test('reopening uses Beijing end date plus two days even after early purchase', async () => { + for (const ending of ['2026-09-23T02:00:00+08:00', '2026-09-23T23:59:00+08:00']) { + const user = reset({ pay_user: true, starter_pack: Date.parse(ending), starter_packState: 1, starterPackRound: 4 }); + now = Date.parse('2026-09-24T23:59:59.999+08:00'); await trigger(); + assert.equal(user.starterPackRound, 4); + now++; await Promise.all([trigger(), trigger('level_purchase')]); + assert.equal(user.starterPackRound, 5); + assert.equal(user.starter_packState, 0); + assert.equal(user.starter_pack, now + DAY / 4); + } +}); + +test('old paid users with no historical deadline can enter a new paid round', async () => { + const user = reset({ pay_user: true, starter_packState: 1 }); await trigger(); + assert.equal(user.starterPackOffer.priceFen, 600); assert.equal(user.starterPackRound, 2); +}); + +test('approved triggers are independent; unknown resources are not treated as zero', async () => { + const valid = [ + ['low_coin', { coinAmount: 499 }], ['low_health', { healthAmount: 0, userPowerTime: 0, healthtimestamp: NOW }], + ['low_props', { freezeAmount: 3, hammerAmount: 2, magicAmount: 0 }], + ['shop', {}], ['level_purchase', {}], ['purchase', {}], ['four_failures', {}], + ]; + for (const [reason, patch] of valid) { + const user = reset(patch); await trigger(reason, { level: 15, failureCount: 4 }); + assert.equal(user.starterPackRound, 1, reason); + } + for (const [reason, patch, extra] of [ + ['low_coin', { coinAmount: 500 }], ['low_coin', { coinAmount: null }], + ['low_health', { healthAmount: 0 }], ['low_health', { healthAmount: 0, userPowerTime: NOW / 1000 + 1 }], + ['low_health', { healthAmount: 0, userPowerTime: 0, healthtimestamp: NOW - 1800000 }], + ['low_props', { freezeAmount: 3, hammerAmount: 3 }], ['low_props', { freezeAmount: 3, hammerAmount: 3, magicAmount: 4 }], + ['four_failures', {}, { level: 15, failureCount: 3 }], ['four_failures', {}, { level: 16, failureCount: 4 }], ['login', {}], + ]) { + const user = reset(patch); await trigger(reason, extra); assert.equal(user.starter_pack, 0, reason); + } +}); + +test('level 15 is mandatory; user identity and authentication remain enforced', async () => { for (const levelAmount of [undefined, NaN, 14]) { - const user = reset({ levelAmount }); await save(); assert.equal(user.starter_pack, 0); + const user = reset({ levelAmount }); await trigger(); assert.equal(user.starter_pack, 0); } - reset(); assert.equal((await activity(ctx({ action: 'save', event: 'starter_pack', token: 'wrong' }))).code, 0); - assert.equal((await activity(ctx({ action: 'save', event: 'starter_pack', uid: 'missing' }))).code, 0); -}); -test('payment callback racing activation/upgrade cannot have purchased status cleared', async () => { - const user = reset({ starter_pack: NOW + DAY }); - beforeUpdate = () => { user.starter_packState = 1; }; - await refreshStarterPack(copy(user), true); - assert.equal(user.starter_packState, 1); assert.equal(user.starter_pack, NOW + DAY); -}); -test('legacy records with absent status/version fields migrate', async () => { - const user = reset({ starter_pack: NOW + DAY }); delete user.starter_packState; - await read(); assert.equal(user.starter_pack, NOW + 2 * DAY); -}); -test('existing 48-hour activity marker prevents another extension and returns server time', async () => { - const user = reset({ starter_pack: NOW + DAY, starterPackVersion: 2 }); - const result = await read(); await save(); - assert.equal(user.starter_pack, NOW + DAY); - assert.equal(result.data.serverTime, NOW); - assert.equal(result.data.starterPackRewards, undefined); + reset(); assert.equal((await activity(ctx({ action: 'reactivate', reason: 'shop', token: 'wrong' }))).code, 0); + assert.equal((await activity(ctx({ action: 'read', uid: 'missing' }))).code, 0); }); -test('each reactivation reason independently opens another 48 hours', async () => { - for (const reason of ['low_coin','shop','level_purchase','four_failures']) { - const user=expired(); - const result=await reactivate(reason,{failureCount:4,level:15}); - assert.equal(result.data.reactivated,true); assert.equal(user.starter_pack,NOW+2*DAY); - assert.equal(user.starter_packState,0); - assert.equal(user.starterPackLastShownAt,NOW-2*DAY-1); - } -}); -test('reactivation requires strictly more than 48 hours since actual popup', async () => { - for (const ago of [DAY,2*DAY,2*DAY+1]) { - const user=expired({starterPackLastShownAt:NOW-ago}); - const result=await reactivate('shop'); - assert.equal(result.data.reactivated,ago>2*DAY); - assert.equal(user.starter_pack,ago>2*DAY?NOW+2*DAY:NOW-DAY); - } -}); -test('paid, unexpired, untriggered and low-level users cannot reactivate', async () => { - for (const patch of [{starter_packState:1},{starter_pack:NOW+1},{starter_pack:0},{levelAmount:14}]) { - const user=expired(patch), deadline=user.starter_pack; - assert.equal((await reactivate('shop')).data.reactivated,false); assert.equal(user.starter_pack,deadline); - } -}); -test('coin and failure thresholds are exact; unknown reasons cannot reopen', async () => { - for (const coinAmount of [500,501,undefined,null,'invalid']) { - expired({coinAmount}); assert.equal((await reactivate('low_coin')).data.reactivated,false); - } - for (const extra of [{failureCount:3,level:15},{failureCount:4,level:16},{failureCount:4.5,level:15}]) { - expired(); assert.equal((await reactivate('four_failures',extra)).data.reactivated,false); - } - expired(); assert.equal((await reactivate('login')).data.reactivated,false); -}); -test('only displaying the current live offer updates last shown; reads do not postpone', async () => { - const user=reset({starter_pack:NOW+2*DAY,starterPackVersion:2}); - await read(); assert.equal(user.starterPackLastShownAt,undefined); - const show=expiry=>activity(ctx({action:'shown',event:'starter_pack',expiry})); - await show(user.starter_pack-1); assert.equal(user.starterPackLastShownAt,undefined); - await show(user.starter_pack); assert.equal(user.starterPackLastShownAt,NOW); - now+=DAY; await show(user.starter_pack); assert.equal(user.starterPackLastShownAt,now); - now+=DAY+1; await show(user.starter_pack); assert.equal(user.starterPackLastShownAt,NOW+DAY); - assert.equal((await reactivate('shop')).data.reactivated,false); -}); -test('concurrent triggers never repeatedly extend, and later reads preserve reopened deadline', async () => { - const user=expired(); await Promise.all([reactivate('shop'),reactivate('level_purchase'),reactivate('low_coin')]); - assert.equal(user.starter_pack,NOW+2*DAY); - now+=10000; await read(); await save(); await reactivate('shop'); assert.equal(user.starter_pack,NOW+2*DAY); -}); -test('a paid callback or recent exposure racing reactivation wins the compare-and-set', async () => { - for (const patch of [{starter_packState:1},{starterPackLastShownAt:NOW}]) { - const user=expired(), deadline=user.starter_pack; - beforeUpdate=()=>Object.assign(user,patch); - const result=await reactivate('shop'); - assert.equal(result.data.reactivated,false); assert.equal(user.starter_pack,deadline); - } -}); -test('legacy exposure fallback and locally remembered unsynced popup cannot reopen early', async () => { - const user=expired(); delete user.starterPackLastShownAt; - assert.equal((await reactivate('shop')).data.reactivated,false); - now=NOW+DAY+1; assert.equal((await reactivate('shop')).data.reactivated,true); - expired(); assert.equal((await reactivate('shop',{lastShownAt:NOW-DAY})).data.reactivated,false); - expired(); assert.equal((await reactivate('shop',{lastShownAt:NOW+DAY})).data.reactivated,false); +test('concurrent identity or resource changes cannot publish stale offers', async () => { + const user = reset(); beforeUpdate = () => { user.pay_user = true; }; + await trigger(); assert.equal(user.starterPackOffer.priceFen, 600); + const poor = reset({ coinAmount: 499 }); beforeUpdate = () => { poor.coinAmount = 500; }; + await trigger('low_coin'); assert.equal(poor.starter_pack, 0); }); -test('buy round counts offer cycles, never reads, daily impressions or concurrent activation requests', async () => { +test('automatic popup tracking is per round and never changes reopening time', async () => { + const user = reset(); await trigger(); const expiry = user.starter_pack; + await activity(ctx({ action: 'shown', expiry: expiry - 1, automatic: true })); + assert.equal(user.starterPackAutoShownRound, undefined); + await activity(ctx({ action: 'shown', expiry })); + assert.equal(user.starterPackAutoShownRound, undefined); + await activity(ctx({ action: 'shown', expiry, automatic: true })); + assert.equal(user.starterPackAutoShownRound, 1); + assert.equal(user.starter_pack, expiry); +}); + +test('form-encoded failure counts and automatic flags match the client transport', async () => { const user = reset(); - await Promise.all([save(), save()]); + await trigger('four_failures', { level: '15', failureCount: '4' }); assert.equal(user.starterPackRound, 1); - await activity(ctx({ action: 'shown', event: 'starter_pack', expiry: user.starter_pack })); - now += DAY; - await read(); await save(); - await activity(ctx({ action: 'shown', event: 'starter_pack', expiry: user.starter_pack })); - assert.equal(user.starterPackRound, 1); - now += 2 * DAY + 1; - await Promise.all([reactivate('shop'), reactivate('shop')]); - assert.equal(user.starterPackRound, 2); - now += 2 * DAY + 1; - await reactivate('shop'); - assert.equal(user.starterPackRound, 3); + const expiry = String(user.starter_pack); + await activity(ctx({ action: 'shown', expiry, automatic: 'false' })); + assert.equal(user.starterPackAutoShownRound, undefined); + await activity(ctx({ action: 'shown', expiry, automatic: 'true' })); + assert.equal(user.starterPackAutoShownRound, 1); + for (const failureCount of ['', '3', '4.5', 'invalid']) { + const rejected = reset(); + await trigger('four_failures', { level: '15', failureCount }); + assert.equal(rejected.starter_pack, 0); + } }); -test('legacy cycle starts at one and upgrade does not add a round', async () => { - const user = reset({ starter_pack: NOW + DAY }); - assert.equal((await read()).data.starterPackRound, 1); - assert.equal(user.starterPackRound, 1); - expired(); - assert.equal((await reactivate('shop')).data.starterPackRound, 2); +test('order snapshots bind a valid current SKU, price, reward and cycle', async () => { + const user = reset({ pay_user: true }); profile(user, 3000); await trigger(); + const snapshot = starterPackOrderSnapshot(user, 'starter_pack_5'); + assert.equal(snapshot.starterPackOffer.priceFen, 3000); + assert.equal(snapshot.starterPackOffer.reward.coin, 8000); + assert.equal(snapshot.activityType, 'lucky'); + assert.equal(snapshot.rewardVersion, PAYMENT_PROFILE_RULE.starterPackRewardVersion); + assert.throws(() => starterPackOrderSnapshot(user, 'starter_pack'), /档位/); + user.starter_packState = 1; assert.throws(() => starterPackOrderSnapshot(user, 'starter_pack_5'), /已购买/); + user.starter_packState = 0; now = user.starter_pack; + assert.throws(() => starterPackOrderSnapshot(user, 'starter_pack_5'), /未开放/); + assert.deepEqual(starterPackOrderSnapshot(user, 'other_product'), {}); +}); + +test('formula ignores rejection state and historical successful price', () => { + const user = reset({ pay_user: true, lastStarterPackPrice: 3000, rejectionCount: 2 }); + const row = profile(user, 1200); + const patch = starterPackPatch(user, now, true, { reason: 'shop' }, row); + assert.equal(patch.starterPackOffer.priceFen, 1200); + assert.equal(patch.starterPackOffer.rewardVersion, PAYMENT_PROFILE_RULE.starterPackRewardVersion); +}); + +test('account migration preserves the locked offer and its per-round popup record', async () => { + const { normalizeWucaiUser } = await import('../functions/wucaiMigration.ts'); + const user = reset(); await trigger(); + user.starterPackLastShownAt = NOW; user.starterPackAutoShownRound = 1; + const migrated = normalizeWucaiUser(user); + assert.deepEqual(migrated.starterPackOffer, user.starterPackOffer); + assert.notEqual(migrated.starterPackOffer, user.starterPackOffer); + assert.equal(migrated.starterPackAutoShownRound, 1); + assert.equal(migrated.starterPackLastShownAt, NOW); }); diff --git a/tools/test-starter-pack.cjs b/tools/test-starter-pack.cjs index ed0814b7..01e1484a 100644 --- a/tools/test-starter-pack.cjs +++ b/tools/test-starter-pack.cjs @@ -36,10 +36,11 @@ function fixture() { let now = 1800000000000; const timers = [], storage = new Map(), events = [], calls = []; const info = { uid: 'u1', level: 15, canIos: true, iosCanPay: true, starter_packTime: (now + 172800000) / 1000, - starterPackPurchased: false, starterPackServerOffset: 0, iosStarterOrder: '' }; + starterPackPurchased: false, starterPackServerOffset: 0, iosStarterOrder: '', coin: 499 }; class TestDate extends Date { constructor(...args) { super(...(args.length ? args : [now])); } static now() { return now; } } const cc = { - Node: class {}, Label: 'Label', Button: 'Button', Prefab: 'Prefab', + Node: class {}, Label: 'Label', Button: 'Button', Sprite: 'Sprite', SpriteAtlas: 'Atlas', Prefab: 'Prefab', + assetManager: { getBundle: () => ({ load(path, type, done) { done(null, { getSpriteFrame: name => name }); } }) }, Component: class { schedule() {} unschedule() {} }, _decorator: { ccclass: x => x, property: () => () => {} }, isValid: value => !!value && !value.destroyed, @@ -64,13 +65,17 @@ function fixture() { const raw = prefab[id]; const node = { name: raw._name, active: raw._active, opacity: raw._opacity, children: [], components: {}, emit() {}, getChildByName(name) { return this.children.find(c => c.name === name); }, - getComponent(type) { return this.components[type]; } }; + getComponent(type) { return this.components[type]; }, + addChild(child) { child.parent = this; this.children.push(child); } }; for (const ref of raw._components || []) { const comp = prefab[ref.__id__]; + if (comp.__type__ === 'cc.Sprite') node.components.Sprite = {}; if (comp.__type__ === 'cc.Label') node.components.Label = { string: comp._string }; if (comp.__type__ === 'cc.Button') node.components.Button = { interactable: true, clickEvents: comp.clickEvents.map(e => prefab[e.__id__]) }; } node.children = (raw._children || []).map(c => makeNode(c.__id__)); + node.children.forEach(child => child.parent = node); + node.x = raw._trs?.array?.[0] || 0; return node; } const Gift = moduleFrom(giftPath, globals, { @@ -81,6 +86,7 @@ function fixture() { '../../Script/module/Config/StarterPack': rules, }).default; const gift = new Gift(); gift.node = makeNode(1); + cc.instantiate = node => ({ ...node, components: { Sprite: {} }, children: [] }); return { cc, info, globals, rules, gift, api, storage, timers, events, calls, setNow: value => { now = value; }, getNow: () => now }; } @@ -124,38 +130,31 @@ function homeFixture() { h.getStarterPackHomeButton = () => button; h.refreshHomeActivityEntryLayout = () => {}; h.setStarterPackHomeButtonVisible = v => { h.visible = v; }; + f.api.markStarterPackShown = () => {}; + const recorder = gameToolMethods(f, ['recordStarterPackShown']); + f.cc.fx.GameTool.recordStarterPackShown = recorder.recordStarterPackShown.bind(recorder); let opens = 0; f.cc.instantiate = () => ({ active: false, getComponent: () => ({ init() { opens++; }, refreshCountdown() {} }) }); Home.cachedActionPrefab = {}; return { ...f, Home, h, opens: () => opens }; } -test('first activation counts today; same-day reentry does not popup; next day does', () => { +test('resource reminder is once per round, including a later calendar day', () => { const f = homeFixture(); - f.api.getStarter_pack = done => done({ code: 1, data: { starter_pack: 0, starter_packState: 0 } }); - f.api.setStarter_pack = done => done({ code: 1, data: { starter_pack: f.info.starter_packTime * 1000, starter_packState: 0 } }); f.h.checkStarter_pack(); assert.equal(f.opens(), 1); - assert.equal(f.storage.get(f.rules.starterPackPopupKey()), f.rules.starterPackDay()); - f.api.getStarter_pack = f.api.setStarter_pack; + assert.equal(f.storage.get(f.rules.starterPackPopupKey()), true); f.h.actionpNode.active = false; f.h.checkStarter_pack(); assert.equal(f.opens(), 1); assert.equal(f.h.visible, true); - f.setNow(f.getNow() + 86400000); f.h.checkStarter_pack(); assert.equal(f.opens(), 2); + f.setNow(f.getNow() + 86400000); f.h.checkStarter_pack(); assert.equal(f.opens(), 1); }); -test('starter activation keeps its request guard until success or failure', () => { - for (const response of [{ code: 0 }, { code: 1, data: { starter_pack: 0, starter_packState: 1 } }]) { - const f = homeFixture(); - let read, activate; +test('activity reads release their guard and do not create an offer without a trigger', () => { + for (const response of [{ code: 0 }, { code: 1, data: { starter_pack: 0, starter_packState: 0 } }]) { + const f = homeFixture(); let read; f.api.getStarter_pack = done => { read = done; }; - f.api.setStarter_pack = done => { activate = done; }; - f.h.checkStarter_pack(); - assert.equal(f.h.starterPackChecking, true); - read({ code: 1, data: { starter_pack: 0, starter_packState: 0 } }); - assert.equal(f.h.starterPackChecking, true); - activate(response); - assert.equal(f.h.starterPackChecking, false); + f.api.setStarter_pack = () => assert.fail('unconditional activation'); + f.h.checkStarter_pack(); assert.equal(f.h.starterPackChecking, true); + read(response); assert.equal(f.h.starterPackChecking, false); } - const f = homeFixture(); f.info.level = 0; - f.h.checkStarter_pack(); - assert.ok(!f.h.starterPackChecking); + const f = homeFixture(); f.info.level = 0; f.h.checkStarter_pack(); assert.ok(!f.h.starterPackChecking); }); test('failed prefab load does not consume daily reminder; account keys differ', () => { const f = homeFixture(); f.Home.cachedActionPrefab = null; @@ -217,7 +216,7 @@ test('HTTP timeout or empty response terminates once, allowing payment UI to rec const f = fixture(), xhr = { open() {}, setRequestHeader() {}, send() {} }; f.cc.loader = { getXMLHttpRequest: () => xhr }; f.cc.fx.GameTool.getWechatGameVersion = () => '开发版'; - const Utils = methods(utilsPath, ['POST'], f.globals); Utils.testHttpip = 'https://test.invalid/'; + const Utils = methods(utilsPath, ['POST', 'apiBaseUrl'], f.globals); Utils.testHttpip = 'https://test.invalid/'; const responses = []; Utils.POST('limitedTimeEvent', {}, res => responses.push(res)); xhr.ontimeout(); xhr.onerror(); xhr.readyState=4; xhr.status=0; xhr.responseText=''; xhr.onreadystatechange(); @@ -321,7 +320,8 @@ test('login compensation accepts legacy ok response and keeps original order id const Home = methods(homePath, ['getOrder'], f.globals), h = new Home(); h.deferHomePopupWhileTransfer = () => false; h.isHomeRuntimeAlive = () => false; h.getOrder(); - assert.deepEqual(JSON.parse(JSON.stringify(context)), { outTradeNo: 'old-login-order' }); + assert.equal(context.outTradeNo, 'old-login-order'); + assert.equal(context.starterPackOffer, undefined); assert.equal(f.info.allOutTradeNo.length, 0); assert.deepEqual([f.info.hp, f.info.hp_Max, f.info.doubleCoin], [7,7,2]); }); @@ -423,9 +423,9 @@ function gameToolMethods(f, names) { } test('reactivation requests coalesce, preserve a different pending trigger and open the returned offer once', () => { - const f=fixture(); f.info.starter_packTime=f.getNow()/1000-1; f.info.coin=499; + const f=fixture(); f.info.starter_packTime=f.getNow()/1000-3*86400; f.info.coin=499; const tool=gameToolMethods(f,['tryReactivateStarterPack']); const requests=[]; let opens=0; - f.cc.find=()=>({getComponent:kind=>kind==='Home'?{onStarterPackReactivated(){opens++;}}:null}); + f.cc.find=()=>({getChildByName:()=>null,getComponent:kind=>kind==='Home'?{onStarterPackReactivated(automatic){opens++; assert.equal(automatic,false);}}:null}); f.api.reactivateStarter_pack=(reason,count,shown,done)=>requests.push({reason,done}); tool.tryReactivateStarterPack('low_coin'); tool.tryReactivateStarterPack('shop'); tool.tryReactivateStarterPack('shop'); assert.equal(requests.length,1); @@ -438,29 +438,29 @@ test('reactivation requests coalesce, preserve a different pending trigger and o f.rules.applyStarterPackStatus({starter_pack:f.getNow()-1000,starter_packState:0}); assert.equal(f.info.starter_packTime,deadline/1000); }); -test('reactivation guards cooldown, paid state and coin boundary; account switches ignore stale callbacks', () => { - const f=fixture(), tool=gameToolMethods(f,['tryReactivateStarterPack']); let done,requests=0; +test('reactivation uses original expiry calendar cooldown and permits a previously purchased round', () => { + const f=fixture(), tool=gameToolMethods(f,['tryReactivateStarterPack']); let done, requests=0; + f.cc.find=()=>({getChildByName:()=>null,getComponent:()=>({onStarterPackReactivated(){}})}); f.api.reactivateStarter_pack=(reason,count,shown,callback)=>{requests++;done=callback;}; f.info.starter_packTime=f.getNow()/1000-1; f.info.coin=500; tool.tryReactivateStarterPack('low_coin'); assert.equal(requests,0); - f.info.starterPackLastShownAt=f.getNow()-48*3600000; tool.tryReactivateStarterPack('shop'); assert.equal(requests,0); - f.info.starterPackLastShownAt--; f.info.starterPackPurchased=true; - tool.tryReactivateStarterPack('shop'); assert.equal(requests,0); - f.info.starterPackPurchased=false; tool.tryReactivateStarterPack('shop'); assert.equal(requests,1); + f.info.starter_packTime=f.getNow()/1000-3*86400; f.info.starterPackPurchased=true; + tool.tryReactivateStarterPack('shop'); assert.equal(requests,1); f.info.uid='other'; - done({code:1,data:{starter_pack:f.getNow()+48*3600000,reactivated:true}}); - assert.equal(f.info.starter_packTime,f.getNow()/1000-1); + done({code:1,data:{starter_pack:f.getNow()+6*3600000,reactivated:true}}); + assert.equal(f.info.starter_packTime,f.getNow()/1000-3*86400); }); -test('actual popup display records exact time and daily reminder; expired display does not', () => { +test('actual automatic display records round reminder; manual display does not consume it', () => { const f=fixture(),tool=gameToolMethods(f,['recordStarterPackShown']); const reports=[]; - f.api.markStarterPackShown=expiry=>reports.push(expiry); - f.cc.fx.GameTool.recordStarterPackShown=tool.recordStarterPackShown.bind(tool); - f.gift.init(true); - assert.deepEqual(reports,[f.info.starter_packTime*1000]); + f.api.markStarterPackShown=(expiry,automatic)=>reports.push([expiry,automatic]); + tool.recordStarterPackShown(false); + assert.equal(f.storage.get(f.rules.starterPackPopupKey()),undefined); + tool.recordStarterPackShown(true); + assert.deepEqual(reports,[[f.info.starter_packTime*1000,false],[f.info.starter_packTime*1000,true]]); assert.equal(f.storage.get('StarterPackLastShown_u1'),f.getNow()); - assert.equal(f.storage.get(f.rules.starterPackPopupKey()),f.rules.starterPackDay()); - f.info.starter_packTime=f.getNow()/1000-1; tool.recordStarterPackShown(); assert.equal(reports.length,1); + assert.equal(f.storage.get(f.rules.starterPackPopupKey()),true); + f.info.starter_packTime=f.getNow()/1000-1; tool.recordStarterPackShown(); assert.equal(reports.length,2); }); test('four failed challenges on one level trigger once per attempt; revive failure is not a new attempt and win resets', () => { const f=fixture(), requests=[]; @@ -673,3 +673,80 @@ test('shop level entry uses shared loader and close event preserves the shop pau assert.equal(gift.active, false); assert.equal(opened, 1); assert.equal(map.pause, false); assert.equal(map.shopPause, false); }); + + +const tiers = [ + ['starter_pack', 300, 3000, 1, 1800], ['starter_pack_2', 600, 3000, 1, 1800], + ['starter_pack_3', 1200, 4500, 3, 2700], ['starter_pack_4', 1800, 5000, 5, 3600], + ['starter_pack_5', 3000, 8000, 8, 10800], +]; +for (const [productId, priceFen, coin, props, seconds] of tiers) test(`${productId} uses order snapshot for UI, payment and legacy grant flow`, () => { + const f=fixture(), coins=[], grants=[], health=[], displayed=[]; + const offer={version:3,round:2,productId,priceFen,reward:{coin,hammer:props,freeze:props,magic_wand:props,infinite_health:seconds}}; + Object.assign(f.info,{starterPackRound:2,starterPackOffer:offer}); + f.gift.init(true); + for(const value of [3,6,12,18,30]) assert.equal(f.cc.find('btnContainer/'+value,f.gift.node).active,value*100===priceFen); + assert.equal(f.cc.find('daoju1/chuiziCount',f.gift.node).components.Sprite.spriteFrame,String(props)); + assert.deepEqual(f.cc.find('goldContainer',f.gift.node).children.filter(n=>n.name==='New Sprite').map(n=>n.components.Sprite.spriteFrame),String(coin).split('').map(n=>'scoin_'+n)); + if(seconds===10800) assert.equal(f.cc.find('tiliContainer/tiliHundreds',f.gift.node).components.Sprite.spriteFrame,'1'); + f.api.buyProp=(id,count,price,platform,name,done,passEnd,round)=>{ + assert.deepEqual([id,count,price,name,round],[productId,1,priceFen,productId,2]);done({outTradeNo:'paid'}); + }; + const context={itemid:productId,starterPackOffer:offer,starterPackBuyRound:2}; + f.api.getPayInfo=done=>done({code:1,data:{pay_state:2,...context}}); + f.api.setPayInfo=done=>done({code:1,data:'ok'}); + f.globals.MapConroler={_instance:{SceneManager:{openRewardWindow:data=>displayed.push(data)}}}; + const tool=gameToolMethods(f,['shopBuy']); + f.cc.fx.GameTool.changeCoin=n=>coins.push(n); tool.getShopProp=data=>grants.push(data); + tool.setUserPowerTime=seconds=>health.push(seconds); + f.cc.fx.GameTool.shopBuy=tool.shopBuy.bind(tool); + f.gift.buyProduct(); + assert.deepEqual(coins,[coin]); assert.equal(grants[0].hammer,props); assert.equal(grants[0].price,priceFen); + assert.deepEqual(health,[seconds]); assert.equal(f.info.starterPackPurchased,true); + tool.shopBuy(productId,true,{...context,outTradeNo:'paid'}); assert.equal(coins.length,1); +}); + +test('new round resets purchased status but stale round and same-round factor changes cannot replace the offer', () => { + const f=fixture(); f.info.starterPackRound=1; f.info.starterPackPurchased=true; + const first={round:2,productId:'starter_pack_3',priceFen:1200}; + f.rules.applyStarterPackStatus({starterPackRound:2,starterPackOffer:first,starter_pack:f.getNow()+21600000,starter_packState:0}); + assert.equal(f.info.starterPackPurchased,false); assert.equal(f.info.starterPackOffer,first); + f.rules.applyStarterPackStatus({starterPackRound:2,starterPackOffer:{...first,priceFen:3000},starter_pack:f.getNow()+21600000}); + assert.equal(f.info.starterPackOffer,first); + f.rules.applyStarterPackStatus({starterPackRound:1,starter_pack:f.getNow()+1000,starter_packState:1}); + assert.equal(f.info.starterPackRound,2); assert.equal(f.info.starterPackPurchased,false); +}); + +test('late old order gives its original reward and leaves the current round purchasable', () => { + const f=fixture(); f.info.starterPackRound=4; f.info.starterPackOffer={round:4,reward:{coin:8000}}; + const coins=[], props=[]; f.globals.MapConroler={_instance:null}; + const tool=gameToolMethods(f,['shopBuy']); + f.cc.fx.GameTool.changeCoin=n=>coins.push(n); tool.getShopProp=data=>props.push(data); tool.setUserPowerTime=()=>{}; + tool.shopBuy('starter_pack',true,{outTradeNo:'legacy'}); + assert.equal(coins[0],3000); assert.equal(props[0].hammer,3); assert.equal(f.info.starterPackPurchased,false); + tool.shopBuy('starter_pack_3',true,{outTradeNo:'old3',starterPackBuyRound:3,starterPackOffer:{priceFen:1200,reward:{coin:4500,freeze:3,hammer:3,magic_wand:3,infinite_health:2700}}}); + assert.equal(coins[1],4500); assert.equal(f.info.starterPackPurchased,false); +}); + +test('level purchase waits for a safe screen and queued purchase intent never forces a resource popup', () => { + const f=fixture(); f.info.starter_packTime=0; const tool=gameToolMethods(f,['tryReactivateStarterPack']); const requests=[]; + f.api.reactivateStarter_pack=(reason,count,shown,done)=>requests.push({reason,done}); + tool.tryReactivateStarterPack('level_purchase'); assert.equal(requests.length,0); assert.equal(tool.starterPackQueuedReason,'level_purchase'); + let automatic; + f.cc.find=()=>({getChildByName:()=>null,getComponent:()=>({onStarterPackReactivated:value=>{automatic=value;}})}); + tool.tryReactivateStarterPack('resources'); assert.equal(requests[0].reason,'level_purchase'); + requests[0].done({code:1,data:{starterPackRound:1,starter_pack:f.getNow()+21600000,starter_packState:0,reactivated:true}}); + assert.equal(automatic,false); +}); + + +test('new SKU without its order snapshot is not acknowledged or granted legacy rewards', () => { + const f=fixture(); const tool=gameToolMethods(f,['shopBuy']); + assert.equal(tool.shopBuy('starter_pack_5',true,{outTradeNo:'missing'}),false); + f.gift.pendingOrder='missing'; + f.api.getPayInfo=done=>done({code:1,data:{pay_state:2,itemid:'starter_pack_5'}}); + f.api.setPayInfo=()=>assert.fail('missing snapshot acknowledged'); + f.cc.fx.GameTool.shopBuy=()=>assert.fail('missing snapshot rewarded'); + f.gift.onShow(); + assert.equal(f.gift.pendingOrder,'missing'); assert.equal(f.gift.polling,false); assert.equal(f.gift.paying,false); +});