const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const { loadMembers, withNewPlayerConfig, loadModule } = require('./new-player-ab-test-support.cjs'); const prefab = JSON.parse(fs.readFileSync('assets/lose/prefab/Lose.prefab', 'utf8')); const frameData = Array.from({ length: 6 }, (_, i) => { const meta = JSON.parse(fs.readFileSync(`assets/lose/texture/giveup${i + 1}.png.meta`, 'utf8')); return Object.values(meta.subMetas)[0]; }); function fixture({ infinite = false, level = 50, group = 'B', streak = 10, help = 0, rainbow = false } = {}) { const config = withNewPlayerConfig({ GM_INFO: { level, otherLevel: help, winStreak: streak, revive: 0, abTestAssignments: { layer_1: { name: 'new1_50', group, enabled: true } }, }, isRainbowWinStreak: () => rainbow }); let resets = 0, stoppedRevive = 0; const cc = { v2: (x, y) => ({ x, y }), Sprite: { SizeMode: { RAW: 2 } }, tween: () => ({ to() { return this; }, start() {} }), fx: { GameConfig: config, AudioManager: { _instance: { playEffect() {} } }, GameTool: { getUserPowerTime: () => infinite, shushu_Track() {}, setWinStreak: () => resets++ } }, }; const nodes = prefab.map(data => data.__type__ === 'cc.Node' ? { name: data._name, active: data._active, width: data._contentSize.width, height: data._contentSize.height, x: data._trs.array[0], y: data._trs.array[1], scaleX: data._trs.array[7], scaleY: data._trs.array[8], children: [], sprite: {}, getChildByName(name) { return this.children.find(n => n.name === name); }, getComponent() { return this.sprite; }, setPosition(x, y) { this.x = typeof x === 'object' ? x.x : x; this.y = typeof x === 'object' ? x.y : y; }, setScale(value) { this.scaleX = this.scaleY = value; }, setContentSize({ width, height }) { this.width = width; this.height = height; }, } : null); nodes.forEach((node, i) => { if (node) node.children = prefab[i]._children.map(ref => nodes[ref.__id__]); }); const root = nodes[1], lose = root.getChildByName('lose'); const confirmation = loadModule('assets/Script/GiveUpConfirmation.ts', {}, { cc }); const Panel = loadMembers('assets/lose/script/LosePanel.ts', ['showGiveUpConfirmation'], { cc, ...confirmation }); const panel = Object.assign(new Panel(), { node: root, giveUpFrames: frameData.map(data => ({ uuid: data.uuid, getOriginalSize: () => ({ width: data.rawWidth, height: data.rawHeight }), })) }); root.getComponent = () => panel; const MapController = loadMembers('assets/Script/Map.ts', ['homeBtn', 'openHealth', 'closeWinStreak', 'hideLoseReasonPanels'], { cc, ...confirmation }); const map = Object.assign(new MapController(), { node: { active: true }, homeCanTouch: true, reviewState: false, isMapRuntimeAlive: () => true, getLoseRoot: () => root, trackFinishi() {}, revive: { getComponent: () => ({ offShow: () => stoppedRevive++ }) }, }); map.hideLoseReasonPanels(lose); lose.getChildByName('Time').active = true; return { panel, map, lose, config, setInfinite: value => infinite = value, resets: () => resets, stoppedRevive: () => stoppedRevive }; } test('all six title frames are serialized in giveup1–6 order', () => { const component = prefab.find(data => data.__type__ === '0fbfcSygcRKl6AshXSeUHTO'); assert.deepEqual(component.giveUpFrames.map(ref => ref.__uuid__), frameData.map(data => data.uuid)); }); for (const rainbow of [false, true]) { test(`all eight loss combinations reuse the ${rainbow ? 'rainbow' : 'normal'} panel without stale icons`, () => { const { panel, lose } = fixture(); const cases = [ [true, true, true, 3], [true, true, false, 2], [true, false, false, 1], [false, true, false, 4], [false, true, true, 5], [false, false, true, 6], [true, false, true, 1], [false, false, false, null], ]; for (const [hp, day, streak, titleNumber] of cases) { assert.equal(panel.showGiveUpConfirmation(hp, day, streak, rainbow), titleNumber !== null); const current = lose.getChildByName(rainbow ? 'WinStreak2' : 'WinStreak'); const other = lose.getChildByName(rainbow ? 'WinStreak' : 'WinStreak2'); assert.equal(other.active, false); assert.equal(current.active, titleNumber !== null); if (!titleNumber) continue; const icons = ['hp', 'day', 'winStreak'].map(name => current.getChildByName(name)); assert.deepEqual(icons.map(icon => icon.active), [hp, day, streak]); const title = current.getChildByName('result_title'); assert.equal(title.sprite.spriteFrame.uuid, frameData[titleNumber - 1].uuid); assert.equal(title.sprite.trim, false); assert.equal(title.width / title.height, frameData[titleNumber - 1].rawWidth / frameData[titleNumber - 1].rawHeight); const visible = icons.filter(icon => icon.active); for (const icon of visible) { assert.equal(icon.scaleX, icon.scaleY, 'icons retain their aspect ratio'); assert.ok(icon.y - icon.height * icon.scaleY / 2 > -263, 'icons stay above the think-again button'); } if (visible.length === 1) assert.equal(visible[0].x, 0); if (visible.length >= 2) { assert.ok(visible[0].x < 0 && visible[1].x > 0); assert.ok(visible[0].y >= visible[1].y); } if (visible.length === 3) assert.ok(visible[2].x === 0 && visible[2].y < visible[0].y); } }); } test('mainline unlock boundaries and help levels select the correct losses', () => { const cases = [ [{ level: 34 }, [true, false, false]], [{ level: 35 }, [true, true, false]], [{ level: 49 }, [true, true, false]], [{ level: 50 }, [true, true, true]], [{ level: 50, streak: 9 }, [true, true, false]], [{ group: 'A', level: 0, streak: 0 }, [true, true, false]], [{ infinite: true }, [false, true, true]], [{ help: 1 }, [true, false, false]], [{ help: 1, infinite: true }, null], [{ level: 34, infinite: true }, null], ]; for (const rainbow of [false, true]) for (const [options, expected] of cases) { const f = fixture({ ...options, rainbow }); const before = JSON.stringify(f.config.GM_INFO); f.map.homeBtn(null, 'time'); const current = f.lose.getChildByName(rainbow ? 'WinStreak2' : 'WinStreak'); assert.equal(current.active, !!expected, JSON.stringify(options)); assert.equal(f.lose.getChildByName('Health').active, !expected); assert.equal(JSON.stringify(f.config.GM_INFO), before, 'display does not mutate reward or task state'); if (expected) { assert.deepEqual(['hp', 'day', 'winStreak'].map(name => current.getChildByName(name).active), expected); assert.equal(f.resets(), 0, 'opening confirmation must not clear a streak'); assert.equal(f.stoppedRevive(), 0, 'revive remains available until exit is confirmed'); } else { assert.equal(f.lose.getChildByName('Health').getChildByName('node2').active, true); } } }); test('cancel keeps the revive screen available; confirm opens Health with the current stamina state', () => { for (const help of [0, 1]) for (const infinite of [false, true]) { const f = fixture({ help }); f.map.homeBtn(null, 'time'); f.map.closeWinStreak(); assert.equal(f.map.homeCanTouch, true); assert.equal(f.lose.getChildByName('Time').active, true); assert.equal(f.resets(), 0); f.map.homeBtn(null, 'time'); f.setInfinite(infinite); f.map.openHealth(); const health = f.lose.getChildByName('Health'); assert.equal(health.active, true); assert.equal(health.getChildByName('node1').active, !infinite); assert.equal(health.getChildByName('node2').active, infinite); assert.equal(f.lose.getChildByName('WinStreak').active, false); assert.equal(f.lose.getChildByName('Time').active, false); assert.equal(f.resets(), help ? 0 : 1, 'assist exits must not clear the player streak'); } });