825 lines
48 KiB
JavaScript
825 lines
48 KiB
JavaScript
// Розділ 3 «Невпізнаний» — сервер: static + ws-синхронізація + сейви на диск.
|
||
// Код сесії = seed справи: той самий код — та сама справа (і її сейв).
|
||
const http = require('http');
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
const os = require('os');
|
||
const crypto = require('crypto');
|
||
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 hashedCode = crypto.createHash('sha512').update(code).digest('hex');
|
||
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];
|
||
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 справи) і різні ролі.');
|
||
});
|