const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('fs'); const vm = require('vm'); const ts = require('typescript'); const cp = require('node:child_process'); const rows = JSON.parse(fs.readFileSync('assets/resources/Json/PASS_CHECK.json', 'utf8')).Sheet1; const oldRows = JSON.parse(fs.readFileSync('assets/resources/Json/PASS_CHECK_OLD.json', 'utf8')).Sheet1; 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 }); return exports; } const model = load('assets/Script/module/Config/BattlePassModel.ts'); function fixture(owned = 18) { const start = Date.now() - 60000, end = start + model.PASS_PERIOD; const stage = { time: String(end), progressLevel: 20, progress: 1, activate: !!owned, free: Array(19).fill(1), passCheck: Array(19).fill(1) }; const tables = { users: [{ _id: 'u', openid: 'o', token: 't', coinAmount: 100, passCheck: JSON.stringify({ 1: { time: '0', free: [], passCheck: [] }, 2: stage }) }], order: [{ outTradeNo: 'paid', openid: 'o', itemid: owned ? 'battlepass_12' : 'battlepass_30', passVersion: 2, passEnd: String(end), time: Date.now(), state: 1 }], idcount: [{ _id: '69fac1528463a95668c5e5db', passcheckTime: start }] }; const db = { collection: name => ({ where: query => { const match = row => Object.entries(query).every(([key, value]) => value === null ? row[key] == null : row[key] === value); return { getOne: async () => ({ data: structuredClone(tables[name].find(match)) }), get: async () => ({ data: structuredClone(tables[name].filter(match)) }), update: async values => { const row = tables[name].find(match); if (row) Object.assign(row, values); return { updated: row ? 1 : 0, ok: true }; } }; } }) }; const deps = { '@lafjs/cloud': { default: { database: () => db } }, '@/Utils': { default: { checkToken: (a, b) => a === b } } }; const upgrade = load('server/laf-cloud/functions/passCheckUpgrade.ts', deps); deps['@/passCheckUpgrade'] = upgrade; const legacy = load('server/laf-cloud/functions/passCheck.ts', deps).default; const call = body => upgrade.default({ body: { uid: 'u', token: 't', ...body } }); const old = body => legacy({ body: { uid: 'u', token: 't', ...body } }); const cc = { fx: { GameConfig: { PASS_CHECK: rows, PASS_CHECK_OLD: oldRows, GM_INFO: { uid: 'u', coin: 100 } }, StorageMessage: { setStorage() {}, getStorage() {} } } }; const context = { cc, ...model, Date, console, setTimeout, clearTimeout }; const source = ts.createSourceFile('Utils.ts', fs.readFileSync('assets/Script/module/Pay/Utils.ts', 'utf8'), 99, true); const names = ['passGiftPending', 'applyPassGift', 'getPassCheckInfo', 'setPassCheckInfo', 'passRequest', 'passUpload', 'addPassProgress', 'completePassOrder']; const methods = source.statements.find(ts.isClassDeclaration).members.filter(n => names.includes(n.name?.getText(source))).map(n => n.getText(source)).join('\n'); vm.runInNewContext(ts.transpileModule('class Utils {' + methods + '} globalThis.Utils=Utils;', { compilerOptions: { target: ts.ScriptTarget.ES2020 } }).outputText, context); context.Utils.POST = (url, body, callback) => (url === 'passCheck' ? old(body) : call(body)).then(callback); return { tables, call, old, upgrade, cc, Utils: context.Utils, start, end, stage, db }; } const read = f => new Promise(resolve => f.Utils.getPassCheckInfo(resolve)); const save = (f, pass) => new Promise(resolve => f.Utils.setPassCheckInfo(resolve, pass)); const xpAt = level => rows.slice(0, level).reduce((sum, row) => sum + row.token, 0); const at = (level, tier = 30) => model.normalizePass({ time: '100', experience: xpAt(level), tier, activate: tier > 0 }, rows); test('previous season uses OLD experience and preserves claims through save and reload', async () => { const f = fixture(0); const previous = { time: String(f.start), progress: 2, progressLevel: 6, activate: false, free: [0, 0, 0, 1, 0, 0, ...Array(24).fill(-1)], passCheck: [...Array(6).fill(1), ...Array(24).fill(-1)] }; f.tables.users[0].passCheck = JSON.stringify({ 1: previous, 2: { time: String(f.end), progress: 1, progressLevel: 1, activate: false, free: [1], passCheck: [1] } }); const pass = JSON.parse((await read(f)).data.passCheck); assert.equal(pass[1].experience, 11); assert.equal(pass[1].unlocked, 6); assert.equal(pass[1].progress, 2); assert.equal(pass[2].experience, 0); assert.equal(pass[2].unlocked, 1); assert.deepEqual(plain(model.passBounds(pass[1], oldRows.length)), { generated: 30, content: 30 }); assert.equal(model.canClaimPass(pass[1], 3, true), true); assert.equal(model.canClaimPass(pass[1], 3, false), false); pass[1].free[3] = 0; assert.equal((await save(f, pass)).code, 1); const restored = JSON.parse((await read(f)).data.passCheck); assert.equal(restored[1].unlocked, 6); assert.equal(restored[1].free[3], 0); assert.equal(restored[2].experience, 0); const legacy = JSON.parse(f.tables.users[0].passCheck); assert.equal(legacy[1].progressLevel, 6); assert.equal(legacy[1].progress, 2); }); test('previous partial progress projects with OLD costs and missing OLD cannot use new costs', async () => { const pass = { 1: model.normalizePass({ time: '100', experience: 10 }, oldRows) }; const projected = model.legacyPassInfo(pass, rows, oldRows); assert.equal(projected[1].progressLevel, 6); assert.equal(projected[1].progress, 1); const f = fixture(); delete f.cc.fx.GameConfig.PASS_CHECK_OLD; assert.equal((await read(f)).code, 0); }); test('authored levels and two reward columns remain finite and contiguous', () => { assert.ok(rows.length >= 40); rows.forEach((r, i) => { assert.equal(r.level, i + 1); assert.ok(r.token >= 0); assert.match(r.free, /^\d+:\d+$/); assert.match(r.paid, /^\d+:\d+$/); }); }); test('free and VIP stop at 36, SVIP stops one beyond unlocked, all keep extra rows for elastic preview', () => { assert.deepEqual(plain(model.passBounds(at(90, 0), rows.length)), { generated: 40, content: 36 }); assert.deepEqual(plain(model.passBounds(at(90, 18), rows.length)), { generated: 40, content: 36 }); assert.deepEqual(plain(model.passBounds(at(1), rows.length)), { generated: 40, content: 36 }); assert.deepEqual(plain(model.passBounds(at(35), rows.length)), { generated: 40, content: 36 }); assert.deepEqual(plain(model.passBounds(at(37), rows.length)), { generated: 42, content: 38 }); assert.deepEqual(plain(model.passBounds(at(38), rows.length)), { generated: 43, content: 39 }); assert.deepEqual(plain(model.passBounds(at(40), rows.length)), { generated: 45, content: 41 }); assert.equal(model.canClaimPass(at(90, 18), 35, true), false); assert.equal(model.canClaimPass(at(35, 0), 34, true), true); assert.equal(model.canClaimPass(at(35, 0), 34, false), false); assert.equal(model.canClaimPass(at(40), 39, false), true); assert.deepEqual(plain(model.passBounds(at(rows.length), rows.length)), { generated: rows.length, content: rows.length }); }); test('legacy save stays successful under concurrency and read adds no clock field', async () => { const f = fixture(); const raw = f.tables.users[0].passCheck; const result = await f.old({ action: 'read' }); assert.equal(result.data.passCheck, raw); assert.equal(result.data.time, f.start); assert.equal('serverNow' in result.data, false); const calls = await Promise.all([f.old({ action: 'save', passCheck: raw }), f.old({ action: 'save', passCheck: raw })]); assert.deepEqual(calls.map(r => r.code), [1, 1]); }); test('legacy read does not rotate or remap a 300-day stage or an expired stage', async () => { for (const offset of [10 * model.PASS_PERIOD, -1000]) { const f = fixture(); const raw = JSON.parse(f.tables.users[0].passCheck); raw[2].time = String(f.start + offset); f.tables.users[0].passCheck = JSON.stringify(raw); const r = await f.old({ action: 'read' }); assert.equal(JSON.parse(r.data.passCheck)[2].time, raw[2].time); } }); test('new progress is separately saved while legacy receives only 30 levels', async () => { const f = fixture(); const r = await read(f), pass = JSON.parse(r.data.passCheck); pass[2].experience = xpAt(40); assert.equal((await save(f, pass)).code, 1); const old = JSON.parse((await f.old({ action: 'read' })).data.passCheck)[2]; assert.equal(old.free.length, 30); assert.equal(old.progressLevel, 30); assert.equal(old.progress, 4); const current = JSON.parse((await read(f)).data.passCheck)[2]; assert.equal(current.unlocked, 40); assert.equal(model.canClaimPass(current, 35, true), false); }); test('legacy uploads cannot erase premium, extra XP or new claims', async () => { const f = fixture(); await f.Utils.completePassOrder('paid'); let pass = JSON.parse((await read(f)).data.passCheck); pass[2].experience = xpAt(40); pass[2].free[37] = 0; pass[2].free[2] = 0; await save(f, pass); const old = JSON.parse((await f.old({ action: 'read' })).data.passCheck); old[2].activate = false; old[2].free[2] = 1; assert.equal((await f.old({ action: 'save', passCheck: JSON.stringify(old) })).code, 1); const restored = JSON.parse((await read(f)).data.passCheck)[2]; assert.equal(restored.tier, 30); assert.equal(restored.unlocked, 40); assert.equal(restored.free[37], 0); assert.equal(JSON.parse((await f.old({ action: 'read' })).data.passCheck)[2].free[2], 0); }); test('18 product and non-pass orders keep legacy ordering compatibility', () => { const f = fixture(); assert.deepEqual(plain(f.upgrade.passOrderFields(f.tables.users[0], { itemid: 'battlepass' })), {}); assert.deepEqual(plain(f.upgrade.passOrderFields(f.tables.users[0], { itemid: 'gold_1' })), {}); assert.throws(() => f.upgrade.passOrderFields(f.tables.users[0], { itemid: 'battlepass_12' })); assert.equal(f.upgrade.passOrderFields(f.tables.users[0], { itemid: 'battlepass_12', passVersion: 2, passEnd: String(f.end) }).passEnd, String(f.end)); }); test('direct 30 and upgrade 12 each grant exactly 3000 coins and 20 XP once', async () => { for (const owned of [0, 18]) { const f = fixture(owned); const initial = JSON.parse((await read(f)).data.passCheck)[2].experience; await f.Utils.completePassOrder('paid'); await f.Utils.completePassOrder('paid'); assert.equal(f.cc.fx.GameConfig.GM_INFO.coin, 3100); assert.equal(f.tables.users[0].coinAmount, 3100); assert.equal(JSON.parse((await read(f)).data.passCheck)[2].experience, initial + 20); assert.equal(f.tables.order[0].state, 2); } }); test('concurrent fulfillment cannot duplicate gift; unpaid and foreign orders fail', async () => { const f = fixture(); await Promise.all([f.Utils.completePassOrder('paid'), f.Utils.completePassOrder('paid')]); assert.equal(f.cc.fx.GameConfig.GM_INFO.coin, 3100); const unpaid = fixture(); unpaid.tables.order[0].state = 0; await assert.rejects(unpaid.Utils.completePassOrder('paid')); unpaid.tables.order[0].state = 1; unpaid.tables.order[0].openid = 'other'; await assert.rejects(unpaid.Utils.completePassOrder('paid')); }); test('lost gift response is confirmed locally once without touching generic coin sync', async () => { const f = fixture(); const send = f.Utils.POST; let lost = false; f.Utils.POST = (url, body, callback) => send(url, body, res => { if (body.action === 'save' && body.outTradeNo && !lost) { lost = true; callback({ code: 0, msg: 'response lost' }); } else callback(res); }); await f.Utils.completePassOrder('paid'); await f.Utils.completePassOrder('paid'); await read(f); assert.equal(f.cc.fx.GameConfig.GM_INFO.coin, 3100); assert.equal(f.tables.users[0].coinAmount, 3100); }); test('delayed duplicate order response cannot re-arm an already applied gift', async () => { const f = fixture(); const send = f.Utils.POST; let release, orderReads = 0; const held = new Promise(resolve => { f.Utils.POST = (url, body, callback) => send(url, body, res => { if (body.action === 'order' && ++orderReads === 2) { release = () => callback(res); resolve(); } else callback(res); }); }); const first = f.Utils.completePassOrder('paid'); const second = f.Utils.completePassOrder('paid'); await held; await first; release(); await second; await read(f); assert.equal(f.cc.fx.GameConfig.GM_INFO.coin, 3100); assert.equal(f.tables.users[0].coinAmount, 3100); }); test('victories add one XP before upgrade and two after; old-client XP still merges', async () => { const f = fixture(); const win = () => new Promise(resolve => { const set = f.Utils.setPassCheckInfo; f.Utils.setPassCheckInfo = (callback, pass) => set.call(f.Utils, result => { f.Utils.setPassCheckInfo = set; callback(result); resolve(result); }, pass); f.Utils.addPassProgress(); }); const initial = JSON.parse((await read(f)).data.passCheck)[2].experience; assert.equal((await win()).code, 1); assert.equal(JSON.parse((await read(f)).data.passCheck)[2].experience, initial + 1); await f.Utils.completePassOrder('paid'); assert.equal((await win()).code, 1); assert.equal(JSON.parse((await read(f)).data.passCheck)[2].experience, initial + 23); const old = JSON.parse((await f.old({ action: 'read' })).data.passCheck); if (old[2].progress === 4) { old[2].progressLevel++; old[2].progress = 1; } else old[2].progress++; await f.old({ action: 'save', passCheck: JSON.stringify(old) }); assert.equal(JSON.parse((await read(f)).data.passCheck)[2].experience, initial + 24); }); test('legacy recovery skips previous fulfilled order and finds current missing activation', async () => { const f = fixture(0); const base = JSON.parse(f.tables.users[0].passCheck); base[1] = { ...base[2], time: String(f.start), activate: true }; f.tables.users[0].passCheck = JSON.stringify(base); f.tables.order = [ { outTradeNo: 'previous', openid: 'o', itemid: 'battlepass', state: 2, time: f.start - 1000 }, { outTradeNo: 'current', openid: 'o', itemid: 'battlepass', state: 2, time: f.start + 1000 }, ]; assert.equal((await f.call({ action: 'legacyOrder' })).data.outTradeNo, 'current'); await f.Utils.completePassOrder('current'); assert.equal(JSON.parse(f.tables.users[0].passCheck)[2].activate, true); }); test('null clearing remains compatible with the existing expired-period initialization', async () => { const f = fixture(); assert.equal((await save(f, null)).code, 1); assert.equal(f.tables.users[0].passCheck, 'null'); assert.equal(JSON.parse((await read(f)).data.passCheck), null); const initial = { 1: { time: '0', free: [], passCheck: [] }, 2: { ...f.stage, activate: false } }; assert.equal((await save(f, initial)).code, 1); assert.equal(JSON.parse((await read(f)).data.passCheck)[2].time, String(f.end)); }); test('gift belongs to displayed order period and expired orders remain pending', async () => { const f = fixture(); const pass = JSON.parse(f.tables.users[0].passCheck); pass[1] = { ...pass[2], time: String(Date.now() - 1000) }; f.tables.order[0].passEnd = pass[1].time; f.tables.users[0].passCheck = JSON.stringify(pass); await f.Utils.completePassOrder('paid'); const extra = JSON.parse(f.tables.users[0].passCheckUpgrade); assert.equal(extra[pass[1].time].tier, 30); assert.notEqual(extra[pass[2].time].tier, 30); const expired = fixture(); expired.tables.order[0].passEnd = String(Date.now() - 2 * model.PASS_PERIOD); await assert.rejects(expired.Utils.completePassOrder('paid'), e => e.code === 410); assert.equal(expired.tables.order[0].state, 1); }); test('shared coin, login restoration and original HTTP implementation are not rewritten', () => { for (const path of ['server/laf-cloud/functions/userCoin.ts', 'server/laf-cloud/functions/passCheckTime.ts', 'assets/Script/GameManager.ts']) { assert.equal(fs.readFileSync(path, 'utf8').replaceAll('\r\n', '\n'), cp.execFileSync('git', ['show', 'HEAD:' + path], { encoding: 'utf8' }).replaceAll('\r\n', '\n')); } assert.equal(fs.existsSync('assets/Script/module/Pay/CoinSync.ts'), false); assert.equal(fs.existsSync('assets/Script/module/Pay/BattlePassService.ts'), false); const path = 'assets/Script/module/Pay/Utils.ts'; const extract = source => { const ast = ts.createSourceFile(path, source, 99, true); return ast.statements.find(ts.isClassDeclaration).members.find(n => n.name?.getText(ast) === 'POST').getText(ast).replaceAll('\r\n', '\n'); }; assert.equal(extract(fs.readFileSync(path, 'utf8')), extract(cp.execFileSync('git', ['show', 'HEAD:' + path], { encoding: 'utf8' }))); });