216 lines
14 KiB
JavaScript
216 lines
14 KiB
JavaScript
|
|
// Інтеграційний тест: реальний сервер + два ws-клієнти проходять справу до кінця
|
|||
|
|
// новими механіками: обшук місця, запити-форми, ордер у відділку, ручна лабораторія.
|
|||
|
|
const assert = require('assert');
|
|||
|
|
const fs = require('fs');
|
|||
|
|
const path = require('path');
|
|||
|
|
const { spawn } = require('child_process');
|
|||
|
|
const WebSocket = require('ws');
|
|||
|
|
const { generateCase } = require('./casegen');
|
|||
|
|
|
|||
|
|
const PORT = +process.env.PORT || 3102;
|
|||
|
|
const NO_SPAWN = !!process.env.NO_SPAWN; // підключитись до вже запущеного сервера (для дебагу з логами)
|
|||
|
|
const SEED = 'TEST1';
|
|||
|
|
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
|||
|
|
// чекаємо на умову замість фіксованого сну — сервер асинхронний, а після
|
|||
|
|
// «зливу» 16 проходів його черга повідомлень може бути ще не розібрана
|
|||
|
|
const waitFor = async (fn, ms = 4000) => {
|
|||
|
|
for (const t0 = Date.now(); Date.now() - t0 < ms; await sleep(40)) if (fn()) return true;
|
|||
|
|
return false;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
function client(role) {
|
|||
|
|
const ws = new WebSocket(`ws://127.0.0.1:${PORT}`);
|
|||
|
|
const c = { ws, role, state: null, init: null, ended: null };
|
|||
|
|
ws.on('message', raw => {
|
|||
|
|
const m = JSON.parse(raw);
|
|||
|
|
if (m.t === 'init') c.init = m;
|
|||
|
|
if (m.t === 'state') { c.state = m; c.ended = m.ended; }
|
|||
|
|
});
|
|||
|
|
c.open = new Promise(r => ws.on('open', () => { ws.send(JSON.stringify({ t: 'join', session: c.session, role })); r(); }));
|
|||
|
|
c.send = m => ws.send(JSON.stringify(m));
|
|||
|
|
return c;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// будинок, чий центр збігається з трьома дугами тріангуляції
|
|||
|
|
function solveTriangulate(cs, towers) {
|
|||
|
|
let best = null, bestErr = Infinity;
|
|||
|
|
for (const b of cs.district.buildings) {
|
|||
|
|
const cx = b.pos.x + b.pos.w / 2, cy = b.pos.y + b.pos.h / 2;
|
|||
|
|
const err = towers.reduce((a, t) => a + Math.abs(Math.hypot(t.x - cx, t.y - cy) - t.dist), 0);
|
|||
|
|
if (err < bestErr) { bestErr = err; best = b.id; }
|
|||
|
|
}
|
|||
|
|
return best;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function playToTheEnd(session, accusation) {
|
|||
|
|
const cs = generateCase(session);
|
|||
|
|
const killer = cs.citizens.find(c => c.id === cs.finale.killer);
|
|||
|
|
const field = client('field'); field.session = session;
|
|||
|
|
const analyst = client('analyst'); analyst.session = session;
|
|||
|
|
await field.open; await analyst.open; await sleep(300);
|
|||
|
|
|
|||
|
|
// міні-ігри: тест «грає» їх одразу правильно
|
|||
|
|
const react = c => c.ws.on('message', raw => {
|
|||
|
|
const m = JSON.parse(raw);
|
|||
|
|
if (m.t === 'mg') {
|
|||
|
|
if (m.kind === 'collect') c.send({ t: 'mg_done', evId: m.evId });
|
|||
|
|
if (m.kind === 'triangulate') c.send({ t: 'triangulate_pick', building: solveTriangulate(cs, m.towers) });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
react(field); react(analyst);
|
|||
|
|
|
|||
|
|
assert.ok(field.init && analyst.init, 'обидва отримали init');
|
|||
|
|
assert.strictEqual(field.init.citizens.length, cs.citizens.length, 'мешканці на місці');
|
|||
|
|
assert.ok(field.init.citizens.every(c =>
|
|||
|
|
!('evening' in c) && !('liar' in c) && !('excuse' in c) && !('regShoe' in c) && !('regCoat' in c)),
|
|||
|
|
'таємниці слідства не течуть у клієнт');
|
|||
|
|
assert.ok(field.state.found.length >= 1, 'брифінг уже на дошці');
|
|||
|
|
|
|||
|
|
const stn = cs.district.buildings.find(b => b.id === 'station');
|
|||
|
|
const realFound = () => analyst.state.found.filter(f => cs.evidence[f.id]);
|
|||
|
|
|
|||
|
|
// ганяємо всі можливі дії по колу, як робили б гравці
|
|||
|
|
for (let pass = 0; pass < 16; pass++) {
|
|||
|
|
const found = new Set(realFound().map(f => f.id));
|
|||
|
|
for (const p of cs.puzzles)
|
|||
|
|
analyst.send({ t: 'puzzle_try', id: p.id, shift: p.shift, answer: p.answer });
|
|||
|
|
|
|||
|
|
// ордери: польовий іде у відділок і підкладає доказ, що прямо вказує
|
|||
|
|
field.send({ t: 'move', x: stn.pos.x + stn.pos.w / 2 | 0, y: stn.pos.y + stn.pos.h / 2 | 0 });
|
|||
|
|
for (const id of found) {
|
|||
|
|
const ev = cs.evidence[id];
|
|||
|
|
if (ev.gives.pointsTo) field.send({ t: 'warrant', cid: ev.gives.pointsTo, evId: id });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
for (const ev of Object.values(cs.evidence)) {
|
|||
|
|
if (found.has(ev.id)) continue;
|
|||
|
|
if (ev.at.type === 'spot') field.send({ t: 'search', building: ev.at.building, spot: ev.at.spot });
|
|||
|
|
if (ev.at.type === 'unit') field.send({ t: 'search_unit', unit: ev.at.unit, slot: ev.at.slot });
|
|||
|
|
if (ev.at.type === 'person') field.send({ t: 'ask', evId: ev.id });
|
|||
|
|
if (ev.at.type === 'sys' && ev.q) {
|
|||
|
|
const q = ev.q;
|
|||
|
|
if (q.sys === 'cctv') {
|
|||
|
|
const cam = cs.district.cameras.find(c => c.covers.includes(q.building));
|
|||
|
|
const [h, mi] = cs.crimeTime.split(':').map(Number);
|
|||
|
|
analyst.send({ t: 'sys_query', sys: 'cctv', camera: cam.id, minutes: h * 60 + mi });
|
|||
|
|
} else {
|
|||
|
|
analyst.send({ t: 'sys_query', sys: q.sys, kind: q.kind || 'search', input: [].concat(q.match || [''])[0] });
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
// лабораторія: порівняння вручну (тест одразу тицяє правильних власників)
|
|||
|
|
analyst.send({ t: 'dna_pick', sampleEv: 'sf_dna', cid: cs.accomplice || cs.finale.killer });
|
|||
|
|
if (cs.dna2) analyst.send({ t: 'dna_pick', sampleEv: 'sf_dna2', cid: cs.dna2.owner });
|
|||
|
|
analyst.send({ t: 'print_pick', cid: cs.finale.killer });
|
|||
|
|
await sleep(250);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// зламане алібі: допит вбивці + архів камери над його «алібі» будить сплячий доказ
|
|||
|
|
const killerClaim = cs.citizens.find(c => c.id === cs.finale.killer).claim;
|
|||
|
|
const [ch, cm] = cs.crimeTime.split(':').map(Number);
|
|||
|
|
field.send({ t: 'ask_alibi', cid: cs.finale.killer, minutes: ch * 60 + cm });
|
|||
|
|
// спершу дочекатись, що алібі вбивці ЗАПИСАНЕ (видно в стані), і лише тоді
|
|||
|
|
// тягнути архів — інакше camera-запит спрацює до запису й нічого не розбудить
|
|||
|
|
await waitFor(() => analyst.state && analyst.state.alibiClaims && analyst.state.alibiClaims[cs.finale.killer] != null);
|
|||
|
|
const claimCam = cs.district.cameras.find(c => c.covers.includes(killerClaim));
|
|||
|
|
analyst.send({ t: 'sys_query', sys: 'cctv', camera: claimCam.id, minutes: 1230 });
|
|||
|
|
await waitFor(() => realFound().some(f => f.id === 'alibi_broken'));
|
|||
|
|
assert.ok(realFound().some(f => f.id === 'alibi_broken'), 'брехня вбивці + камери = доказ');
|
|||
|
|
|
|||
|
|
assert.strictEqual(realFound().length, Object.keys(cs.evidence).length,
|
|||
|
|
`зібрано все: ${realFound().length}/${Object.keys(cs.evidence).length}`);
|
|||
|
|
assert.ok(analyst.state.identity, 'особа жертви встановлена');
|
|||
|
|
|
|||
|
|
// брехун ламається і від пред'явленого архіву, не лише від повторного питання:
|
|||
|
|
// заявив, що був там, куди дивиться камера, а в кадрах його нема.
|
|||
|
|
const liar = cs.citizens.find(c => c.liar && c.id !== cs.finale.killer && c.id !== cs.accomplice &&
|
|||
|
|
cs.district.cameras.some(cam => cam.covers.includes(c.claim)));
|
|||
|
|
if (liar) {
|
|||
|
|
const liarCam = cs.district.cameras.find(cam => cam.covers.includes(liar.claim));
|
|||
|
|
field.send({ t: 'ask_alibi', cid: liar.id, minutes: ch * 60 + cm });
|
|||
|
|
await waitFor(() => analyst.state.alibiClaims[liar.id] != null);
|
|||
|
|
analyst.send({ t: 'sys_query', sys: 'cctv', camera: liarCam.id, minutes: 1230 });
|
|||
|
|
await waitFor(() => analyst.state.found.some(f => f.title === `Архів за вечір: ${liarCam.name}`));
|
|||
|
|
const arch = analyst.state.found.find(f => f.title === `Архів за вечір: ${liarCam.name}`);
|
|||
|
|
field.send({ t: 'show_ev', cid: liar.id, evId: arch.id });
|
|||
|
|
await waitFor(() => (analyst.state.log[liar.id] || []).some(e => e.a.includes(liar.excuse)));
|
|||
|
|
const said = analyst.state.log[liar.id].at(-1).a;
|
|||
|
|
assert.ok(said.includes(liar.excuse), 'пред\'явлений архів ламає брехуна так само, як повторний допит');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// допити: алібі, «про людину», пред'явлення доказу — відповіді пишуться в журнал
|
|||
|
|
field.send({ t: 'ask_about', cid: cs.herring, about: cs.finale.killer });
|
|||
|
|
field.send({ t: 'show_ev', cid: cs.decoyOwner, evId: 'dec_footprint' });
|
|||
|
|
await sleep(300);
|
|||
|
|
assert.ok((analyst.state.log[cs.finale.killer] || []).length >= 1, 'алібі вбивці в журналі');
|
|||
|
|
assert.ok(analyst.state.found.some(f => f.id.startsWith('x') && f.title.toLowerCase().includes('відбиток')),
|
|||
|
|
'фейковий слід закрито карткою на дошці');
|
|||
|
|
|
|||
|
|
// мітка аналітика видна в стані
|
|||
|
|
analyst.send({ t: 'marker', x: 500, y: 100 });
|
|||
|
|
await sleep(200);
|
|||
|
|
assert.strictEqual(analyst.state.markers.length, 1, 'мітка на мапі');
|
|||
|
|
|
|||
|
|
for (const f of analyst.state.found) field.send({ t: 'pin', evId: f.id });
|
|||
|
|
await sleep(300);
|
|||
|
|
|
|||
|
|
const how = cs.caseType === 'kidnap' ? cs.finale.where : cs.finale.method;
|
|||
|
|
const acc = accusation === 'right'
|
|||
|
|
? { who: killer.name, how, why: cs.finale.motive }
|
|||
|
|
: { who: cs.citizens.find(c => c.id === cs.herring).name, how, why: cs.finale.motive };
|
|||
|
|
field.send({ t: 'accuse_draft', ...acc });
|
|||
|
|
await waitFor(() => field.state.accuseDraft && field.state.accuseDraft.who === acc.who);
|
|||
|
|
field.send({ t: 'accuse_confirm' });
|
|||
|
|
analyst.send({ t: 'accuse_confirm' });
|
|||
|
|
await waitFor(() => analyst.ended);
|
|||
|
|
|
|||
|
|
const ended = analyst.ended;
|
|||
|
|
assert.ok(ended, 'гра завершилась після двох підписів');
|
|||
|
|
if (accusation === 'right') assert.ok(ended.solved, 'правильне звинувачення = справу розкрито');
|
|||
|
|
else { assert.ok(ended.fired, 'хибне звинувачення = звільнення'); assert.ok(ended.recap, 'справжня картина показана'); }
|
|||
|
|
|
|||
|
|
field.ws.close(); analyst.ws.close();
|
|||
|
|
return ended;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
(async () => {
|
|||
|
|
for (const f of [SEED, 'TEST2', 'TEST3']) { try { fs.unlinkSync(path.join(__dirname, 'saves', f + '.json')); } catch {} }
|
|||
|
|
const srv = NO_SPAWN ? null : spawn('node', ['server.js'], { cwd: __dirname, env: { ...process.env, PORT } });
|
|||
|
|
if (srv) await new Promise(r => srv.stdout.on('data', d => { if (String(d).includes('localhost')) r(); }));
|
|||
|
|
else console.error('[test] NO_SPAWN: підключаюсь до сервера на PORT=' + PORT);
|
|||
|
|
try {
|
|||
|
|
await playToTheEnd(SEED, 'right');
|
|||
|
|
const savePath = path.join(__dirname, 'saves', SEED + '.json');
|
|||
|
|
// дочекатись, поки фінальний стан ДОПИСАЛО на диск (жодних відкладених записів),
|
|||
|
|
// і лише тоді видаляти — інакше запис-«хвіст» відтворив би файл після unlink
|
|||
|
|
await waitFor(() => { try { return !!JSON.parse(fs.readFileSync(savePath, 'utf8')).ended; } catch { return false; } });
|
|||
|
|
assert.ok(fs.existsSync(savePath), 'сейв на диску');
|
|||
|
|
// видалення сейву з диска = чиста справа після перепідключення
|
|||
|
|
fs.unlinkSync(savePath);
|
|||
|
|
const fresh = client('field'); fresh.session = SEED;
|
|||
|
|
await fresh.open;
|
|||
|
|
await waitFor(() => fresh.state != null);
|
|||
|
|
assert.ok(fresh.state && !fresh.state.ended && fresh.state.found.length <= 2,
|
|||
|
|
'видалений сейв = справа з нуля, на дошці лише брифінг: ' + (fresh.state && fresh.state.found.length));
|
|||
|
|
fresh.ws.close();
|
|||
|
|
await playToTheEnd('TEST2', 'wrong');
|
|||
|
|
// TEST3 — перевіряємо, що і викрадення проходиться (якщо сід дає kidnap — тим краще)
|
|||
|
|
const k = await playToTheEnd('TEST3', 'right');
|
|||
|
|
assert.ok(k.solved, 'третя справа теж розкривається');
|
|||
|
|
|
|||
|
|
// -- мультіплеєр: до 2 на роль, свій номер кожному, 3-й дістає помилку ліміту
|
|||
|
|
const grab = ws => new Promise(r => { const o = {}; ws.on('message', raw => { const m = JSON.parse(raw); if (m.t === 'init') { o.num = m.num; r(o); } if (m.t === 'joinerr') { o.err = m.txt; r(o); } }); });
|
|||
|
|
const mk = () => { const ws = new WebSocket(`ws://127.0.0.1:${PORT}`); const g = grab(ws); ws.on('open', () => ws.send(JSON.stringify({ t: 'join', session: 'MPTEST', role: 'field' }))); return { ws, g }; };
|
|||
|
|
// послідовно: кожен join опрацьовується до наступного, щоб номери були детерміновані
|
|||
|
|
const f1 = mk(); assert.strictEqual((await f1.g).num, 1, 'перший польовий — №1');
|
|||
|
|
const f2 = mk(); assert.strictEqual((await f2.g).num, 2, 'другий польовий — №2');
|
|||
|
|
const f3 = mk(); const r3 = await f3.g;
|
|||
|
|
assert.ok(r3.err && !r3.num, '3-й польовий дістає помилку ліміту, а не вхід');
|
|||
|
|
// звільнили слот — новий займає найменший вільний номер
|
|||
|
|
f1.ws.close(); await sleep(200);
|
|||
|
|
const f4 = mk(); assert.strictEqual((await f4.g).num, 1, 'після виходу №1 звільняється — новий стає №1');
|
|||
|
|
for (const x of [f2, f4]) x.ws.close();
|
|||
|
|
console.log('✓ інтеграція: справи розкриваються новими механіками, хибне звинувачення = звільнення, сейви пишуться, мультіплеєр тримає ліміт по 2 на роль.');
|
|||
|
|
} finally { if (srv) srv.kill(); }
|
|||
|
|
})().catch(e => { console.error('✗', e.message); process.exit(1); });
|