// Розділ 3 «Невпізнаний» — сервер: static + ws-синхронізація + сейви на диск. // Код сесії = seed справи: той самий код — та сама справа (і її сейв). const http = require('http'); const fs = require('fs'); const path = require('path'); const os = require('os'); const { WebSocketServer } = require('ws'); const { generateCase, caseSeed, checkAccusation, md5 } = require('./casegen'); const { unitBuilding } = require('./casegen/district'); const RT = require('./casegen/runtime'); const PORT = process.env.PORT || 3002; const SAVES = path.join(__dirname, 'saves'); fs.mkdirSync(SAVES, { recursive: true }); const sessions = Object.create(null); let clientSeq = 0; // стабільний id на кожне з'єднання (для підписів звинувачення) // Допустимі мітки на дошці. Клієнт малює їх із того ж поділу (public/client.js). const EV_MARKS = ['tick', 'cross', 'hide']; // докази і записи журналу const PERSON_MARKS = ['green', 'red', 'blue', 'amber', 'hide']; // люди: кольори без нав'язаного значення // Присутність зараз (не плутати з evening — правдою вечора злочину): вдома або на роботі. // Перетасовується, коли польовий заходить у будівлю, — крім будівлі, куди він зайшов. function reshuffleLocs(cs, s, except) { for (const c of cs.citizens) { if (c.id === cs.victim) continue; if (except && s.locs[c.id] === except) continue; s.locs[c.id] = c.workplace && s.rng() < 0.6 ? c.workplace : unitBuilding(c.home); } } // Seeded PRNG (mulberry32) — детерміновані перетасовки function seedRand(seed) { let s = seed | 0; return function() { s |= 0; s = s + 0x6D2B79F5 | 0; let t = Math.imul(s ^ s >>> 15, 1 | s); t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; return ((t ^ t >>> 14) >>> 0) / 4294967296; }; } function getSession(code) { if (sessions[code]) return sessions[code]; const hashedCode = caseSeed(code); const cs = generateCase(hashedCode); const rng = seedRand([...hashedCode].reduce((a, c) => a * 31 + c.charCodeAt(0), 0)); const stn = cs.district.buildings.find(b => b.id === 'station'); const s = { code, cs, rng, st: RT.newState(), board: { pins: [], marks: {}, notes: [], enotes: {} }, // marks: {'e:id'|'c:id': 'tick'|'cross'|'hide'} spawn: { x: stn.pos.x + stn.pos.w / 2, y: stn.pos.y + stn.pos.h + 15 }, // точка появи у відділку extra: [], xn: 0, // динамічні картки (результати запитів, зізнання) log: {}, // журнал допитів: cid → [{id, q, a}] logN: 0, // лічильник id записів журналу (для міток на них) alibiClaims: {}, // cid → building: що людина заявила про вечір злочину cracked: {}, // cid → true: брехун уже зізнався в дрібній брехні markers: [], // мітки аналітика на мапі locs: {}, accuseDraft: null, ended: null, clients: new Set() }; reshuffleLocs(cs, s, null); const file = path.join(SAVES, code + '.json'); let loaded = false; if (fs.existsSync(file)) { try { const j = JSON.parse(fs.readFileSync(file, 'utf8')); Object.assign(s, j, { st: RT.newState(), locs: s.locs, board: Object.assign(s.board, j.board) }); // Сейв міг лишитися від старішої версії генератора: первинний стан — found, // puzzlesSolved і warrants; решту перераховуємо по нинішній справі. const old = j.st || {}; s.st.puzzlesSolved = (old.puzzlesSolved || []).filter(id => cs.puzzles.some(p => p.id === id)); for (const id of old.found || []) if (cs.evidence[id]) RT.applyFound(cs, s.st, cs.evidence[id]); s.st.warrants = (old.warrants || []).filter(cid => cs.citizens.some(c => c.id === cid)); s.board.pins = s.board.pins.filter(id => cs.evidence[id] || (s.extra || []).some(x => x.id === id)); // лічильник id журналу не має відкотитись нижче вже наявних (сумісність зі старими сейвами) s.logN = Math.max(s.logN || 0, ...Object.values(s.log).flat().map(e => e.id || 0), 0); loaded = true; } catch {} } if (!loaded) { for (const id of cs.startingFacts) RT.applyFound(cs, s.st, cs.evidence[id]); saveNow(s); // синхронно: файл має існувати одразу, до першого throttle-запису } return sessions[code] = s; } // Персистенція «лінива»: дії лише зводять прапорець «брудно», а на диск пишемо // не частіше ніж раз на SAVE_MS — тисячі швидких дій (4 гравці) схлопуються в // кілька записів замість запису-на-кожну-дію. Пишемо в тимчасовий файл і атомарно // перейменовуємо: паралельні гравці ніколи не лишать напівзаписаного (і, отже, // непарсабельного) JSON. const SAVE_MS = 300; function saveData(s) { const { st, board, accuseDraft, ended, extra, xn, log, logN, markers, alibiClaims, cracked, mgOffered } = s; return JSON.stringify({ st, board, accuseDraft, ended, extra, xn, log, logN, markers, alibiClaims, cracked, mgOffered }); } // Синхронний запис на диск — для першого сейву щойно створеної сесії. Інакше файл // з'явиться лише через SAVE_MS, і другий гравець, що зайшов раніше, тригерне // «сейв зник → почати з нуля» і скине сесію, викинувши першого. function saveNow(s) { try { fs.writeFileSync(path.join(SAVES, s.code + '.json'), saveData(s)); } catch {} } function save(s) { s._dirty = true; if (s._timer || s._writing) return; // запис уже заплановано або триває s._timer = setTimeout(() => { s._timer = null; flushSave(s); }, SAVE_MS); } function flushSave(s) { if (s._writing || !s._dirty) return; s._writing = true; s._dirty = false; const final = path.join(SAVES, s.code + '.json'); const tmp = final + '.' + process.pid + '.tmp'; fs.writeFile(tmp, saveData(s), err => { const done = () => { s._writing = false; if (s._dirty) save(s); }; // хвіст → ще запис if (err) return done(); fs.rename(tmp, final, done); }); } // ---------- Видимість для клієнтів ---------- function carrierOf(cs, pid) { return Object.values(cs.evidence).find(ev => ev.puzzle === pid); } function visiblePuzzles(s) { return s.cs.puzzles.filter(p => { const ev = carrierOf(s.cs, p.id); return s.st.found.includes(ev.id) || RT.available(s.cs, s.st, ev, { ignorePuzzles: true }); }).map(p => ({ id: p.id, kind: p.kind, prompt: p.prompt, payload: p.payload, hint: p.hint, solved: s.st.puzzlesSolved.includes(p.id) })); } // Походження знахідки — короткий рядок «де це взяли», щоб через годину гри // дошка на 30 карток пам'ятала, що звідки. Лише для фізичних знахідок: // sys/lab/person/start приходять із термінала або з розмови, і відсутність // позначки вже означає це саме — дошка не роздувається. function originOf(cs, ev) { const at = ev.at || {}; if (at.type === 'spot') return `знайдено: ${bName(cs, at.building)}, ${at.spot}`; if (at.type === 'unit') return `знайдено: кв. ${at.unit}, ${at.slot}`; return null; } function view(s) { const { cs, st } = s; const found = st.found.map(id => { const ev = cs.evidence[id]; const card = { id, title: ev.title, detail: ev.detail, side: ev.side, tag: ev.tag || 'запит' }; // Звідки знахідка — для фільтра й для пам'яті гравця (див. originOf). const from = originOf(cs, ev); if (from) card.from = from; // Експертиза (crim/expert) розбирає конкретний доказ — його id у requires[0]. // Віддаємо як ref, щоб на дошці було видно, ЧИЙ це аналіз, і клік вів до нього. if (ev.q && ev.q.kind === 'expert' && ev.requires[0]) card.ref = ev.requires[0]; return card; // Динамічні картки (результати запитів) тега не мають — даємо дефолтний, // інакше вони випадали б із будь-якого зрізу по чипах. }).concat(s.extra.map(x => (x.tag ? x : { ...x, tag: 'запит' }))); const killerC = cs.citizens.find(c => c.id === cs.finale.killer); const dnaOwner = cs.citizens.find(c => c.id === (cs.accomplice || cs.finale.killer)); return { t: 'state', online: rolesOnline(s), found, identity: st.identity, victim: st.identity ? cs.victim : null, keys: st.keys, warrants: st.warrants, puzzles: visiblePuzzles(s), locs: s.locs, markers: s.markers, log: s.log, alibiClaims: s.alibiClaims, // Хто вже зізнався, що збрехав про вечір → його СПРАВЖНЄ місце (c.evening). // Клієнт правди не має (evening вирізано в pubCitizens), тож віддаємо тут — // тільки для розколотих, тож нічого понад уже сказане в зізнанні не тече. alibiReal: Object.fromEntries(Object.keys(s.alibiClaims) .filter(cid => s.cracked[cid]) .map(cid => [cid, byIdC(cs, cid).evening])), cctvPulled: s.extra.filter(x => x.key && x.key.startsWith('cctv|')).map(x => x.key.slice(5)), dnaSample: st.found.includes('sf_dna') ? dnaOwner.dna : null, dnaLabel: st.found.includes('sf_dna') ? cs.dnaDesc : null, dnaSample2: cs.dna2 && st.found.includes('sf_dna2') ? byIdC(cs, cs.dna2.owner).dna : null, dna2Label: cs.dna2 && st.found.includes('sf_dna2') ? cs.dna2Desc : null, printSample: st.found.includes('ev_weapon') ? killerC.printSeed : null, board: s.board, accuseDraft: accuseView(s), ended: s.ended }; } function rolesOnline(s) { const o = { field: 0, analyst: 0 }; for (const c of s.clients) if (c.role) o[c.role]++; return o; } // Розсилку коалесуємо: багато мутацій в одному тіку (злив дій) → один снапшот // стану на всіх, а не серіалізація+розсилка на кожну дію. function broadcast(s) { if (s._bc) return; s._bc = true; setImmediate(() => { s._bc = false; const msg = JSON.stringify(view(s)); for (const c of s.clients) { try { c.send(msg); } catch { } } // мертвий сокет не має валити розсилку іншим }); } function toast(ws, txt) { ws.send(JSON.stringify({ t: 'toast', txt })); } // позиції польових (у кожного своя точка) — легка розсилка окремо від стану function fieldPositions(s) { return [...s.clients].filter(c => c.role === 'field' && c.pos).map(c => ({ num: c.num, x: c.pos.x, y: c.pos.y })); } function pushPositions(s) { const msg = JSON.stringify({ t: 'pos', field: fieldPositions(s) }); for (const c of s.clients) { try { c.send(msg); } catch { } } } // Звинувачення закривається, лише коли підписалися ВСІ під'єднані гравці. // Підписи ключуються id з'єднання; рахуємо тільки серед присутніх зараз, тож // підпис того, хто вийшов, не тримає справу заручником. function resolveAccusationIfReady(s) { const d = s.accuseDraft; if (!d || s.ended) return; const ids = [...s.clients].map(c => c.id); if (!ids.length || !ids.every(id => d.confirms[id])) return; const res = checkAccusation(s.cs, d, s.board.pins); s.ended = res.ok ? { solved: true, recap: s.cs.finale.recap } : { fired: true, hint: res.hint, recap: s.cs.finale.recap }; } // для клієнта: чернетка + скільки з присутніх уже підписали function accuseView(s) { const d = s.accuseDraft; if (!d) return null; const ids = [...s.clients].map(c => c.id); return { who: d.who, how: d.how, why: d.why, signed: ids.filter(id => d.confirms[id]).length, need: ids.length }; } // позиція від клієнта (для відновлення на реконекті) — приймаємо лише в межах світу function validFieldPos(p, cs) { const W = cs.district.world; if (!p || typeof p.x !== 'number' || typeof p.y !== 'number') return null; if (p.x < 0 || p.x > W.w || p.y < 0 || p.y > W.h) return null; return { x: p.x | 0, y: p.y | 0 }; } function reveal(s, ev) { RT.applyFound(s.cs, s.st, ev); } // головоломка розв'язана / нові вимоги виконані → носії відкриваються ланцюжком function autoUnlockCarriers(s) { let changed = true; while (changed) { changed = false; for (const p of s.cs.puzzles) { if (!s.st.puzzlesSolved.includes(p.id)) continue; const ev = carrierOf(s.cs, p.id); if (!s.st.found.includes(ev.id) && RT.available(s.cs, s.st, ev)) { reveal(s, ev); changed = true; } } } } // динамічна картка на дошку (результат запиту, зізнання); key — від дублів. // pointsTo (опційно) — на кого картка вказує, щоб з неї теж можна було взяти ордер. function extraCard(s, key, title, detail, pointsTo) { if (s.extra.some(x => x.key === key)) return; s.extra.push({ id: 'x' + (++s.xn), key, title, detail, side: 'analyst', ...(pointsTo ? { pointsTo } : {}) }); } // На кого вказує доказ (для ордера) — і зі статичних доказів, і з динамічних карток. function evPointsTo(s, evId) { const ev = s.cs.evidence[evId]; if (ev) return s.st.found.includes(evId) ? ev.gives.pointsTo || null : null; const x = s.extra.find(e => e.id === evId); // extra-картки завжди «знайдені» return x ? x.pointsTo || null : null; } // Польова знахідка: справжній доказ → міні-гра вилучення, дрібниця → одразу function offerOrReveal(s, ws, ev, done) { if (ev.tag !== 'дрібниця') { (s.mgOffered ??= {})[ev.id] = true; return ws.send(JSON.stringify({ t: 'mg', kind: 'collect', evId: ev.id, title: ev.title })); } reveal(s, ev); done(); } // ---------- Дрібні помічники ---------- const byIdC = (cs, cid) => cs.citizens.find(c => c.id === cid); const bName = (cs, bid) => { const b = cs.district.buildings.find(x => x.id === bid); return b ? b.name.split(',')[0] : bid; }; const g = (c, m, f) => (c.sex === 'ч' ? m : f); const normPhone = v => String(v || '').replace(/\D/g, ''); const fmtMin = v => `${String(Math.floor(v / 60)).padStart(2, '0')}:${String(v % 60).padStart(2, '0')}`; const pickBy = (arr, str) => arr[[...String(str)].reduce((a, ch) => a + ch.charCodeAt(0), 0) % arr.length]; function crimeMin(cs) { const [h, mi] = cs.crimeTime.split(':').map(Number); return h * 60 + mi; } // Чи витягнуто архів камери, що дивиться на назване людиною місце. const claimCovered = (s, c) => s.cs.district.cameras.some(cam => cam.covers.includes(c.claim) && s.extra.some(x => x.key === 'cctv|' + cam.id)); // Чи людина потрапляє в кадри камери (та ж логіка, що й у списку «хто був у кадрі»: // жертву й убивцю камери не фіксують — убивця їх уникав). const shownBy = (cs, cam, c) => c.id !== cs.victim && c.id !== cs.finale.killer && cam.covers.includes(c.evening); // Алібі спростоване: витягнутий архів камери над НАЗВАНИМ місцем, де людини в кадрі немає. // Для чесних (claim === evening) камера їх якраз показує → не спрацьовує. const alibiBroken = (s, c) => s.cs.district.cameras.some(cam => cam.covers.includes(c.claim) && s.extra.some(x => x.key === 'cctv|' + cam.id) && !shownBy(s.cs, cam, c)); // Цих словами не зламати — тільки доказами. const hardened = (s, c) => c.id === s.cs.finale.killer || c.id === s.cs.accomplice; // Брехуна ламає не питання, а факт: записана заява + архів камери над названим // місцем, де його немає. Байдуже, чи це повторний допит, чи архів пред'явлено в лоб. function crackLine(s, c) { const { cs } = s; if (!c.liar || hardened(s, c) || s.cracked[c.id]) return null; if (!s.alibiClaims[c.id] || !claimCovered(s, c)) return null; s.cracked[c.id] = true; const mate = c.mate && byIdC(cs, c.mate); return `Пауза. Погляд у підлогу. «Гаразд... Насправді я ${g(c, 'був', 'була')} біля «${bName(cs, c.evening)}». ${c.excuse}` + (mate ? ` Спитайте ${mate.name}, ми були там разом.»` : '»'); } // Зламане алібі — для БУДЬ-КОГО, чиї покази суперечать камерам, а не лише вбивці: // інакше поява картки одразу «спойлерить» винного. Убивці — сплячий доказ-підтвердження // (alibi_broken), решті брехунів — нейтральний лід «алібі не сходиться», щоб уточнювати далі. function checkAlibiBreaks(s) { const { cs, st } = s; for (const cid of Object.keys(s.alibiClaims)) { const c = byIdC(cs, cid); if (!c || c.id === cs.victim || !alibiBroken(s, c)) continue; if (c.id === cs.finale.killer) { const ev = cs.evidence.alibi_broken; if (ev && !st.found.includes(ev.id)) reveal(s, ev); } else { const where = c.claim === unitBuilding(c.home) ? g(c, 'був удома', 'була вдома') : `${g(c, 'був', 'була')} біля «${bName(cs, c.claim)}»`; extraCard(s, 'alibibreak|' + c.id, `Алібі не сходиться: ${c.name}`, `${c.name} на допиті ${g(c, 'заявив', 'заявила')}, що ${where}, але витягнутий архів камер за той вечір ${g(c, 'його', 'її')} там не показує. Хтось збрехав про свій вечір — з'ясуйте чому. Брехня про алібі ще не робить винним.`, c.id); // теж підстава для ордера — інакше «єдина картка, що дає ордер» видала б вбивцю } } } function logQA(s, ws, cid, q, a) { (s.log[cid] ??= []).push({ id: ++s.logN, q, a }); // id — стабільний ключ для міток на записі if (s.log[cid].length > 60) { const removed = s.log[cid].shift(); const key = 'q:' + removed.id; if (s.board.marks[key]) delete s.board.marks[key]; } ws.send(JSON.stringify({ t: 'answer', cid, q, a })); } // Один пошук за іменем на всі поля, де вводять людину: кожне слово запиту — початок // слова в імені, регістр не важить. Раніше банк вимагав точний повний рядок, а реєстри // шукали підрядок — два різні правила для однакових на вигляд полів. const WORDSEP = /[^а-яіїєґa-z0-9]+/i; function findByName(cs, q) { const words = String(q || '').trim().toLowerCase().split(WORDSEP).filter(Boolean); if (!words.length) return []; return cs.citizens.filter(c => { const nw = c.name.toLowerCase().split(WORDSEP); return words.every(w => nw.some(x => x.startsWith(w))); }); } // збіг запиту з довідником доказу function matchQ(q, kind, inp) { const vals = [].concat(q.match || []); if (!vals.length) return true; // expert-запити без вводу if (['owner', 'calls', 'locate'].includes(kind)) return vals.some(v => normPhone(v) === normPhone(inp)); const low = String(inp).toLowerCase().trim(); if (low.length < 3) return false; const qWords = low.split(WORDSEP).filter(Boolean); return vals.some(v => { const lv = String(v).toLowerCase(); if (lv === low || lv.startsWith(low)) return true; // «стріч» → «стрічка» // Довідкове значення мусить стояти в запиті ОКРЕМИМ словом. Раніше було // взаємне входження підрядка в будь-який бік — і довга вставка матчила будь-що. const vWords = lv.split(WORDSEP).filter(Boolean); return vWords.length > 0 && vWords.every(w => qWords.includes(w)); }); } // стосунок z до x (за реєстровими даними) function relationOf(z, x) { if (z.spouse === x.id) return 'подружжя'; if (z.last === x.last && z.home === x.home) return 'родина, живемо разом'; if (z.home === x.home) return 'сусіди по кімнаті'; if (unitBuilding(z.home) === unitBuilding(x.home)) return 'сусіди по будинку'; if (z.workplace && z.workplace === x.workplace) return 'колеги'; return null; } // ---------- Обробка дій ---------- function handle(s, ws, m) { const { cs, st } = s; const done = () => { autoUnlockCarriers(s); save(s); broadcast(s); }; if (s.ended && !['note', 'note_del', 'enote', 'enote_del', 'mark'].includes(m.t)) return; // справу закрито switch (m.t) { case 'search': { // публічна точка: {building, spot} const ev = Object.values(cs.evidence).find(e => e.at.type === 'spot' && e.at.building === m.building && e.at.spot === m.spot && !st.found.includes(e.id)); if (!ev) return toast(ws, 'Нічого цікавого.'); if (!RT.available(cs, st, ev)) return toast(ws, ev.puzzle ? 'Тут щось є, але воно під замком — потрібна робота аналітика.' : 'Нічого цікавого.'); return offerOrReveal(s, ws, ev, done); } case 'enter_unit': { // {unit} const ok = RT.unitAccess(cs, st, m.unit); ws.send(JSON.stringify({ t: 'unit', unit: m.unit, allowed: ok, slots: ok ? cs.district.homeSlots : [] })); if (!ok) toast(ws, 'Замкнено. Потрібен ключ або ордер на когось із мешканців.'); return; } case 'search_unit': { // {unit, slot} if (!RT.unitAccess(cs, st, m.unit)) return toast(ws, 'Замкнено.'); const ev = Object.values(cs.evidence).find(e => e.at.type === 'unit' && e.at.unit === m.unit && e.at.slot === m.slot && !st.found.includes(e.id)); if (!ev) return toast(ws, 'Нічого цікавого.'); if (!RT.available(cs, st, ev)) return toast(ws, ev.puzzle ? 'Знахідка заблокована (шифр/пароль) — передайте аналітику.' : 'Нічого цікавого.'); return offerOrReveal(s, ws, ev, done); } case 'mg_done': { // польовий упорався з вилученням const ev = cs.evidence[m.evId]; if (!ev || !s.mgOffered || !s.mgOffered[m.evId] || st.found.includes(ev.id)) return; if (!RT.available(cs, st, ev)) return; delete s.mgOffered[m.evId]; reveal(s, ev); return done(); } case 'visit': { // польовий зайшов у будівлю → тасуємо присутність у всіх ІНШИХ if (ws.role !== 'field' || !cs.district.buildings.some(b => b.id === m.building)) return; reshuffleLocs(cs, s, m.building); return broadcast(s); } // ---- системи-форми аналітика ---- case 'sys_query': { // {sys, kind, input, camera, minutes} const kind = m.kind || 'search'; const inp = String(m.input || '').trim(); if (m.sys === 'cctv') { const cam = cs.district.cameras.find(c => c.id === m.camera); if (!cam) return; const t = m.minutes | 0, crime = crimeMin(cs); let got = false; if (Math.abs(t - crime) <= 12) { for (const id of ['rec_cctv', 'her_alibi']) { const ev = cs.evidence[id]; if (ev && ev.q && cam.covers.includes(ev.q.building) && !st.found.includes(id) && RT.available(cs, st, ev)) { reveal(s, ev); got = true; } } } // хто взагалі потрапляв у кадри того вечора (вбивця камер уникав — його тут нема). // Порожній архів — теж доказ: там, де хтось «був», його ніхто не зняв. const seen = cs.citizens.filter(c => c.id !== cs.victim && c.id !== cs.finale.killer && cam.covers.includes(c.evening)); extraCard(s, 'cctv|' + cam.id, `Архів за вечір: ${cam.name}`, seen.length ? 'У кадрах протягом вечора: ' + seen.map(c => `${c.name} (${bName(cs, c.evening)})`).join(', ') + '.' : 'За весь вечір у кадрах — нікого. Дощ, ліхтар, порожнеча.'); if (!got && Math.abs(t - crime) <= 12) toast(ws, 'На сам час злочину ця камера нічого суттєвого не зняла.'); checkAlibiBreaks(s); return done(); } // Тріангуляція: чотири геть різні відмови раніше віддавали один і той самий // тост, тож правильна дія до кінця Акту I читалася як зламана механіка. // Порядок перевірок важливий: гейт «особу не встановлено» — ЗАГАЛЬНИЙ, до // звірки номера, інакше окрема відповідь саме на номер убивці видала б його. if (m.sys === 'phone' && kind === 'locate') { const tri = cs.evidence.rec_triangulate; const killer = byIdC(cs, cs.finale.killer); const num = normPhone(inp); if (!num) return toast(ws, 'Оператор: введіть номер. Формат вільний — усе, крім цифр, відкидається.'); if (tri && tri.needsIdentity && !st.identity) return toast(ws, 'Оператор: пеленг — за постановою, а підстав немає: особу загиблого ще не встановлено.'); if (tri && st.found.includes(tri.id) && num === normPhone(killer.phone)) return toast(ws, 'Оператор: цей пеленг уже в матеріалах справи.'); const owner = cs.citizens.find(x => normPhone(x.phone) === num); if (!owner) return toast(ws, 'Оператор: номер не обслуговується.'); if (tri && !st.found.includes(tri.id) && RT.available(cs, st, tri) && num === normPhone(killer.phone)) { s.triTarget = s.locs[killer.id]; const b = cs.district.buildings.find(x => x.id === s.triTarget); const cx = b.pos.x + b.pos.w / 2, cy = b.pos.y + b.pos.h / 2; return ws.send(JSON.stringify({ t: 'mg', kind: 'triangulate', number: inp, towers: cs.district.towers.map(tw => ({ name: tw.name, x: tw.pos.x, y: tw.pos.y, dist: Math.round(Math.hypot(tw.pos.x - cx, tw.pos.y - cy)) })) })); } return toast(ws, 'Оператор: номер у мережі, але підстав пеленгувати саме його немає — потрібен доказ, що апарат стосується справи.'); } // Текстові системи: запит коротший за 3 символи раніше мовчки давав порожньо. if (m.sys === 'bank' || m.sys === 'db' || (m.sys === 'crim' && kind === 'archive')) { if (!inp) return toast(ws, 'Порожній запит.'); if (inp.length < 3) return toast(ws, 'Уточніть запит: мінімум 3 символи.'); } // реальні докази, що відповідають запиту const hits = Object.values(cs.evidence).filter(ev => ev.q && ev.q.sys === m.sys && (ev.q.kind || 'search') === kind && !st.found.includes(ev.id) && RT.available(cs, st, ev) && matchQ(ev.q, kind, inp)); if (hits.length) { for (const ev of hits) reveal(s, ev); return done(); } // філери: навіть дрібниця лягає на дошку — фільтруйте самі if (m.sys === 'phone' && kind === 'owner') { const c = cs.citizens.find(x => normPhone(x.phone) === normPhone(inp)); if (!c) return toast(ws, 'Номер не обслуговується.'); extraCard(s, 'own|' + c.id, `Власник номера ${inp}`, `${c.name}, прож. ${c.home}, ${bName(cs, unitBuilding(c.home))}.`); return done(); } if (m.sys === 'phone' && kind === 'calls') { const c = cs.citizens.find(x => normPhone(x.phone) === normPhone(inp)); if (!c) return toast(ws, 'Номер не обслуговується.'); const list = (cs.calls || []).filter(x => x.a === c.id || x.b === c.id).sort((x, y) => x.t - y.t); const lines = list.slice(0, 8).map(x => { const other = byIdC(cs, x.a === c.id ? x.b : x.a); return `${fmtMin(x.t)} ${x.a === c.id ? '→ вихідний:' : '← вхідний:'} ${other.name}`; }); let txt = lines.length ? 'Вечір злочину: ' + lines.join('; ') + '.' : 'Вечір злочину: жодного дзвінка.'; if (c.id === cs.finale.killer) txt += ` З ${fmtMin(cs.phoneOff.off)} апарат поза мережею${cs.phoneOff.on > 1439 ? ' — до самого ранку' : `, знову в мережі о ${fmtMin(cs.phoneOff.on % 1440)}`}.`; extraCard(s, 'calls|' + c.id, `Дзвінки за вечір: ${c.name}`, txt); return done(); } if (m.sys === 'bank') { const hits = findByName(cs, inp); if (!hits.length) return toast(ws, 'Банк: рахунків за таким запитом не знайдено.'); if (hits.length > 1) return toast(ws, `Банк: під запит підходить осіб — ${hits.length}. Уточніть ім'я.`); const c = hits[0]; extraCard(s, 'bank|' + c.id, `Виписка: ${c.name}`, `${c.job === 'пенсіонер' ? 'Пенсія' : c.job === 'студент' ? 'Стипендія і перекази від рідні' : 'Зарплата'}, комуналка, дрібні витрати. Аномалій не видно.`); return done(); } if (m.sys === 'db') { const low = inp.toLowerCase(); // Ім'я — за тим самим правилом, що й банк і база аналітика (префікси слів); // тату й кличка собаки лишаються підрядком, це прикмети, а не імена. const named = new Set(findByName(cs, inp).map(c => c.id)); const rs = cs.citizens.filter(c => named.has(c.id) || (c.tattoo || '').toLowerCase().includes(low) || (c.dog || '').toLowerCase().includes(low)); if (!rs.length) return toast(ws, 'Реєстри: збігів немає.'); extraCard(s, 'db|' + low, `Реєстри: «${inp}»`, rs.slice(0, 6).map(c => `${c.name}, прож. ${c.home}${c.tattoo ? ', тату: ' + c.tattoo : ''}${c.dog ? ', собака: ' + c.dog : ''}`).join('; ') + (rs.length > 6 ? ' — і ще записи, уточніть запит.' : '.')); return done(); } return toast(ws, 'За запитом нічого не знайдено.'); } // ---- допит ---- case 'ask_alibi': { // {cid, minutes} const c = byIdC(cs, m.cid); if (!c || c.id === cs.victim) return; const t = m.minutes | 0, crime = crimeMin(cs); const q = `Де ви були о ${fmtMin(t)} того вечора?`; let a; if (Math.abs(t - crime) <= 90) { const askedBefore = !!s.alibiClaims[c.id]; const hard = hardened(s, c); const crack = crackLine(s, c); // притиснули фактами — брехун ламається if (s.cracked[c.id] && !crack) { const mate = c.mate && byIdC(cs, c.mate); a = `«Я ж уже все розпові${g(c, 'в', 'ла')}: ${g(c, 'був', 'була')} біля «${bName(cs, c.evening)}».` + (mate ? ` ${mate.name} підтвердить.»` : '»'); } else if (crack) { a = crack; } else { const honest = !c.liar && !hard; const mate = honest ? cs.citizens.find(o => o !== c && o.id !== cs.victim && o.id !== cs.finale.killer && !o.liar && o.evening === c.evening) : null; a = `«${g(c, 'Був', 'Була')} ${c.claim === unitBuilding(c.home) ? 'удома' : 'біля «' + bName(cs, c.claim) + '»'}. ` + (mate ? `Питайте ${mate.name} — ми бачилися там.»` : `Підтвердити нікому, ${g(c, 'сам', 'сама')} ${g(c, 'був', 'була')}.»`); if (hard && askedBefore && claimCovered(s, c)) a = `«Я вже відповіда${g(c, 'в', 'ла')} на це. Біля «${bName(cs, c.claim)}». Ми закінчили?» — ані м'яза на обличчі.`; } s.alibiClaims[c.id] = c.claim; checkAlibiBreaks(s); } else { a = `«${fmtMin(t)}? ` + (c.workplace ? 'На роботі або дорогою з неї' : 'Удома') + ', як завжди. А що сталося?»'; } logQA(s, ws, c.id, q, a); return done(); } case 'ask_about': { // {cid, about} — «що скажете про людину X?» const z = byIdC(cs, m.cid), x = byIdC(cs, m.about); if (!z || !x || z.id === x.id || z.id === cs.victim) return; const q = `Що скажете про: ${x.name}?`; const own = relationOf(z, x); const close = !!own; let a; if (z.id === cs.finale.killer && x.id === cs.victim) { a = `«${x.first}? Бачив кілька разів у дворі. Майже не знав.»`; // брехня } else if (z.liar) { a = pickBy(['«Не знаю таких.»', '«Нічого не скажу, не моя справа.»', '«Уперше чую це ім\'я.»'], z.id + x.id); } else { a = own ? `«Ми ${own}.» ` : `«Знаю у обличчя, не більше.» `; const pair = (cs.relations || []).find(p => p.a === x.id || p.b === x.id); if (pair && (close || relationOf(z, byIdC(cs, pair.a === x.id ? pair.b : pair.a)))) { const other = byIdC(cs, pair.a === x.id ? pair.b : pair.a); a += `«Кажуть, у ${x.first} з ${other.name} — ${pair.kind}.»`; } else if (close) { a += `«${g(x, 'Нормальний', 'Нормальна')}. Живе тихо, зайвого не скажу.»`; } } logQA(s, ws, z.id, q, a); return done(); } case 'show_ev': { // {cid, evId} — «що ви скажете на це?» const c = byIdC(cs, m.cid); const real = cs.evidence[m.evId]; const ev = real || s.extra.find(x => x.id === m.evId); if (!c || !ev || c.id === cs.victim) return; if (real && !st.found.includes(real.id)) return; const K = c.id === cs.finale.killer; const gives = (real && real.gives) || {}; const q = `Пред'явлено: ${ev.title}`; // архів камери, що дивиться на назване людиною місце, — це пряма суперечність // її показам: у кадрах її немає. Пред'явити його = те саме, що притиснути на допиті. const camId = ev.key && ev.key.startsWith('cctv|') && ev.key.slice(5); const cam = camId && cs.district.cameras.find(x => x.id === camId); const vsClaim = cam && s.alibiClaims[c.id] && cam.covers.includes(c.claim); const crack = vsClaim ? crackLine(s, c) : null; let a; if (crack) { a = crack; } else if (vsClaim && hardened(s, c)) { a = `Довгий погляд у список. «Значить, камера мене не зачепила. Я там ${g(c, 'був', 'була')}. Далі що?» — голос рівний.`; } else if (vsClaim && s.cracked[c.id]) { a = `«Я ж уже зізна${g(c, 'вся', 'лася')}, що збреха${g(c, 'в', 'ла')}. ${g(c, 'Був', 'Була')} біля «${bName(cs, c.evening)}». Що ще вам треба?»`; } else if (vsClaim && !c.liar) { a = `«То придивіться — я ж у цьому списку і є. ${g(c, 'Казав', 'Казала')} же вам.»`; } else if (m.evId === 'dec_footprint' && c.id === cs.decoyOwner) { a = `«Розмір ${c.shoe}? Та це ж мій слід — я там ${g(c, 'ходив', 'ходила')} того ж вечора, як і щодня. Вам би хто одразу сказав.»`; extraCard(s, 'dec_owned', 'Відбиток взуття: власника знайдено', `${c.name} впізнає слід біля місця злочину: ${g(c, 'проходив', 'проходила')} там того вечора. До справи відношення не має.`); } else if (K && gives.confirms) { a = `Довга пауза. «...Гаразд. Ваша взяла.» — і ${c.name} починає говорити.`; // Зізнання підтверджує ХТО/ЯК, але мотив цим не доводиться: прокурор вимагає // ЗАКРІПЛЕНИЙ доказ мотиву (accusation.js). Тож картка чесно каже, що ЧОМУ — // ще відкрите, інакше гравець думає, що вже все, і зависає на звинуваченні. const motiveBacked = st.found.some(id => cs.evidence[id] && cs.evidence[id].gives && cs.evidence[id].gives.motive); const whyLine = motiveBacked ? `Мотив уже підкріплено доказом. Лишилося оформити справу: ХТО / ЯК / ЧОМУ і закріпити докази для прокурора.` // 50/50 (вирішено при генерації справи): інколи вбивця сам проговорює мотив. // Але слова — ще не доказ: прокурор усе одно вимагає ЗАКРІПЛЕНИЙ доказ мотиву. : cs.finale.revealsMotive && cs.finale.recapWhy ? `Цього разу ${g(c, 'він заговорив', 'вона заговорила')} і про мотив: ${cs.finale.recapWhy} Та зізнання на словах — ще не доказ: щоб оформити ЧОМУ, знайдіть і закріпіть доказ мотиву.` : `Але про мотив мовчить: ЧОМУ ще не доведено. Прокурор не прийме справу без ЗАКРІПЛЕНОГО доказу мотиву — знайдіть і закріпіть його, тоді оформлюйте ХТО / ЯК / ЧОМУ.`; extraCard(s, 'confess', `Зізнання: ${c.name}`, `Після пред'явлення (${ev.title.toLowerCase()}) ${c.name} ${g(c, 'зізнався', 'зізналася')} у скоєному. Протокол підписано. ${whyLine}`); } else if (cs.dna2 && c.id === cs.dna2.owner && ['sf_dna2', 'lab_dna_match2'].includes(m.evId)) { // невинна ДНК: людина пояснює, звідки вона там, — вірити чи ні, вирішують гравці const vic = byIdC(cs, cs.victim); a = cs.dna2.why === 'brawl' ? `«Так, це моє! Ми зчепилися — ${cs.dna2.reason}. Удень, у барі, пів зали бачило! Але коли я ${g(c, 'пішов', 'пішла')}, ${vic.first} стояв біля стійки живий-здоровий. Спитайте будь-кого з бару!»` : cs.dna2.why === 'finder' ? `«Я ж і ${g(c, 'знайшов', 'знайшла')}… ${g(c, 'Нахилявся', 'Нахилялася')}, пульс перевіряти, за плече торкався. Тому й слід мій. Я одразу подзвонив у відділок — перевірте час дзвінка.»` : `«Я там буваю щодня, як і пів району. ДНК на публічному місці? Це ще нічого не значить, і ви це знаєте.»`; } else if (gives.pointsTo === c.id) { a = K ? '«Це нічого не доводить. Мало що де написано. Розмову закінчено.»' : c.id === cs.herring ? (m.evId.startsWith('fm_') ? `Мовчанка. Потім, крізь зуби: «Так, це правда — ${cs.falseMotive.admit}. Конфлікт був. Але вбивство?! Перевірте камери за той вечір — мене там і близько не було!»` : `«Знову я?! Виглядає погано, знаю. Але я тут ні до чого — перевірте камери за той вечір, я ${g(c, 'був', 'була')} зовсім не там!»`) : '«Я можу це пояснити. Це не те, чим здається...» — і пояснення, схоже, щире.'; } else if (K && (gives.dnaSample || gives.filter)) { a = '«І що це мені має сказати?» — але щелепа помітно напружилась.'; } else { a = pickBy(['«Вперше бачу.»', '«Нічим не допоможу, на жаль.»', '«Хм. Питайте когось ближчого до цього.»'], c.id + m.evId); } logQA(s, ws, c.id, q, a); return done(); } // ---- лабораторія ---- case 'dna_pick': { // аналітик обрав доріжку гелю; {sampleEv: 'sf_dna'|'sf_dna2', cid} const two = m.sampleEv === 'sf_dna2'; const ev = cs.evidence[two ? 'lab_dna_match2' : 'lab_dna_match']; const target = two ? (cs.dna2 && cs.dna2.owner) : (cs.accomplice || cs.finale.killer); if (!ev || !target || st.found.includes(ev.id) || !RT.available(cs, st, ev)) return; if (m.cid !== target) { const c = byIdC(cs, m.cid); return toast(ws, `${c ? c.name : 'Профіль'}: доріжки не збігаються зі зразком. Це не ${c && c.sex === 'ж' ? 'вона' : 'він'}.`); } reveal(s, ev); return done(); } case 'print_pick': { // аналітик обрав візерунок відбитка const ev = cs.evidence.lab_print_match; if (!ev || st.found.includes(ev.id) || !RT.available(cs, st, ev)) return; if (m.cid !== cs.finale.killer) { const c = byIdC(cs, m.cid); return toast(ws, `${c ? c.name : 'Візерунок'}: точки не сходяться. Відбиток не ${c && c.sex === 'ж' ? 'її' : 'його'}.`); } reveal(s, ev); return done(); } case 'triangulate_pick': { // {building} — клік по будинку на перетині дуг const ev = cs.evidence.rec_triangulate; if (!ev || st.found.includes(ev.id) || !RT.available(cs, st, ev) || !s.triTarget) return; if (m.building !== s.triTarget) return toast(ws, 'Сота не сходиться: жоден будинок не лежить на перетині всіх трьох дуг. Придивіться уважніше.'); reveal(s, ev); return done(); } case 'talk': { // {cid} → список тем у справі const topics = Object.values(cs.evidence) .filter(e => e.at.type === 'person' && e.at.cid === m.cid) .filter(e => st.found.includes(e.id) || RT.available(cs, st, e)) .map(e => ({ id: e.id, title: e.title, found: st.found.includes(e.id) })); return ws.send(JSON.stringify({ t: 'topics', cid: m.cid, topics })); } case 'ask': { // {evId} const ev = cs.evidence[m.evId]; if (!ev || ev.at.type !== 'person' || st.found.includes(ev.id)) return; if (!RT.available(cs, st, ev)) return toast(ws, 'Людина знизує плечима. Немає з чим до неї йти.'); reveal(s, ev); return done(); } case 'inspect': { // {cid} — очний огляд за ордером; результат нікуди не пишеться if (ws.role !== 'field') return; const c = byIdC(cs, m.cid); if (!c || c.id === cs.victim) return; if (!st.warrants.includes(c.id)) return toast(ws, 'Без ордера людина має право послати вас куди завгодно. І пошле.'); ws.send(JSON.stringify({ t: 'inspect', cid: c.id, body: `Зріст ${c.height} см. Взуття ${c.shoe} розміру. Зараз у ${c.coat}.` + `${c.tattoo ? ' Татуювання: ' + c.tattoo + '.' : ''}${c.smoker ? ' Пальці курця.' : ''}` + ' Запишіть у нотатки, що важливо, — у протокол огляд не йде.' })); return; } case 'warrant': { // {cid, evId} — лише польовий, лише у відділку, лише з доказом if (ws.role !== 'field') return toast(ws, 'Ордери подає польовий — особисто у відділку.'); const stn = cs.district.buildings.find(b => b.id === 'station').pos; const p = ws.pos || s.spawn; if (p.x < stn.x - 45 || p.x > stn.x + stn.w + 45 || p.y < stn.y - 45 || p.y > stn.y + stn.h + 45) return toast(ws, 'За ордером — до відділка, до прокурора.'); if (st.warrants.includes(m.cid)) return; if (evPointsTo(s, m.evId) !== m.cid) return toast(ws, 'Прокурор повертає подання: цей доказ прямо не вказує на цю особу.'); st.warrants.push(m.cid); toast(ws, 'Прокурор підписав ордер.'); return done(); } case 'puzzle_try': { // {id, answer|shift} const p = cs.puzzles.find(x => x.id === m.id); if (!p || st.puzzlesSolved.includes(p.id)) return; const ok = p.kind === 'caesar' ? Number(m.shift) === p.shift : p.kind === 'b64' ? String(m.answer || '').trim() === p.answer : md5(String(m.answer || '').trim()) === p.hash; if (!ok) return toast(ws, 'Не підходить.'); st.puzzlesSolved.push(p.id); toast(ws, 'Розшифровано!'); return done(); } // ---- дошка ---- case 'pin': { const i = s.board.pins.indexOf(m.evId); if (i >= 0) s.board.pins.splice(i, 1); else if (st.found.includes(m.evId) || s.extra.some(x => x.id === m.evId)) s.board.pins.push(m.evId); return done(); } case 'mark': { // {key:'e:..'|'c:..'|'q:..', kind} // Раніше сюди приймався будь-який рядок як kind. Палітри дві: на людях — // кольори (кілька версій одночасно), на доказах і записах журналу — ✓/✗. if (typeof m.key !== 'string') return; const allowed = m.key.startsWith('c:') ? PERSON_MARKS : EV_MARKS; if (!allowed.includes(m.kind)) return; if (s.board.marks[m.key] === m.kind) delete s.board.marks[m.key]; else s.board.marks[m.key] = m.kind; return done(); } case 'note': { if (typeof m.txt === 'string' && m.txt.trim()) s.board.notes.push({ id: Date.now() + Math.random(), txt: m.txt.slice(0, 500), by: ws.role }); return done(); } case 'note_del': { s.board.notes = s.board.notes.filter(n => n.id !== m.id); return done(); } case 'enote': { // нотатка на конкретному доказі if (typeof m.txt !== 'string' || !m.txt.trim()) return; (s.board.enotes[m.evId] ??= []).push({ id: Date.now() + Math.random(), txt: m.txt.slice(0, 300), by: ws.role }); return done(); } case 'enote_del': { const l = s.board.enotes[m.evId]; if (l) s.board.enotes[m.evId] = l.filter(n => n.id !== m.id); return done(); } case 'marker': { // {x, y} — мітка аналітика на мапі (повторний клік поруч — зняти) const near = s.markers.findIndex(mk => Math.hypot(mk.x - m.x, mk.y - m.y) < 26); if (near >= 0) s.markers.splice(near, 1); else if (s.markers.length < 8) s.markers.push({ x: m.x | 0, y: m.y | 0 }); return done(); } case 'move': { // позиція польового — без сейву, легка розсилка if (ws.role !== 'field' || !ws.pos) return; ws.pos.x = m.x | 0; ws.pos.y = m.y | 0; pushPositions(s); return; } case 'accuse_draft': { s.accuseDraft = { who: String(m.who || '').slice(0, 60), how: m.how, why: m.why, confirms: {} }; return done(); } case 'accuse_cancel': { s.accuseDraft = null; return done(); } case 'accuse_confirm': { if (!s.accuseDraft) return; s.accuseDraft.confirms[ws.id] = true; resolveAccusationIfReady(s); return done(); } } } // ---------- HTTP: статика ---------- const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8' }; const server = http.createServer((req, res) => { const u = new URL(req.url, 'http://x'); const file = u.pathname === '/' ? 'index.html' : u.pathname.replace(/^\/+/, '').replace(/\.\./g, ''); const full = path.join(__dirname, 'public', file); fs.readFile(full, (err, data) => { if (err) return void res.writeHead(404).end('not found'); res.writeHead(200, { 'Content-Type': MIME[path.extname(full)] || 'application/octet-stream' }).end(data); }); }); // ---------- WS ---------- // evening/claim/liar — таємниці слідства: у клієнтський init не потрапляють function pubCitizens(cs) { return cs.citizens.map(({ evening, claim, liar, excuse, mate, regShoe, regCoat, ...c }) => ({ ...c, shoe: regShoe ?? c.shoe, // реєстр місцями застарілий: клієнт бачить те, що в базі, coat: regCoat ?? c.coat // а правду показує лише очний огляд за ордером })); } const wss = new WebSocketServer({ server }); // Heartbeat: без нього обірване з'єднання (сон ноута, зміна Wi-Fi, kill) лишає // мертвий сокет у s.clients назавжди — застигла точка на мапі, «Польовий2» за // одного гравця, роздутий лічильник. Пінгуємо всіх; хто не відповів між тіками — // terminate, і штатний 'close' прибирає його з сесії. const HEARTBEAT_MS = +process.env.HEARTBEAT_MS || 30000; const heartbeat = setInterval(() => { for (const ws of wss.clients) { if (ws.isAlive === false) { ws.terminate(); continue; } ws.isAlive = false; try { ws.ping(); } catch { } } }, HEARTBEAT_MS); wss.on('close', () => clearInterval(heartbeat)); wss.on('connection', ws => { ws.id = ++clientSeq; ws.isAlive = true; ws.on('pong', () => { ws.isAlive = true; }); ws.on('message', raw => { let m; try { m = JSON.parse(raw); } catch { return; } if (m.t === 'join') { const code = String(m.session || 'DEFAULT').trim().toUpperCase().slice(0, 24) || 'DEFAULT'; // сейв видалили з диска — це наказ почати справу з нуля if (sessions[code] && !fs.existsSync(path.join(SAVES, code + '.json'))) { clearTimeout(sessions[code]._timer); // щоб відкладений запис не воскресив видалений сейв for (const c of sessions[code].clients) c.send(JSON.stringify({ t: 'reset' })); delete sessions[code]; } const role = m.role === 'analyst' ? 'analyst' : 'field'; const s = getSession(code); // макс 2 на роль; новому — найменший вільний номер (Польовий1/2, Аналітик1/2) const nums = new Set([...s.clients].filter(c => c.role === role).map(c => c.num)); if (nums.size >= 2) { // ворота не пройдено: НЕ закріплюємо сокет за сесією, тож жодна його дія // далі не виконається (`if (!ws.session) return`). ws.send(JSON.stringify({ t: 'joinerr', txt: `Ліміт ${role === 'field' ? 'польових' : 'аналітиків'}: у цій справі вже двоє.` })); return; } ws.role = role; ws.session = code; ws.num = nums.has(1) ? 2 : 1; s.clients.add(ws); // на реконекті польовий повертається туди, де був (клієнт шле останню позицію) if (role === 'field') ws.pos = validFieldPos(m.pos, s.cs) || { ...s.spawn }; const cs = s.cs; ws.send(JSON.stringify({ t: 'init', role, num: ws.num, district: cs.district, citizens: pubCitizens(cs), sceneBuilding: cs.scene.building, caseType: cs.caseType, options: { methods: cs.caseType === 'kidnap' ? cs.finale.whereOptions : cs.finale.methodOptions, motives: cs.finale.motiveOptions }, intro: { title: cs.title, scene: cs.scene.name, time: cs.crimeTime, text: cs.introText }, pos: ws.pos || s.spawn })); pushPositions(s); broadcast(s); return; } if (!ws.session) { return; } handle(getSession(ws.session), ws, m); }); ws.on('close', () => { if (!ws.session || !sessions[ws.session]) return; const s = sessions[ws.session]; s.clients.delete(ws); // якщо пішов саме останній, хто не підписав, — звинувачення тепер готове. // Зберігаємо ЛИШЕ коли цей вихід сам завершив справу (інакше пост-фінальні // закриття сокетів переписували б сейв уже після його видалення). const wasEnded = s.ended; resolveAccusationIfReady(s); if (s.ended && !wasEnded) save(s); pushPositions(s); broadcast(s); }); }); server.listen(PORT, () => { console.log('Невпізнаний (розділ 3) — сервер запущено.\n'); console.log(`На цьому пристрої: http://localhost:${PORT}`); for (const ifs of Object.values(os.networkInterfaces())) for (const i of ifs) if (i.family === 'IPv4' && !i.internal) console.log(`У локальній мережі: http://${i.address}:${PORT}`); console.log('\nОбидва гравці відкривають адресу, вводять однаковий код сесії (він же — seed справи) і різні ролі.'); });