2026-07-23 17:03:26 +03:00
// Спільне ядро клієнта: мережа, стан, бічна дошка, звинувачення, модалки.
"use strict" ;
const App = ( window . App = {
ws : null ,
role : null ,
session : null ,
init : null , // {district, citizens, intro, options...}
state : null , // останній снапшот від сервера
handlers : { } ,
on ( t , fn ) {
( this . handlers [ t ] ? ? = [ ] ) . push ( fn ) ;
} ,
send ( m ) {
if ( this . ws && this . ws . readyState === 1 ) this . ws . send ( JSON . stringify ( m ) ) ;
} ,
byId ( cid ) {
return this . init . citizens . find ( ( c ) => c . id === cid ) ;
} ,
} ) ;
const $ = ( sel ) => document . querySelector ( sel ) ;
const el = ( tag , cls , html ) => {
const e = document . createElement ( tag ) ;
if ( cls ) e . className = cls ;
if ( html != null ) e . innerHTML = html ;
return e ;
} ;
const esc = ( s ) =>
String ( s ) . replace (
/[&<>"]/g ,
( c ) => ( { "&" : "&" , "<" : "<" , ">" : ">" , '"' : """ } ) [ c ] ,
) ;
function toast ( txt ) {
const t = el ( "div" , "toast" , esc ( txt ) ) ;
$ ( "#toasts" ) . append ( t ) ;
setTimeout ( ( ) => t . remove ( ) , 4200 ) ;
}
// Підказки приховані за замовчуванням — спершу думаємо самі.
function hintBlock ( text ) {
const wrap = el ( "div" , "hintwrap" ) ;
if ( ! text ) return wrap ;
const btn = el ( "button" , "small" , "💡 Підказка" ) ;
const body = el ( "div" , "small dim" ) ;
body . style . cssText = "display:none;margin-top:4px" ;
body . textContent = text ;
btn . onclick = ( ) => {
body . style . display = body . style . display === "none" ? "block" : "none" ;
} ;
wrap . append ( btn , body ) ;
return wrap ;
}
// ---------- Мережа ----------
function connect ( code , role ) {
App . noReconnect = false ;
const ws = ( App . ws = new WebSocket ( ` ws:// ${ location . host } ` ) ) ;
ws . onopen = ( ) =>
ws . send (
JSON . stringify ( { t : "join" , session : code , role , pos : App . lastFieldPos } ) ,
) ;
ws . onmessage = ( e ) => {
const m = JSON . parse ( e . data ) ;
if ( m . t === "reset" ) {
toast ( "Сейв видалено — справа починається з нуля." ) ;
setTimeout ( ( ) => location . reload ( ) , 1400 ) ;
return ;
}
if ( m . t === "joinerr" ) {
// сесія заповнена по цій ролі — лишаємось у лобі, без автоперепідключення
App . noReconnect = true ;
toast ( m . txt ) ;
ws . close ( ) ;
return ;
}
if ( m . t === "pos" ) App . fieldPositions = m . field || [ ] ;
if ( m . t === "toast" ) {
if ( m . txt . includes ( "підписав ордер" ) && window . Snd ) Snd . stamp ( ) ;
return toast ( m . txt ) ;
}
if ( m . t === "init" ) {
App . init = m ;
App . role = m . role ;
App . myNum = m . num ;
document . body . classList . add ( "role-" + m . role ) ;
$ ( "#lobby" ) . style . display = "none" ;
$ ( "#app" ) . classList . add ( "on" ) ;
$ ( "#b-session" ) . textContent = "справа: " + App . session ;
for ( const fn of App . handlers . init ) fn ( m ) ;
showIntro ( ) ;
return ;
}
if ( m . t === "state" ) {
App . state = m ;
$ ( "#b-field" ) . classList . toggle ( "on" , m . online . field > 0 ) ;
$ ( "#b-analyst" ) . classList . toggle ( "on" , m . online . analyst > 0 ) ;
$ ( "#b-field" ) . textContent = "поле" + ( m . online . field > 1 ? " × " + m . online . field : "" ) ;
$ ( "#b-analyst" ) . textContent = "аналітик" + ( m . online . analyst > 1 ? " × " + m . online . analyst : "" ) ;
renderSide ( ) ;
renderEnd ( ) ;
for ( const fn of App . handlers . state ) fn ( m ) ;
return ;
}
for ( const fn of App . handlers [ m . t ] || [ ] ) fn ( m ) ;
} ;
ws . onclose = ( ) => {
if ( App . noReconnect ) return ;
toast ( "Зв'язок втрачено, перепідключення…" ) ;
setTimeout ( ( ) => connect ( code , role ) , 1500 ) ;
} ;
}
// ---------- Лобі ----------
for ( const b of document . querySelectorAll ( ".rolebtn" ) ) {
b . onclick = ( ) => {
const code = ( $ ( "#code" ) . value . trim ( ) || "DEFAULT" ) . toUpperCase ( ) ;
App . session = code ;
localStorage . setItem ( "ch3" , JSON . stringify ( { code , role : b . dataset . role } ) ) ;
if ( window . Snd ) Snd . start ( b . dataset . role ) ;
connect ( code , b . dataset . role ) ;
} ;
}
try {
const prev = JSON . parse ( localStorage . getItem ( "ch3" ) ) ;
if ( prev && prev . code ) $ ( "#code" ) . value = prev . code ;
} catch { }
// ---------- Бічна дошка ----------
let sideTab = "ev" ;
let showHidden = false ; // згорнута секція «Приховані» на дні дошки
2026-08-15 15:37:54 +03:00
let evFilter = "" ; // текстовий фільтр по картках доказів
let evTag = null ; // активний чип-тег (null = всі)
2026-07-23 17:03:26 +03:00
// Дошка перемальовується на кожен стан — тож недодруковані нотатки треба зберігати,
// інакше broadcast напарника стирає те, що ти зараз друкуєш. Тримаємо чернетку в мапі
// (ключ = data-draft-key поля) і відновлюємо фокус/курсор після перемальовки.
const noteDraft = { } ;
2026-08-15 15:37:54 +03:00
// Але фокус повертаємо, ЛИШЕ якщо гравець справді щойно друкував. Раніше було
// «завжди, якщо колись друкував»: варто польовому один раз клацнути в нотатку,
// і кожен наступний broadcast знову садив фокус у той інпут — а обробник клавіш
// у field.js свідомо ігнорує натискання в INPUT/TEXTAREA. Клавіатура мовчала,
// візуально нічого не змінювалось.
const FOCUS _GRACE _MS = 3000 ;
let lastTypedAt = 0 ;
let restoringFocus = false ; // наш власний .focus() не має продовжувати вікно
{
const side = $ ( "#side" ) ;
const touch = ( ) => {
if ( ! restoringFocus ) lastTypedAt = Date . now ( ) ;
} ;
// capture: фільтр дошки сам кличе renderSide() зі свого oninput, тож позначку
// часу треба поставити ДО того, як обробник цілі встигне перемалювати панель.
side . addEventListener ( "input" , touch , true ) ;
side . addEventListener ( "focusin" , touch , true ) ;
}
2026-07-23 17:03:26 +03:00
function wireDraft ( input , key ) {
input . dataset . draftKey = key ;
if ( noteDraft [ key ] ) input . value = noteDraft [ key ] ;
input . addEventListener ( "input" , ( ) => {
noteDraft [ key ] = input . value ;
} ) ;
}
$ ( "#sidetabs" ) . onclick = ( e ) => {
const b = e . target . closest ( "button" ) ;
if ( ! b ) return ;
sideTab = b . dataset . tab ;
for ( const x of $ ( "#sidetabs" ) . children ) x . classList . toggle ( "on" , x === b ) ;
renderSide ( ) ;
} ;
2026-08-15 15:37:54 +03:00
// Мітки. Н а картках доказів лишається ✓/✗/…, а на людях трьох станів замало —
// треба вести кілька версій одночасно. Тому для ключів 'c:' окрема палітра
// кольорів. Що саме означає кожен колір — вирішують гравці: жодних підписів,
// легенд і тултипів ми не нав'язуємо. «Сховати» лишається в о б о х : на ньому
// тримається секція «Приховані».
const EV _MARKS = [
[ "tick" , "відмітити" , null ] ,
[ "cross" , "викреслити" , null ] ,
[ "hide" , "сховати" , null ] ,
] ;
const PEOPLE _MARKS = [
[ "green" , null , "var(--mk-green)" ] ,
[ "red" , null , "var(--mk-red)" ] ,
[ "blue" , null , "var(--mk-blue)" ] ,
[ "amber" , null , "var(--mk-amber)" ] ,
[ "hide" , "сховати" , null ] ,
] ;
const MARK _GLYPH = { tick : "✓" , cross : "✗" , hide : "…" } ;
// Сейви зі старих версій тримають на людях ✓/✗ — читаємо їх як два перші кольори.
const LEGACY _PERSON _MARK = { tick : "green" , cross : "red" } ;
const isPersonKey = ( key ) => String ( key ) . startsWith ( "c:" ) ;
function markOf ( key ) {
const m = App . state . board . marks [ key ] ;
if ( ! m ) return null ;
return isPersonKey ( key ) ? LEGACY _PERSON _MARK [ m ] || m : m ;
}
2026-07-23 17:03:26 +03:00
function markButtons ( key ) {
2026-08-15 15:37:54 +03:00
const cur = markOf ( key ) ;
2026-07-23 17:03:26 +03:00
const mk = el ( "div" , "mk" ) ;
2026-08-15 15:37:54 +03:00
for ( const [ kind , title , colour ] of isPersonKey ( key )
? PEOPLE _MARKS
: EV _MARKS ) {
const b = el (
"button" ,
( colour ? "swatch " : "" ) + ( cur === kind ? "on" : "" ) ,
colour
? ` <span class="dot" style="background: ${ colour } "></span> `
: MARK _GLYPH [ kind ] ,
) ;
if ( title ) b . title = title ; // кольори лишаємо без підписів — значення за гравцями
if ( colour ) b . style . color = colour ;
2026-07-23 17:03:26 +03:00
b . onclick = ( ev ) => {
ev . stopPropagation ( ) ;
App . send ( { t : "mark" , key , kind } ) ;
} ;
mk . append ( b ) ;
}
return mk ;
}
function cardClasses ( key ) {
2026-08-15 15:37:54 +03:00
const m = markOf ( key ) ;
if ( ! m || m === "hide" ) return "" ;
return isPersonKey ( key ) ? " mk-" + m : " " + m ;
}
// Свотч кольору для місць, де мітку лише показують (база польового).
function markSwatch ( kind ) {
const found = PEOPLE _MARKS . find ( ( [ k ] ) => k === kind ) ;
if ( ! found || ! found [ 2 ] ) return "" ;
return ` <span class="dot" style="background: ${ found [ 2 ] } "></span> ` ;
2026-07-23 17:03:26 +03:00
}
2026-08-15 14:07:39 +03:00
// Прокрутити дошку до доказу за id і коротко підсвітити. Якщо він у згорнутій
// секції «Приховані» — спершу розгортаємо її й перемальовуємо.
function jumpToEvidence ( id ) {
const find = ( ) =>
document . querySelector ( ` #sidebody [data-ev=" ${ CSS . escape ( id ) } "] ` ) ;
let card = find ( ) ;
if ( ! card && ! showHidden ) {
showHidden = true ;
renderSide ( ) ;
card = find ( ) ;
}
if ( ! card ) return ;
card . scrollIntoView ( { behavior : "smooth" , block : "center" } ) ;
card . classList . add ( "flash" ) ;
setTimeout ( ( ) => card . classList . remove ( "flash" ) , 1300 ) ;
}
2026-07-23 17:03:26 +03:00
function renderSide ( ) {
if ( ! App . state ) return ;
const body = $ ( "#sidebody" ) ;
// запам'ятати, яке поле нотатки в фокусі, щоб повернути його після перемальовки
const act = document . activeElement ;
const activeKey = act && act . dataset ? act . dataset . draftKey : null ;
const caret = activeKey ? act . selectionStart : null ;
body . innerHTML = "" ;
const marks = App . state . board . marks ;
if ( sideTab === "ev" ) {
const pins = App . state . board . pins ;
2026-08-15 15:37:54 +03:00
const found = App . state . found ;
const tagOf = ( e ) => e . tag || "запит" ;
2026-07-23 17:03:26 +03:00
body . append (
el (
"div" ,
"dim small" ,
2026-08-15 15:37:54 +03:00
"Закріплене 📌 піде в справу для прокурора. Знайдено: " + found . length ,
2026-07-23 17:03:26 +03:00
) ,
) ;
2026-08-15 15:37:54 +03:00
// Пошук + чипи-теги. До фіналу на дошці 30+ карток одним стовпчиком, і знайти
// потрібну можна лише прокруткою. Чипи будуємо з тегів, що реально є на дошці, —
// так жоден доказ не лишається поза жодним зрізом.
const q = el ( "input" , "small evfilter" ) ;
q . placeholder = "🔎 фільтр за текстом…" ;
q . value = evFilter ;
q . dataset . draftKey = "evfilter" ; // щоб фокус/курсор пережили перемальовку
q . oninput = ( ) => {
evFilter = q . value ;
renderSide ( ) ;
} ;
body . append ( q ) ;
const chips = el ( "div" , "chips" ) ;
const allTags = [ ... new Set ( found . map ( tagOf ) ) ] . sort ( ( a , b ) =>
a . localeCompare ( b , "uk" ) ,
) ;
for ( const t of [ null , ... allTags ] ) {
const b = el ( "button" , "chip" + ( evTag === t ? " on" : "" ) , t || "всі" ) ;
b . onclick = ( ) => {
evTag = evTag === t ? null : t ;
renderSide ( ) ;
} ;
chips . append ( b ) ;
}
body . append ( chips ) ;
// Фільтр застосовуємо ДО поділу на закріплені/решту, щоб 📌 не зникали з очей.
const low = evFilter . trim ( ) . toLowerCase ( ) ;
const passes = ( e ) =>
( ! evTag || tagOf ( e ) === evTag ) &&
( ! low ||
` ${ e . title } ${ e . detail } ${ e . from || "" } ` . toLowerCase ( ) . includes ( low ) ) ;
const shown = found . filter ( passes ) ;
2026-07-23 17:03:26 +03:00
// Закріплені — зверху, від останнього запіненого до першого; далі решта доказів,
// від найновішого знайденого до найстарішого. (pins — у порядку закріплення.)
const pinnedCards = [ ... pins ]
. reverse ( )
2026-08-15 15:37:54 +03:00
. map ( ( id ) => shown . find ( ( e ) => e . id === id ) )
2026-07-23 17:03:26 +03:00
. filter ( Boolean ) ;
2026-08-15 15:37:54 +03:00
const rest = [ ... shown ] . reverse ( ) . filter ( ( e ) => ! pins . includes ( e . id ) ) ;
2026-07-23 17:03:26 +03:00
const buildCard = ( e ) => {
const key = "e:" + e . id ;
2026-08-15 15:37:54 +03:00
const isPinned = pins . includes ( e . id ) ;
const c = el ( "div" , "card" + cardClasses ( key ) + ( isPinned ? " pinned" : "" ) ) ;
2026-08-15 14:07:39 +03:00
c . dataset . ev = e . id ; // якір для прокрутки-до-доказу
2026-07-23 17:03:26 +03:00
const t = el ( "div" , "t" ) ;
2026-08-15 15:37:54 +03:00
t . append ( el ( "b" , "" , esc ( e . title ) ) , el ( "span" , "tag" , esc ( tagOf ( e ) ) ) ) ;
const pin = el ( "button" , "pinbtn" + ( isPinned ? " on" : "" ) , "📌" ) ;
pin . title = isPinned ? "відкріпити" : "закріпити для прокурора" ;
2026-07-23 17:03:26 +03:00
pin . onclick = ( ) => App . send ( { t : "pin" , evId : e . id } ) ;
t . append ( pin ) ;
2026-08-15 15:37:54 +03:00
c . append ( t ) ;
// Звідки знахідка — лише для фізичних: решта прийшла з термінала чи розмови.
if ( e . from ) c . append ( el ( "div" , "small dim orig" , esc ( e . from ) ) ) ;
c . append ( el ( "div" , "small" , esc ( e . detail ) ) ) ;
2026-08-15 14:07:39 +03:00
// Експертиза називає доказ, який розбирала, і веде до нього (прокрутка + підсвітка).
if ( e . ref ) {
const src = found . find ( ( x ) => x . id === e . ref ) ;
if ( src ) {
const link = el ( "button" , "evref small" , ` ↳ аналіз доказу: « ${ esc ( src . title ) } » ` ) ;
link . onclick = ( ) => jumpToEvidence ( e . ref ) ;
c . append ( link ) ;
}
}
c . append ( markButtons ( key ) ) ;
2026-07-23 17:03:26 +03:00
const notes = ( App . state . board . enotes || { } ) [ e . id ] || [ ] ;
for ( const n of notes ) {
const row = el (
"div" ,
"small dim" ,
` ✎ ${ esc ( n . txt ) } <span class="tag"> ${ n . by === "field" ? "польовий" : "аналітик" } </span> ` ,
) ;
const d = el ( "button" , "small" , "× " ) ;
d . title = "видалити нотатку" ;
d . onclick = ( ) => App . send ( { t : "enote_del" , evId : e . id , id : n . id } ) ;
row . append ( d ) ;
c . append ( row ) ;
}
const ni = el ( "input" , "small" ) ;
ni . placeholder = "нотатка до доказу…" ;
ni . style . cssText = "width:100%;margin-top:4px" ;
wireDraft ( ni , "e:" + e . id ) ;
ni . onkeydown = ( ev ) => {
if ( ev . key === "Enter" && ni . value . trim ( ) ) {
App . send ( { t : "enote" , evId : e . id , txt : ni . value } ) ;
ni . value = "" ;
delete noteDraft [ "e:" + e . id ] ;
}
} ;
c . append ( ni ) ;
return c ;
} ;
const hidden = [ ] ;
2026-08-15 15:37:54 +03:00
const visible = ( arr ) =>
arr . filter ( ( e ) => {
if ( marks [ "e:" + e . id ] === "hide" ) {
hidden . push ( e ) ;
return false ;
}
return true ;
} ) ;
const pinnedVisible = visible ( pinnedCards ) ;
const restVisible = visible ( rest ) ;
for ( const e of pinnedVisible ) body . append ( buildCard ( e ) ) ;
// Межа теки: видно, де закінчується те, що піде прокуророві.
if ( pinnedVisible . length && restVisible . length )
body . append ( el ( "div" , "divider small dim" , "— решта доказів —" ) ) ;
for ( const e of restVisible ) body . append ( buildCard ( e ) ) ;
if ( ! pinnedVisible . length && ! restVisible . length && ! hidden . length )
body . append (
el (
"div" ,
"dim small" ,
found . length
? "З а цим фільтром нічого немає."
: "Дошка порожня — знахідки з'являться тут." ,
) ,
) ;
2026-07-23 17:03:26 +03:00
// Приховані — окрема згорнута секція на дні, кожен доказ окремо (не «одна кнопка = все»)
if ( hidden . length ) {
const head = el (
"button" ,
"small" ,
` ${ showHidden ? "▾" : "▸" } Приховані ( ${ hidden . length } ) ` ,
) ;
head . style . cssText = "width:100%;margin-top:10px;text-align:left" ;
head . onclick = ( ) => {
showHidden = ! showHidden ;
renderSide ( ) ;
} ;
body . append ( head ) ;
if ( showHidden ) for ( const e of hidden ) body . append ( buildCard ( e ) ) ;
}
}
if ( sideTab === "notes" ) {
const ta = el ( "textarea" , "" , "" ) ;
ta . rows = 2 ;
ta . style . width = "100%" ;
ta . placeholder = "спільна нотатка…" ;
wireDraft ( ta , "shared" ) ;
const add = el ( "button" , "primary" , "Додати" ) ;
add . onclick = ( ) => {
if ( ta . value . trim ( ) ) {
App . send ( { t : "note" , txt : ta . value } ) ;
ta . value = "" ;
delete noteDraft [ "shared" ] ;
}
} ;
body . append ( ta , add ) ;
for ( const n of [ ... App . state . board . notes ] . reverse ( ) ) {
const c = el ( "div" , "card" ) ;
c . append (
el ( "div" , "" , esc ( n . txt ) ) ,
el ( "div" , "small dim" , n . by === "field" ? "польовий" : "аналітик" ) ,
) ;
const d = el ( "button" , "small" , "видалити" ) ;
d . onclick = ( ) => App . send ( { t : "note_del" , id : n . id } ) ;
c . append ( d ) ;
body . append ( c ) ;
}
}
2026-08-15 15:37:54 +03:00
// Повернути фокус і курсор у поле, де друкували до перемальовки, — але лише
// якщо друкували щойно (див. FOCUS_GRACE_MS): інакше поле перехоплює клавіатуру
// польового назавжди.
if ( activeKey && Date . now ( ) - lastTypedAt < FOCUS _GRACE _MS ) {
2026-07-23 17:03:26 +03:00
const again = body . querySelector ( ` [data-draft-key=" ${ activeKey } "] ` ) ;
if ( again ) {
2026-08-15 15:37:54 +03:00
restoringFocus = true ;
2026-07-23 17:03:26 +03:00
again . focus ( ) ;
if ( caret != null )
try {
again . setSelectionRange ( caret , caret ) ;
} catch { }
2026-08-15 15:37:54 +03:00
restoringFocus = false ;
2026-07-23 17:03:26 +03:00
}
}
}
// ---------- Досьє ----------
function showDossier ( c ) {
const dnaCells = c . dna
. map (
( v ) =>
` <span style="display:inline-block;width:10px;height: ${ 6 + v * 2 } px;background:var(--blue);margin-right:2px;vertical-align:bottom"></span> ` ,
)
. join ( "" ) ;
const m = openModal ( `
< h3 > $ { esc ( c . name ) } < / h 3 >
< table >
< tr > < th > Вік / стать < /th><td>${c.age} / $ { c . sex === "ч" ? "чол." : "жін." } ( $ { c . birthYear } р . н . ) < / t d > < / t r >
< tr > < th > Адреса < / t h > < t d > $ { e s c ( c . h o m e ) } < / t d > < / t r >
< tr > < th > Робота < / t h > < t d > $ { e s c ( c . j o b ) } $ { c . w o r k p l a c e ? " — " + e s c ( b N a m e ( c . w o r k p l a c e ) ) : " " } < / t d > < / t r >
< tr > < th > Телефон < / t h > < t d > $ { e s c ( c . p h o n e ) } < / t d > < / t r >
< tr > < th > Зріст / взуття < /th><td>${c.height} см / $ { c . shoe } розмір < / t d > < / t r >
< tr > < th > Кров / відбитки < /th><td>${esc(c.blood)} / $ { esc ( c . printType ) } < / t d > < / t r >
< tr > < th > Верхній одяг < / t h > < t d > $ { e s c ( c . c o a t ) } < / t d > < / t r >
< tr > < th > Звички < / t h > < t d > $ { c . s m o k e r ? " к у р и т ь ; " : " " } $ { c . d o g ? " с о б а к а " + e s c ( c . d o g ) + " ; " : " " } $ { c . t a t t o o ? " т а т у : " + e s c ( c . t a t t o o ) : " " } < / t d > < / t r >
< tr > < th > ДНК - профіль < / t h > < t d > $ { d n a C e l l s } < / t d > < / t r >
< / t a b l e > ` ) ;
if ( App . state && App . state . warrants . includes ( c . id ) )
m . inner . append ( el ( "div" , "small" , "📜 Ордер на обшук житла — отримано" ) ) ;
m . inner . append ( logSection ( c . id ) ) ;
}
// Журнал допитів із мітками (✓ виділити / ✗ закреслити) на кожному записі. Мітки
// спільні (board.marks, ключ 'q:'+id) — бачать обидві ролі. Модалка статична, тож
// клік дає оптимістичний відгук одразу; збережений стан підтягнеться при повторному відкритті.
function logSection ( cid ) {
const wrap = el ( "div" ) ;
const log = ( App . state && App . state . log && App . state . log [ cid ] ) || [ ] ;
if ( ! log . length ) return wrap ;
wrap . append ( el ( "div" , "syshead" , "Журнал допитів" ) ) ;
for ( const e of log . slice ( - 12 ) ) {
const key = "q:" + e . id ;
let cur = App . state . board . marks [ key ] ;
2026-08-15 15:37:54 +03:00
const row = el ( "div" , "small logrow" ) ;
2026-07-23 17:03:26 +03:00
row . style . cssText = "padding-left:7px;border-left:2px solid transparent;margin:5px 0" ;
const apply = ( mk ) => {
row . style . textDecoration = mk === "cross" ? "line-through" : "none" ;
row . style . opacity = mk === "cross" ? ".5" : "1" ;
row . style . borderLeftColor = mk === "tick" ? "var(--green,#5aa870)" : "transparent" ;
} ;
row . append (
el ( "div" , "" , ` <span class="dim"> ${ esc ( e . q ) } </span><br> ${ esc ( e . a ) } ` ) ,
) ;
const ctrl = el ( "div" , "mk" ) ;
for ( const [ kind , label ] of [
[ "tick" , "✓" ] ,
[ "cross" , "✗" ] ,
] ) {
const b = el ( "button" , cur === kind ? "on" : "" , label ) ;
b . title = kind === "tick" ? "виділити" : "закреслити" ;
b . onclick = ( ) => {
App . send ( { t : "mark" , key , kind } ) ;
cur = cur === kind ? undefined : kind ; // сервер toggl-ить так само
apply ( cur ) ;
for ( const bb of ctrl . children ) bb . classList . remove ( "on" ) ;
if ( cur ) b . classList . add ( "on" ) ;
} ;
ctrl . append ( b ) ;
}
row . append ( ctrl ) ;
apply ( cur ) ;
wrap . append ( row ) ;
}
return wrap ;
}
function bName ( bid ) {
const b = App . init . district . buildings . find ( ( x ) => x . id === bid ) ;
return b ? b . name : bid ;
}
2026-08-15 15:37:54 +03:00
// Пошук за іменем — ОДИН на обидві ролі. Кожне слово запиту має бути початком
// якогось слова в імені; регістр не важить. Раніше це були дві копії (база
// аналітика і база польового), і аналітикова загубила .toLowerCase() з о б о х
// боків — «поліна» не знаходило «Поліна». Сервер шукає банк і реєстри так само.
function matchName ( citizens , query ) {
const words = String ( query || "" )
. trim ( )
. toLowerCase ( )
. split ( /\s+/ )
. filter ( Boolean ) ;
if ( ! words . length ) return [ ] ;
return citizens . filter ( ( c ) => {
const nameWords = c . name . toLowerCase ( ) . split ( /\s+/ ) ;
return words . every ( ( w ) => nameWords . some ( ( nw ) => nw . startsWith ( w ) ) ) ;
} ) ;
}
2026-07-23 17:03:26 +03:00
// ---------- Модалки ----------
2026-08-15 15:37:54 +03:00
// Кроки більше не заміщають один одного: батьківська модалка лишається під
// поточною, тож скасування picker'а повертає до розмови, а не в порожнечу.
// opts.back — явне «← Назад» там, де попередній крок треба перезібрати
// (порожня тека ордера, шортліст лабораторії); opts.onClose — прибирання за собою.
2026-07-23 17:03:26 +03:00
function openModal ( html , opts = { } ) {
const wrap = el ( "div" , "modal" ) ;
const inner = el ( "div" , "" , html ) ;
wrap . append ( inner ) ;
2026-08-15 15:37:54 +03:00
const close = ( ) => {
wrap . remove ( ) ;
if ( opts . onClose ) opts . onClose ( ) ;
} ;
2026-07-23 17:03:26 +03:00
if ( ! opts . locked ) {
wrap . onclick = ( e ) => {
2026-08-15 15:37:54 +03:00
if ( e . target === wrap ) close ( ) ;
2026-07-23 17:03:26 +03:00
} ;
2026-08-15 15:37:54 +03:00
const foot = el ( "div" , "modalfoot" ) ;
if ( opts . back ) {
const bk = el ( "button" , "small" , "← Назад" ) ;
bk . onclick = ( ) => {
wrap . remove ( ) ;
opts . back ( ) ;
} ;
foot . append ( bk ) ;
}
2026-07-23 17:03:26 +03:00
const x = el ( "button" , "small" , "Закрити" ) ;
2026-08-15 15:37:54 +03:00
x . onclick = close ;
foot . append ( x ) ;
inner . append ( foot ) ;
// Автори модалок дописують вміст уже після openModal (журнал допитів, підказки).
// Мікрозадача переносить кнопки в кінець, коли всі синхронні append'и минули.
queueMicrotask ( ( ) => inner . append ( foot ) ) ;
2026-07-23 17:03:26 +03:00
}
document . body . append ( wrap ) ;
2026-08-15 15:37:54 +03:00
return { wrap , inner , close } ;
2026-07-23 17:03:26 +03:00
}
function showIntro ( ) {
const i = App . init . intro ;
const fieldHow = `
< div class = "syshead" > Польовий детектив < / d i v >
< ul class = "small" >
< li > WASD / стрілки — р у х , E — увійти . Почніть із < b > місця злочину < / b > ( п о з н а ч е н е ✖ ) : о б ш у к у й т е й о г о т о ч к у , п о к и з н а х і д к и н е с к і н ч а т ь с я . Н е к о ж е н с л і д — в б и в ц і : м і с ц е п у б л і ч н е . < / l i >
< li > < b > Розмови : < / b > у к о ж н о ї л ю д и н и — т е м и у с п р а в і , « Д е в и б у л и о … ? » ( п о в з у н о к ч а с у ) , « П р о л ю д и н у … » , « П о к а з а т и д о к а з … » . Л ю д и б р е ш у т ь і п о м и л я ю т ь с я . З б р е х а в ? П е р е в і р т е й о г о м і с ц е к а м е р а м и і < b > с п и т а й т е в д р у г е < / b > — р о з к о л е т ь с я і с к а ж е , ч о м у б р е х а в . В і р и т и ч и н і — в и р і ш у є т е в и . < / l i >
< li > < b > Ордер < / b > — о с о б и с т о у в і д д і л к у : о б е р і т ь л ю д и н у і д о к а з і з з а к р і п л е н и х 📌 , я к и й п р я м о н а н е ї в к а з у є . < / l i >
< li > < b > Пристрої : < / b > в ж и т л і п і д о р д е р о м т р а п л я ю т ь с я т е л е ф о н и й н о у т б у к и . В и л у ч а й т е — с а м і в и ї х н е в і д к р и є т е , в о н и й д у т ь а н а л і т и к у в л а б о р а т о р і ю н а з л а м . < / l i >
< li > З ордером можна < b > оглянути людину особисто < / b > — р е є с т р м і с ц я м и з а с т а р і л и й , о ч і н а д і й н і ш і . Р е з у л ь т а т н і д е н е з а п и с у є т ь с я : з а н о т о в у й т е с а м і . < / l i >
< li > Червоні ✖ на мапі — мітки напарника « перевір тут » . < / l i >
< / u l > ` ;
const analystHow = `
< div class = "syshead" > Аналітик < / d i v >
< ul class = "small" >
2026-08-15 15:37:54 +03:00
< li > < b > База мешканців : < / b > ф і л ь т р и в в о д и т е р у к а м и — з н а ч е н н я б е р і т ь і з д о к а з і в . М і т ь т е л ю д е й к о л ь о р а м и — ч о т и р и к о л ь о р и п л ю с « … » ( с х о в а т и ) ; щ о о з н а ч а є к о ж е н , в и р і ш у є т е в и з н а п а р н и к о м , г р а н е п і д к а з у є . Р е є с т р м і с ц я м и з а с т а р і л и й : о д я г / в з у т т я н а д і й н і ш е з в і р я т и о ч н и м о г л я д о м п о л ь о в о г о . < / l i >
2026-07-23 17:03:26 +03:00
< li > < b > Системи : < / b > о п е р а т о р ( в л а с н и к , д з в і н к и , т р і а н г у л я ц і я « д е з а р а з » ) , б а н к , р е є с т р и , к р и м і н а л і с т и к а ( е к с п е р т и з а + а р х і в ) , к а м е р и . Д а н і д л я з а п и т і в ш у к а й т е в д о к а з а х . < b > К о ж е н р е з у л ь т а т л я г а є н а д о ш к у < / b > — н а в і т ь д р і б н и ц я ; щ о з т о г о с п р а в а , в и р і ш у є т е в и . < / l i >
< li > < b > Журнал дзвінків < / b > — ц е п о к а з и , я к і н е б р е ш у т ь : х т о з к и м г о в о р и в і о к о т р і й у в е ч і р з л о ч и н у . З в і р я й т е з т и м , щ о л ю д и к а ж у т ь н а д о п и т і . П о р о ж н і й в е ч і р т е ж п р о м о в и с т и й : а п а р а т м о г л и в и м к н у т и . < / l i >
< li > < b > Вилучені пристрої < / b > п о т р а п л я ю т ь у л а б о р а т о р і ю з а б л о к о в а н и м и : P I N — ц е з а з в и ч а й р і к н а р о д ж е н н я в л а с н и к а ( є в р е є с т р і ) , п а р о л ь н о у т б у к а — а б о щ о с ь о с о б и с т е л а т и н и ц е ю , а б о с л о в н и к о в и й п е р е б і р . У с е р е д и н і б у в а є і с т о р і я б р а у з е р а й ч а т и — а б у в а є п о р о ж н ь о . < / l i >
< li > < b > Камери : < /b> час злочину ±12 хв — головні кадри; будь-який вечірній час — хто взагалі потрапляв у кадри. Звіряйте з тим, що люди <i>кажуть</i > ( « Зведення алібі » ) . Брехня вбивці , спіймана камерою , — повноцінний доказ для прокурора . < / l i >
< li > < b > Лабораторія : < / b > Д Н К і в і д б и т к и п о р і в н ю є т е в р у ч н у — д о 6 п р о ф і л і в з а р а з . Ш и ф р и т е ж т у т . У в а г а : Д Н К к а ж е л и ш е , Ч И Я в о н а , — н е к о л и й ч о м у л и ш е н а . Н а м і с ц і б у в а є Д Н К л ю д е й , щ о н е м а ю т ь с т о с у н к у д о з л о ч и н у : з б і г — п р и в і д д л я д о п и т у , а н е в и р о к . < / l i >
< li > Клік по порожньому місцю мапи — мітка для напарника . < / l i >
< / u l > ` ;
openModal ( ` <h3> ${ esc ( i . title ) } : ${ esc ( i . scene ) } , ~ ${ esc ( i . time ) } </h3><p> ${ esc ( i . text ) } </p>
< p class = "dim small" > Роль : $ { App . role === "field" ? "польовий детектив — район, обшуки, розмови" : "аналітик — бази, камери, лабораторія" } . < / p >
$ { App . role === "field" ? fieldHow : analystHow }
< div class = "syshead" > Фінал < / d i v >
< p class = "small" > Одне звинувачення на двох : Х Т О ( ім ' я з клавіатури ) / ЯК чи ДЕ / ЧОМУ , підписи о б о х . Прокурор дивиться лише на закріплені 📌 докази : потрібне підтвердження особи ( ДНК , відбиток а б о зламане алібі ) , мотив і спосіб . Нотатки — спільні : загальні у вкладці , точкові — прямо на картці доказу . < / p > ` ) ;
}
$ ( "#introbtn" ) . onclick = showIntro ;
const sndBtn = $ ( "#sndbtn" ) ;
if ( sndBtn && window . Snd ) {
sndBtn . textContent = Snd . muted ? "🔇" : "🔊" ;
sndBtn . onclick = ( ) => ( sndBtn . textContent = Snd . toggle ( ) ? "🔇" : "🔊" ) ;
}
// ---------- Звинувачення ----------
$ ( "#accusebtn" ) . onclick = ( ) => {
const st = App . state ;
if ( ! st || st . ended ) return ;
if ( st . accuseDraft ) return showAccuseStatus ( ) ;
const o = App . init . options ;
const isK = App . init . caseType === "kidnap" ;
const m = openModal ( `
< h3 > $ { isK ? "Рапорт на штурм — один шанс" : "Звинувачення — один шанс" } < / h 3 >
< p class = "dim small" > Помилка в будь - якому полі — і $ { isK ? "штурм піде не туди" : "вас звільнять" } . Прокурор дивиться лише на закріплені 📌 докази . < / p >
< p > Х Т О ( повне ім 'я, з клавіатури):<br><input id="ac-who" style="width:100%" placeholder="Ім' я Прізвище " > < / p >
< p > $ { isK ? "ДЕ утримують:" : "ЯК / ЧИМ:" } < br > < select id = "ac-how" style = "width:100%" > $ { o . methods . map ( ( x ) => ` <option> ${ esc ( x ) } </option> ` ) . join ( "" ) } < / s e l e c t > < / p >
< p > ЧОМУ : < br > < select id = "ac-why" style = "width:100%" > $ { o . motives . map ( ( x ) => ` <option> ${ esc ( x ) } </option> ` ) . join ( "" ) } < / s e l e c t > < / p >
< p > Закріплено доказів : < b > $ { st . board . pins . length } < / b > < / p >
< button class = "danger" id = "ac-go" > Подати на підпис напарнику < / b u t t o n > ` ) ;
m . inner . querySelector ( "#ac-go" ) . onclick = ( ) => {
const who = m . inner . querySelector ( "#ac-who" ) . value . trim ( ) ;
if ( ! who ) return toast ( "Впишіть ім'я." ) ;
App . send ( {
t : "accuse_draft" ,
who ,
how : m . inner . querySelector ( "#ac-how" ) . value ,
why : m . inner . querySelector ( "#ac-why" ) . value ,
} ) ;
m . wrap . remove ( ) ;
} ;
} ;
let accuseModal = null ;
function showAccuseStatus ( ) {
const d = App . state . accuseDraft ;
if ( accuseModal ) accuseModal . wrap . remove ( ) ;
accuseModal = openModal ( `
< h3 > $ { App . init . caseType === "kidnap" ? "Рапорт на штурм — на підписі" : "Звинувачення на підписі" } < / h 3 >
< table >
< tr > < th > Х Т О < / t h > < t d > $ { e s c ( d . w h o ) } < / t d > < / t r >
< tr > < th > $ { App . init . caseType === "kidnap" ? "ДЕ" : "ЯК" } < / t h > < t d > $ { e s c ( d . h o w ) } < / t d > < / t r >
< tr > < th > ЧОМУ < / t h > < t d > $ { e s c ( d . w h y ) } < / t d > < / t r >
< / t a b l e >
< p class = "small" > Підписано : $ { d . signed } з $ { d . need } < / p >
< p class = "dim small" > Потрібен підпис КОЖНОГО гравця — і вироку вже не відкликати . < / p >
< button class = "danger" id = "ac-sign" > Підписати < / b u t t o n >
< button id = "ac-cancel" > Відкликати < / b u t t o n > ` ) ;
accuseModal . inner . querySelector ( "#ac-sign" ) . onclick = ( ) =>
App . send ( { t : "accuse_confirm" } ) ;
accuseModal . inner . querySelector ( "#ac-cancel" ) . onclick = ( ) =>
App . send ( { t : "accuse_cancel" } ) ;
}
App . on ( "state" , ( ) => {
if ( accuseModal ) {
accuseModal . wrap . remove ( ) ;
accuseModal = null ;
}
if ( App . state . accuseDraft && ! App . state . ended ) showAccuseStatus ( ) ;
} ) ;
// ---------- Фінал ----------
let endShown = false ;
function renderEnd ( ) {
const e = App . state . ended ;
if ( ! e || endShown ) return ;
endShown = true ;
if ( window . Snd ) Snd . sting ( ! ! e . solved ) ;
if ( accuseModal ) {
accuseModal . wrap . remove ( ) ;
accuseModal = null ;
}
openModal (
`
< div id = "endscreen" >
< h3 > $ { e . solved ? ( App . init . caseType === "kidnap" ? "ЛЮДИНУ ВРЯТОВАНО" : "СПРАВУ РОЗКРИТО" ) : "В А С ЗВІЛЬНЕНО" } < / h 3 >
$ { e . hint ? ` <p class="dim">Де ви схибили: ${ esc ( e . hint ) } </p> ` : "" }
< p > $ { esc ( e . recap ) } < / p >
< p class = "dim small" > $ { e . solved ? "Новий код сесії — цілком нова справа." : "Справжню картину відновив ваш наступник. Новий код сесії — нова спроба в новій справі." } < / p >
< / d i v > ` ,
{ locked : false } ,
) ;
}