feat: 发布咸鱼喵喵全能助手,加入全收集地图与图文说明
This commit is contained in:
@@ -0,0 +1,498 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const vm = require('vm');
|
||||
const { EventEmitter } = require('events');
|
||||
const SAFETY = require('../lib/safety');
|
||||
const FILES = require('../lib/files');
|
||||
const { OPS } = require('../lib/ops');
|
||||
const { loadAll } = require('../lib/data');
|
||||
const { storyPosition, chooseStoryPosition } = require('../lib/story-position');
|
||||
|
||||
function fixture(t) {
|
||||
const root = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'nyaruru-regression-')));
|
||||
t.after(() => {
|
||||
const resolved = path.resolve(root);
|
||||
assert.equal(path.dirname(resolved), fs.realpathSync.native(os.tmpdir()));
|
||||
assert.match(path.basename(resolved), /^nyaruru-regression-/);
|
||||
fs.rmSync(resolved, { recursive: true, force: true });
|
||||
});
|
||||
const game = path.join(root, 'game'), sandbox = path.join(root, 'sandbox'), backup = path.join(root, 'backups');
|
||||
fs.mkdirSync(path.join(game, 'save'), { recursive: true });
|
||||
fs.mkdirSync(path.join(game, 'aitemp', 'tool'), { recursive: true });
|
||||
fs.writeFileSync(path.join(game, 'save', 'file1.rmmzsave'), 'first');
|
||||
fs.writeFileSync(path.join(game, 'save', 'global.rmmzsave'), 'global');
|
||||
fs.writeFileSync(path.join(game, 'save', 'steam_autocloud.vdf'), 'cloud');
|
||||
fs.writeFileSync(path.join(game, 'package.json'), '{}');
|
||||
fs.writeFileSync(path.join(game, 'index.html'), 'game');
|
||||
return { root, game, sandbox, backup, tool: path.join(game, 'aitemp', 'tool') };
|
||||
}
|
||||
test('reject overlapping paths, relative paths and identical ports at startup and save', t => {
|
||||
const f = fixture(t), defaults = { cdpPort: 9223, webPort: 8787, sandboxDir: f.sandbox };
|
||||
assert.equal(SAFETY.validateConfig(defaults, f.game).sandboxDir, f.sandbox);
|
||||
for (const patch of [{ cdpPort: 8787 }, { sandboxDir: f.game }, { sandboxDir: f.root }, { sandboxDir: path.join(f.game, 'nested') }, { sandboxDir: 'relative' }]) {
|
||||
assert.throws(() => SAFETY.validateConfig({ ...defaults, ...patch }, f.game));
|
||||
}
|
||||
const file = path.join(f.root, 'config.json');
|
||||
fs.writeFileSync(file, '{bad'); assert.throws(() => SAFETY.readConfig(file, defaults, f.game), /损坏/);
|
||||
fs.writeFileSync(file, JSON.stringify({ webPort: 9223 })); assert.throws(() => SAFETY.readConfig(file, defaults, f.game), /不能相同/);
|
||||
});
|
||||
|
||||
test('standalone layout uses configured game directory and rejects incomplete or overlapping targets', t => {
|
||||
const f = fixture(t);
|
||||
const defaults = { cdpPort: 9224, webPort: 8788, sandboxDir: f.sandbox, gameDir: f.game };
|
||||
const inferred = path.join(f.root, 'unrelated-workspace');
|
||||
assert.equal(SAFETY.validateConfig(defaults, inferred).gameDir, f.game);
|
||||
assert.throws(() => SAFETY.validateConfig({ ...defaults, sandboxDir: path.join(f.game, 'copy') }, inferred), /完全分开/);
|
||||
assert.throws(() => SAFETY.validateConfig({ ...defaults, gameDir: f.root }, inferred), /缺少/);
|
||||
assert.throws(() => SAFETY.validateConfig({ ...defaults, gameDir: 'relative' }, inferred), /绝对路径/);
|
||||
const configFile = path.join(f.tool, 'config.json');
|
||||
fs.writeFileSync(configFile, JSON.stringify(defaults));
|
||||
const config = SAFETY.readConfig(configFile, {}, inferred);
|
||||
assert.equal(config.gameDir, f.game);
|
||||
assert.equal(FILES.sandboxStatus(config.gameDir, config.sandboxDir, path.dirname(f.tool)).ready, false);
|
||||
});
|
||||
test('backup is unique, verifies content, records source and rejects traversal/corruption', t => {
|
||||
const f = fixture(t);
|
||||
const a = FILES.backupSaves(f.backup, f.game, 'original');
|
||||
fs.writeFileSync(path.join(f.game, 'save', 'file1.rmmzsave'), 'second');
|
||||
const b = FILES.backupSaves(f.backup, f.game, 'original');
|
||||
assert.notEqual(a.name, b.name);
|
||||
assert.equal(fs.readFileSync(path.join(a.dir, 'file1.rmmzsave'), 'utf8'), 'first');
|
||||
assert.equal(FILES.listBackups(f.backup)[0].sourceDir, f.game);
|
||||
assert.throws(() => FILES.inspectBackup(f.backup, '../outside'), /非法/);
|
||||
fs.writeFileSync(path.join(a.dir, 'file1.rmmzsave'), 'corrupt');
|
||||
assert.throws(() => FILES.inspectBackup(f.backup, a.name), /校验失败/);
|
||||
assert.equal(FILES.listBackups(f.backup).length, 1);
|
||||
});
|
||||
test('restore produces exact save set, preserves cloud settings, keeps previous directory', t => {
|
||||
const f = fixture(t), b = FILES.backupSaves(f.backup, f.game, 'original');
|
||||
fs.writeFileSync(path.join(f.game, 'save', 'file1.rmmzsave'), 'changed');
|
||||
fs.writeFileSync(path.join(f.game, 'save', 'file9.rmmzsave'), 'extra');
|
||||
const r = FILES.restoreBackup(f.backup, b.name, f.game);
|
||||
assert.equal(fs.readFileSync(path.join(f.game, 'save', 'file1.rmmzsave'), 'utf8'), 'first');
|
||||
assert.equal(fs.existsSync(path.join(f.game, 'save', 'file9.rmmzsave')), false);
|
||||
assert.equal(fs.readFileSync(path.join(f.game, 'save', 'steam_autocloud.vdf'), 'utf8'), 'cloud');
|
||||
assert.equal(fs.readFileSync(path.join(r.previousDirectory, 'file9.rmmzsave'), 'utf8'), 'extra');
|
||||
assert.ok(r.safetyBackup);
|
||||
});
|
||||
test('empty backup is rejected and legacy backups remain readable', t => {
|
||||
const f = fixture(t);
|
||||
const empty = path.join(f.root, 'empty'); fs.mkdirSync(path.join(empty, 'save'), { recursive: true });
|
||||
assert.throws(() => FILES.backupSaves(f.backup, empty), /没有找到/);
|
||||
const legacy = path.join(f.backup, '20261002_123456'); fs.mkdirSync(legacy, { recursive: true });
|
||||
fs.writeFileSync(path.join(legacy, 'file1.rmmzsave'), 'old');
|
||||
assert.equal(FILES.listBackups(f.backup)[0].legacy, true);
|
||||
});
|
||||
test('sandbox rejects partial copy; actual robocopy excludes both tool versions', { skip: process.platform !== 'win32' }, async t => {
|
||||
const f = fixture(t);
|
||||
fs.mkdirSync(f.sandbox); fs.writeFileSync(path.join(f.sandbox, 'package.json'), '{}');
|
||||
assert.equal(FILES.sandboxStatus(f.game, f.sandbox, path.dirname(f.tool)).ready, false);
|
||||
fs.mkdirSync(path.join(f.game, 'aitemp发布版')); fs.writeFileSync(path.join(f.game, 'aitemp发布版', 'private.txt'), 'no copy');
|
||||
const target = path.join(f.root, 'complete');
|
||||
await FILES.copySandbox(f.game, target, path.dirname(f.tool));
|
||||
assert.equal(FILES.sandboxStatus(f.game, target, path.dirname(f.tool)).ready, true);
|
||||
assert.equal(fs.existsSync(path.join(target, 'aitemp')), false);
|
||||
assert.equal(fs.existsSync(path.join(target, 'aitemp发布版')), false);
|
||||
assert.equal(fs.readFileSync(path.join(target, 'save', 'file1.rmmzsave'), 'utf8'), 'first');
|
||||
fs.writeFileSync(path.join(target, '.nyaruru-sandbox-incomplete'), 'interrupted');
|
||||
assert.equal(FILES.sandboxStatus(f.game, target, path.dirname(f.tool)).ready, false);
|
||||
});
|
||||
|
||||
function character() {
|
||||
return { _x: 1, _y: 1, px: 48, py: 48, _suspendPhysicsHitTest: true, gravity: true,
|
||||
locate(x, y) { this._x = x; this._y = y; },
|
||||
clearImpact() {}, clearGrounding() {}, resumeFriction() {}, unlockInvincible() {},
|
||||
suspendGravity() { this.gravity = true; }, resumeGravity() { this.gravity = false; },
|
||||
suspendPhysicsHitTest() { this._suspendPhysicsHitTest = true; }, resumePhysicsHitTest() { this._suspendPhysicsHitTest = false; },
|
||||
isSuspendGravity() { return this.gravity; }, isInvincible() { return false; },
|
||||
isRemove() { return !!this.removed; }, hitPreview() { return [{ terrain: true, type: 0 }]; },
|
||||
};
|
||||
}
|
||||
function gameContext() {
|
||||
const c = { window: {}, $gamePlayer: character(), $gameLily: character(), $dataMapInfos: [null, {}, {}], $dataMap: {},
|
||||
SceneManager: { _scene: { constructor: { name: 'Scene_Map' } } },
|
||||
$gameMap: { mapId: () => 1, width: () => 20, height: () => 20, tileHeight: () => 48 },
|
||||
};
|
||||
vm.createContext(c); return c;
|
||||
}
|
||||
async function run(c, script) { return JSON.parse(await vm.runInContext(script, c)); }
|
||||
test('land restores both companions, preserves unsafe lily and restore handles hidden lily', async () => {
|
||||
const c = gameContext(); assert.equal((await run(c, OPS.land())).ok, true);
|
||||
assert.equal(c.$gamePlayer.gravity, false); assert.equal(c.$gameLily.gravity, false);
|
||||
assert.equal(c.$gameLily._suspendPhysicsHitTest, false);
|
||||
c.$gameLily.hitPreview = () => [{ terrain: true, type: 2 }];
|
||||
const unsafe = await run(c, OPS.land()); assert.match(unsafe.warning, /不安全/); assert.equal(c.$gameLily.gravity, true);
|
||||
c.$gameLily.removed = true; await run(c, OPS.restoreAll());
|
||||
assert.equal(c.$gameLily.gravity, false); assert.equal(c.$gameLily._suspendPhysicsHitTest, false);
|
||||
});
|
||||
test('invalid coordinates cannot reach locate; unfinished transfer fails and cancels reservation', async () => {
|
||||
assert.throws(() => OPS.teleport(NaN, 2)); assert.throws(() => OPS.teleport('', 2)); assert.throws(() => OPS.teleport(1, 2, { mapId: -1 }));
|
||||
const c = gameContext(); let id = 1, now = 0, cancelled = false;
|
||||
c.$gameMap.mapId = () => id; c.$gamePlayer.reserveTransfer = () => { id = 2; };
|
||||
c.$gamePlayer.isTransferring = () => true; c.$gamePlayer.clearTransferInfo = () => { cancelled = true; };
|
||||
c.Date = { now: () => now }; c.setTimeout = (fn, ms) => { now += ms; fn(); };
|
||||
const result = await run(c, OPS.teleport(3, 4, { mapId: 2 }));
|
||||
assert.equal(result.ok, false); assert.equal(cancelled, true); assert.equal(now, 12000);
|
||||
});
|
||||
test('identity is based on runtime storage path and changes for reload and revisiting a map', async () => {
|
||||
const c = gameContext();
|
||||
c.require = require; c.process = { mainModule: { filename: path.join(os.tmpdir(), 'game', 'index.html') } };
|
||||
c.StorageManager = { fileDirectoryPath: () => path.join(os.tmpdir(), 'game', 'save') };
|
||||
c.location = { href: 'chrome-extension://id/index.html', pathname: '/index.html' };
|
||||
const a = await run(c, OPS.selfPath()); c.$dataMap = {};
|
||||
const b = await run(c, OPS.selfPath()); assert.notEqual(a.mapToken, b.mapToken);
|
||||
c.$gamePlayer = character(); const d = await run(c, OPS.selfPath()); assert.notEqual(b.mapToken, d.mapToken);
|
||||
assert.equal(a.gameDir, path.join(os.tmpdir(), 'game'));
|
||||
});
|
||||
test('scene pin is inert and reversible; staging permits all game saves and panel saves', async () => {
|
||||
const c = gameContext(); let started = 0, saved = 0;
|
||||
let now = 0, watchdog;
|
||||
c.Date = { now: () => now }; c.setInterval = fn => { watchdog = fn; };
|
||||
c.Game_Map = function() {}; c.Game_Map.prototype.setup = function() {};
|
||||
c.DataManager = { maxSavefiles: () => 23, extractSaveContents() {} };
|
||||
const saveMethods = ['saveGame', 'saveGameHidden', 'autoSaveGame', 'autoSaveGameInternal'];
|
||||
const originals = {};
|
||||
for (const name of saveMethods) {
|
||||
originals[name] = c.DataManager[name] = function(slot) {
|
||||
assert.equal(this, c.DataManager); assert.equal(slot, 23);
|
||||
saved++; return Promise.resolve(name);
|
||||
};
|
||||
}
|
||||
c.$gameSystem = { onBeforeSave() {} }; c.SceneManager.snapForBackground = () => {};
|
||||
c.StorageManager = { saveZip() {}, saveObject() {} };
|
||||
const ev = { _erased: true, _pageIndex: -1, physics: true, _statemMembers: { name: 'old' },
|
||||
eventId: () => 1, event: () => ({ name: 'event', pages: [{ image: { characterName: 'body' } }] }),
|
||||
findProperPageIndex: () => 0, setupPageSettings() { this._trigger = 4; this._interpreter = {}; this._statemMembers.name = 'new'; },
|
||||
characterName: () => 'body', start() { started++; }, update() { this.start(); },
|
||||
};
|
||||
const originalUpdate = ev.update; c.$gameMap.events = () => [ev]; c.$gameMap.requestRefresh = () => {};
|
||||
assert.equal((await run(c, OPS.forcePage(1, 0))).ok, true);
|
||||
ev.update(); ev.start(); assert.equal(started, 0); assert.equal(ev._interpreter, null); assert.equal(ev.physics, false);
|
||||
for (const name of saveMethods) {
|
||||
assert.equal(c.DataManager[name], originals[name]);
|
||||
assert.equal(await c.DataManager[name](23), name);
|
||||
}
|
||||
assert.equal(saved, 4);
|
||||
assert.equal((await run(c, OPS.savePhotoSlot(23))).ok, true); assert.equal(saved, 5);
|
||||
assert.equal(c.window.__nyaruruPhoto.count(), 1); assert.equal(ev.physics, false);
|
||||
await run(c, OPS.unpinPages()); assert.equal(ev._erased, true); assert.equal(ev._pageIndex, -1);
|
||||
assert.equal(ev.update, originalUpdate); assert.equal(ev._statemMembers.name, 'old');
|
||||
await c.DataManager.saveGame(23); assert.equal(saved, 6);
|
||||
await run(c, OPS.forcePage(1, 0)); now = 119000; watchdog(); assert.equal(c.window.__nyaruruPhoto.count(), 1);
|
||||
c.window.__nyaruruPhoto.touch(); now += 119000; watchdog(); assert.equal(c.window.__nyaruruPhoto.count(), 1);
|
||||
now += 2000; watchdog(); assert.equal(c.window.__nyaruruPhoto.count(), 0); assert.equal(ev._erased, true);
|
||||
});
|
||||
test('staged actors settle on terrain, animate without AI and face the player; undo restores pose', async () => {
|
||||
const c = gameContext(); let aiCalls = 0;
|
||||
c.Game_Map = function() {}; c.Game_Map.prototype.setup = function() {};
|
||||
c.DataManager = { extractSaveContents() {} };
|
||||
c.$gameMap.tileWidth = c.$gameMap.tileHeight = () => 96;
|
||||
c.$gameMap.requestRefresh = () => {};
|
||||
c.ImageFrame = { info: () => ({ width: 3, height: 1 }) };
|
||||
const ai = { smoothfloor: 'true', skipSmoothfloor: 'false', fix: 'false' };
|
||||
const ev = { _x: 16, _y: 12, _realX: 16, _realY: 12, px: 1584, py: 1248, rx: 1584, ry: 1248,
|
||||
_direction: 6, _directionFix: true, _framePos: 0, physics: true, fix: false,
|
||||
eventId: () => 1, event: () => ({ pages: [{ list: [{ code: 357, parameters: ['ai-plugin', 'ai', '', ai] }] }] }),
|
||||
setupPageSettings() {}, characterName: () => 'actor',
|
||||
tileXToCenterX: x => (x + 0.5) * 96, tileYToCenterY: y => (y + 1) * 96,
|
||||
locate(x, y) { this._realX = x; this._realY = y; this._x = Math.round(x); this._y = Math.round(y); this.px = this.rx = (x + 0.5) * 96; this.py = this.ry = (y + 1) * 96; },
|
||||
setDirection(d) { if (!this._directionFix) this._direction = d; },
|
||||
update() { aiCalls++; }, start() { aiCalls++; },
|
||||
updateFrameAnimation(info) { this._framePos = (this._framePos + 1) % info.width; },
|
||||
hitPreview(dx, dy) { return [{ terrain: false, type: 0 }, ...(dy >= 47 ? [{ terrain: true, type: 0 }] : [])]; },
|
||||
};
|
||||
c.$gameMap.events = () => [ev]; c.$gamePlayer.px = 1488;
|
||||
const originalUpdate = ev.update;
|
||||
await run(c, OPS.forcePage(1, 0));
|
||||
assert.equal(ev.py, 1294); assert.equal(ev.ry, 1294); assert.equal(ev.physics, false);
|
||||
assert.equal(ev._direction, 4); assert.equal(ev._directionFix, true);
|
||||
ev.update(); assert.equal(ev._framePos, 1);
|
||||
ev.update(); ev.update(); assert.equal(ev._framePos, 0); ev.start(); assert.equal(aiCalls, 0);
|
||||
c.$gamePlayer.px = 1680; c.window.__nyaruruPhoto.facePlayer(ev); assert.equal(ev._direction, 6);
|
||||
await run(c, OPS.unpinPages());
|
||||
assert.equal(ev.py, 1248); assert.equal(ev.ry, 1248); assert.equal(ev.physics, true); assert.equal(ev.update, originalUpdate);
|
||||
const originalProbe = ev.hitPreview;
|
||||
for (const mode of ['flying', 'hazard', 'no-ground']) {
|
||||
ai.smoothfloor = mode === 'flying' ? 'false' : 'true';
|
||||
ev.hitPreview = mode === 'hazard' ? () => [{ terrain: true, type: 2 }] : mode === 'no-ground' ? () => [] : originalProbe;
|
||||
await run(c, OPS.forcePage(1, 0)); assert.equal(ev.py, 1248);
|
||||
await run(c, OPS.unpinPages());
|
||||
}
|
||||
});
|
||||
|
||||
for (const delayed of [false, true]) test(`staging crops the sprite sheet on pin and undo (${delayed ? 'delayed' : 'cached'} bitmap)`, async () => {
|
||||
const c = gameContext();
|
||||
c.Game_Map = function() {}; c.Game_Map.prototype.setup = function() {};
|
||||
c.DataManager = { extractSaveContents() {} };
|
||||
const ev = {
|
||||
_characterName: 'original', _pageIndex: 0, _framePos: 0,
|
||||
eventId: () => 1, event: () => ({ pages: [{}, {}] }),
|
||||
characterName() { return this._characterName; },
|
||||
bitmapName() { return this._characterName + '_atlas'; },
|
||||
characterIndex: () => 0, tileId: () => 0,
|
||||
setupPageSettings() { this._characterName = 'dracula'; },
|
||||
};
|
||||
c.$gameMap.events = () => [ev]; c.$gameMap.requestRefresh = () => {};
|
||||
const images = {}, sprite = {
|
||||
_character: ev, _framePos: 0, children: [], _contentSprite: {}, updates: 0,
|
||||
setCharacterBitmap() {
|
||||
const name = this._characterName;
|
||||
const bitmap = images[name] ||= {
|
||||
width: name === 'dracula_atlas' ? 558 : 240, height: 73,
|
||||
columns: name === 'dracula_atlas' ? 6 : 3, ready: !delayed, listeners: [],
|
||||
addLoadListener(fn) { if (this.ready) fn(); else this.listeners.push(fn); },
|
||||
load() { this.ready = true; for (const fn of this.listeners.splice(0)) fn(); },
|
||||
};
|
||||
this._contentSprite.bitmap = bitmap;
|
||||
bitmap.addLoadListener(() => {
|
||||
if (this._contentSprite.bitmap === bitmap) this._contentSprite.frame = { width: bitmap.width, height: bitmap.height };
|
||||
});
|
||||
},
|
||||
updateFrame() {
|
||||
this.updates++;
|
||||
if (this._framePos === ev._framePos) return;
|
||||
this._framePos = ev._framePos;
|
||||
const bitmap = this._contentSprite.bitmap;
|
||||
this._contentSprite.frame = { width: bitmap.width / bitmap.columns, height: bitmap.height };
|
||||
},
|
||||
};
|
||||
c.SceneManager._scene.children = [sprite];
|
||||
assert.equal((await run(c, OPS.forcePage(1, 1))).ok, true);
|
||||
if (delayed) images.dracula_atlas.load();
|
||||
assert.equal(sprite._characterName, 'dracula_atlas');
|
||||
assert.deepEqual(sprite._contentSprite.frame, { width: 93, height: 73 });
|
||||
await run(c, OPS.unpinPages());
|
||||
if (delayed) images.original_atlas.load();
|
||||
assert.equal(ev.characterName(), 'original');
|
||||
assert.deepEqual(sprite._contentSprite.frame, { width: 80, height: 73 });
|
||||
if (delayed) {
|
||||
images.dracula_atlas.ready = false;
|
||||
await run(c, OPS.forcePage(1, 1));
|
||||
await run(c, OPS.unpinPages());
|
||||
const updates = sprite.updates;
|
||||
images.dracula_atlas.load(); // 旧图迟到,不能改动已撤销的显示状态。
|
||||
assert.equal(sprite.updates, updates);
|
||||
assert.deepEqual(sprite._contentSprite.frame, { width: 80, height: 73 });
|
||||
}
|
||||
});
|
||||
|
||||
test('save awaits disk rejection instead of reporting success', async () => {
|
||||
const c = gameContext(); c.setTimeout = setTimeout;
|
||||
c.$gameSystem = { onBeforeSave() {} }; c.SceneManager.snapForBackground = () => {};
|
||||
const originalZip = () => new Promise((resolve, reject) => setTimeout(() => reject(new Error('disk full')), 5));
|
||||
c.StorageManager = { saveZip: originalZip, saveObject() { return Promise.resolve(); } };
|
||||
c.DataManager = { maxSavefiles: () => 23, saveGame() { c.StorageManager.saveZip(); return Promise.resolve(); } };
|
||||
const r = await run(c, OPS.savePhotoSlot(23)); assert.equal(r.ok, false); assert.match(r.error, /disk full/);
|
||||
assert.equal(c.StorageManager.saveZip, originalZip);
|
||||
});
|
||||
|
||||
test('CDP shares concurrent connect, rejects pending on disconnect, ignores old socket close', async () => {
|
||||
const sockets = [];
|
||||
class WS {
|
||||
constructor() { this.readyState = 1; sockets.push(this); queueMicrotask(() => this.onopen()); }
|
||||
send(raw) {
|
||||
const p = JSON.parse(raw);
|
||||
if (p.method === 'Runtime.evaluate') return;
|
||||
const result = p.method === 'Target.getTargets' ? { targetInfos: [{ type: 'page', targetId: 'game', url: 'chrome-extension://id/index.html' }] } : { sessionId: 'session' };
|
||||
queueMicrotask(() => this.onmessage({ data: JSON.stringify({ id: p.id, result }) }));
|
||||
}
|
||||
close() {}
|
||||
}
|
||||
const c = { module: { exports: {} }, WebSocket: WS, fetch: async () => ({ ok: true, json: async () => ({ webSocketDebuggerUrl: 'ws://test' }) }), AbortSignal, setTimeout, clearTimeout };
|
||||
vm.runInNewContext(fs.readFileSync(path.join(__dirname, '../lib/cdp.js'), 'utf8'), c);
|
||||
const client = new c.module.exports.CdpClient();
|
||||
await Promise.all([client.connect(), client.connect()]); assert.equal(sockets.length, 1);
|
||||
const old = sockets[0]; client.close(); await client.connect(); old.onclose(); assert.equal(client.ws, sockets[1]);
|
||||
const pending = client.eval('test'); await new Promise(r => setImmediate(r));
|
||||
const rejection = assert.rejects(pending, /断开/); sockets[1].onclose(); await rejection;
|
||||
assert.equal(client._pending.size, 0); client.close();
|
||||
});
|
||||
|
||||
function serverHarness(t, ops = OPS) {
|
||||
const f = fixture(t), calls = [];
|
||||
const fake = { generation: 1, port: 9223, close() {}, async isAlive() { return false; },
|
||||
async evalJson(script) { calls.push(script); return { ok: true, gameDir: f.game, saveDir: path.join(f.game, 'save'), mapToken: 'mock:1', mapId: 1 }; } };
|
||||
const c = { __dirname: f.tool, module: { exports: {} }, process, console: { log() {}, error() {} }, URL, Buffer, setTimeout,
|
||||
require(n) {
|
||||
if (n === './lib/cdp.js') return { CdpClient: function() { return fake; } };
|
||||
if (n === './lib/ops.js') return { OPS: ops };
|
||||
if (n === './lib/story-position') return { storyPosition };
|
||||
if (n === './lib/data.js') return { loadAll: () => ({ stats: {}, triggers: [] }) };
|
||||
if (n === './lib/safety') return SAFETY;
|
||||
if (n === './lib/files') return FILES;
|
||||
if (n === './lib/processes') return { assertGamesStopped: async () => {} };
|
||||
if (n === './lib/story-cache') return require('../lib/story-cache');
|
||||
return require(n);
|
||||
},
|
||||
};
|
||||
vm.runInNewContext(fs.readFileSync(path.join(__dirname, '../server.js'), 'utf8'), c);
|
||||
c.module.exports.CONFIG.sandboxDir = f.sandbox;
|
||||
return { ...f, api: c.module.exports, fake, calls };
|
||||
}
|
||||
async function request(h, url, body, method = 'POST') {
|
||||
const req = new EventEmitter(); req.method = method; req.headers = {};
|
||||
let result, code;
|
||||
const res = { writeHead(c) { code = c; }, end(s) { result = JSON.parse(s); } };
|
||||
const task = h.api.handleApi(req, res, url);
|
||||
if (method === 'POST') { req.emit('data', JSON.stringify(body)); req.emit('end'); }
|
||||
await task; return { result, code };
|
||||
}
|
||||
test('controller stories use the verified scene entry without changing other or explicit positions', () => {
|
||||
const data = loadAll(path.resolve(__dirname, '../..'));
|
||||
for (const [story, map, expected] of [[449, 9, [11, 9]], [449, 296, [28, 13]], [467, 254, [6, 5]]]) {
|
||||
const trigger = data.triggers.find(t => t.storyId === story && t.mapId === map);
|
||||
assert.ok(trigger);
|
||||
const p = storyPosition(trigger); assert.deepEqual([p.x, p.y], expected);
|
||||
}
|
||||
// Published tests validate the structural trigger index; original map scripts are not distributed.
|
||||
for (const storyId of [298, 299, 305]) {
|
||||
const trigger = data.triggers.find(t => t.storyId === storyId && t.mapId === 182 && Number(t.eventId) === 3 && t.pageIndex === 0);
|
||||
assert.ok(trigger); assert.equal(trigger.x, 0); assert.equal(trigger.y, 0);
|
||||
const p = storyPosition(trigger);
|
||||
assert.equal(p.x, 11); assert.equal(p.y, 11); assert.ok(p.positionNote);
|
||||
for (const patch of [{ mapId: 183 }, { eventId: 4 }, { storyId: 370 }, { pageIndex: 1 }, { pageIndex: null }, { x: 7, y: 8 }, { x: null }]) {
|
||||
const input = { ...trigger, ...patch };
|
||||
assert.deepEqual(storyPosition(input), { x: input.x, y: input.y });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('unknown controller scenes preserve safe positions or find floor without story-number rules', () => {
|
||||
const actor = { _x: 6, _y: 7, eventId: () => 2, characterName: () => 'actor', event: () => ({ pages: [] }) };
|
||||
const page = { trigger: 3, image: { characterName: '', tileId: 0 }, list: [] };
|
||||
let probes = 0, hazard = false, noFloor = false;
|
||||
const player = { _x: 0, _y: 0, hitPreview(dx, dy) {
|
||||
probes++; if (noFloor || dy < 8 * 96) return [];
|
||||
return [{ terrain: true, type: hazard && dx === 5 * 96 ? 2 : 0 }];
|
||||
} };
|
||||
const input = { map: { tileWidth: () => 96, tileHeight: () => 96, width: () => 20, height: () => 15, events: () => [actor] },
|
||||
player, data: { events: [null, { x: 0, y: 0, pages: [page] }] }, eventId: 1, pageIndex: 0,
|
||||
storyId: 987654, sameMap: true, previous: { x: 10, y: 7 }, requested: { x: 0, y: 0 } };
|
||||
let r = chooseStoryPosition(input); assert.equal(r.x, 10); assert.equal(r.y, 7);
|
||||
input.sameMap = false; hazard = true;
|
||||
r = chooseStoryPosition(input); assert.equal(r.x, 7); assert.equal(r.y, 7); // 左侧陷阱被拒绝,选右侧。
|
||||
page.trigger = 4; r = chooseStoryPosition(input); assert.equal(r.controller, true);
|
||||
noFloor = true; probes = 0; r = chooseStoryPosition(input);
|
||||
assert.equal(r.ok, false); assert.match(r.error, /未找到/); assert.ok(probes <= 800);
|
||||
page.trigger = 0; probes = 0; r = chooseStoryPosition(input);
|
||||
assert.equal(r.controller, false); assert.equal(r.x, 0); assert.equal(probes, 0);
|
||||
page.trigger = 3; page.image.characterName = 'visible';
|
||||
assert.equal(chooseStoryPosition(input).controller, false); // 有形事件不能当成控制事件。
|
||||
});
|
||||
|
||||
test('scene, dialogue and goto APIs correct stale controller coordinates before teleport', async t => {
|
||||
const targets = [], order = [];
|
||||
const h = serverHarness(t, { ...OPS,
|
||||
teleport(x, y, options) { targets.push({ x, y, map: options.mapId }); return OPS.teleport(x, y, options); },
|
||||
runStoryScene(...args) { order.push('scene'); return OPS.runStoryScene(...args); },
|
||||
land() { order.push('land'); return OPS.land(); },
|
||||
});
|
||||
const body = { context: '1:mock:1', mapId: 182, eventId: 3, pageIndex: 0, storyId: 305, x: 0, y: 0 };
|
||||
await request(h, '/api/story/start', { ...body, mode: 'scene' });
|
||||
await request(h, '/api/story/start', { ...body, mode: 'play' });
|
||||
await request(h, '/api/story/goto', { ...body, pairs: [] });
|
||||
assert.deepEqual(order, ['scene', 'land']); // 只对运行过场自动落地,且在补角色之后。
|
||||
assert.deepEqual(targets, Array.from({ length: 3 }, () => ({ x: 11, y: 11, map: 182 })));
|
||||
await request(h, '/api/story/start', { ...body, mode: 'play', x: 7, y: 8 });
|
||||
assert.deepEqual(targets[3], { x: 7, y: 8, map: 182 });
|
||||
});
|
||||
|
||||
test('restore never defaults to original; GET cannot mutate', async t => {
|
||||
const h = serverHarness(t);
|
||||
await assert.rejects(request(h, '/api/restore-backup', { name: 'any', confirm: true }), /明确/);
|
||||
assert.equal((await request(h, '/api/restore', null, 'GET')).code, 405);
|
||||
assert.equal(h.calls.length, 0);
|
||||
});
|
||||
test('photo save checks target and blocks both stale context and backup failure', async t => {
|
||||
const h = serverHarness(t);
|
||||
await assert.rejects(request(h, '/api/save-photo', { context: '1:mock:1', slot: 23, target: 'sandbox' }), /不一致/);
|
||||
await assert.rejects(request(h, '/api/save-photo', { context: '1:old', slot: 23, target: 'original' }), /已变化/);
|
||||
fs.unlinkSync(path.join(h.game, 'save', 'file1.rmmzsave')); fs.unlinkSync(path.join(h.game, 'save', 'global.rmmzsave'));
|
||||
await assert.rejects(request(h, '/api/save-photo', { context: '1:mock:1', slot: 23, target: 'original' }), /没有找到/);
|
||||
assert.equal(h.calls.some(s => s.includes('await DataManager.saveGame(s)')), false);
|
||||
});
|
||||
test('mutations are serialized and stale map-local actions are rejected', async t => {
|
||||
const h = serverHarness(t); let active = 0, max = 0;
|
||||
h.fake.evalJson = async s => {
|
||||
if (!s.includes('const i=JSON.parse')) return { ok: true, gameDir: h.game, saveDir: path.join(h.game, 'save'), mapToken: 'mock:1' };
|
||||
active++; max = Math.max(max, active); await new Promise(r => setTimeout(r, 10)); active--; return { ok: true };
|
||||
};
|
||||
await Promise.all([request(h, '/api/gravity', { context: '1:mock:1', on: true }), request(h, '/api/gravity', { context: '1:mock:1', on: false })]);
|
||||
assert.equal(max, 1);
|
||||
await assert.rejects(request(h, '/api/events/force-page', { context: '1:old-map', eventId: 1, page: 0 }), /已变化/);
|
||||
});
|
||||
test('public structural index excludes text and preserves distinct event/page alternatives', () => {
|
||||
const data = loadAll(path.resolve(__dirname, '../..'));
|
||||
assert.ok(data.triggers.length > 0);
|
||||
assert.ok(data.triggers.every(t => !('lines' in t) && !('line' in t) && !('searchText' in t)));
|
||||
assert.equal(data.storyIndex, undefined);
|
||||
const story11 = data.triggers.filter(t => t.storyId === 11);
|
||||
assert.ok(new Set(story11.map(t => t.page)).size > 1);
|
||||
});
|
||||
|
||||
function launcherHarness(t, { ready = false, choice = '3', occupied = false, identityError = false, backupError = false } = {}) {
|
||||
const f = fixture(t), actions = [];
|
||||
fs.writeFileSync(path.join(f.tool, 'config.json'), JSON.stringify({ sandboxDir: f.sandbox }));
|
||||
const c = { __dirname: f.tool, module: { exports: {} }, console: { log() {} }, setTimeout,
|
||||
process: { versions: { node: '24.0.0' }, argv: [], stdin: {}, stdout: {}, execPath: process.execPath },
|
||||
require(n) {
|
||||
if (n === './lib/safety') return SAFETY;
|
||||
if (n === './lib/files') return { sandboxStatus: () => ({ ready, reason: 'mock' }), backupSaves: () => { actions.push('backup'); if (backupError) throw Error('backup failed'); return { name: 'mock' }; } };
|
||||
if (n === './lib/cdp') return { CdpClient: function() { return { close() {}, async evalJson() { if (identityError) throw Error('unknown'); return { ok: true, gameDir: f.game, saveDir: path.join(f.game, 'save') }; } }; } };
|
||||
if (n === './lib/ops') return { OPS };
|
||||
if (n === 'readline/promises') return { createInterface: () => ({ question: async () => choice, close() {} }) };
|
||||
if (n === 'net') return { connect: ({ port }) => { const s = new EventEmitter(); s.destroy = () => {}; s.setTimeout = () => {}; queueMicrotask(() => s.emit(occupied && port === 9223 ? 'connect' : 'error')); return s; } };
|
||||
if (n === 'child_process') return { spawn: () => { actions.push('spawn'); throw Error('unexpected spawn'); } };
|
||||
return require(n);
|
||||
},
|
||||
};
|
||||
vm.runInNewContext(fs.readFileSync(path.join(__dirname, '../launcher.js'), 'utf8'), c);
|
||||
return { main: c.module.exports.main, actions };
|
||||
}
|
||||
test('launcher exit stops completely, wrong and unknown instances stop before backup/panel', async t => {
|
||||
const exit = launcherHarness(t); await exit.main(); assert.deepEqual(exit.actions, []);
|
||||
for (const identityError of [false, true]) {
|
||||
const h = launcherHarness(t, { ready: true, occupied: true, identityError });
|
||||
await assert.rejects(h.main(), /不能确认/); assert.deepEqual(h.actions, []);
|
||||
}
|
||||
});
|
||||
test('launcher backup failure prevents starting either game or panel', async t => {
|
||||
const h = launcherHarness(t, { ready: true, backupError: true });
|
||||
await assert.rejects(h.main(), /backup failed/); assert.deepEqual(h.actions, ['backup']);
|
||||
});
|
||||
test('panel save warning lets the user continue or cancel using the existing confirmation', async () => {
|
||||
const source = fs.readFileSync(path.join(__dirname, '../web/app.js'), 'utf8');
|
||||
const start = source.indexOf("$('#photo-save').onclick");
|
||||
const end = source.indexOf('// ---------- 对话跳转点', start);
|
||||
for (const proceed of [false, true]) {
|
||||
const button = {}, calls = []; let confirmation;
|
||||
vm.runInNewContext(source.slice(start, end), {
|
||||
$: key => key === '#photo-save' ? button : { value: '23' },
|
||||
CURRENT_CONTEXT: 'current', CURRENT_GAME: { kind: 'sandbox', saveDir: 'sandbox/save' },
|
||||
confirm: text => { confirmation = text; return proceed; },
|
||||
api: async (route, body) => { calls.push({ route, body }); return { ok: true, slot: 23 }; },
|
||||
toast() {}, loadBackups() {},
|
||||
});
|
||||
await button.onclick();
|
||||
assert.match(confirmation, /不推荐.*保存/); assert.match(confirmation, /仍可确定继续保存/);
|
||||
assert.equal(calls.length, proceed ? 1 : 0);
|
||||
if (proceed) { assert.equal(calls[0].route, '/save-photo'); assert.equal(calls[0].body.slot, 23); assert.equal(calls[0].body.target, 'sandbox'); }
|
||||
}
|
||||
});
|
||||
|
||||
test('map event search matches later lines and displays the matched text', () => {
|
||||
const source = fs.readFileSync(path.join(__dirname, '../web/app.js'), 'utf8');
|
||||
const start = source.indexOf('function renderEvents()');
|
||||
const end = source.indexOf("$('#ev-load').onclick", start);
|
||||
const list = { innerHTML: '', querySelectorAll: () => [] };
|
||||
const elements = { '#ev-filter': { value: 'later sentence' }, '#ev-all': { checked: false }, '#ev-list': list };
|
||||
vm.runInNewContext(source.slice(start, end) + ';renderEvents();', {
|
||||
$: key => elements[key], esc: value => String(value), spriteLabel: () => '',
|
||||
EVENTS: [{ id: 1, hasStory: true, name: 'event', sprite: '', trigger: 'test', x: 0, y: 0,
|
||||
lines: [{ first: 'first sentence', speaker: 'actor', searchText: 'first sentence\nlater sentence', lines: [{ text: 'first sentence', speaker: 'actor' }, { text: 'later sentence', speaker: 'actor' }] }] }],
|
||||
});
|
||||
assert.match(list.innerHTML, /later sentence/); assert.doesNotMatch(list.innerHTML, /没有匹配|没有带剧情/);
|
||||
});
|
||||
Reference in New Issue
Block a user