// Спільне ядро клієнта: мережа, стан, бічна дошка, звинувачення, модалки. "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; // згорнута секція «Приховані» на дні дошки let evFilter = ""; // текстовий фільтр по картках доказів let evTag = null; // активний чип-тег (null = всі) // Дошка перемальовується на кожен стан — тож недодруковані нотатки треба зберігати, // інакше broadcast напарника стирає те, що ти зараз друкуєш. Тримаємо чернетку в мапі // (ключ = data-draft-key поля) і відновлюємо фокус/курсор після перемальовки. const noteDraft = {}; // Але фокус повертаємо, ЛИШЕ якщо гравець справді щойно друкував. Раніше було // «завжди, якщо колись друкував»: варто польовому один раз клацнути в нотатку, // і кожен наступний 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); } 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(); }; // Мітки. На картках доказів лишається ✓/✗/…, а на людях трьох станів замало — // треба вести кілька версій одночасно. Тому для ключів '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; } function markButtons(key) { const cur = markOf(key); const mk = el("div", "mk"); for (const [kind, title, colour] of isPersonKey(key) ? PEOPLE_MARKS : EV_MARKS) { const b = el( "button", (colour ? "swatch " : "") + (cur === kind ? "on" : ""), colour ? `` : MARK_GLYPH[kind], ); if (title) b.title = title; // кольори лишаємо без підписів — значення за гравцями if (colour) b.style.color = colour; b.onclick = (ev) => { ev.stopPropagation(); App.send({ t: "mark", key, kind }); }; mk.append(b); } return mk; } function cardClasses(key) { 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 ``; } // Прокрутити дошку до доказу за 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); } 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; const found = App.state.found; const tagOf = (e) => e.tag || "запит"; body.append( el( "div", "dim small", "Закріплене 📌 піде в справу для прокурора. Знайдено: " + found.length, ), ); // Пошук + чипи-теги. До фіналу на дошці 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); // Закріплені — зверху, від останнього запіненого до першого; далі решта доказів, // від найновішого знайденого до найстарішого. (pins — у порядку закріплення.) const pinnedCards = [...pins] .reverse() .map((id) => shown.find((e) => e.id === id)) .filter(Boolean); const rest = [...shown].reverse().filter((e) => !pins.includes(e.id)); const buildCard = (e) => { const key = "e:" + e.id; const isPinned = pins.includes(e.id); const c = el("div", "card" + cardClasses(key) + (isPinned ? " pinned" : "")); c.dataset.ev = e.id; // якір для прокрутки-до-доказу const t = el("div", "t"); t.append(el("b", "", esc(e.title)), el("span", "tag", esc(tagOf(e)))); const pin = el("button", "pinbtn" + (isPinned ? " on" : ""), "📌"); pin.title = isPinned ? "відкріпити" : "закріпити для прокурора"; pin.onclick = () => App.send({ t: "pin", evId: e.id }); t.append(pin); c.append(t); // Звідки знахідка — лише для фізичних: решта прийшла з термінала чи розмови. if (e.from) c.append(el("div", "small dim orig", esc(e.from))); c.append(el("div", "small", esc(e.detail))); // Експертиза називає доказ, який розбирала, і веде до нього (прокрутка + підсвітка). 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)); const notes = (App.state.board.enotes || {})[e.id] || []; for (const n of notes) { const row = el( "div", "small dim", `✎ ${esc(n.txt)} ${n.by === "field" ? "польовий" : "аналітик"}`, ); 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 = []; 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 ? "За цим фільтром нічого немає." : "Дошка порожня — знахідки з'являться тут.", ), ); // Приховані — окрема згорнута секція на дні, кожен доказ окремо (не «одна кнопка = все») 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); } } // Повернути фокус і курсор у поле, де друкували до перемальовки, — але лише // якщо друкували щойно (див. FOCUS_GRACE_MS): інакше поле перехоплює клавіатуру // польового назавжди. if (activeKey && Date.now() - lastTypedAt < FOCUS_GRACE_MS) { const again = body.querySelector(`[data-draft-key="${activeKey}"]`); if (again) { restoringFocus = true; again.focus(); if (caret != null) try { again.setSelectionRange(caret, caret); } catch {} restoringFocus = false; } } } // ---------- Досьє ---------- function showDossier(c) { const dnaCells = c.dna .map( (v) => ``, ) .join(""); const m = openModal(`

${esc(c.name)}

