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' ) ;
2026-08-15 14:07:39 +03:00
const { generateCase , caseSeed , checkAccusation , md5 } = require ( './casegen' ) ;
2026-07-23 17:03:26 +03:00
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 на кожне з'єднання (для підписів звинувачення)
2026-08-15 15:37:54 +03:00
// Допустимі мітки на дошці. Клієнт малює їх із того ж поділу (public/client.js).
const EV _MARKS = [ 'tick' , 'cross' , 'hide' ] ; // докази і записи журналу
const PERSON _MARKS = [ 'green' , 'red' , 'blue' , 'amber' , 'hide' ] ; // люди: кольори без нав'язаного значення
2026-07-23 17:03:26 +03:00
// Присутність зараз (не плутати з 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 ] ;
2026-08-15 14:07:39 +03:00
const hashedCode = caseSeed ( code ) ;
2026-07-23 19:36:19 +03:00
const cs = generateCase ( hashedCode ) ;
const rng = seedRand ( [ ... hashedCode ] . reduce ( ( a , c ) => a * 31 + c . charCodeAt ( 0 ) , 0 ) ) ;
2026-07-23 17:03:26 +03:00
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 ) } ) ) ;
}
2026-08-15 15:37:54 +03:00
// Походження знахідки — короткий рядок «де це взяли», щоб через годину гри
// дошка на 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 ;
}
2026-07-23 17:03:26 +03:00
function view ( s ) {
const { cs , st } = s ;
const found = st . found . map ( id => {
const ev = cs . evidence [ id ] ;
2026-08-15 15:37:54 +03:00
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 ;
2026-08-15 14:07:39 +03:00
// Експертиза (crim/expert) розбирає конкретний доказ — його id у requires[0].
// Віддаємо як ref, щоб на дошці було видно, ЧИЙ це аналіз, і клік вів до нього.
if ( ev . q && ev . q . kind === 'expert' && ev . requires [ 0 ] ) card . ref = ev . requires [ 0 ] ;
return card ;
2026-08-15 15:37:54 +03:00
// Динамічні картки (результати запитів) тега не мають — даємо дефолтний,
// інакше вони випадали б із будь-якого зрізу по чипах.
} ) . concat ( s . extra . map ( x => ( x . tag ? x : { ... x , tag : 'запит' } ) ) ) ;
2026-07-23 17:03:26 +03:00
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 ,
2026-08-15 14:07:39 +03:00
// Хто вже зізнався, що збрехав про вечір → його СПРАВЖНЄ місце (c.evening).
// Клієнт правди не має (evening вирізано в pubCitizens), тож віддаємо тут —
// тільки для розколотих, тож нічого понад уже сказане в зізнанні не тече.
alibiReal : Object . fromEntries ( Object . keys ( s . alibiClaims )
. filter ( cid => s . cracked [ cid ] )
. map ( cid => [ cid , byIdC ( cs , cid ) . evening ] ) ) ,
2026-07-23 17:03:26 +03:00
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 } ) ) ;
}
2026-08-15 15:37:54 +03:00
// Один пошук за іменем на всі поля, де вводять людину: кожне слово запиту — початок
// слова в імені, регістр не важить. Раніше банк вимагав точний повний рядок, а реєстри
// шукали підрядок — два різні правила для однакових на вигляд полів.
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 ) ) ) ;
} ) ;
}
2026-07-23 17:03:26 +03:00
// збіг запиту з довідником доказу
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 ) ) ;
2026-08-15 15:37:54 +03:00
const low = String ( inp ) . toLowerCase ( ) . trim ( ) ;
2026-07-23 17:03:26 +03:00
if ( low . length < 3 ) return false ;
2026-08-15 15:37:54 +03:00
const qWords = low . split ( WORDSEP ) . filter ( Boolean ) ;
2026-07-23 17:03:26 +03:00
return vals . some ( v => {
const lv = String ( v ) . toLowerCase ( ) ;
2026-08-15 15:37:54 +03:00
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 ) ) ;
2026-07-23 17:03:26 +03:00
} ) ;
}
// стосунок 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 ( ) ;
}
2026-08-15 15:37:54 +03:00
// Тріангуляція: чотири геть різні відмови раніше віддавали один і той самий
// тост, тож правильна дія до кінця Акту I читалася як зламана механіка.
// Порядок перевірок важливий: гейт «особу не встановлено» — ЗАГАЛЬНИЙ, до
// звірки номера, інакше окрема відповідь саме на номер убивці видала б його.
2026-07-23 17:03:26 +03:00
if ( m . sys === 'phone' && kind === 'locate' ) {
const tri = cs . evidence . rec _triangulate ;
const killer = byIdC ( cs , cs . finale . killer ) ;
2026-08-15 15:37:54 +03:00
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 ) ) {
2026-07-23 17:03:26 +03:00
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 ) ) } ) )
} ) ) ;
}
2026-08-15 15:37:54 +03:00
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 символи.' ) ;
2026-07-23 17:03:26 +03:00
}
// реальні докази, що відповідають запиту
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' ) {
2026-08-15 15:37:54 +03:00
const hits = findByName ( cs , inp ) ;
if ( ! hits . length ) return toast ( ws , 'Банк: рахунків за таким запитом не знайдено.' ) ;
if ( hits . length > 1 )
return toast ( ws , ` Банк: під запит підходить о с і б — ${ hits . length } . Уточніть ім'я. ` ) ;
const c = hits [ 0 ] ;
2026-07-23 17:03:26 +03:00
extraCard ( s , 'bank|' + c . id , ` Виписка: ${ c . name } ` ,
` ${ c . job === 'пенсіонер' ? 'Пенсія' : c . job === 'студент' ? 'Стипендія і перекази від рідні' : 'Зарплата' } , комуналка, дрібні витрати. Аномалій не видно. ` ) ;
return done ( ) ;
}
if ( m . sys === 'db' ) {
const low = inp . toLowerCase ( ) ;
2026-08-15 15:37:54 +03:00
// Ім'я — за тим самим правилом, що й банк і база аналітика (префікси слів);
// тату й кличка собаки лишаються підрядком, це прикмети, а не імена.
const named = new Set ( findByName ( cs , inp ) . map ( c => c . id ) ) ;
const rs = cs . citizens . filter ( c => named . has ( c . id ) ||
2026-07-23 17:03:26 +03:00
( 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 } починає говорити. ` ;
2026-08-15 14:07:39 +03:00
// Зізнання підтверджує Х Т О /ЯК, але мотив цим не доводиться: прокурор вимагає
// ЗАКРІПЛЕНИЙ доказ мотиву (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 } Т а зізнання на словах — ще не доказ: щоб оформити ЧОМУ, знайдіть і закріпіть доказ мотиву. `
: ` Але про мотив мовчить: ЧОМУ ще не доведено. Прокурор не прийме справу без ЗАКРІПЛЕНОГО доказу мотиву — знайдіть і закріпіть його, тоді оформлюйте Х Т О / ЯК / ЧОМУ. ` ;
2026-07-23 17:03:26 +03:00
extraCard ( s , 'confess' , ` Зізнання: ${ c . name } ` ,
2026-08-15 14:07:39 +03:00
` Після пред'явлення ( ${ ev . title . toLowerCase ( ) } ) ${ c . name } ${ g ( c , 'зізнався' , 'зізналася' ) } у скоєному. Протокол підписано. ${ whyLine } ` ) ;
2026-07-23 17:03:26 +03:00
} 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 ( ) ;
}
2026-08-15 15:37:54 +03:00
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 ;
2026-07-23 17:03:26 +03:00
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 } ) ;
2026-08-15 14:07:39 +03:00
// 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 ) ) ;
2026-07-23 17:03:26 +03:00
wss . on ( 'connection' , ws => {
ws . id = ++ clientSeq ;
2026-08-15 14:07:39 +03:00
ws . isAlive = true ;
ws . on ( 'pong' , ( ) => { ws . isAlive = true ; } ) ;
2026-07-23 17:03:26 +03:00
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 справи) і різні ролі.' ) ;
} ) ;