704 lines
33 KiB
JavaScript
704 lines
33 KiB
JavaScript
// Спільне ядро клієнта: мережа, стан, бічна дошка, звинувачення, модалки.
|
||
"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
|
||
? `<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 },
|
||
);
|
||
}
|