169 lines
8.4 KiB
JavaScript
169 lines
8.4 KiB
JavaScript
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 { stripTypeScriptTypes } = require('node:module');
|
|
|
|
const root = path.resolve(__dirname, '..');
|
|
const baseline = process.env.ONLINE_BUFFER_BASELINE;
|
|
const trackerSource = fs.readFileSync(baseline ? path.join(baseline, 'DailyTaskProgressTracker.ts')
|
|
: path.join(root, 'assets/Script/module/Task/DailyTaskProgressTracker.ts'), 'utf8');
|
|
// Inject server snapshots directly to isolate buffered-time recovery. The actual
|
|
// cache, request queue and GameTool integration are tested in test-daily-task-cache.cjs.
|
|
const code = stripTypeScriptTypes(trackerSource.replace('export type ', 'type ')
|
|
.replace('export default class ', 'class ') + `
|
|
const GameTool = {getDailyQuestsInfo(callback) {
|
|
Utils.getDailyQuestInfo(response => {
|
|
if (response && response.code === 1 && response.data && response.data.task)
|
|
DailyTaskProgressTracker.setTaskData(response.data.task);
|
|
callback(response);
|
|
});
|
|
}};
|
|
globalThis.Tracker = DailyTaskProgressTracker; globalThis.GameTool = GameTool;`);
|
|
|
|
function taskData(value = 0, extra = {}) {
|
|
return { version: 2, dayKey: '2026-09-08', dailyTasks: [
|
|
{ taskId: 'online_5', metric: 'online_seconds', target: 300, value, state: 0 },
|
|
], ...extra };
|
|
}
|
|
|
|
function setup() {
|
|
let now = 1000, nextId = 1;
|
|
const timers = new Map(), reads = [], saves = [];
|
|
const info = { otherLevel: 0, tasks: null };
|
|
const addTimer = (callback, delay, interval = 0) => {
|
|
const id = nextId++;
|
|
timers.set(id, { callback, at: now + delay, interval });
|
|
return id;
|
|
};
|
|
const cc = { fx: { GameConfig: { GM_INFO: info }, GameTool: {
|
|
setDailyQuestInfo(callback) {
|
|
const data = structuredClone(info.tasks);
|
|
saves.push({ data, callback(response) {
|
|
// Match Utils: a successful save replaces global task data before invoking the callback.
|
|
if (response.code === 1) info.tasks = structuredClone(response.data?.task || data);
|
|
callback(response);
|
|
} });
|
|
},
|
|
shushu_Track() {},
|
|
} } };
|
|
const context = { cc, Date: { now: () => now }, Utils: { getDailyQuestInfo: callback => reads.push(callback) },
|
|
setInterval: (callback, delay) => addTimer(callback, delay, delay),
|
|
clearInterval: id => timers.delete(id),
|
|
setTimeout: (callback, delay) => addTimer(callback, delay),
|
|
clearTimeout: id => timers.delete(id),
|
|
};
|
|
vm.runInNewContext(code, context);
|
|
function advance(ms) {
|
|
const target = now + ms;
|
|
while (true) {
|
|
const next = [...timers].filter(([, timer]) => timer.at <= target).sort((a, b) => a[1].at - b[1].at)[0];
|
|
if (!next) break;
|
|
const [id, timer] = next;
|
|
now = timer.at;
|
|
if (timer.interval) timer.at += timer.interval;
|
|
else timers.delete(id);
|
|
timer.callback();
|
|
}
|
|
now = target;
|
|
}
|
|
function load(data, callback = () => {}) {
|
|
context.GameTool.getDailyQuestsInfo(callback);
|
|
reads.shift()({ code: 1, data: { task: data } });
|
|
}
|
|
const value = () => info.tasks.dailyTasks[0].value;
|
|
return { tracker: context.Tracker, gameTool: context.GameTool, info, saves, reads, advance, load, value };
|
|
}
|
|
|
|
test('settled time before loading is restored before a read callback renders tasks', () => {
|
|
const h = setup(); h.tracker.startOnline(); h.advance(65000);
|
|
assert.equal(h.saves.length, 0);
|
|
h.load(taskData(10), response => assert.equal(response.data.task.dailyTasks[0].value, 70));
|
|
assert.equal(h.value(), 70);
|
|
h.tracker.setTaskData(h.info.tasks); // JiaZai calls the setter again with the same response.
|
|
assert.equal(h.value(), 70);
|
|
h.advance(1000);
|
|
assert.equal(h.saves.length, 1); assert.equal(h.saves[0].data.dailyTasks[0].value, 70);
|
|
h.saves[0].callback({ code: 1 });
|
|
h.advance(24000); assert.equal(h.value(), 100); // Normal next 30-second settlement.
|
|
});
|
|
|
|
test('loading before the first settlement retains the existing 30-second cadence', () => {
|
|
const h = setup(); h.tracker.startOnline(); h.advance(10000); h.load(taskData(7));
|
|
assert.equal(h.value(), 7); assert.equal(h.saves.length, 0);
|
|
h.advance(20000); assert.equal(h.value(), 37);
|
|
});
|
|
|
|
test('hide before loading retains settled seconds and excludes time in the background', () => {
|
|
const h = setup(); h.tracker.startOnline(); h.advance(12500); h.tracker.stopOnline();
|
|
h.advance(300000); h.load(taskData()); assert.equal(h.value(), 12);
|
|
h.tracker.startOnline(); h.advance(15000); h.tracker.stopOnline();
|
|
assert.equal(h.value(), 27);
|
|
});
|
|
|
|
test('failed reads and invalid task data do not consume buffered time', () => {
|
|
const h = setup(); h.tracker.startOnline(); h.advance(30000);
|
|
h.gameTool.getDailyQuestsInfo(() => {}); h.reads.shift()({ code: 0 });
|
|
for (const data of [null, {version: 1, dailyTasks: []}, {version: 2, dailyTasks: null}]) h.tracker.setTaskData(data);
|
|
h.advance(30000); h.load(taskData()); assert.equal(h.value(), 60);
|
|
});
|
|
|
|
test('a stale second read before save cannot erase recovered progress', () => {
|
|
const h = setup(); h.tracker.startOnline(); h.advance(60000);
|
|
const server = taskData(5);
|
|
h.load(structuredClone(server)); h.load(structuredClone(server));
|
|
assert.equal(h.value(), 65);
|
|
h.advance(1000); assert.equal(h.saves.length, 1);
|
|
assert.equal(h.saves[0].data.dailyTasks[0].value, 65);
|
|
});
|
|
|
|
test('recovery is protected during a pending or failed save, and released after success', () => {
|
|
const h = setup(); h.tracker.startOnline(); h.advance(60000); h.load(taskData());
|
|
h.advance(1000); h.load(taskData()); assert.equal(h.value(), 60);
|
|
h.saves[0].callback({ code: 0 }); h.load(taskData()); assert.equal(h.value(), 60);
|
|
h.advance(1000); h.saves[1].callback({ code: 1 });
|
|
h.load(taskData(3)); assert.equal(h.value(), 3); // An explicit later reset is authoritative.
|
|
});
|
|
|
|
test('a previous save callback cannot clear a newer recovery batch', () => {
|
|
const h = setup(); h.tracker.startOnline(); h.advance(30000); h.load(taskData()); h.advance(1000);
|
|
h.info.tasks = null; h.advance(29000); h.load(taskData(30)); assert.equal(h.value(), 60);
|
|
h.saves[0].callback({ code: 1 }); assert.equal(h.value(), 60);
|
|
h.load(taskData(30)); assert.equal(h.value(), 60);
|
|
h.advance(1000); assert.equal(h.saves[1].data.dailyTasks[0].value, 60);
|
|
});
|
|
|
|
test('recovery never crosses into another day or another task', () => {
|
|
const h = setup(); h.tracker.startOnline(); h.advance(60000); h.load(taskData());
|
|
h.load(taskData(0, {dayKey: '2026-09-09'})); assert.equal(h.value(), 0);
|
|
const other = setup(); other.tracker.startOnline(); other.advance(60000); other.load(taskData());
|
|
const data = taskData(); data.dailyTasks[0].taskId = 'online_10'; other.load(data); assert.equal(other.value(), 0);
|
|
});
|
|
|
|
test('recovery respects targets and claimed tasks, and only updates online metrics', () => {
|
|
const h = setup(); h.tracker.startOnline(); h.advance(90000);
|
|
const data = taskData(280);
|
|
data.dailyTasks.push({taskId:'claimed',metric:'online_seconds',target:600,value:600,state:2});
|
|
data.dailyTasks.push({taskId:'coins',metric:'collect_coin',target:500,value:20,state:0});
|
|
h.load(data);
|
|
assert.equal(data.dailyTasks[0].value, 300); assert.equal(data.dailyTasks[0].state, 1);
|
|
assert.equal(data.dailyTasks[1].value, 600); assert.equal(data.dailyTasks[1].state, 2);
|
|
assert.equal(data.dailyTasks[2].value, 20);
|
|
const claimed = taskData(300); claimed.dailyTasks[0].state = 2; h.load(claimed);
|
|
assert.equal(claimed.dailyTasks[0].state, 2);
|
|
});
|
|
|
|
test('time is not carried forward to a future task when no online task was selected', () => {
|
|
const h = setup(); h.tracker.startOnline(); h.advance(60000);
|
|
h.load(taskData(0, {dailyTasks: [{taskId:'coins',metric:'collect_coin',target:500,value:0,state:0}]}));
|
|
assert.equal(h.saves.length, 0);
|
|
h.load(taskData()); assert.equal(h.value(), 0);
|
|
});
|
|
|
|
test('level initialization and temporary login snapshots do not prematurely consume the buffer', () => {
|
|
const h = setup(); h.tracker.startOnline(); h.advance(60000); h.tracker.beginLevel();
|
|
h.info.tasks = taskData(2); // Raw login snapshot, before the task refresh API returns.
|
|
h.load(taskData()); assert.equal(h.value(), 60);
|
|
});
|