diff --git a/assets/Scene/HomeScene.fire b/assets/Scene/HomeScene.fire index 9e81c74..e012061 100644 --- a/assets/Scene/HomeScene.fire +++ b/assets/Scene/HomeScene.fire @@ -2415,7 +2415,7 @@ "__id__": 50 } ], - "_active": true, + "_active": false, "_components": [], "_prefab": null, "_opacity": 255, @@ -2586,8 +2586,8 @@ }, "_contentSize": { "__type__": "cc.Size", - "width": 92, - "height": 86 + "width": 290, + "height": 252 }, "_anchorPoint": { "__type__": "cc.Vec2", @@ -2598,15 +2598,15 @@ "__type__": "TypedArray", "ctor": "Float64Array", "array": [ - -107.106, + -91.077, -1.322, 0, 0, 0, 0, 1, - 1, - 1, + 0.27, + 0.27, 1 ] }, @@ -2642,7 +2642,7 @@ "__uuid__": "b6b570b9-d928-4646-8a2a-70f18cc10991" }, "_type": 0, - "_sizeMode": 0, + "_sizeMode": 1, "_fillType": 0, "_fillCenter": { "__type__": "cc.Vec2", @@ -2913,15 +2913,15 @@ "__type__": "TypedArray", "ctor": "Float64Array", "array": [ - -110.285, + -94.256, -0.124, 0, 0, 0, 0, 1, - 0.35, - 0.35, + 0.27, + 0.27, 1 ] }, @@ -3105,14 +3105,14 @@ "__type__": "TypedArray", "ctor": "Float64Array", "array": [ - 0, + -18, 0, 0, 0, 0, 0, 1, - 1, + 0.8, 1, 1 ] @@ -3724,15 +3724,15 @@ "__type__": "TypedArray", "ctor": "Float64Array", "array": [ - -107, + -108, -1.322, 0, 0, 0, 0, 1, - 1, - 1, + 0.85, + 0.85, 1 ] }, diff --git a/assets/Script/module/Config/GameConfig.ts b/assets/Script/module/Config/GameConfig.ts index a274f02..fe779b0 100644 --- a/assets/Script/module/Config/GameConfig.ts +++ b/assets/Script/module/Config/GameConfig.ts @@ -757,7 +757,7 @@ export class GameConfig { vibrateOpen: true, //震动 coinnum: 0, //每局的金币数 paid_user: false, //是否是付费用户 - version: "1.0.66", //版本号 + version: "1.0.67", //版本号 shushu_DistinctId: "", //数数访客ID shushu_AccountId: "", //数数账号ID uid: "", //用户和后端唯一id diff --git a/assets/shop/prefab/shop.prefab b/assets/shop/prefab/shop.prefab index 1638060..b50c9c9 100644 --- a/assets/shop/prefab/shop.prefab +++ b/assets/shop/prefab/shop.prefab @@ -18806,8 +18806,8 @@ }, "_contentSize": { "__type__": "cc.Size", - "width": 92, - "height": 86 + "width": 290, + "height": 252 }, "_anchorPoint": { "__type__": "cc.Vec2", @@ -18818,15 +18818,15 @@ "__type__": "TypedArray", "ctor": "Float64Array", "array": [ - -107.106, + -91.077, -1.322, 0, 0, 0, 0, 1, - 1, - 1, + 0.27, + 0.27, 1 ] }, @@ -18862,7 +18862,7 @@ "__uuid__": "b6b570b9-d928-4646-8a2a-70f18cc10991" }, "_type": 0, - "_sizeMode": 0, + "_sizeMode": 1, "_fillType": 0, "_fillCenter": { "__type__": "cc.Vec2", @@ -19281,15 +19281,15 @@ "__type__": "TypedArray", "ctor": "Float64Array", "array": [ - -110.285, + -94.249, -0.124, 0, 0, 0, 0, 1, - 0.35, - 0.35, + 0.27, + 0.27, 1 ] }, @@ -19499,14 +19499,14 @@ "__type__": "TypedArray", "ctor": "Float64Array", "array": [ - 0, + -18, 0, 0, 0, 0, 0, 1, - 1, + 0.8, 1, 1 ] @@ -20092,15 +20092,15 @@ "__type__": "TypedArray", "ctor": "Float64Array", "array": [ - -107, + -108, -1.322, 0, 0, 0, 0, 1, - 1, - 1, + 0.85, + 0.85, 1 ] }, diff --git a/assets/shop/script/passCheck.ts b/assets/shop/script/passCheck.ts index 8dc63eb..e86ad4d 100644 --- a/assets/shop/script/passCheck.ts +++ b/assets/shop/script/passCheck.ts @@ -125,7 +125,6 @@ export default class passCheck extends cc.Component { passCheck = { ...this.stage }; const maxLevel = Math.min(passCheckData.length, passTier(this.stage) === 30 ? passCheckData.length : 35); this.activateBtn.node.active = Number(this.stage.time) > Date.now() && passTier(this.stage) < 30; - this.ProgressIndex = cc.fx.StorageMessage.getStorage("ProgressIndex") || 1; // 设置固定开始时间:2025年11月4日10:00 @@ -147,29 +146,12 @@ export default class passCheck extends cc.Component { } } this.timeNumber = 0; - this.progressNum1 = passCheck.progress; - if (passCheck.progressLevel > maxLevel) { - passCheck.progressLevel = maxLevel; - this.progressNum1 = passCheckData[passCheck.progressLevel - 1].token; - } - this.progressNum2 = passCheckData[passCheck.progressLevel - 1].token; - - let lvNum = passCheck.progressLevel; - - if (this.progressNum1 == this.progressNum2) { - lvNum += 1; - this.currentLvNum = passCheck.progressLevel; - } else { - this.currentLvNum = passCheck.progressLevel - 1; - } - if (this.currentLvNum <= 0) { - this.currentLvNum = 1; - } else if (this.currentLvNum > maxLevel) { - this.currentLvNum = maxLevel; - } - if (lvNum >= maxLevel) { - lvNum = maxLevel; - } + const completed = Math.min(this.stage.unlocked, maxLevel); + const lvNum = Math.min(completed + 1, maxLevel); + this.currentLvNum = Math.max(1, completed); + this.progressNum2 = Number(passCheckData[lvNum - 1].token); + this.progressNum1 = completed >= maxLevel ? this.progressNum2 + : Math.max(0, this.stage.experience - passCheckData.slice(0, completed).reduce((sum, row) => sum + Number(row.token), 0)); NumberToImage.numberToImageNodesPassCheck(lvNum, 45, 48, "half_black_", this.currentLv, true); // NumberToImage.numberToImageNodesShop(lvNum, 23, 35, "Black", this.currentLv, true); @@ -182,73 +164,7 @@ export default class passCheck extends cc.Component { NumberToImage.numberToImageNodesShop(this.progressNum1, 20, 15, "Black", this.progress_1, true); NumberToImage.numberToImageNodesShop(this.progressNum2, 20, 15, "Black", this.progress_2, true); cc.fx.GameConfig.GM_INFO.passCheckActivate = passCheck.activate; - // this.barSpr.fillRange = this.progressNum1 / this.progressNum2; - let barSprFillRange = this.progressNum2 ? this.progressNum1 / this.progressNum2 : 1; - this.barSpr.node.active = true; - let forInt = passCheck.progressLevel - this.ProgressIndex; - // if (forInt < 1) { - // forInt = 1; - // } - if (cc.fx.GameConfig.GM_INFO.getItemType == 1) { - forInt = 0; - // 当前是第二期领第一期物品 - } - // let time = 0.3; - let fillRangeFun = (fillRange, time, reset?: boolean) => { - cc.tween(this.barSpr) - .delay(time) - .to(time, { fillRange: fillRange }) - .call(() => { - if (reset) { - this.barSpr.fillRange = 0; - if (this.progressNum1 == this.progressNum2) { - if (this.currentLvNum >= maxLevel) { - // NumberToImage.numberToImageNodes(4, 20, 15, "mul", this.progress_1, true); - NumberToImage.numberToImageNodesShop(this.progressNum2, 20, 15, "Black", this.progress_1, true); - - } else { - // NumberToImage.numberToImageNodes(0, 20, 15, "mul", this.progress_1, true); - NumberToImage.numberToImageNodesShop(0, 20, 15, "Black", this.progress_1, true); - } - - let lvNumber = passCheck.progressLevel; - if (passCheck.progressLevel >= maxLevel) { - lvNumber = maxLevel - 1; - } - // NumberToImage.numberToImageNodes(passCheckData[lvNumber].token, 20, 15, "mul", this.progress_2, true); - NumberToImage.numberToImageNodesShop(passCheckData[lvNumber].token, 20, 15, "Black", this.progress_2, true); - - } - // NumberToImage.numberToImageNodes(0, 20, 15, "mul", this.progress_1, true); - // NumberToImage.numberToImageNodes(0, 20, 15, "mul", this.progress_2, true); - // 可能还需要更新其他相关数据 - // self.currentLvNum += 1; - // NumberToImage.numberToImageNodes(self.currentLvNum, 35, 20, "month_", self.currentLv, true); - } - }) - .start(); - } - - // 确保动画按顺序执行 - for (let j = 0; j < forInt; j++) { - cc.tween(this.barSpr).delay(j * 0.6 + 0.3).call(() => { - if (j == forInt - 1) { - // 最后一个动画,根据当前 fillRange 值决定 - if (barSprFillRange == 1) { - // 延迟执行最后一个动画,确保前面的动画完成 - fillRangeFun(1, 0.3, true); - } else { - fillRangeFun(barSprFillRange, 0.3); - } - } else { - // 中间的动画,都填满到1 - fillRangeFun(1, 0.3, true); - } - }).start(); - } - if (forInt == 0) { - fillRangeFun(1, 0, true); - } + this.updateProgressAnimation(completed, maxLevel); this.setbarNodePosX() @@ -289,10 +205,39 @@ export default class passCheck extends cc.Component { this.onShowList(this.passListData); } + private updateProgressAnimation(completed: number, maxLevel: number) { + const fill = this.progressNum2 ? this.progressNum1 / this.progressNum2 : 1; + const uid = cc.fx.GameConfig.GM_INFO.uid || cc.fx.StorageMessage.getStorage("uid") || ""; + const key = "PassProgress:" + uid + ":" + this.endTime; + const saved = cc.fx.StorageMessage.getStorage(key); + // 旧 ProgressIndex 没有期次信息;首次打开只记录当前状态,不补播历史动画。 + const valid = saved && Number.isInteger(saved.completed) && saved.completed >= 0 + && saved.completed <= completed && Number.isFinite(saved.fill) && saved.fill >= 0 && saved.fill <= 1 + && (saved.completed < completed || saved.fill <= fill); + const previous = valid && cc.fx.GameConfig.GM_INFO.getItemType !== 1 ? saved : { completed, fill }; + this.ProgressIndex = Math.max(1, previous.completed); + cc.Tween.stopAllByTarget(this.barSpr); + this.barSpr.node.active = true; + this.barSpr.fillRange = previous.fill; + // 打开即记为已展示,关闭或刷新弹窗时不重复播放这一段。 + cc.fx.StorageMessage.setStorage(key, { completed, fill }); + if (previous.completed === completed && previous.fill === fill) return; + const animation = cc.tween(this.barSpr).delay(0.3); + for (let level = previous.completed + 1; level <= completed; level++) { + animation.delay(0.3).to(0.3, { fillRange: 1 }).call(() => { + this.barSpr.fillRange = level >= maxLevel ? 1 : 0; + }); + } + if (completed < maxLevel && (completed === previous.completed || fill > 0)) { + animation.delay(0.3).to(0.3, { fillRange: fill }); + } + animation.start(); + } + onShowList(data: any) { this.node.getChildByName("scrollViewNode").active = true; var passCheckMgr = this.node.getChildByName("scrollViewNode").getComponent("passCheckMgr"); - passCheckMgr.init(data, this.currentLvNum, this.oldPassCheckInfo, this.endTime, this.stage, this.passData); + passCheckMgr.init(data, this.currentLvNum, this.oldPassCheckInfo, this.endTime, this.stage, this.passData, this.ProgressIndex); this.passCheckMgr = passCheckMgr; // passCheckMgr.updateProgressBars(); } @@ -768,6 +713,7 @@ export default class passCheck extends cc.Component { } onDestroy() { + if (this.barSpr) cc.Tween.stopAllByTarget(this.barSpr); // 组件销毁时清除定时器 if (this.countdownTimer) { clearInterval(this.countdownTimer); diff --git a/assets/shop/script/passCheckMgr.ts b/assets/shop/script/passCheckMgr.ts index 17bb2d3..c15c186 100644 --- a/assets/shop/script/passCheckMgr.ts +++ b/assets/shop/script/passCheckMgr.ts @@ -30,7 +30,7 @@ export default class passCheckMgr extends cc.Component { start() { } - init(data: any[], progressLevel: number, oldPassCheckInfo: any[], time: any, stage?: any, passData?: any) { + init(data: any[], progressLevel: number, oldPassCheckInfo: any[], time: any, stage?: any, passData?: any, progressStart?: number) { // 确保组件已经被正确加载 if (!this.node) { console.error("passCheckMgr: node is null"); @@ -45,6 +45,7 @@ export default class passCheckMgr extends cc.Component { if (progressLevel !== undefined) { this.progressLevel = progressLevel; } + this.ProgressIndex = progressStart === undefined ? this.progressLevel : progressStart; // 获取ScrollView组件(使用不区分大小写的查找) let scrollViewNode: cc.Node | null = null; @@ -238,8 +239,7 @@ export default class passCheckMgr extends cc.Component { this.progressBar3.setAnchorPoint(0.5, 1); this.progressBar3.setPosition(centerX, firstItemCenterY); - // 获取已保存的进度索引 - this.ProgressIndex = cc.fx.StorageMessage.getStorage("ProgressIndex") || 0; + // 与上方动画使用同一期次、同一次读取的已完成档数。 if (this.ProgressIndex > this.progressLevel) { this.ProgressIndex = this.progressLevel; } @@ -259,6 +259,7 @@ export default class passCheckMgr extends cc.Component { this.progressBar4.setAnchorPoint(0.5, 1); this.progressBar4.setPosition(centerX, firstItemCenterY); + cc.Tween.stopAllByTarget(this.progressBar4); this.progressBar4.height = progressLength1; let growthLength = progressLength2 - progressLength1; @@ -279,7 +280,6 @@ export default class passCheckMgr extends cc.Component { // 特殊情况:当进度为1级时,进度条应该为0 this.progressBar4.height = 0; } - cc.fx.StorageMessage.setStorage("ProgressIndex", this.progressLevel); } public updatePassCheckStatus(updates?: Array<{ itemIndex: number, isLeftReward: boolean }>) { @@ -621,6 +621,8 @@ export default class passCheckMgr extends cc.Component { console.log("销毁passCheckMgr"); cc.Tween.stopAllByTarget(this); + if (this.progressBar4) cc.Tween.stopAllByTarget(this.progressBar4); + // 清理可能存在的列表数据 if (this.listData) { this.listData = null; diff --git a/assets/shop/script/shop.ts b/assets/shop/script/shop.ts index dae19a7..7eb6e92 100644 --- a/assets/shop/script/shop.ts +++ b/assets/shop/script/shop.ts @@ -500,7 +500,7 @@ export default class NewClass extends cc.Component { setHealthInfo(type) { if (cc.fx.GameConfig.GM_INFO.hp >= cc.fx.GameConfig.GM_INFO.hp_Max && this.Stamina) { - this.Stamina.getChildByName("man").active = true; + this.Stamina.getChildByName("man").active = !(cc.fx.GameConfig.GM_INFO.userPowerTime > Math.floor(Date.now() / 1000)); this.Stamina.getChildByName("health").active = true; this.Stamina.getChildByName("time").active = false; NumberToImage.numberToImageNodes(cc.fx.GameConfig.GM_INFO.hp, 25, 15, "hp_", this.Stamina.getChildByName("health"), false); diff --git a/tools/test-battle-pass-ui.cjs b/tools/test-battle-pass-ui.cjs index 987e126..a9067b6 100644 --- a/tools/test-battle-pass-ui.cjs +++ b/tools/test-battle-pass-ui.cjs @@ -22,6 +22,8 @@ class Node { addChild(node) { node.parent = this; } getChildByName(name) { return this.children.find(n => n.name === name) || null; } removeAllChildren() { [...this.children].forEach(n => n.parent = null); } + destroyAllChildren() { [...this.children].forEach(n => n.destroy()); } + setContentSize(width, height) { this.width = width; this.height = height; } destroy() { this.destroyed = true; this.parent = null; } setPosition(x, y) { this.x = x; this.y = y; } setAnchorPoint(x, y) { this.anchorX = x; this.anchorY = y; } @@ -34,6 +36,7 @@ class Node { function runtime() { class Component { scheduleOnce() {} } class Sprite extends Component {} + Sprite.SizeMode = { TRIMMED: 0 }; class Button extends Component {} class ScrollView extends Component { getMaxScrollOffset() { return { y: this.content.height }; } getScrollOffset() { return { y: 0 }; } } class NodePool { nodes = []; size() { return this.nodes.length; } get() { return this.nodes.pop(); } put(node) { node.parent = null; this.nodes.push(node); } } @@ -61,12 +64,176 @@ function runtime() { } const item = node.addComponent(Item); item.passCheckItemLvPrefab = () => { const level = new Node('level'); child(level, 'bright_bg').addComponent(Sprite); child(level, 'levelNum'); return level; }; - item.uiAtlas = item.texture_atlas = { getSpriteFrame: name => name }; + item.uiAtlas = item.texture_atlas = item.passCheckFont = { getSpriteFrame: name => name }; return node; }; return { cc, Item, Pool, List, Manager, child, itemPrefab }; } const stageAt = (level, tier) => model.normalizePass({ time: '100', experience: rows.slice(0, level).reduce((sum, row) => sum + row.token, 0), tier, activate: tier > 0 }, rows); + +function animationFixture() { + const { cc, Manager } = runtime(); + const storage = new Map(), actions = [], stopped = new Set(); + cc.fx.StorageMessage = { getStorage: key => storage.get(key), setStorage: (key, value) => storage.set(key, value) }; + cc.fx.GameConfig.GM_INFO = { uid: 'animation-player', getItemType: 2 }; + cc.fx.GameConfig.PASS_CHECK_OLD = JSON.parse(fs.readFileSync('assets/resources/Json/PASS_CHECK_OLD.json', 'utf8')).Sheet1; + cc.Tween.stopAllByTarget = target => actions.filter(a => a.target === target).forEach(a => stopped.add(a)); + cc.tween = target => { + const steps = []; + const chain = { delay: seconds => { steps.push({ seconds }); return chain; }, + to: (seconds, values) => { steps.push({ seconds, values }); return chain; }, + call: fn => { steps.push({ fn }); return chain; }, + start: () => { actions.push({ target, steps }); return chain; } }; + return chain; + }; + const writeNumber = (value, _w, _h, _prefix, node) => { node.number = value; }; + const Panel = load('assets/shop/script/passCheck.ts', { + '../../Script/module/Config/BattlePassModel': model, + '../../Script/NumberToImage': { default: { numberToImageNodesPassCheck: writeNumber, numberToImageNodesShop: writeNumber } }, + }, { cc }).default; + const panel = new Panel(), manager = new Manager(); + Object.assign(panel, { activateBtn: { node: {} }, currentLv: new Node(), progress_1: new Node(), progress_2: new Node(), + barSpr: { node: {}, fillRange: 0 }, setbarNodePosX() {}, initCountDown() {} }); + const content = new Node(); + Object.assign(manager, { list: { itemHeight: 350, spacingY: 0, paddingTop: 0, paddingBottom: 0 }, + progressBar3: new Node(), progressBar4: new Node() }); + manager.progressBar3.parent = manager.progressBar4.parent = content; + // Use the real panel -> manager init arguments, but skip unrelated reward rendering and scrolling. + manager.scrollToLastClaimedReward = () => {}; + manager.setupProgressBars = () => manager.updateProgressBars(); + const list = manager.list; + Object.assign(list, { init() {}, setData(data) { manager.listData = data; } }); + const scroll = new Node('scrollView'); + scroll.getComponent = type => type === cc.ScrollView ? { content } : list; + manager.node = new Node('scrollViewNode'); manager.node.addChild(scroll); + panel.node = { getChildByName: () => ({ active: true, getComponent: () => manager }) }; + function show(level, partial = 0, options = {}) { + const season = options.season || '2000000000000', tier = options.tier || 0; + cc.fx.GameConfig.GM_INFO.getItemType = options.old ? 1 : 2; + const config = options.old ? cc.fx.GameConfig.PASS_CHECK_OLD : rows; + const stage = { time: season, experience: config.slice(0, level).reduce((sum, row) => sum + row.token, 0) + partial, + free: Array(level).fill(0), passCheck: Array(level).fill(1), tier, activate: tier > 0 }; + const input = { 1: options.old ? stage : { time: '0' }, 2: options.old ? { time: '0' } : stage }; + const before = JSON.stringify(input); + actions.length = 0; stopped.clear(); panel.onInit(input); + assert.equal(JSON.stringify(input), before, 'animation must not mutate XP or claim data'); + return actions; + } + const topFills = () => actions.filter(a => a.target === panel.barSpr && !stopped.has(a)) + .flatMap(a => a.steps.filter(s => s.values).map(s => s.values.fillRange)); + function finish() { for (const action of actions) if (!stopped.has(action)) for (const step of action.steps) { + if (step.values) Object.assign(action.target, step.values); if (step.fn) step.fn(); + } } + return { cc, panel, manager, storage, actions, stopped, show, topFills, finish }; +} + +test('battle pass animation ignores unscoped history and preserves already claimed tier five', () => { + const f = animationFixture(); f.storage.set('ProgressIndex', 4); + f.show(7); + assert.equal(f.actions.length, 0); + assert.equal(f.manager.progressBar4.height, 6 * 350); + assert.equal(f.panel.stage.free[4], 0); + assert.equal(f.panel.barSpr.fillRange, 0); + assert.equal(f.panel.currentLv.number, 8); + assert.equal(f.storage.get('ProgressIndex'), 4); +}); + +test('battle pass animation plays only new completed tiers and does not repeat on reopening', () => { + const f = animationFixture(); f.show(4); f.show(7); + assert.deepEqual(f.topFills(), [1, 1, 1]); + assert.equal(f.manager.progressBar4.height, 3 * 350); + f.finish(); assert.equal(f.manager.progressBar4.height, 6 * 350); + assert.equal(f.panel.barSpr.fillRange, 0); + f.show(7); assert.equal(f.actions.length, 0); +}); + +test('battle pass partial progress animates within a tier without moving the completed-tier bar', () => { + const f = animationFixture(); f.show(5, 1); f.show(5, 2); + assert.equal(f.panel.barSpr.fillRange, 0.25); + assert.deepEqual(f.topFills(), [0.5]); + assert.equal(f.actions.length, 1); + f.finish(); assert.equal(f.panel.barSpr.fillRange, 0.5); + assert.equal(f.panel.progress_1.number, 2); assert.equal(f.panel.progress_2.number, 4); + f.show(5, 2); assert.equal(f.actions.length, 0); assert.equal(f.panel.barSpr.fillRange, 0.5); +}); + +test('battle pass crosses a tier from its saved fraction and finishes at the new fraction', () => { + const f = animationFixture(); f.show(5, 3); f.show(6, 1); + assert.equal(f.panel.barSpr.fillRange, 0.75); + assert.deepEqual(f.topFills(), [1, 0.25]); + f.finish(); assert.equal(f.panel.barSpr.fillRange, 0.25); + assert.equal(f.manager.progressBar4.height, 5 * 350); +}); + +test('battle pass periods and accounts cannot overwrite each other animation records', () => { + const f = animationFixture(); f.show(5); f.show(4, 0, { old: true, season: '1900000000000' }); + assert.equal(f.actions.length, 0); + f.show(7); assert.deepEqual(f.topFills(), [1, 1]); + f.show(4, 0, { old: true, season: '1900000000000' }); assert.equal(f.actions.length, 0); + f.show(7); assert.equal(f.actions.length, 0); + f.show(1, 0, { season: '2100000000000' }); assert.equal(f.actions.length, 0); + f.cc.fx.GameConfig.GM_INFO.uid = 'another-player'; f.show(7); assert.equal(f.actions.length, 0); +}); + +test('battle pass cap stays full and gaining experience above the cap does not replay', () => { + for (const tier of [0, 18, 30]) { + const f = animationFixture(), cap = tier === 30 ? rows.length : 35; + f.show(cap - 1, 0, { tier }); f.show(cap, 0, { tier }); + assert.deepEqual(f.topFills(), [1]); f.finish(); assert.equal(f.panel.barSpr.fillRange, 1); + f.show(cap, 1, { tier }); assert.equal(f.actions.length, 0); assert.equal(f.panel.barSpr.fillRange, 1); + } +}); + +test('battle pass invalid or ahead-of-server records show the current state without a backward replay', () => { + const f = animationFixture(), key = 'PassProgress:animation-player:2000000000000'; + for (const value of [null, 4, { completed: -1, fill: 0 }, { completed: 7, fill: 0 }, { completed: 5, fill: NaN }, { completed: 5, fill: 0.9 }]) { + f.storage.set(key, value); f.show(5, 1); + assert.equal(f.actions.length, 0); assert.equal(f.panel.barSpr.fillRange, 0.25); + } +}); + +test('battle pass refresh and destroy stop pending progress animations', () => { + const f = animationFixture(); f.show(4); f.show(7); + const previous = [...f.actions]; + // Refresh without removing pending tween handles from the test scheduler. + f.panel.onInit({ 1: { time: '0' }, 2: { time: '2000000000000', experience: 16, free: [], passCheck: [] } }); + previous.forEach(action => assert.equal(f.stopped.has(action), true)); + f.finish(); assert.equal(f.panel.barSpr.fillRange, 0); assert.equal(f.manager.progressBar4.height, 6 * 350); + f.show(8); f.panel.onDestroy(); f.manager.onDestroy(); + f.actions.forEach(action => assert.equal(f.stopped.has(action), true)); +}); + +test('battle pass one/two XP increments agree between both bars at every early-tier boundary', () => { + for (const tier of [0, 18, 30]) { + const f = animationFixture(); + let previous; + for (let xp = 0; xp <= 100; xp += tier === 30 ? 2 : 1) { + const stage = model.normalizePass({ time: '100', experience: xp }, rows); + const level = stage.unlocked; + const partial = xp - rows.slice(0, level).reduce((sum, row) => sum + row.token, 0); + f.show(level, partial, { tier }); + const fills = f.topFills(); + if (previous !== undefined) assert.equal(fills.filter(value => value === 1).length, level - previous); + f.finish(); + assert.equal(f.manager.progressBar4.height, Math.max(0, level - 1) * 350); + assert.equal(f.panel.barSpr.fillRange, partial / rows[level].token); + previous = level; + } + } +}); + +test('battle pass zero XP, cost changes and premium extension keep progress display consistent', () => { + const f = animationFixture(); + f.show(0, 0, { old: true, season: '1900000000000' }); + assert.equal(f.panel.barSpr.fillRange, 0); assert.equal(f.panel.progress_1.number, 0); + f.show(4); f.show(5); f.finish(); + assert.equal(f.panel.progress_1.number, 0); assert.equal(f.panel.progress_2.number, 4); + assert.equal(f.panel.currentLv.number, 6); + f.show(35, 0, { tier: 18 }); f.show(35, 0, { tier: 30 }); + assert.equal(f.actions.length, 0); assert.equal(f.panel.barSpr.fillRange, 0); + f.show(36, 0, { tier: 30 }); assert.deepEqual(f.topFills(), [1]); f.finish(); + assert.equal(f.manager.progressBar4.height, 35 * 350); +}); function listData(count, tier) { return Array.from({ length: count }, (_, i) => ({ id: i, ItemID1: 1001, ItemNum1: 1500, ItemID2: 1001, ItemNum2: 500, free: 1, passCheck: 1, leftAllowed: i < 35 || tier === 30, rightAllowed: i < 35 ? tier >= 18 : tier === 30 })); } test('free and VIP preview 40 with clickable masks after 35, pooled SVIP rows hide every mask', () => { diff --git a/tools/test-battle-pass.cjs b/tools/test-battle-pass.cjs index 634d90f..20e830e 100644 --- a/tools/test-battle-pass.cjs +++ b/tools/test-battle-pass.cjs @@ -10,7 +10,8 @@ const plain = value => JSON.parse(JSON.stringify(value)); function load(path, deps = {}, globals = {}) { const exports = {}; const code = ts.transpileModule(fs.readFileSync(path, 'utf8'), { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 } }).outputText; - vm.runInNewContext(code, { exports, require: name => deps[name], console, Date, setTimeout, clearTimeout, ...globals }); + vm.runInNewContext(code, { exports, require: name => deps[name], console, Date, setTimeout, clearTimeout, + process: { env: { PASSCHECKTIME_ID: '69fac1528463a95668c5e5db' } }, ...globals }); return exports; } const model = load('assets/Script/module/Config/BattlePassModel.ts'); diff --git a/tools/test-shop-stamina.cjs b/tools/test-shop-stamina.cjs new file mode 100644 index 0000000..486342b --- /dev/null +++ b/tools/test-shop-stamina.cjs @@ -0,0 +1,90 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { loadMembers } = require('./new-player-ab-test-support.cjs'); + +function fixture(hp = 5, powerSeconds = 60) { + let now = 1000; + const info = { hp, hp_Max: 5, userPowerTime: powerSeconds ? now + powerSeconds : 0, + min_Time: 120, useravatarIcon: '', useravaterkuang: '', coin: 100 }; + const node = () => { + const children = new Map(), component = {}; + return { active: true, opacity: 255, getChildByName(name) { + if (!children.has(name)) children.set(name, node()); + return children.get(name); + }, getComponent: () => component }; + }; + const cc = { sys: { platform: 'browser', WECHAT_GAME: 'wechat', BYTEDANCE_GAME: 'bytedance' }, + Sprite: {}, Label: {}, find: () => ({ getComponent: () => null }), fx: { + GameConfig: { GM_INFO: info }, GameTool: { + getUserPowerTime() { if (info.userPowerTime <= now) { info.userPowerTime = 0; return false; } return true; }, + getTimeMargin: value => String(value), getTimeMargin2: value => String(value), + getActionName() {}, loadSpineSimple() {}, getSetScreenResolutionFlag: () => false, + }, + } }; + const Shop = loadMembers('assets/shop/script/shop.ts', [ + 'init', 'setHealthInfo', 'updatePower', 'startPowerTime', 'stopPowerTime', + 'startTimeCutDown', 'stopTimeCutDown', 'removeOnShowListener', + ], { cc, Date: { now: () => now * 1000 }, SceneManager: {}, MapConroler: {}, + NumberToImage: { numberToImageNodes() {}, numberToImageNodesCoin() {} }, console: { log() {} } }); + const shop = new Shop(), scheduled = new Set(); + Object.assign(shop, { node: node(), Stamina: node(), coin: node(), ui: { getSpriteFrame() {} }, + refreshJungleEntryLayout() {}, openShop() {}, updateIcon() {}, + schedule: callback => scheduled.add(callback), unschedule: callback => scheduled.delete(callback) }); + const get = name => shop.Stamina.getChildByName(name); + const open = () => { shop.node.active = true; shop.init({}); }; + const tick = seconds => { now += seconds; [...scheduled].forEach(callback => callback()); }; + return { shop, info, scheduled, get, open, tick, advance: seconds => { now += seconds; } }; +} + +test('full stamina and infinite stamina never show both labels when repeatedly reopening the cached shop', () => { + const f = fixture(); + for (let i = 0; i < 3; i++) { + f.open(); + assert.equal(f.get('man').active, false, 'full label must already be hidden before the first timer tick'); + assert.equal(f.get('skyLine').active, true); + assert.equal(f.scheduled.size, 1, 'reopening must not duplicate the infinite stamina timer'); + f.tick(1); + assert.equal(f.get('man').active, false); + assert.equal(f.get('skyLine').getChildByName('skyTime').getComponent().string, String(59 - i)); + f.shop.node.active = false; + } +}); + +test('a later ordinary health refresh cannot cover the infinite stamina countdown', () => { + const f = fixture(); f.open(); f.tick(1); f.shop.setHealthInfo(true); + assert.equal(f.get('man').active, false); + assert.equal(f.get('skyLine').active, true); + assert.equal(f.info.hp, 5); +}); + +test('full stamina without infinite stamina still displays full immediately', () => { + const f = fixture(5, 0); + for (let i = 0; i < 2; i++) { + f.open(); assert.equal(f.get('man').active, true); assert.equal(f.get('skyLine').active, false); + assert.equal(f.get('time').active, false); assert.equal(f.scheduled.size, 0); + } +}); + +test('infinite stamina expiring while the shop is closed restores the full label on reopening', () => { + const f = fixture(5, 2); f.open(); f.shop.node.active = false; f.advance(2); f.open(); + assert.equal(f.get('skyLine').active, false); assert.equal(f.get('man').active, true); + assert.equal(f.scheduled.size, 0); +}); + +test('infinite stamina expiring in the open shop restores the full label', () => { + const f = fixture(5, 2); f.open(); f.tick(2); + assert.equal(f.get('skyLine').active, false); assert.equal(f.get('man').active, true); + assert.equal(f.scheduled.size, 0); +}); + +test('non-full stamina preserves ordinary and infinite countdown visibility', () => { + for (const power of [0, 60]) { + const f = fixture(3, power); f.open(); + assert.equal(f.get('man').active, false); + assert.equal(f.get('skyLine').active, power > 0); + assert.equal(f.get('time').active, true); + assert.equal(f.get('time').opacity, power ? 0 : 255); + assert.equal(f.get('progresss').getComponent().fillRange, 3 / 5); + assert.equal(f.info.hp, 3); + } +});