525 lines
24 KiB
JavaScript
525 lines
24 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; // згорнута секція «Приховані» на дні дошки
|
|||
|
|
|
|||
|
|
// Дошка перемальовується на кожен стан — тож недодруковані нотатки треба зберігати,
|
|||
|
|
// інакше broadcast напарника стирає те, що ти зараз друкуєш. Тримаємо чернетку в мапі
|
|||
|
|
// (ключ = data-draft-key поля) і відновлюємо фокус/курсор після перемальовки.
|
|||
|
|
const noteDraft = {};
|
|||
|
|
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();
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
function markButtons(key) {
|
|||
|
|
const marks = App.state.board.marks;
|
|||
|
|
const mk = el("div", "mk");
|
|||
|
|
for (const [kind, label] of [
|
|||
|
|
["tick", "✓"],
|
|||
|
|
["cross", "✗"],
|
|||
|
|
["hide", "…"],
|
|||
|
|
]) {
|
|||
|
|
const b = el("button", marks[key] === kind ? "on" : "", label);
|
|||
|
|
b.title = {
|
|||
|
|
tick: "відмітити",
|
|||
|
|
cross: "викреслити",
|
|||
|
|
hide: "сховати",
|
|||
|
|
}[kind];
|
|||
|
|
b.onclick = (ev) => {
|
|||
|
|
ev.stopPropagation();
|
|||
|
|
App.send({ t: "mark", key, kind });
|
|||
|
|
};
|
|||
|
|
mk.append(b);
|
|||
|
|
}
|
|||
|
|
return mk;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function cardClasses(key) {
|
|||
|
|
const m = App.state.board.marks[key];
|
|||
|
|
return m && m !== "hide" ? " " + m : "";
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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;
|
|||
|
|
body.append(
|
|||
|
|
el(
|
|||
|
|
"div",
|
|||
|
|
"dim small",
|
|||
|
|
"Закріплене 📌 піде в справу для прокурора. Знайдено: " +
|
|||
|
|
App.state.found.length,
|
|||
|
|
),
|
|||
|
|
);
|
|||
|
|
const found = App.state.found;
|
|||
|
|
// Закріплені — зверху, від останнього запіненого до першого; далі решта доказів,
|
|||
|
|
// від найновішого знайденого до найстарішого. (pins — у порядку закріплення.)
|
|||
|
|
const pinnedCards = [...pins]
|
|||
|
|
.reverse()
|
|||
|
|
.map((id) => found.find((e) => e.id === id))
|
|||
|
|
.filter(Boolean);
|
|||
|
|
const rest = [...found].reverse().filter((e) => !pins.includes(e.id));
|
|||
|
|
const ordered = [...pinnedCards, ...rest];
|
|||
|
|
|
|||
|
|
const buildCard = (e) => {
|
|||
|
|
const key = "e:" + e.id;
|
|||
|
|
const c = el("div", "card" + cardClasses(key));
|
|||
|
|
const t = el("div", "t");
|
|||
|
|
t.append(el("b", "", esc(e.title)));
|
|||
|
|
const pin = el(
|
|||
|
|
"button",
|
|||
|
|
"pinbtn" + (pins.includes(e.id) ? " on" : ""),
|
|||
|
|
"📌",
|
|||
|
|
);
|
|||
|
|
pin.onclick = () => App.send({ t: "pin", evId: e.id });
|
|||
|
|
t.append(pin);
|
|||
|
|
c.append(t, el("div", "small", esc(e.detail)), 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 = [];
|
|||
|
|
for (const e of ordered) {
|
|||
|
|
if (marks["e:" + e.id] === "hide") {
|
|||
|
|
hidden.push(e);
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
body.append(buildCard(e));
|
|||
|
|
}
|
|||
|
|
// Приховані — окрема згорнута секція на дні, кожен доказ окремо (не «одна кнопка = все»)
|
|||
|
|
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);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// повернути фокус і курсор у поле, де друкували до перемальовки
|
|||
|
|
if (activeKey) {
|
|||
|
|
const again = body.querySelector(`[data-draft-key="${activeKey}"]`);
|
|||
|
|
if (again) {
|
|||
|
|
again.focus();
|
|||
|
|
if (caret != null)
|
|||
|
|
try {
|
|||
|
|
again.setSelectionRange(caret, caret);
|
|||
|
|
} catch {}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---------- Досьє ----------
|
|||
|
|
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");
|
|||
|
|
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;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---------- Модалки ----------
|
|||
|
|
function openModal(html, opts = {}) {
|
|||
|
|
const wrap = el("div", "modal");
|
|||
|
|
const inner = el("div", "", html);
|
|||
|
|
wrap.append(inner);
|
|||
|
|
if (!opts.locked) {
|
|||
|
|
wrap.onclick = (e) => {
|
|||
|
|
if (e.target === wrap) wrap.remove();
|
|||
|
|
};
|
|||
|
|
const x = el("button", "small", "Закрити");
|
|||
|
|
x.style.marginTop = "12px";
|
|||
|
|
x.onclick = () => wrap.remove();
|
|||
|
|
inner.append(x);
|
|||
|
|
}
|
|||
|
|
document.body.append(wrap);
|
|||
|
|
return { wrap, inner };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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 },
|
|||
|
|
);
|
|||
|
|
}
|