2026-07-23 17:03:26 +03:00
// Аналітик: база мешканців, мапа з мітками, системи-форми і лабораторія.
'use strict' ;
( ( ) => {
let stage , panels = { } , mapCtx , mapCv ;
const UKR = 'абвгґдеєжзиіїйклмнопрстуфхцчшщьюя' ;
const caesarDec = ( text , s ) => [ ... text ] . map ( ch => {
const low = ch . toLowerCase ( ) , i = UKR . indexOf ( low ) ;
if ( i < 0 ) return ch ;
const out = UKR [ ( i - s % UKR . length + UKR . length ) % UKR . length ] ;
return ch === low ? out : out . toUpperCase ( ) ;
} ) . join ( '' ) ;
const b64dec = s => { try { return decodeURIComponent ( escape ( atob ( s . trim ( ) ) ) ) ; } catch { return '⚠ не схоже на base64' ; } } ;
App . on ( 'init' , m => {
if ( App . role !== 'analyst' ) return ;
stage = document . getElementById ( 'stage' ) ;
stage . innerHTML = '' ;
const tabs = el ( 'div' , 'atabs' ) ;
const names = { db : 'База мешканців' , map : 'Мапа району' , sys : 'Системи й лабораторія' } ;
for ( const id of Object . keys ( names ) ) {
const b = el ( 'button' , id === 'db' ? 'on' : '' , names [ id ] ) ;
b . onclick = ( ) => {
for ( const x of tabs . children ) x . classList . toggle ( 'on' , x === b ) ;
for ( const p of Object . values ( panels ) ) p . classList . remove ( 'on' ) ;
panels [ id ] . classList . add ( 'on' ) ;
} ;
tabs . append ( b ) ;
panels [ id ] = el ( 'div' , 'apanel' + ( id === 'db' ? ' on' : '' ) ) ;
}
stage . append ( tabs , panels . db , panels . map , panels . sys ) ;
buildDb ( ) ; buildMap ( ) ; buildSys ( ) ;
setInterval ( ( ) => {
if ( ! panels . map . classList . contains ( 'on' ) ) return ;
if ( ! mapCv . _k ) fitCanvas ( mapCv ) ;
drawDistrict ( mapCtx , { partner : true } ) ;
} , 400 ) ;
addEventListener ( 'resize' , ( ) => { if ( mapCv ) { fitCanvas ( mapCv ) ; drawDistrict ( mapCtx , { partner : true } ) ; } } ) ;
} ) ;
App . on ( 'pos' , ( ) => { if ( mapCtx ) drawDistrict ( mapCtx , { partner : true } ) ; } ) ;
App . on ( 'state' , ( ) => { if ( App . role !== 'analyst' || ! stage ) return ; renderPuzzles ( ) ; renderRows ( ) ; renderLab ( ) ; renderAlibi ( ) ; } ) ;
// ---------- База мешканців ----------
// Сліди в базу не підставляються самі: читайте докази і вводьте значення руками.
let dbOut = null , lastRows = null ;
let dbShowHidden = false ; // згорнута секція прихованих у базі
function buildDb ( ) {
const p = panels . db ;
p . innerHTML = `
< h3 > База мешканців району < / h 3 >
2026-08-15 15:37:54 +03:00
< p class = "dim small" > Пошук за іменем : досить початку — « шев » знайде « Шевченко » , регістр не важить . Кілька слів звужують ( ім ' я + прізвище ) . Значення для фільтрів беріть зі знайдених слідів і вводьте самі . Реєстр місцями застарілий : одяг і розмір взуття надійніше звіряти очним оглядом ( ордер у польового ) . < / p >
2026-07-23 17:03:26 +03:00
< div class = "filters" >
2026-08-15 15:37:54 +03:00
< input id = "q-name" placeholder = "Ім'я Прізвище" style = "min-width:220px" >
2026-07-23 17:03:26 +03:00
< button id = "q-go" class = "primary" > Знайти < / b u t t o n >
< / d i v >
< div class = "filters" >
< select id = "f-sex" > < option value = "" > стать : будь - яка < / o p t i o n > < o p t i o n v a l u e = " ч " > ч о л . < / o p t i o n > < o p t i o n v a l u e = " ж " > ж і н . < / o p t i o n > < / s e l e c t >
< input id = "f-smin" type = "number" placeholder = "взуття від" min = "35" max = "46" >
< input id = "f-smax" type = "number" placeholder = "взуття до" min = "35" max = "46" >
< input id = "f-hmin" type = "number" placeholder = "зріст від" >
< input id = "f-hmax" type = "number" placeholder = "зріст до" >
< select id = "f-print" > < option value = "" > відбитки : всі < / o p t i o n > < o p t i o n > д у г а < / o p t i o n > < o p t i o n > п е т л я < / o p t i o n > < o p t i o n > з а в и т о к < / o p t i o n > < / s e l e c t >
< select id = "f-coat" > < option value = "" > одяг : будь - який < / o p t i o n > $ { [ . . . n e w S e t ( A p p . i n i t . c i t i z e n s . m a p ( c = > c . c o a t ) ) ] . m a p ( x = > ` < o p t i o n > $ { e s c ( x ) } < / o p t i o n > ` ) . j o i n ( ' ' ) } < / s e l e c t >
< label class = "small dim" > < input type = "checkbox" id = "f-smoke" > курить < / l a b e l >
< button id = "f-go" > Фільтрувати < / b u t t o n >
< / d i v >
< div id = "db-out" > < / d i v > ` ;
dbOut = p . querySelector ( '#db-out' ) ;
p . querySelector ( '#q-go' ) . onclick = ( ) => {
2026-07-23 18:22:22 +03:00
const q = p . querySelector ( '#q-name' ) . value . trim ( ) ;
if ( ! q ) { lastRows = null ; dbOut . innerHTML = '<p class="dim">— Введіть ім\'я а б о прізвище.</p>' ; return ; }
2026-08-15 15:37:54 +03:00
lastRows = matchName ( App . init . citizens , q ) ; // спільний пошук, див. client.js
2026-07-23 18:22:22 +03:00
if ( ! lastRows . length ) {
dbOut . innerHTML = '<p class="dim">— Записів не знайдено. Спробуйте інший початок.</p>' ;
return ;
}
renderRows ( ) ;
2026-07-23 17:03:26 +03:00
} ;
p . querySelector ( '#f-go' ) . onclick = ( ) => {
const v = id => p . querySelector ( id ) . value ;
lastRows = App . init . citizens . filter ( c =>
( ! v ( '#f-sex' ) || c . sex === v ( '#f-sex' ) ) &&
( ! v ( '#f-smin' ) || c . shoe >= + v ( '#f-smin' ) ) &&
( ! v ( '#f-smax' ) || c . shoe <= + v ( '#f-smax' ) ) &&
( ! v ( '#f-hmin' ) || c . height >= + v ( '#f-hmin' ) ) &&
( ! v ( '#f-hmax' ) || c . height <= + v ( '#f-hmax' ) ) &&
( ! v ( '#f-print' ) || c . printType === v ( '#f-print' ) ) &&
( ! v ( '#f-coat' ) || c . coat === v ( '#f-coat' ) ) &&
( ! p . querySelector ( '#f-smoke' ) . checked || c . smoker ) ) ;
renderRows ( ) ;
} ;
}
2026-08-15 15:37:54 +03:00
// рядки з кольоровими мітками: ведіть кілька версій одночасно, по одній людині
2026-07-23 17:03:26 +03:00
function renderRows ( ) {
if ( ! dbOut || ! lastRows || ! App . state ) return ;
dbOut . innerHTML = ` <p class="dim small">Збігів: ${ lastRows . length } </p> ` ;
const t = el ( 'table' , '' , '<tr><th>Ім\'я</th><th>Вік</th><th>Адреса</th><th>Робота</th><th>Зріст</th><th>Взуття</th><th>Відбитки</th><th></th></tr>' ) ;
const hidden = [ ] ;
for ( const c of lastRows ) {
const key = 'c:' + c . id ;
if ( App . state . board . marks [ key ] === 'hide' ) { hidden . push ( c ) ; continue ; }
const tr = el ( 'tr' , 'rowlink' + cardClasses ( key ) , ` <td> ${ esc ( c . name ) } ${ App . state . victim === c . id ? ( App . init . caseType === 'kidnap' ? ' ⚠' : ' †' ) : '' } </td><td> ${ c . age } </td><td> ${ c . home } </td><td> ${ esc ( c . job ) } </td><td> ${ c . height } </td><td> ${ c . shoe } </td><td> ${ c . printType } </td> ` ) ;
const mk = el ( 'td' ) ;
mk . onclick = ev => ev . stopPropagation ( ) ;
mk . append ( markButtons ( key ) ) ;
tr . append ( mk ) ;
tr . onclick = ( ) => showDossier ( c ) ;
t . append ( tr ) ;
}
dbOut . append ( t ) ;
if ( hidden . length ) {
const head = el ( 'button' , 'small' , ` ${ dbShowHidden ? '▾' : '▸' } Приховані ( ${ hidden . length } ) ` ) ;
head . style . cssText = 'width:100%;margin-top:10px;text-align:left' ;
head . onclick = ( ) => {
dbShowHidden = ! dbShowHidden ;
renderRows ( ) ;
} ;
dbOut . append ( head ) ;
if ( dbShowHidden ) {
const ht = el ( 'table' , '' , '<tr><th>Ім\'я</th><th>Вік</th><th>Адреса</th><th>Робота</th><th></th></tr>' ) ;
for ( const c of hidden ) {
const tr = el ( 'tr' , 'rowlink dim' , ` <td> ${ esc ( c . name ) } </td><td> ${ c . age } </td><td> ${ c . home } </td><td> ${ esc ( c . job ) } </td> ` ) ;
const showBtn = el ( 'button' , 'small' , 'Показати' ) ;
showBtn . onclick = ( ev ) => {
ev . stopPropagation ( ) ;
App . send ( { t : 'mark' , key : 'c:' + c . id , kind : 'hide' } ) ; // зняти hide
} ;
const td = el ( 'td' ) ; td . append ( showBtn ) ;
tr . append ( td ) ;
ht . append ( tr ) ;
}
dbOut . append ( ht ) ;
}
}
}
// ---------- Мапа ----------
function buildMap ( ) {
const p = panels . map ;
p . innerHTML = '<h3>Мапа району</h3><p class="dim small">Клік по будівлі — адреси й люди. Клік по порожньому місцю — мітка «перевір тут» для напарника (повторний клік — зняти). Золота точка — напарник.</p>' ;
mapCv = el ( 'canvas' ) ; mapCv . id = 'cv' ;
mapCv . width = App . init . district . world . w ; mapCv . height = App . init . district . world . h ;
p . append ( mapCv ) ;
mapCtx = mapCv . getContext ( '2d' ) ;
drawDistrict ( mapCtx , { partner : true } ) ;
mapCv . onclick = e => {
const r = mapCv . getBoundingClientRect ( ) , D = App . init . district . world ;
const x = ( e . clientX - r . left ) * D . w / r . width , y = ( e . clientY - r . top ) * D . h / r . height ;
const b = App . init . district . buildings . find ( b => x > b . pos . x && x < b . pos . x + b . pos . w && y > b . pos . y && y < b . pos . y + b . pos . h ) ;
if ( b ) buildingInfo ( b ) ;
else App . send ( { t : 'marker' , x , y } ) ;
} ;
}
function buildingInfo ( b ) {
const res = App . init . citizens . filter ( c => b . units && b . units . includes ( c . home ) ) ;
const work = App . init . citizens . filter ( c => c . workplace === b . id ) ;
const here = App . init . citizens . filter ( c => App . state && App . state . locs [ c . id ] === b . id && c . id !== App . state . victim ) ;
let html = ` <h3> ${ esc ( b . name ) } </h3> ` ;
if ( res . length ) html += '<div class="syshead">Мешканці</div>' + res . map ( c => ` <div class="small"> ${ c . home } : ${ esc ( c . name ) } </div> ` ) . join ( '' ) ;
if ( work . length ) html += '<div class="syshead">Працюють</div>' + work . map ( c => ` <div class="small"> ${ esc ( c . name ) } — ${ esc ( c . job ) } </div> ` ) . join ( '' ) ;
html += ` <div class="syshead">Зараз тут</div><div class="small dim"> ${ here . length ? here . map ( c => esc ( c . name ) ) . join ( ', ' ) : 'нікого не зафіксовано' } </div> ` ;
openModal ( html ) ;
}
// ---------- Системи-форми ----------
// Кожен запит вводиться руками; результат — навіть дрібниця — лягає на дошку.
let pzList = null , labBox = null ;
const q$ = ( p , sel ) => p . querySelector ( sel ) ;
const fmtT = v => ` ${ String ( Math . floor ( v / 60 ) ) . padStart ( 2 , '0' ) } : ${ String ( v % 60 ) . padStart ( 2 , '0' ) } ` ;
function buildSys ( ) {
const p = panels . sys ;
p . innerHTML = `
< h3 > Системи й лабораторія < / h 3 >
< p class = "dim small" > Дані для запитів — номери , імена , прикмети , роки — вичитуйте з доказів і вводьте самі . Кожен результат лягає на дошку : що з нього стосується справи — вирішуєте ви . < / p >
< div class = "syshead" > Оператор зв ' язку < / d i v >
< div class = "filters" >
2026-08-15 15:37:54 +03:00
< input id = "ph-n" placeholder = "+380…" style = "min-width:200px" >
< / d i v >
< p class = "dim small" > Формат номера вільний : пробіли , дужки й дефіси система відкидає сама . < / p >
< div class = "filters" >
< button data - q = "owner" > Архів : власник номера < / b u t t o n >
< button data - q = "calls" > Архів : журнал дзвінків за вечір < / b u t t o n >
2026-07-23 17:03:26 +03:00
< / d i v >
2026-08-15 15:37:54 +03:00
< div class = "filters" >
< button data - q = "locate" > 📍 Тріангуляція — де апарат З А Р А З < / b u t t o n >
< / d i v >
< p class = "dim small" > Перші два запити — архівні , про вечір злочину . Пеленг — живий : показує поточну позицію апарата , і оператор дає його лише за наявності підстав . < / p >
2026-07-23 17:03:26 +03:00
< div class = "syshead" > Банк < / d i v >
< div class = "filters" >
2026-08-15 15:37:54 +03:00
< input id = "bk-n" placeholder = "Ім'я Прізвище" style = "min-width:220px" >
2026-07-23 17:03:26 +03:00
< button id = "bk-go" > Запросити виписку < / b u t t o n >
< / d i v >
2026-08-15 15:37:54 +03:00
< p class = "dim small" > Досить початку імені чи прізвища — те саме правило , що й у базі мешканців . Якщо під запит підходить кілька о с і б , банк попросить уточнити . < / p >
2026-07-23 17:03:26 +03:00
< div class = "syshead" > Реєстри < / d i v >
< div class = "filters" >
2026-08-15 15:37:54 +03:00
< input id = "db-n" placeholder = "ім'я / тату / кличка собаки…" style = "min-width:220px" >
2026-07-23 17:03:26 +03:00
< button id = "db-go" > Пошук у реєстрах < / b u t t o n >
< / d i v >
< div class = "syshead" > Криміналістика < / d i v >
< div class = "filters" >
< button id = "cr-exp" class = "primary" > Експертиза місця / тіла < / b u t t o n >
2026-08-15 15:37:54 +03:00
< input id = "cr-n" placeholder = "архів: рік, слово, прізвище…" style = "min-width:220px" >
2026-07-23 17:03:26 +03:00
< button id = "cr-go" > Пошук в архіві справ < / b u t t o n >
< / d i v >
< div class = "syshead" > Камери — архів ночі злочину < / d i v >
< div class = "filters" >
< select id = "cc-cam" > $ { App . init . district . cameras . map ( c => ` <option value=" ${ c . id } "> ${ esc ( c . name ) } </option> ` ) . join ( '' ) } < / s e l e c t >
< input type = "range" id = "cc-t" min = "1200" max = "1439" value = "1290" style = "width:180px" >
< span id = "cc-v" style = "font-family:monospace" > < / s p a n >
< button id = "cc-go" > Переглянути ± 10 хв < / b u t t o n >
< / d i v >
< div class = "syshead" > Зведення алібі < / d i v >
< div id = "al-table" class = "small dim" > Ще нікого не питали , де він був того вечора . < / d i v >
< div class = "syshead" > Лабораторія < / d i v >
< div class = "filters" id = "lab-btns" > < / d i v >
< div id = "pz-list" > < / d i v >
< details style = "margin-top:10px" > < summary class = "dim small" > Інструменти дешифрування ( Base64 , Цезар ) < / s u m m a r y >
< div class = "card" >
< div class = "syshead" > Base64 < / d i v >
< textarea id = "tb64" rows = "2" style = "width:100%" placeholder = "вставте рядок…" > < / t e x t a r e a >
< button id = "tb64go" > Розкодувати < / b u t t o n >
< div id = "tb64out" class = "small" style = "word-break:break-all" > < / d i v >
< div class = "syshead" > Цезар — перебір у с і х зсувів < / d i v >
< textarea id = "tcz" rows = "2" style = "width:100%" placeholder = "вставте текст…" > < / t e x t a r e a >
< button id = "tczgo" > Показати всі 32 < / b u t t o n >
< pre id = "tczout" class = "small" style = "white-space:pre-wrap;max-height:220px;overflow:auto" > < / p r e >
< / d i v >
< / d e t a i l s > ` ;
for ( const b of p . querySelectorAll ( '[data-q]' ) )
b . onclick = ( ) => App . send ( { t : 'sys_query' , sys : 'phone' , kind : b . dataset . q , input : q$ ( p , '#ph-n' ) . value } ) ;
q$ ( p , '#bk-go' ) . onclick = ( ) => App . send ( { t : 'sys_query' , sys : 'bank' , input : q$ ( p , '#bk-n' ) . value } ) ;
q$ ( p , '#db-go' ) . onclick = ( ) => App . send ( { t : 'sys_query' , sys : 'db' , input : q$ ( p , '#db-n' ) . value } ) ;
q$ ( p , '#cr-exp' ) . onclick = ( ) => App . send ( { t : 'sys_query' , sys : 'crim' , kind : 'expert' } ) ;
q$ ( p , '#cr-go' ) . onclick = ( ) => App . send ( { t : 'sys_query' , sys : 'crim' , kind : 'archive' , input : q$ ( p , '#cr-n' ) . value } ) ;
const sl = q$ ( p , '#cc-t' ) , out = q$ ( p , '#cc-v' ) ;
const upd = ( ) => out . textContent = fmtT ( + sl . value ) ;
sl . oninput = upd ; upd ( ) ;
q$ ( p , '#cc-go' ) . onclick = ( ) => App . send ( { t : 'sys_query' , sys : 'cctv' , camera : q$ ( p , '#cc-cam' ) . value , minutes : + sl . value } ) ;
q$ ( p , '#tb64go' ) . onclick = ( ) => q$ ( p , '#tb64out' ) . textContent = b64dec ( q$ ( p , '#tb64' ) . value ) ;
q$ ( p , '#tczgo' ) . onclick = ( ) => {
const s = q$ ( p , '#tcz' ) . value ;
q$ ( p , '#tczout' ) . textContent = s . trim ( )
? Array . from ( { length : 32 } , ( _ , i ) => ` ${ String ( i + 1 ) . padStart ( 2 , ' ' ) } → ${ caesarDec ( s , i + 1 ) } ` ) . join ( '\n' ) : '' ;
} ;
pzList = q$ ( p , '#pz-list' ) ;
labBox = q$ ( p , '#lab-btns' ) ;
renderLab ( ) ;
}
// Зведення того, що люди КАЖУТЬ про свій вечір. Суперечності не підсвічуються —
// порівнювати покази з архівами камер гравці мають самі.
function renderAlibi ( ) {
const box = panels . sys && panels . sys . querySelector ( '#al-table' ) ;
if ( ! box || ! App . state ) return ;
const claims = Object . entries ( App . state . alibiClaims || { } ) ;
if ( ! claims . length ) { box . innerHTML = 'Ще нікого не питали, де він був того вечора.' ; box . className = 'small dim' ; return ; }
box . className = '' ;
const bn = id => { const b = App . init . district . buildings . find ( x => x . id === id ) ; return b ? b . name . split ( ',' ) [ 0 ] : id ; } ;
2026-08-15 14:07:39 +03:00
const t = el ( 'table' , '' , '<tr><th>Хто</th><th>Каже, що був</th><th>Камери над тим місцем</th><th></th></tr>' ) ;
2026-07-23 17:03:26 +03:00
for ( const [ cid , claim ] of claims ) {
const c = App . byId ( cid ) ;
2026-08-15 14:07:39 +03:00
// Той самий ключ, що й у базі мешканців: галочка/викреслення на о с о б і — спільні.
const key = 'c:' + cid ;
if ( App . state . board . marks [ key ] === 'hide' ) continue ; // сховане в базі — сховане й тут
// Зізнався, що збрехав → показуємо перехід «стара заява → реальне місце»,
// і камери підтягуємо вже над реальним місцем (обидві колонки синхронно).
const real = ( App . state . alibiReal || { } ) [ cid ] ;
const place = real || claim ;
const said = real ? ` ${ esc ( bn ( claim ) ) } → ${ esc ( bn ( real ) ) } ` : esc ( bn ( claim ) ) ;
const cams = App . init . district . cameras . filter ( cam => cam . covers . includes ( place ) ) ;
2026-07-23 17:03:26 +03:00
const cell = cams . length
? cams . map ( cam => ` ${ esc ( cam . name ) } ${ ( App . state . cctvPulled || [ ] ) . includes ( cam . id ) ? ' — архів на дошці' : ' — архів не тягнули' } ` ) . join ( '<br>' )
: 'жодна камера туди не дивиться' ;
2026-08-15 14:07:39 +03:00
const tr = el ( 'tr' , cardClasses ( key ) , ` <td> ${ esc ( c . name ) } </td><td> ${ said } </td><td class="small"> ${ cell } </td> ` ) ;
const mk = el ( 'td' ) ; mk . append ( markButtons ( key ) ) ; tr . append ( mk ) ;
t . append ( tr ) ;
2026-07-23 17:03:26 +03:00
}
box . innerHTML = '' ; box . append ( t ) ;
}
function renderLab ( ) {
if ( ! labBox || ! App . state ) return ;
labBox . innerHTML = '' ;
const foundId = id => App . state . found . some ( f => f . id === id ) ;
2026-08-15 15:37:54 +03:00
// Обрізаємо з кінця по символах, а не по трьох словах: два описи ДНК часто
// починаються однаково («волосся, вирване з коренем…»), і кнопки ставали
// нерозрізненними. Повний текст лишається в title=.
const short = ( t , n = 40 ) => ! t ? '' : t . length > n ? t . slice ( 0 , n - 1 ) . trimEnd ( ) + '…' : t ;
2026-07-23 17:03:26 +03:00
const mk = ( label , sample , doneId , noSampleMsg , run ) => {
2026-08-15 15:37:54 +03:00
const b = el ( 'button' , '' , short ( label ) + ( foundId ( doneId ) ? ' ✓' : '' ) ) ;
b . title = label ;
2026-07-23 17:03:26 +03:00
b . disabled = foundId ( doneId ) ;
b . onclick = ( ) => sample == null ? toast ( noSampleMsg ) : run ( ) ;
labBox . append ( b ) ;
} ;
2026-08-15 15:37:54 +03:00
mk ( App . state . dnaLabel ? ` ДНК: ${ App . state . dnaLabel } ` : 'Порівняти ДНК зі зразком' ,
2026-07-23 17:03:26 +03:00
App . state . dnaSample , 'lab_dna_match' ,
'Немає зразка ДНК: його ще не знайшли. Хай польовий огляне місце злочину.' ,
2026-08-15 15:37:54 +03:00
( ) => pickCompare ( 'Гель-електрофорез: кого порівнюємо?' , ( ids , nav ) => dnaModal ( ids , 'sf_dna' , App . state . dnaSample , nav ) ) ) ;
2026-07-23 17:03:26 +03:00
if ( App . state . dnaSample2 )
2026-08-15 15:37:54 +03:00
mk ( ` ДНК (2): ${ App . state . dna2Label } ` , App . state . dnaSample2 , 'lab_dna_match2' ,
2026-07-23 17:03:26 +03:00
'' ,
2026-08-15 15:37:54 +03:00
( ) => pickCompare ( 'Другий зразок: кого порівнюємо?' , ( ids , nav ) => dnaModal ( ids , 'sf_dna2' , App . state . dnaSample2 , nav ) ) ) ;
2026-07-23 17:03:26 +03:00
mk ( 'Звірити відбиток зі знаряддя' , App . state . printSample , 'lab_print_match' ,
'Немає повного відбитка: спершу треба знайти знаряддя злочину (підказку дає розтин).' ,
( ) => pickCompare ( 'Дактилоскопія: чиї картки підняти?' , printModal ) ) ;
}
// ---------- Шифри (у лабораторії) ----------
let pzSig = null ;
function renderPuzzles ( ) {
if ( ! pzList ) return ;
const sig = App . state . puzzles . map ( x => x . id + x . solved ) . join ( ) ;
if ( sig === pzSig ) return ; pzSig = sig ;
pzList . innerHTML = '' ;
if ( ! App . state . puzzles . length ) pzList . append ( el ( 'p' , 'dim small' , 'Зашифрованих знахідок поки немає.' ) ) ;
for ( const z of App . state . puzzles ) {
const c = el ( 'div' , 'card' + ( z . solved ? ' tick' : '' ) ) ;
c . append ( el ( 'div' , 't' , ` <b> ${ z . solved ? '✓ ' : '' } ${ esc ( z . prompt ) } </b> ` ) ) ;
if ( z . solved ) { pzList . append ( c ) ; continue ; }
if ( z . kind === 'caesar' ) {
c . append ( el ( 'div' , 'small' , ` <code> ${ esc ( z . payload ) } </code> ` ) ) ;
const out = el ( 'div' , 'small' , '' ) ;
const sl = el ( 'input' ) ; sl . type = 'range' ; sl . min = 1 ; sl . max = 32 ; sl . value = 1 ; sl . style . width = '100%' ;
const upd = ( ) => out . innerHTML = ` зсув ${ sl . value } : <code> ${ esc ( caesarDec ( z . payload , + sl . value ) ) } </code> ` ;
sl . oninput = upd ; upd ( ) ;
const b = el ( 'button' , 'primary' , 'Зафіксувати зсув' ) ;
b . onclick = ( ) => App . send ( { t : 'puzzle_try' , id : z . id , shift : + sl . value } ) ;
c . append ( sl , out , b ) ;
} else if ( z . kind === 'b64' ) {
c . append ( el ( 'div' , 'small' , ` <code> ${ esc ( z . payload ) } </code> ` ) ) ;
const inp = el ( 'input' ) ; inp . placeholder = 'розкодований текст (інструменти нижче)' ; inp . style . width = '100%' ;
const b = el ( 'button' , 'primary' , 'Надіслати' ) ;
b . onclick = ( ) => App . send ( { t : 'puzzle_try' , id : z . id , answer : inp . value . trim ( ) } ) ;
c . append ( inp , b ) ;
} else { // pin / md5
const inp = el ( 'input' ) ; inp . placeholder = z . kind === 'pin' ? 'PIN (4 цифри)' : 'пароль' ;
const b = el ( 'button' , 'primary' , 'Спробувати' ) ;
b . onclick = ( ) => App . send ( { t : 'puzzle_try' , id : z . id , answer : inp . value } ) ;
inp . onkeydown = e => { if ( e . key === 'Enter' ) b . click ( ) ; } ;
c . append ( inp , b ) ;
}
c . append ( hintBlock ( z . hint ) ) ;
pzList . append ( c ) ;
}
}
// ---------- Лабораторні міні-ігри ----------
const rng32 = seed => { let a = seed | 0 ; return ( ) => { a |= 0 ; a = ( a + 0x6D2B79F5 ) | 0 ; let t = Math . imul ( a ^ ( a >>> 15 ) , 1 | a ) ; t = ( t + Math . imul ( t ^ ( t >>> 7 ) , 61 | t ) ) ^ t ; return ( ( t ^ ( t >>> 14 ) ) >>> 0 ) / 4294967296 ; } ; } ;
function drawPrint ( ctx , seed , rot ) {
const rr = rng32 ( seed ) , W = ctx . canvas . width , c = W / 2 ;
ctx . fillStyle = '#0d1116' ; ctx . fillRect ( 0 , 0 , W , W ) ;
ctx . save ( ) ; ctx . translate ( c , c ) ; ctx . rotate ( rot || 0 ) ;
ctx . strokeStyle = '#9db4c8' ; ctx . lineWidth = 1.4 ;
for ( let i = 0 ; i < 11 ; i ++ ) {
const rad = 4 + i * ( c - 8 ) / 11 + rr ( ) * 3 , a0 = rr ( ) * 6.28 , len = 1.2 + rr ( ) * 3.6 ;
ctx . beginPath ( ) ; ctx . arc ( 0 , 0 , rad , a0 , a0 + len ) ; ctx . stroke ( ) ;
if ( rr ( ) < 0.4 ) { ctx . beginPath ( ) ; ctx . arc ( 0 , 0 , rad * 0.72 , a0 + 2 , a0 + 2.7 ) ; ctx . stroke ( ) ; }
}
ctx . restore ( ) ;
}
const laneHtml = ( dna , name ) =>
2026-08-15 15:37:54 +03:00
` <div style="text-align:center;width:86px">
2026-07-23 17:03:26 +03:00
< div style = "position:relative;width:26px;height:180px;background:#0d1116;border:1px solid var(--line);margin:0 auto" >
$ { dna . map ( v => ` <div style="position:absolute;left:2px;right:2px;top: ${ 8 + v * 10 } px;height:4px;background:var(--blue)"></div> ` ) . join ( '' ) }
2026-08-15 15:37:54 +03:00
< / d i v > < d i v c l a s s = " s m a l l d i m " > $ { n a m e } < / d i v > < / d i v > ` ;
2026-07-23 17:03:26 +03:00
// Ручний шортліст: аналітик сам вирішує, кого нести в лабораторію.
// ponytail: сервер не гейтить ДНК — перебір усієї бази можливий, але по 6 і руками
function pickCompare ( title , go ) {
const mm = openModal ( ` <h3> ${ esc ( title ) } </h3>
< p class = "dim small" > Оберіть до 6 профілів для порівняння зі зразком . < / p >
< input id = "pk-q" placeholder = "фільтр за іменем…" style = "width:100%" >
< div id = "pk-list" style = "max-height:280px;overflow-y:auto;margin:8px 0" > < / d i v >
< button class = "primary" id = "pk-go" > Порівняти < / b u t t o n > < s p a n c l a s s = " d i m s m a l l " i d = " p k - n " > < / s p a n > ` ) ;
const sel = new Set ( ) ;
const list = mm . inner . querySelector ( '#pk-list' ) , q = mm . inner . querySelector ( '#pk-q' ) , n = mm . inner . querySelector ( '#pk-n' ) ;
const draw = ( ) => {
list . innerHTML = '' ;
const f = q . value . trim ( ) . toLowerCase ( ) ;
for ( const c of App . init . citizens ) {
if ( c . id === App . state . victim ) continue ;
if ( f && ! c . name . toLowerCase ( ) . includes ( f ) ) continue ;
const lb = el ( 'label' , 'small' ) ; lb . style . display = 'block' ;
const cb = el ( 'input' ) ; cb . type = 'checkbox' ; cb . checked = sel . has ( c . id ) ;
cb . onchange = ( ) => {
if ( cb . checked && sel . size >= 6 ) { cb . checked = false ; return ; }
if ( cb . checked ) sel . add ( c . id ) ; else sel . delete ( c . id ) ;
n . textContent = ` обрано: ${ sel . size } /6 ` ;
} ;
lb . append ( cb , ` ${ c . name } — ${ c . home } , ${ c . job } ` ) ;
list . append ( lb ) ;
}
} ;
q . oninput = draw ; draw ( ) ;
mm . inner . querySelector ( '#pk-go' ) . onclick = ( ) => {
if ( ! sel . size ) return ;
2026-08-15 15:37:54 +03:00
// Шортліст не викидаємо, а ховаємо: «← Назад» із гелю повертає до вже
// обраних шести профілів, а не до нуля.
mm . wrap . style . display = 'none' ;
go ( [ ... sel ] , {
back : ( ) => { mm . wrap . style . display = '' ; } ,
onClose : ( ) => mm . wrap . remove ( )
} ) ;
2026-07-23 17:03:26 +03:00
} ;
}
2026-08-15 15:37:54 +03:00
function dnaModal ( ids , sampleEv , sample , nav ) {
2026-07-23 17:03:26 +03:00
if ( ! sample || ! ids . length ) return ;
const mm = openModal ( ` <h3>Гель-електрофорез</h3>
< p class = "dim small" > Зразок із місця злочину — ліворуч . Клацніть по профілю , який вважаєте збігом . Памʼятайте : збіг скаже лише , ЧИЯ це ДНК , — не коли і не чому вона там . < / p >
2026-08-15 15:37:54 +03:00
< div style = "display:flex;flex-wrap:wrap;align-items:flex-start;gap:8px 6px" > $ { laneHtml ( sample , '<b>З Р А З О К </b>' ) } < span style = "width:0;border-left:1px solid var(--line);height:180px" > < / s p a n > $ { i d s . m a p ( i d = > { c o n s t c = A p p . b y I d ( i d ) ; r e t u r n ` < s p a n d a t a - c i d = " $ { i d } " s t y l e = " c u r s o r : p o i n t e r " > $ { l a n e H t m l ( c . d n a , e s c ( c . n a m e ) ) } < / s p a n > ` ; } ) . j o i n ( ' ' ) } < / d i v > ` , n a v ) ;
2026-07-23 17:03:26 +03:00
mm . inner . append ( hintBlock ( 'З б і г — це коли В С І смуги стоять на тих самих висотах, що у зразка. Помилковий вибір чесно скаже «ні» і викреслить людину.' ) ) ;
mm . inner . onclick = e => {
const s = e . target . closest ( '[data-cid]' ) ;
2026-08-15 15:37:54 +03:00
if ( s ) { App . send ( { t : 'dna_pick' , sampleEv , cid : s . dataset . cid } ) ; mm . close ( ) ; }
2026-07-23 17:03:26 +03:00
} ;
}
2026-08-15 15:37:54 +03:00
function printModal ( ids , nav ) {
2026-07-23 17:03:26 +03:00
if ( App . state . printSample == null || ! ids . length ) return ;
const mm = openModal ( ` <h3>Дактилоскопія: відбиток зі знаряддя</h3>
< p class = "dim small" > Зразок ліворуч . Клацніть по власнику відбитка . < / p >
< canvas id = "psample" width = "150" height = "150" style = "border:1px solid var(--acc);border-radius:6px" > < / c a n v a s >
< span class = "dim small" > ← зразок < / s p a n >
2026-08-15 15:37:54 +03:00
< div id = "pcands" style = "display:flex;gap:12px;flex-wrap:wrap;margin-top:10px" > < / d i v > ` , n a v ) ;
2026-07-23 17:03:26 +03:00
drawPrint ( mm . inner . querySelector ( '#psample' ) . getContext ( '2d' ) , App . state . printSample , 0.7 ) ;
const box = mm . inner . querySelector ( '#pcands' ) ;
for ( const id of ids ) {
const c = App . byId ( id ) ;
const w = el ( 'div' , '' , '' ) ; w . style . cursor = 'pointer' ; w . style . textAlign = 'center' ;
const cv = el ( 'canvas' ) ; cv . width = cv . height = 110 ; cv . style . border = '1px solid var(--line)' ; cv . style . borderRadius = '6px' ;
w . append ( cv , el ( 'div' , 'small dim' , esc ( c . name ) ) ) ;
drawPrint ( cv . getContext ( '2d' ) , c . printSeed , 0 ) ;
2026-08-15 15:37:54 +03:00
w . onclick = ( ) => { App . send ( { t : 'print_pick' , cid : id } ) ; mm . close ( ) ; } ;
2026-07-23 17:03:26 +03:00
box . append ( w ) ;
}
mm . inner . append ( hintBlock ( 'Зразок знято під кутом — подумки поверніть його. Порівнюйте будову: кількість і хід дуг, а не поворот.' ) ) ;
}
function triangulateModal ( data ) {
const mm = openModal ( ` <h3>Тріангуляція номера ${ esc ( data . number ) } </h3>
< p class = "dim small" > Три вежі , три відстані до апарата — станом на зараз . Клацніть будинок , що лежить на перетині всіх трьох дуг . < / p >
< canvas id = "trcv" > < / c a n v a s >
< div id = "trreads" class = "small" style = "margin-top:6px" > < / d i v > ` ) ;
const cv = mm . inner . querySelector ( '#trcv' ) ; cv . id = 'cv' ;
fitCanvas ( cv ) ;
const ctx = cv . getContext ( '2d' ) ;
drawDistrict ( ctx ) ; // лишає трансформацію у світових координатах
for ( const t of data . towers ) {
ctx . strokeStyle = 'rgba(232,176,75,.55)' ; ctx . lineWidth = 1.5 ;
ctx . beginPath ( ) ; ctx . arc ( t . x , t . y , t . dist , 0 , 7 ) ; ctx . stroke ( ) ;
ctx . fillStyle = '#e8b04b' ; ctx . beginPath ( ) ; ctx . arc ( t . x , t . y , 6 , 0 , 7 ) ; ctx . fill ( ) ;
ctx . fillStyle = '#e8b04b' ; ctx . font = '12px Georgia' ; ctx . fillText ( '▲ ' + t . name , t . x + 8 , t . y + 4 ) ;
}
mm . inner . querySelector ( '#trreads' ) . innerHTML = data . towers . map ( t => ` ${ esc ( t . name ) } : ~ ${ t . dist } м ` ) . join ( ' · ' ) ;
mm . inner . append ( hintBlock ( 'Кожна дуга — коло можливих точок навколо вежі. Апарат там, де всі три кола накладаються на один будинок. Клацніть саме той будинок.' ) ) ;
cv . onclick = e => {
const r = cv . getBoundingClientRect ( ) , D = App . init . district . world ;
const x = ( e . clientX - r . left ) * D . w / r . width , y = ( e . clientY - r . top ) * D . h / r . height ;
const b = App . init . district . buildings . find ( b => x > b . pos . x && x < b . pos . x + b . pos . w && y > b . pos . y && y < b . pos . y + b . pos . h ) ;
if ( b ) { App . send ( { t : 'triangulate_pick' , building : b . id } ) ; mm . wrap . remove ( ) ; }
} ;
}
App . on ( 'mg' , m => {
if ( App . role !== 'analyst' ) return ;
if ( m . kind === 'triangulate' ) triangulateModal ( m ) ;
} ) ;
} ) ( ) ;