2PlayerGame/chapter3/test-server.js

215 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Інтеграційний тест: реальний сервер + два 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 <= 3,
'видалений сейв = справа з нуля, на дошці лише брифінг: ' + (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); });