2PlayerGame/chapter3/server.js

824 lines
48 KiB
JavaScript
Raw Normal View History

2026-07-23 17:03:26 +03:00
// Розділ 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, 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 cs = generateCase(code);
const rng = seedRand([...code].reduce((a, c) => a * 31 + c.charCodeAt(0), 0)); // детермінований rand
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];
return { id, title: ev.title, detail: ev.detail, side: ev.side };
}).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,
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} починає говорити.`;
extraCard(s, 'confess', `Зізнання: ${c.name}`,
`Після пред'явлення (${ev.title.toLowerCase()}) ${c.name} ${g(c, 'зізнався', 'зізналася')}. Протокол підписано. Лишилося оформити справу: ХТО / ЯК / ЧОМУ — і закріпити докази для прокурора.`);
} 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 });
wss.on('connection', ws => {
ws.id = ++clientSeq;
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 справи) і різні ролі.');
});