2PlayerGame/detective-game/server.js
UndecimoDia cb93544793 Fixes
2026-08-15 18:04:59 +03:00

969 lines
60 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 на кожне з'єднання (для підписів звинувачення)
// Допустимі мітки на дошці. Клієнт малює їх із того ж поділу (public/client.js).
const EV_MARKS = ['tick', 'cross', 'hide']; // докази і записи журналу
// Люди: кольори без нав'язаного значення. 'blue' лишається в списку лише заради
// старих сейвів — нові клієнти шлють 'violet' (синій надто злипався з зеленим).
const PERSON_MARKS = ['green', 'red', 'violet', 'amber', 'hide', 'blue'];
// Присутність зараз (не плутати з 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, // динамічні картки (результати запитів, зізнання)
// Наскрізний порядок дошки: id карток обох видів у тій послідовності, в якій
// вони на дошці з'явились. Раніше view() зшивав два незалежні масиви
// (st.found + extra), а клієнт розвертав шов як одну хронологію — через це
// будь-який результат запиту назавжди опинявся вище будь-якої фізичної
// знахідки, і нова знахідка після пачки камер лягала десь усередині списку.
order: [],
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]); noteOrder(s, 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, order, log, logN, markers, alibiClaims, cracked, mgOffered } = s;
return JSON.stringify({ st, board, accuseDraft, ended, extra, xn, order, 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: 'запит' })));
// Зшиті два списки розкладаємо в справжню хронологію дошки. Клієнт розвертає
// цей масив (найновіше зверху), тож без сортування результати запитів, які
// йдуть у хвості конкатенації, назавжди перекривали фізичні знахідки.
// Картка без місця в порядку (сейв старішої версії) лишається найстарішою —
// тобто на дні дошки, у тому ж відносному порядку, що й досі.
// Ранг мапою, а не indexOf у компараторі: view() ганяється на кожен broadcast,
// тобто на кожну дію кожного гравця.
const rank = new Map((s.order || []).map((id, i) => [id, i]));
found.sort((a, b) => (rank.get(a.id) ?? -1) - (rank.get(b.id) ?? -1));
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 noteOrder(s, id) {
if (!s.order) s.order = []; // сейв старішої версії — порядку в ньому немає
if (!s.order.includes(id)) s.order.push(id);
}
function reveal(s, ev) { RT.applyFound(s.cs, s.st, ev); noteOrder(s, ev.id); }
// головоломка розв'язана / нові вимоги виконані → носії відкриваються ланцюжком
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;
const id = 'x' + (++s.xn);
s.extra.push({ id, key, title, detail, side: 'analyst', ...(pointsTo ? { pointsTo } : {}) });
noteOrder(s, id); // в один потік із доказами, а не окремим списком у хвіст
}
// На кого вказує доказ (для ордера) — і зі статичних доказів, і з динамічних карток.
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_edit', 'note_del', 'enote', 'enote_edit', '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();
}
// Правка НА МІСЦІ: міняємо лише txt, лишаючи id, автора й позицію в масиві.
// Через це виправлена нотатка не стрибає на верх списку (клієнт малює спільні
// нотатки найновішими зверху) і не ламає хронологію міркувань. Автора теж не
// переписуємо: дошка спільна, і «хто це записав» важить більше, ніж «хто
// виправив одрук у чужому записі».
case 'note_edit': { // {id, txt}
const n = s.board.notes.find(x => x.id === m.id);
if (n && typeof m.txt === 'string' && m.txt.trim()) n.txt = m.txt.slice(0, 500);
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_edit': { // {evId, id, txt}
const n = (s.board.enotes[m.evId] || []).find(x => x.id === m.id);
if (n && typeof m.txt === 'string' && m.txt.trim()) n.txt = m.txt.slice(0, 300);
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 справи) і різні ролі.');
});