// Інтеграційний тест: реальний сервер + два 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); });