Вік / стать${c.age} / ${c.sex === "ч" ? "чол." : "жін."} (${c.birthYear} р.н.)
Адреса${esc(c.home)}
Робота${esc(c.job)}${c.workplace ? " — " + esc(bName(c.workplace)) : ""}
Телефон${esc(c.phone)}
Зріст / взуття${c.height} см / ${c.shoe} розмір
Кров / відбитки${esc(c.blood)} / ${esc(c.printType)}
Верхній одяг${esc(c.coat)}
Звички${c.smoker ? "курить; " : ""}${c.dog ? "собака " + esc(c.dog) + "; " : ""}${c.tattoo ? "тату: " + esc(c.tattoo) : ""}
ДНК-профіль${dnaCells}
`); 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]; const row = el("div", "small logrow"); 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", "", `${esc(e.q)}
${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; } // Пошук за іменем — ОДИН на обидві ролі. Кожне слово запиту має бути початком // якогось слова в імені; регістр не важить. Раніше це були дві копії (база // аналітика і база польового), і аналітикова загубила .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))); }); } // ---------- Модалки ---------- // Кроки більше не заміщають один одного: батьківська модалка лишається під // поточною, тож скасування picker'а повертає до розмови, а не в порожнечу. // opts.back — явне «← Назад» там, де попередній крок треба перезібрати // (порожня тека ордера, шортліст лабораторії); opts.onClose — прибирання за собою. function openModal(html, opts = {}) { const wrap = el("div", "modal"); const inner = el("div", "", html); wrap.append(inner); const close = () => { wrap.remove(); if (opts.onClose) opts.onClose(); }; if (!opts.locked) { wrap.onclick = (e) => { if (e.target === wrap) close(); }; const foot = el("div", "modalfoot"); if (opts.back) { const bk = el("button", "small", "← Назад"); bk.onclick = () => { wrap.remove(); opts.back(); }; foot.append(bk); } const x = el("button", "small", "Закрити"); x.onclick = close; foot.append(x); inner.append(foot); // Автори модалок дописують вміст уже після openModal (журнал допитів, підказки). // Мікрозадача переносить кнопки в кінець, коли всі синхронні append'и минули. queueMicrotask(() => inner.append(foot)); } document.body.append(wrap); return { wrap, inner, close }; } function showIntro() { const i = App.init.intro; const fieldHow = `
Польовий детектив
`; const analystHow = `
Аналітик
`; openModal(`

${esc(i.title)}: ${esc(i.scene)}, ~${esc(i.time)}

${esc(i.text)}

Роль: ${App.role === "field" ? "польовий детектив — район, обшуки, розмови" : "аналітик — бази, камери, лабораторія"}.

${App.role === "field" ? fieldHow : analystHow}
Фінал

Одне звинувачення на двох: ХТО (ім'я з клавіатури) / ЯК чи ДЕ / ЧОМУ, підписи обох. Прокурор дивиться лише на закріплені 📌 докази: потрібне підтвердження особи (ДНК, відбиток або зламане алібі), мотив і спосіб. Нотатки — спільні: загальні у вкладці, точкові — прямо на картці доказу.

`); } $("#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(`

${isK ? "Рапорт на штурм — один шанс" : "Звинувачення — один шанс"}

Помилка в будь-якому полі — і ${isK ? "штурм піде не туди" : "вас звільнять"}. Прокурор дивиться лише на закріплені 📌 докази.

ХТО (повне ім'я, з клавіатури):

${isK ? "ДЕ утримують:" : "ЯК / ЧИМ:"}

ЧОМУ:

Закріплено доказів: ${st.board.pins.length}

`); 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(`

${App.init.caseType === "kidnap" ? "Рапорт на штурм — на підписі" : "Звинувачення на підписі"}

ХТО${esc(d.who)}
${App.init.caseType === "kidnap" ? "ДЕ" : "ЯК"}${esc(d.how)}
ЧОМУ${esc(d.why)}

Підписано: ${d.signed} з ${d.need}

Потрібен підпис КОЖНОГО гравця — і вироку вже не відкликати.

`); 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( `

${e.solved ? (App.init.caseType === "kidnap" ? "ЛЮДИНУ ВРЯТОВАНО" : "СПРАВУ РОЗКРИТО") : "ВАС ЗВІЛЬНЕНО"}

${e.hint ? `

Де ви схибили: ${esc(e.hint)}

` : ""}

${esc(e.recap)}

${e.solved ? "Новий код сесії — цілком нова справа." : "Справжню картину відновив ваш наступник. Новий код сесії — нова спроба в новій справі."}

`, { locked: false }, ); }