2PlayerGame/detective-game/test-server.js

216 lines
14 KiB
JavaScript
Raw Normal View History

2026-07-23 17:03:26 +03:00
// Інтеграційний тест: реальний сервер + два ws-клієнти проходять справу до кінця
// новими механіками: обшук місця, запити-форми, ордер у відділку, ручна лабораторія.
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const { spawn } = require('child_process');
const WebSocket = require('ws');
2026-08-15 14:07:39 +03:00
const { generateCase, caseSeed } = require('./casegen');
2026-07-23 17:03:26 +03:00
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) {
2026-08-15 14:07:39 +03:00
const cs = generateCase(caseSeed(session)); // сервер сіє хешованим кодом — тест мусить так само
2026-07-23 17:03:26 +03:00
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 <= 3,
2026-07-23 17:03:26 +03:00
'видалений сейв = справа з нуля, на дошці лише брифінг: ' + (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); });