2PlayerGame/detective-game/public/client.js
UndecimoDia 2c887ca451 fixes
2026-08-15 15:37:54 +03:00

704 lines
33 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Спільне ядро клієнта: мережа, стан, бічна дошка, звинувачення, модалки.
"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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[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
? `<span class="dot" style="background:${colour}"></span>`
: 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 `<span class="dot" style="background:${found[2]}"></span>`;
}
// Прокрутити дошку до доказу за 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)} <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 = [];
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) =>
`<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)}</h3>
<table>
<tr><th>Вік / стать</th><td>${c.age} / ${c.sex === "ч" ? "чол." : "жін."} (${c.birthYear} р.н.)</td></tr>
<tr><th>Адреса</th><td>${esc(c.home)}</td></tr>
<tr><th>Робота</th><td>${esc(c.job)}${c.workplace ? " — " + esc(bName(c.workplace)) : ""}</td></tr>
<tr><th>Телефон</th><td>${esc(c.phone)}</td></tr>
<tr><th>Зріст / взуття</th><td>${c.height} см / ${c.shoe} розмір</td></tr>
<tr><th>Кров / відбитки</th><td>${esc(c.blood)} / ${esc(c.printType)}</td></tr>
<tr><th>Верхній одяг</th><td>${esc(c.coat)}</td></tr>
<tr><th>Звички</th><td>${c.smoker ? "курить; " : ""}${c.dog ? "собака " + esc(c.dog) + "; " : ""}${c.tattoo ? "тату: " + esc(c.tattoo) : ""}</td></tr>
<tr><th>ДНК-профіль</th><td>${dnaCells}</td></tr>
</table>`);
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", "", `<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;
}
// Пошук за іменем — ОДИН на обидві ролі. Кожне слово запиту має бути початком
// якогось слова в імені; регістр не важить. Раніше це були дві копії (база
// аналітика і база польового), і аналітикова загубила .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 = `
<div class="syshead">Польовий детектив</div>
<ul class="small">
<li>WASD/стрілки — рух, E — увійти. Почніть із <b>місця злочину</b> (позначене ✖): обшукуйте його точку, поки знахідки не скінчаться. Не кожен слід — вбивці: місце публічне.</li>
<li><b>Розмови:</b> у кожної людини — теми у справі, «Де ви були о …?» (повзунок часу), «Про людину…», «Показати доказ…». Люди брешуть і помиляються. Збрехав? Перевірте його місце камерами і <b>спитайте вдруге</b> — розколеться і скаже, чому брехав. Вірити чи ні — вирішуєте ви.</li>
<li><b>Ордер</b> — особисто у відділку: оберіть людину і доказ із закріплених 📌, який прямо на неї вказує.</li>
<li><b>Пристрої:</b> в житлі під ордером трапляються телефони й ноутбуки. Вилучайте — самі ви їх не відкриєте, вони йдуть аналітику в лабораторію на злам.</li>
<li>З ордером можна <b>оглянути людину особисто</b> — реєстр місцями застарілий, очі надійніші. Результат ніде не записується: занотовуйте самі.</li>
<li>Червоні ✖ на мапі — мітки напарника «перевір тут».</li>
</ul>`;
const analystHow = `
<div class="syshead">Аналітик</div>
<ul class="small">
<li><b>База мешканців:</b> фільтри вводите руками — значення беріть із доказів. Мітьте людей кольорами — чотири кольори плюс «…» (сховати); що означає кожен, вирішуєте ви з напарником, гра не підказує. Реєстр місцями застарілий: одяг/взуття надійніше звіряти очним оглядом польового.</li>
<li><b>Системи:</b> оператор (власник, дзвінки, тріангуляція «де зараз»), банк, реєстри, криміналістика (експертиза + архів), камери. Дані для запитів шукайте в доказах. <b>Кожен результат лягає на дошку</b> — навіть дрібниця; що з того справа, вирішуєте ви.</li>
<li><b>Журнал дзвінків</b> — це покази, які не брешуть: хто з ким говорив і о котрій у вечір злочину. Звіряйте з тим, що люди кажуть на допиті. Порожній вечір теж промовистий: апарат могли вимкнути.</li>
<li><b>Вилучені пристрої</b> потрапляють у лабораторію заблокованими: PIN — це зазвичай рік народження власника (є в реєстрі), пароль ноутбука — або щось особисте латиницею, або словниковий перебір. Усередині буває історія браузера й чати — а буває порожньо.</li>
<li><b>Камери:</b> час злочину ±12 хв — головні кадри; будь-який вечірній час — хто взагалі потрапляв у кадри. Звіряйте з тим, що люди <i>кажуть</i> («Зведення алібі»). Брехня вбивці, спіймана камерою, — повноцінний доказ для прокурора.</li>
<li><b>Лабораторія:</b> ДНК і відбитки порівнюєте вручну — до 6 профілів за раз. Шифри теж тут. Увага: ДНК каже лише, ЧИЯ вона, — не коли й чому лишена. На місці буває ДНК людей, що не мають стосунку до злочину: збіг — привід для допиту, а не вирок.</li>
<li>Клік по порожньому місцю мапи — мітка для напарника.</li>
</ul>`;
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">Фінал</div>
<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 ? "Рапорт на штурм — один шанс" : "Звинувачення — один шанс"}</h3>
<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("")}</select></p>
<p>ЧОМУ:<br><select id="ac-why" style="width:100%">${o.motives.map((x) => `<option>${esc(x)}</option>`).join("")}</select></p>
<p>Закріплено доказів: <b>${st.board.pins.length}</b></p>
<button class="danger" id="ac-go">Подати на підпис напарнику</button>`);
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" ? "Рапорт на штурм — на підписі" : "Звинувачення на підписі"}</h3>
<table>
<tr><th>ХТО</th><td>${esc(d.who)}</td></tr>
<tr><th>${App.init.caseType === "kidnap" ? "ДЕ" : "ЯК"}</th><td>${esc(d.how)}</td></tr>
<tr><th>ЧОМУ</th><td>${esc(d.why)}</td></tr>
</table>
<p class="small">Підписано: ${d.signed} з ${d.need}</p>
<p class="dim small">Потрібен підпис КОЖНОГО гравця — і вироку вже не відкликати.</p>
<button class="danger" id="ac-sign">Підписати</button>
<button id="ac-cancel">Відкликати</button>`);
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" ? "ЛЮДИНУ ВРЯТОВАНО" : "СПРАВУ РОЗКРИТО") : "ВАС ЗВІЛЬНЕНО"}</h3>
${e.hint ? `<p class="dim">Де ви схибили: ${esc(e.hint)}</p>` : ""}
<p>${esc(e.recap)}</p>
<p class="dim small">${e.solved ? "Новий код сесії — цілком нова справа." : "Справжню картину відновив ваш наступник. Новий код сесії — нова спроба в новій справі."}</p>
</div>`,
{ locked: false },
);
}