MatchMaster/tools/test-battle-pass.cjs
2026-09-16 12:44:01 +08:00

264 lines
17 KiB
JavaScript

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('legacy battle-pass endpoints and original HTTP implementation are not rewritten', () => {
// Login now reconciles cloudRise awards; its wallet behavior is covered in test-cloud-rise.cjs.
for (const path of ['server/laf-cloud/functions/userCoin.ts', 'server/laf-cloud/functions/passCheckTime.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' })));
});