diff --git a/assets/Script/JiaZai.ts b/assets/Script/JiaZai.ts index dc2a7408..91f8722a 100644 --- a/assets/Script/JiaZai.ts +++ b/assets/Script/JiaZai.ts @@ -1094,6 +1094,8 @@ export default class JiaZai extends cc.Component { cc.fx.GameTool.getHealth((data) => { + // 请求成功或失败都可能在离开首页后回调,不能再刷新已销毁的节点。 + if (!this.isHomeRuntimeAlive()) return; let kuandu = 43; if ((cc.fx.GameConfig.GM_INFO.level + 1) > 999) { kuandu = 35; @@ -1644,6 +1646,8 @@ export default class JiaZai extends cc.Component { top.getChildByName("avatar").getChildByName("avatar").active = true; top.getChildByName("avatar").getChildByName("sp").active = false; cc.assetManager.loadRemote(cc.fx.GameConfig.GM_INFO.useravatarIcon, { ext: '.png' }, (err, texture: cc.Texture2D) => { + // 图片回调可能晚于首页销毁,闭包里的旧 Top 不能再查找子节点。 + if (!this.isHomeRuntimeAlive() || !cc.isValid(top, true)) return; if (texture) { top.getChildByName("avatar").getChildByName("avatar").getComponent(cc.Sprite).spriteFrame = new cc.SpriteFrame(texture); } @@ -2423,7 +2427,8 @@ export default class JiaZai extends cc.Component { // - share:是否来自分享回调 // ============================================ openDailyQuests(event?: cc.Event, customEventData?: string, share?: boolean) { - if (this.dailyQuestsShow) return; + const canOpen = () => this.isHomeRuntimeAlive() && !cc.fx.GameConfig.isGameplayEntryPreparing(); + if (!canOpen() || this.dailyQuestsShow) return; if (event) cc.fx.AudioManager._instance.playEffect("anniu_little", null); if (cc.fx.GameConfig.isNewPlayerB() && cc.fx.GameConfig.GM_INFO.level < 35) return; this.vibrateButtonClick(event); @@ -2450,7 +2455,7 @@ export default class JiaZai extends cc.Component { this.dailyQuestsShow = true; cc.fx.GameTool.getDailyQuestsInfo((data) => { this.dailyQuestsShow = false; - if (!cc.isValid(this.node)) return; + if (!canOpen()) return; this.closeLoad(); console.log('data : ', data); if (data.code != 1) { @@ -2464,12 +2469,15 @@ export default class JiaZai extends cc.Component { cc.error(err.message || err); return; } + if (!canOpen()) return; bundle.load('prefab/dailyQuests', cc.Prefab, (err: Error, prefab: cc.Prefab) => { if (err) { cc.error(err.message || err); return; } JiaZai.cachedDailyQuestsPrefab = prefab; + // 资源可缓存,但离开首页后不能再实例化或挂载任务弹窗。 + if (!canOpen()) return; if (!this.dailyQuestsNode) { // 第一次使用,创建节点 this.dailyQuestsNode = cc.instantiate(JiaZai.cachedDailyQuestsPrefab); @@ -5294,12 +5302,13 @@ export default class JiaZai extends cc.Component { // ============================================ getSRank(isShow: boolean = true, onRendered?: () => void) { // return; - if (this.isCareerDisabledByMemoryWarning()) { + if (!cc.isValid(this, true) || !cc.isValid(this.node, true) || this.isCareerDisabledByMemoryWarning()) { return; } const requestId = ++this._sRankRequestId; const finishRender = () => { if (requestId !== this._sRankRequestId + || !cc.isValid(this, true) || !this.node || !cc.isValid(this.node, true) || this.isCareerDisabledByMemoryWarning()) return; if (this.locatePositionAfterRank) { @@ -5312,8 +5321,9 @@ export default class JiaZai extends cc.Component { Utils.getSRank(res => { if (requestId !== this._sRankRequestId || this.isCareerDisabledByMemoryWarning() + || !cc.isValid(this, true) || !this.node - || !cc.isValid(this.node)) { + || !cc.isValid(this.node, true)) { return; } if (res.data) { @@ -5561,17 +5571,19 @@ export default class JiaZai extends cc.Component { // 加载入职排行榜预制体 LoadCareer(callback: () => void, isShow: boolean = true) { - if (this.isCareerDisabledByMemoryWarning()) { + if (!cc.isValid(this, true) || !cc.isValid(this.node, true) || this.isCareerDisabledByMemoryWarning()) { return; } const generation = ++this.careerLoadGeneration; this.careerPendingLoads++; + // Cocos 销毁组件会清空数组属性;保留原队列,让晚到的回调仍能结束清理等待。 + const loadWaiters = this.careerLoadWaiters; let finished = false; const finish = () => { if (finished) return; finished = true; this.careerPendingLoads--; - if (!this.careerPendingLoads) this.careerLoadWaiters.splice(0).forEach(resolve => resolve()); + if (!this.careerPendingLoads) loadWaiters.splice(0).forEach(resolve => resolve()); }; const loadCareer = (dependencyError?: Error) => { if (dependencyError) { @@ -5579,8 +5591,9 @@ export default class JiaZai extends cc.Component { finish(); return; } if (generation !== this.careerLoadGeneration + || !cc.isValid(this, true) || !this.node - || !cc.isValid(this.node) + || !cc.isValid(this.node, true) || this.isCareerDisabledByMemoryWarning()) { finish(); return; } @@ -5594,8 +5607,9 @@ export default class JiaZai extends cc.Component { finish(); return; } if (generation !== this.careerLoadGeneration + || !cc.isValid(this, true) || !this.node - || !cc.isValid(this.node)) { + || !cc.isValid(this.node, true)) { finish(); return; } @@ -5609,8 +5623,9 @@ export default class JiaZai extends cc.Component { finish(); return; } if (generation !== this.careerLoadGeneration + || !cc.isValid(this, true) || !this.node - || !cc.isValid(this.node)) { + || !cc.isValid(this.node, true)) { finish(); return; } finish(); diff --git a/assets/Script/NumberToImage.ts b/assets/Script/NumberToImage.ts index 56120b7c..363f312c 100644 --- a/assets/Script/NumberToImage.ts +++ b/assets/Script/NumberToImage.ts @@ -379,6 +379,7 @@ export default class NumberToImage extends cc.Component { static numberToImageNodesShop(number, width, posX, name, targetNode: cc.Node, middle: boolean = false) { + if (!targetNode || !cc.isValid(targetNode, true)) return; if (targetNode.name === 'progress_1' || targetNode.name === 'progress_2') BattlePassArt.digits(targetNode, number); const numStr = number.toString(); let cha = 0; diff --git a/assets/Script/module/Config/GameConfig.ts b/assets/Script/module/Config/GameConfig.ts index ffbeec9f..1ad93452 100644 --- a/assets/Script/module/Config/GameConfig.ts +++ b/assets/Script/module/Config/GameConfig.ts @@ -800,7 +800,7 @@ export class GameConfig { vibrateOpen: true, //震动 coinnum: 0, //每局的金币数 paid_user: false, //是否是付费用户 - version: "1.0.95", //版本号 + version: "1.0.96", //版本号 shushu_DistinctId: "", //数数访客ID shushu_AccountId: "", //数数账号ID uid: "", //用户和后端唯一id diff --git a/assets/Script/module/Pay/Utils.ts b/assets/Script/module/Pay/Utils.ts index 8f16c57d..7043ecb2 100644 --- a/assets/Script/module/Pay/Utils.ts +++ b/assets/Script/module/Pay/Utils.ts @@ -672,12 +672,7 @@ export default class Utils { if (!options?.json && dataStr !== '') { dataStr = dataStr.substr(0, dataStr.lastIndexOf('&')); } - if (url != "getSRank" && url != "getRank") { - url = requestHttpip + url; - } - else { - url = "https://bx7gi3d4oq.sealoshzh.site/" + url; - } + url = requestHttpip + url; if (url == requestHttpip + "getAddressRankOther") { diff --git a/tools/test-daily-quests-loading-lifecycle.cjs b/tools/test-daily-quests-loading-lifecycle.cjs new file mode 100644 index 00000000..c25a343b --- /dev/null +++ b/tools/test-daily-quests-loading-lifecycle.cjs @@ -0,0 +1,113 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +const ts = require('typescript'); + +const file = path.join(__dirname, '../assets/Script/JiaZai.ts'); +const source = ts.createSourceFile(file, fs.readFileSync(file, 'utf8'), ts.ScriptTarget.Latest, true); +const declaration = source.statements.find(ts.isClassDeclaration); +const methods = ['isHomeRuntimeAlive', 'openDailyQuests'].map(name => { + const method = declaration.members.find(item => item.name?.getText(source) === name); + assert.ok(method, name); + return method.getText(source); +}).join('\n'); + +function fixture(cached = false) { + const state = { scene: 'HomeScene', preparing: false, requests: 0, bundleLoads: 0, prefabLoads: 0, + created: 0, attached: 0, initialized: [], closed: 0, opened: 0, errors: [], toasts: [] }; + const pending = {}; + const prefab = {}; + const task = { daily: 'fixture' }; + const response = { code: 1, data: { task } }; + const cc = { + isValid: (value, strict) => !!value && !value.destroyed && !(strict && value.destroyPending), + director: { getScene: () => ({ name: state.scene }) }, Prefab: class {}, + error: error => state.errors.push(error), + instantiate(asset) { + assert.equal(asset, prefab); + state.created++; + return { active: true, getComponent: () => ({ init: data => state.initialized.push(data) }) }; + }, + fx: { GameConfig: { GM_INFO: { level: 40 }, isNewPlayerB: () => false, + isGameplayEntryPreparing: () => state.preparing }, + GameTool: { shushu_Track() {}, getDailyQuestsInfo(callback) { state.requests++; pending.response = callback; } } }, + }; + const Home = vm.runInNewContext(ts.transpileModule(`class JiaZai { ${methods} }; JiaZai;`, { + compilerOptions: { target: ts.ScriptTarget.ES2018 }, + }).outputText, { cc, console: { log() {} }, MiniGameSdk: { API: { showToast: text => state.toasts.push(text) } } }); + if (cached) Home.cachedDailyQuestsPrefab = prefab; + const home = new Home(); + home.node = { addChild() { state.attached++; } }; + home.vibrateButtonClick = () => {}; + home.openLoad2 = () => state.opened++; + home.closeLoad = () => state.closed++; + home.loadHomeBundleWithDependencies = (_name, _priority, callback) => { state.bundleLoads++; pending.bundle = callback; }; + const bundle = { load(_path, _type, callback) { state.prefabLoads++; pending.prefab = callback; } }; + const finish = stage => { + if (stage === 'response') pending.response(response); + if (stage === 'bundle') pending.bundle(null, bundle); + if (stage === 'prefab') pending.prefab(null, prefab); + }; + return { home, Home, state, pending, prefab, task, finish }; +} + +const invalidations = { + 'node cleared': f => { f.home.node = null; }, + 'node destroyed': f => { f.home.node.destroyed = true; }, + 'node pending destruction': f => { f.home.node.destroyPending = true; }, + 'component destroyed': f => { f.home.destroyed = true; }, + 'component pending destruction': f => { f.home.destroyPending = true; }, + 'scene changed': f => { f.state.scene = 'GameScene'; }, + 'entering gameplay': f => { f.state.preparing = true; }, +}; +for (const stage of ['entry', 'response', 'bundle', 'prefab']) { + for (const [name, invalidate] of Object.entries(invalidations)) test(`${stage}: ${name} cannot open daily quests`, () => { + const f = fixture(); + if (stage !== 'entry') f.home.openDailyQuests(); + if (stage === 'bundle' || stage === 'prefab') f.finish('response'); + if (stage === 'prefab') f.finish('bundle'); + const closed = f.state.closed; + invalidate(f); + assert.doesNotThrow(() => stage === 'entry' ? f.home.openDailyQuests() : f.finish(stage)); + assert.equal(f.state.created, 0); + assert.equal(f.state.attached, 0); + assert.equal(f.state.closed, closed, 'stale callbacks cannot touch loading UI'); + if (stage === 'entry') assert.equal(f.state.requests, 0); + if (stage === 'response') assert.equal(f.state.bundleLoads, 0); + if (stage === 'bundle') assert.equal(f.state.prefabLoads, 0); + if (stage === 'prefab') assert.equal(f.Home.cachedDailyQuestsPrefab, f.prefab, 'completed resources remain reusable'); + }); +} + +for (const cached of [true, false]) test(`live daily quests opens and reuses its node, cached=${cached}`, () => { + const f = fixture(cached); + f.home.openDailyQuests(); + f.home.openDailyQuests(); + assert.equal(f.state.requests, 1, 'pending API request is not duplicated'); + f.finish('response'); + if (!cached) { f.finish('bundle'); f.finish('prefab'); } + assert.equal(f.state.created, 1); + assert.equal(f.state.attached, 1); + assert.equal(f.state.initialized[0], f.task); + f.home.dailyQuestsNode.active = false; + f.home.openDailyQuests(); f.finish('response'); + assert.equal(f.home.dailyQuestsNode.active, true); + assert.equal(f.state.created, 1); + assert.equal(f.state.initialized.length, 2); +}); + +for (const stage of ['response', 'bundle', 'prefab']) test(`${stage} failure allows a later retry`, () => { + const f = fixture(); + f.home.openDailyQuests(); + if (stage !== 'response') f.finish('response'); + if (stage === 'prefab') f.finish('bundle'); + if (stage === 'response') f.pending.response({ code: 0, data: null }); + else f.pending[stage](new Error('offline')); + assert.equal(f.state.created, 0); + assert.equal(f.state.errors.length + f.state.toasts.length, 1); + f.home.openDailyQuests(); f.finish('response'); f.finish('bundle'); f.finish('prefab'); + assert.equal(f.state.created, 1); + assert.equal(f.state.attached, 1); +}); diff --git a/tools/test-home-avatar-lifecycle.cjs b/tools/test-home-avatar-lifecycle.cjs new file mode 100644 index 00000000..c3f5b6c7 --- /dev/null +++ b/tools/test-home-avatar-lifecycle.cjs @@ -0,0 +1,96 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +const ts = require('typescript'); + +const filename = path.join(__dirname, '../assets/Script/JiaZai.ts'); +const source = ts.createSourceFile(filename, fs.readFileSync(filename, 'utf8'), ts.ScriptTarget.Latest, true); +const declaration = source.statements.find(ts.isClassDeclaration); +const methods = ['isHomeRuntimeAlive', 'closeAvatar'].map(name => { + const method = declaration.members.find(item => item.name?.getText(source) === name); + assert.ok(method, name); + return method.getText(source); +}).join('\n'); + +function fixture() { + const state = { scene: 'HomeScene', pending: null, frames: 0, uploads: 0, lookups: 0 }; + class Node { + constructor(name) { this.name = name; this.children = []; this.opacity = 0; this.sprite = {}; } + getChildByName(name) { + state.lookups++; + // Match Cocos: destruction clears the array, but JS closures retain the node. + const children = this.children; + for (let i = 0, length = children.length; i < length; i++) { + if (children[i].name === name) return children[i]; + } + return null; + } + getComponent() { return this.sprite; } + destroy() { + for (const child of this.children) child.destroy(); + this.destroyed = true; + this.children = null; + } + } + const cc = { + isValid: (value, strict) => !!value && !value.destroyed && !(strict && value.destroyPending), + director: { getScene: () => ({ name: state.scene }) }, + Sprite: class {}, SpriteFrame: class { constructor(texture) { this.texture = texture; state.frames++; } }, + assetManager: { loadRemote: (_url, _options, callback) => { state.pending = callback; } }, + fx: { GameConfig: { GM_INFO: { useravatarIcon: 'https://example.test/avatar.png', useravaterkuang: 'frame1' } }, + GameTool: { setUserAvatar: () => state.uploads++ } }, + }; + const Home = vm.runInNewContext(ts.transpileModule(`class Home { ${methods} }; Home;`, { + compilerOptions: { target: ts.ScriptTarget.ES2018 }, + }).outputText, { cc }); + const add = (parent, name) => { const node = new Node(name); parent.children.push(node); return node; }; + const home = new Home(); + home.node = new Node('Canvas'); + const top = add(add(home.node, 'Load'), 'Top'); + const avatar = add(top, 'avatar'); + const image = add(avatar, 'avatar'); + add(avatar, 'sp'); + const border = add(top, 'kuang1'); + add(top, 'kuang'); + home.avatarUI = { getSpriteFrame: name => name }; + return { home, top, avatar, image, border, state }; +} + +const invalidations = { + 'home destroyed before returning to a new HomeScene': f => { f.home.node.destroy(); f.home.node = null; f.home.destroyed = true; }, + 'canvas destroyed with stale references': f => f.home.node.destroy(), + 'component pending destruction': f => { f.home.destroyPending = true; }, + 'canvas pending destruction': f => { f.home.node.destroyPending = true; }, + 'Top destroyed while component remains valid': f => f.top.destroy(), + 'Top pending destruction': f => { f.top.destroyPending = true; }, + 'scene changed before destruction': f => { f.state.scene = 'GameScene'; }, +}; +for (const [name, invalidate] of Object.entries(invalidations)) { + for (const success of [true, false]) test(`late avatar ${success ? 'success' : 'failure'}: ${name}`, () => { + const f = fixture(); + f.home.closeAvatar(); + invalidate(f); + const lookups = f.state.lookups; + assert.doesNotThrow(() => f.state.pending(success ? null : new Error('offline'), success ? {} : null)); + assert.equal(f.state.lookups, lookups, 'no lookup on obsolete UI'); + assert.equal(f.state.frames, 0); + assert.equal(f.avatar.opacity, 0); + assert.equal(f.border.opacity, 0); + assert.equal(f.state.uploads, 1, 'the original profile upload is preserved'); + }); +} + +for (const success of [true, false]) test(`live home avatar ${success ? 'success' : 'failure'} preserves display behavior`, () => { + const f = fixture(); + const texture = {}; + f.image.sprite.spriteFrame = 'previous'; + f.home.closeAvatar(); + f.state.pending(success ? null : new Error('offline'), success ? texture : null); + assert.equal(f.state.frames, success ? 1 : 0); + assert.equal(success ? f.image.sprite.spriteFrame.texture : f.image.sprite.spriteFrame, success ? texture : 'previous'); + assert.equal(f.avatar.opacity, 255); + assert.equal(f.border.opacity, 255); + assert.equal(f.state.uploads, 1); +}); diff --git a/tools/test-home-health-lifecycle.cjs b/tools/test-home-health-lifecycle.cjs new file mode 100644 index 00000000..d50dbb53 --- /dev/null +++ b/tools/test-home-health-lifecycle.cjs @@ -0,0 +1,102 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +const ts = require('typescript'); + +const root = path.resolve(__dirname, '..'); +const parse = file => ts.createSourceFile(file, fs.readFileSync(path.join(root, file), 'utf8'), ts.ScriptTarget.Latest, true); +const homeSource = parse('assets/Script/JiaZai.ts'); +const homeClass = homeSource.statements.find(ts.isClassDeclaration); +const member = (source, declaration, name) => declaration.members.find(item => item.name?.getText(source) === name); +const alive = member(homeSource, homeClass, 'isHomeRuntimeAlive').getText(homeSource); +const start = member(homeSource, homeClass, 'start'); +// Execute the actual startup request and callback without unrelated home setup. +const healthRequest = start.body.statements.find(item => ts.isExpressionStatement(item) + && ts.isCallExpression(item.expression) + && item.expression.expression.getText(homeSource) === 'cc.fx.GameTool.getHealth'); +assert.ok(healthRequest); +const numberSource = parse('assets/Script/NumberToImage.ts'); +const numberClass = numberSource.statements.find(ts.isClassDeclaration); +const shopDigits = member(numberSource, numberClass, 'numberToImageNodesShop').getText(numberSource); + +function fixture() { + const state = { pending: null, scene: 'HomeScene', created: 0, coinRefreshes: 0, healthRefreshes: 0, artRefreshes: 0 }; + class Node { + constructor(name = '') { this.name = name; this.children = []; this.width = 20; state.created++; } + addComponent() { return this.sprite = {}; } + set parent(value) { value.children.push(this); } + removeAllChildren() { this.children = []; } + } + const cc = { + Node, Sprite: class {}, + isValid: (value, strict) => !!value && !value.destroyed && !(strict && value.destroyPending), + director: { getScene: () => ({ name: state.scene }) }, + color: (...rgb) => rgb.join(','), + fx: { GameConfig: { GM_INFO: { level: 11, coin: 200 } }, + GameTool: { getHealth: callback => { state.pending = callback; } } }, + }; + const context = vm.createContext({ cc, BattlePassArt: { digits: () => state.artRefreshes++ } }); + const compile = code => vm.runInContext(ts.transpileModule(code, { + compilerOptions: { target: ts.ScriptTarget.ES2018 }, + }).outputText, context); + const Numbers = compile(`class NumberToImage { ${shopDigits} }; NumberToImage;`); + Numbers.font3 = { _spriteFrames: { custom1: 'one', custom2: 'two' } }; + Numbers.numberToImageNodesCoin = () => state.coinRefreshes++; + context.NumberToImage = Numbers; + const Home = compile(`class Home { ${alive} startHealth() { ${healthRequest.getText(homeSource)} } }; Home;`); + const home = new Home(); + home.node = new Node('Canvas'); home.level = new Node('level'); home.coin = new Node('coin'); + home.setHealthInfo = () => state.healthRefreshes++; + return { home, state, cc, Numbers, Node }; +} + +const invalidations = { + 'destroyed component with cleared node references': f => { f.home.destroyed = true; f.home.node = f.home.level = f.home.coin = null; }, + 'component pending destruction': f => { f.home.destroyPending = true; }, + 'destroyed canvas': f => { f.home.node.destroyed = true; }, + 'canvas pending destruction': f => { f.home.node.destroyPending = true; }, + 'changed scene': f => { f.state.scene = 'GameScene'; }, +}; +for (const [name, invalidate] of Object.entries(invalidations)) { + for (const code of [0, 1]) test(`late health ${code ? 'success' : 'failure'}: ${name}`, () => { + const f = fixture(); + f.home.startHealth(); + invalidate(f); + const created = f.state.created; + assert.doesNotThrow(() => f.state.pending({ code, data: null, networkError: code === 0 })); + assert.equal(f.state.created, created); + assert.equal(f.state.coinRefreshes, 0); + assert.equal(f.state.healthRefreshes, 0); + }); +} + +for (const code of [0, 1]) test(`live home still refreshes after health ${code ? 'success' : 'failure'}`, () => { + const f = fixture(); + f.home.startHealth(); + f.state.pending({ code, data: null }); + assert.deepEqual(Array.from(f.home.level.children, n => n.sprite.spriteFrame), ['one', 'two']); + assert.deepEqual(Array.from(f.home.level.children, n => n.x), [-31.5, 11.5]); + assert.ok(f.home.level.children.every(n => n.color === '144,97,26')); + assert.equal(f.state.coinRefreshes, 1); + assert.equal(f.state.healthRefreshes, 1); +}); + +test('shop digits ignore null, destroyed and pending-destruction targets', () => { + const f = fixture(); + for (const target of [null, undefined, { destroyed: true }, { destroyPending: true }]) { + const created = f.state.created; + assert.doesNotThrow(() => f.Numbers.numberToImageNodesShop(12, 43, 15, 'custom', target, true)); + assert.equal(f.state.created, created); + } + assert.equal(f.state.artRefreshes, 0); +}); + +test('live battle-pass progress still requests its digit artwork', () => { + const f = fixture(); + const target = new f.Node('progress_1'); + f.Numbers.numberToImageNodesShop(12, 43, 15, 'custom', target, true); + assert.equal(f.state.artRefreshes, 1); + assert.equal(target.children.length, 2); +});