2PlayerGame/detective-game/server.js
2026-08-15 14:07:39 +03:00

860 lines
51 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.

// Розділ 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 на кожне з'єднання (для підписів звинувачення)
// Присутність зараз (не плутати з 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) }));
}
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 };
// Експертиза (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);
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 }));
}
// збіг запиту з довідником доказу
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();
if (low.length < 3) return false;
return vals.some(v => {
const lv = String(v).toLowerCase();
return lv === low || lv.includes(low) || low.includes(lv);
});
}
// стосунок 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();
}
if (m.sys === 'phone' && kind === 'locate') {
const tri = cs.evidence.rec_triangulate;
const killer = byIdC(cs, cs.finale.killer);
if (tri && !st.found.includes(tri.id) && RT.available(cs, st, tri) &&
normPhone(inp) === 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, 'Оператор: номер зараз не в мережі, не існує — або бракує підстав для пеленга.');
}
// реальні докази, що відповідають запиту
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 c = cs.citizens.find(x => x.name.toLowerCase() === inp.toLowerCase());
if (!c) return toast(ws, 'Банк: рахунків за таким запитом не знайдено. Ім\'я — точно як у реєстрі.');
extraCard(s, 'bank|' + c.id, `Виписка: ${c.name}`,
`${c.job === 'пенсіонер' ? 'Пенсія' : c.job === 'студент' ? 'Стипендія і перекази від рідні' : 'Зарплата'}, комуналка, дрібні витрати. Аномалій не видно.`);
return done();
}
if (m.sys === 'db') {
const low = inp.toLowerCase();
if (low.length < 3) return toast(ws, 'Реєстри: уточніть запит (мінімум 3 символи).');
const rs = cs.citizens.filter(c => c.name.toLowerCase().includes(low) ||
(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:..', kind}
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 справи) і різні ролі.');
});