// Аналітик: база мешканців, мапа з мітками, системи-форми і лабораторія. 'use strict'; (() => { let stage, panels = {}, mapCtx, mapCv; const UKR = 'абвгґдеєжзиіїйклмнопрстуфхцчшщьюя'; const caesarDec = (text, s) => [...text].map(ch => { const low = ch.toLowerCase(), i = UKR.indexOf(low); if (i < 0) return ch; const out = UKR[(i - s % UKR.length + UKR.length) % UKR.length]; return ch === low ? out : out.toUpperCase(); }).join(''); const b64dec = s => { try { return decodeURIComponent(escape(atob(s.trim()))); } catch { return '⚠ не схоже на base64'; } }; App.on('init', m => { if (App.role !== 'analyst') return; stage = document.getElementById('stage'); stage.innerHTML = ''; const tabs = el('div', 'atabs'); const names = { db: 'База мешканців', map: 'Мапа району', sys: 'Системи й лабораторія' }; for (const id of Object.keys(names)) { const b = el('button', id === 'db' ? 'on' : '', names[id]); b.onclick = () => { for (const x of tabs.children) x.classList.toggle('on', x === b); for (const p of Object.values(panels)) p.classList.remove('on'); panels[id].classList.add('on'); }; tabs.append(b); panels[id] = el('div', 'apanel' + (id === 'db' ? ' on' : '')); } stage.append(tabs, panels.db, panels.map, panels.sys); buildDb(); buildMap(); buildSys(); setInterval(() => { if (!panels.map.classList.contains('on')) return; if (!mapCv._k) fitCanvas(mapCv); drawDistrict(mapCtx, { partner: true }); }, 400); addEventListener('resize', () => { if (mapCv) { fitCanvas(mapCv); drawDistrict(mapCtx, { partner: true }); } }); }); App.on('pos', () => { if (mapCtx) drawDistrict(mapCtx, { partner: true }); }); App.on('state', () => { if (App.role !== 'analyst' || !stage) return; renderPuzzles(); renderRows(); renderLab(); renderAlibi(); }); // ---------- База мешканців ---------- // Сліди в базу не підставляються самі: читайте докази і вводьте значення руками. let dbOut = null, lastRows = null; let dbShowHidden = false; // згорнута секція прихованих у базі function buildDb() { const p = panels.db; p.innerHTML = `

База мешканців району

Реєстр місцями застарілий: одяг і розмір взуття надійніше звіряти очним оглядом (ордер у польового).

Пошук за іменем

Досить початку — «шев» знайде «Шевченко», регістр не важить. Кілька слів звужують (ім'я + прізвище).

Пошук за прикметами

Значення беріть зі знайдених слідів і вводьте самі. Порожні поля не враховуються.

`; dbOut = p.querySelector('#db-out'); // Enter шукає / фільтрує — щоб не тягнутись мишею до кнопки на кожен із // десятків пошуків за гру. Числові поля фільтрів теж: набрав розмір взуття, // натиснув Enter. У

Формат номера вільний: пробіли, дужки й дефіси система відкидає сама.

Перші два запити — архівні, про вечір злочину. Пеленг — живий: показує поточну позицію апарата, і оператор дає його лише за наявності підстав.

Банк

Досить початку імені чи прізвища — те саме правило, що й у базі мешканців. Якщо під запит підходить кілька осіб, банк попросить уточнити.

Реєстри
Криміналістика
Камери — архів ночі злочину
Зведення алібі
Ще нікого не питали, де він був того вечора.
Лабораторія
Інструменти дешифрування (Base64, Цезар)
Base64
Цезар — перебір усіх зсувів

        
`; for (const b of p.querySelectorAll('[data-q]')) b.onclick = () => App.send({ t: 'sys_query', sys: 'phone', kind: b.dataset.q, input: q$(p, '#ph-n').value }); q$(p, '#bk-go').onclick = () => App.send({ t: 'sys_query', sys: 'bank', input: q$(p, '#bk-n').value }); q$(p, '#db-go').onclick = () => App.send({ t: 'sys_query', sys: 'db', input: q$(p, '#db-n').value }); q$(p, '#cr-exp').onclick = () => App.send({ t: 'sys_query', sys: 'crim', kind: 'expert' }); q$(p, '#cr-go').onclick = () => App.send({ t: 'sys_query', sys: 'crim', kind: 'archive', input: q$(p, '#cr-n').value }); const sl = q$(p, '#cc-t'), out = q$(p, '#cc-v'); const upd = () => out.textContent = fmtT(+sl.value); sl.oninput = upd; upd(); q$(p, '#cc-go').onclick = () => App.send({ t: 'sys_query', sys: 'cctv', camera: q$(p, '#cc-cam').value, minutes: +sl.value }); q$(p, '#tb64go').onclick = () => q$(p, '#tb64out').textContent = b64dec(q$(p, '#tb64').value); q$(p, '#tczgo').onclick = () => { const s = q$(p, '#tcz').value; q$(p, '#tczout').textContent = s.trim() ? Array.from({ length: 32 }, (_, i) => `${String(i + 1).padStart(2, ' ')} → ${caesarDec(s, i + 1)}`).join('\n') : ''; }; pzList = q$(p, '#pz-list'); labBox = q$(p, '#lab-btns'); renderLab(); } // Зведення того, що люди КАЖУТЬ про свій вечір. Суперечності не підсвічуються — // порівнювати покази з архівами камер гравці мають самі. function renderAlibi() { const box = panels.sys && panels.sys.querySelector('#al-table'); if (!box || !App.state) return; const claims = Object.entries(App.state.alibiClaims || {}); if (!claims.length) { box.innerHTML = 'Ще нікого не питали, де він був того вечора.'; box.className = 'small dim'; return; } box.className = ''; const bn = id => { const b = App.init.district.buildings.find(x => x.id === id); return b ? b.name.split(',')[0] : id; }; const t = el('table', '', 'ХтоКаже, що бувКамери над тим місцем'); for (const [cid, claim] of claims) { const c = App.byId(cid); // Той самий ключ, що й у базі мешканців: галочка/викреслення на особі — спільні. const key = 'c:' + cid; if (App.state.board.marks[key] === 'hide') continue; // сховане в базі — сховане й тут // Зізнався, що збрехав → показуємо перехід «стара заява → реальне місце», // і камери підтягуємо вже над реальним місцем (обидві колонки синхронно). const real = (App.state.alibiReal || {})[cid]; const place = real || claim; const said = real ? `${esc(bn(claim))} → ${esc(bn(real))}` : esc(bn(claim)); const cams = App.init.district.cameras.filter(cam => cam.covers.includes(place)); const cell = cams.length ? cams.map(cam => `${esc(cam.name)}${(App.state.cctvPulled || []).includes(cam.id) ? ' — архів на дошці' : ' — архів не тягнули'}`).join('
') : 'жодна камера туди не дивиться'; const tr = el('tr', cardClasses(key), `${esc(c.name)}${said}${cell}`); const mk = el('td'); mk.append(markButtons(key)); tr.append(mk); t.append(tr); } box.innerHTML = ''; box.append(t); } function renderLab() { if (!labBox || !App.state) return; labBox.innerHTML = ''; const foundId = id => App.state.found.some(f => f.id === id); // Обрізаємо з кінця по символах, а не по трьох словах: два описи ДНК часто // починаються однаково («волосся, вирване з коренем…»), і кнопки ставали // нерозрізненними. Повний текст лишається в title=. const short = (t, n = 40) => !t ? '' : t.length > n ? t.slice(0, n - 1).trimEnd() + '…' : t; const mk = (label, sample, doneId, noSampleMsg, run) => { const b = el('button', '', short(label) + (foundId(doneId) ? ' ✓' : '')); b.title = label; b.disabled = foundId(doneId); b.onclick = () => sample == null ? toast(noSampleMsg) : run(); labBox.append(b); }; mk(App.state.dnaLabel ? `ДНК: ${App.state.dnaLabel}` : 'Порівняти ДНК зі зразком', App.state.dnaSample, 'lab_dna_match', 'Немає зразка ДНК: його ще не знайшли. Хай польовий огляне місце злочину.', () => pickCompare('Гель-електрофорез: кого порівнюємо?', (ids, nav) => dnaModal(ids, 'sf_dna', App.state.dnaSample, nav))); if (App.state.dnaSample2) mk(`ДНК (2): ${App.state.dna2Label}`, App.state.dnaSample2, 'lab_dna_match2', '', () => pickCompare('Другий зразок: кого порівнюємо?', (ids, nav) => dnaModal(ids, 'sf_dna2', App.state.dnaSample2, nav))); mk('Звірити відбиток зі знаряддя', App.state.printSample, 'lab_print_match', 'Немає повного відбитка: спершу треба знайти знаряддя злочину (підказку дає розтин).', () => pickCompare('Дактилоскопія: чиї картки підняти?', printModal)); } // ---------- Шифри (у лабораторії) ---------- let pzSig = null; function renderPuzzles() { if (!pzList) return; const sig = App.state.puzzles.map(x => x.id + x.solved).join(); if (sig === pzSig) return; pzSig = sig; pzList.innerHTML = ''; if (!App.state.puzzles.length) pzList.append(el('p', 'dim small', 'Зашифрованих знахідок поки немає.')); for (const z of App.state.puzzles) { const c = el('div', 'card' + (z.solved ? ' tick' : '')); c.append(el('div', 't', `${z.solved ? '✓ ' : ''}${esc(z.prompt)}`)); if (z.solved) { pzList.append(c); continue; } if (z.kind === 'caesar') { c.append(el('div', 'small', `${esc(z.payload)}`)); const out = el('div', 'small', ''); const sl = el('input'); sl.type = 'range'; sl.min = 1; sl.max = 32; sl.value = 1; sl.style.width = '100%'; const upd = () => out.innerHTML = `зсув ${sl.value}: ${esc(caesarDec(z.payload, +sl.value))}`; sl.oninput = upd; upd(); const b = el('button', 'primary', 'Зафіксувати зсув'); b.onclick = () => App.send({ t: 'puzzle_try', id: z.id, shift: +sl.value }); c.append(sl, out, b); } else if (z.kind === 'b64') { c.append(el('div', 'small', `${esc(z.payload)}`)); const inp = el('input'); inp.placeholder = 'розкодований текст (інструменти нижче)'; inp.style.width = '100%'; const b = el('button', 'primary', 'Надіслати'); b.onclick = () => App.send({ t: 'puzzle_try', id: z.id, answer: inp.value.trim() }); c.append(inp, b); } else { // pin / md5 const inp = el('input'); inp.placeholder = z.kind === 'pin' ? 'PIN (4 цифри)' : 'пароль'; const b = el('button', 'primary', 'Спробувати'); b.onclick = () => App.send({ t: 'puzzle_try', id: z.id, answer: inp.value }); inp.onkeydown = e => { if (e.key === 'Enter') b.click(); }; c.append(inp, b); } c.append(hintBlock(z.hint)); pzList.append(c); } } // ---------- Лабораторні міні-ігри ---------- const rng32 = seed => { let a = seed | 0; return () => { a |= 0; a = (a + 0x6D2B79F5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; }; function drawPrint(ctx, seed, rot) { const rr = rng32(seed), W = ctx.canvas.width, c = W / 2; ctx.fillStyle = '#0d1116'; ctx.fillRect(0, 0, W, W); ctx.save(); ctx.translate(c, c); ctx.rotate(rot || 0); ctx.strokeStyle = '#9db4c8'; ctx.lineWidth = 1.4; for (let i = 0; i < 11; i++) { const rad = 4 + i * (c - 8) / 11 + rr() * 3, a0 = rr() * 6.28, len = 1.2 + rr() * 3.6; ctx.beginPath(); ctx.arc(0, 0, rad, a0, a0 + len); ctx.stroke(); if (rr() < 0.4) { ctx.beginPath(); ctx.arc(0, 0, rad * 0.72, a0 + 2, a0 + 2.7); ctx.stroke(); } } ctx.restore(); } const laneHtml = (dna, name) => `
${dna.map(v => `
`).join('')}
${name}
`; // Ручний шортліст: аналітик сам вирішує, кого нести в лабораторію. // ponytail: сервер не гейтить ДНК — перебір усієї бази можливий, але по 6 і руками function pickCompare(title, go) { const mm = openModal(`

${esc(title)}

Оберіть до 6 профілів для порівняння зі зразком.

`); const sel = new Set(); const list = mm.inner.querySelector('#pk-list'), q = mm.inner.querySelector('#pk-q'), n = mm.inner.querySelector('#pk-n'); const draw = () => { list.innerHTML = ''; const f = q.value.trim().toLowerCase(); for (const c of App.init.citizens) { if (c.id === App.state.victim) continue; if (f && !c.name.toLowerCase().includes(f)) continue; const lb = el('label', 'small'); lb.style.display = 'block'; const cb = el('input'); cb.type = 'checkbox'; cb.checked = sel.has(c.id); cb.onchange = () => { if (cb.checked && sel.size >= 6) { cb.checked = false; return; } if (cb.checked) sel.add(c.id); else sel.delete(c.id); n.textContent = `обрано: ${sel.size}/6`; }; lb.append(cb, ` ${c.name} — ${c.home}, ${c.job}`); list.append(lb); } }; q.oninput = draw; draw(); mm.inner.querySelector('#pk-go').onclick = () => { if (!sel.size) return; // Шортліст не викидаємо, а ховаємо: «← Назад» із гелю повертає до вже // обраних шести профілів, а не до нуля. mm.wrap.style.display = 'none'; go([...sel], { back: () => { mm.wrap.style.display = ''; }, onClose: () => mm.wrap.remove() }); }; } function dnaModal(ids, sampleEv, sample, nav) { if (!sample || !ids.length) return; const mm = openModal(`

Гель-електрофорез

Зразок із місця злочину — ліворуч. Клацніть по профілю, який вважаєте збігом. Памʼятайте: збіг скаже лише, ЧИЯ це ДНК, — не коли і не чому вона там.

${laneHtml(sample, 'ЗРАЗОК')}${ids.map(id => { const c = App.byId(id); return `${laneHtml(c.dna, esc(c.name))}`; }).join('')}
`, nav); mm.inner.append(hintBlock('Збіг — це коли ВСІ смуги стоять на тих самих висотах, що у зразка. Помилковий вибір чесно скаже «ні» і викреслить людину.')); mm.inner.onclick = e => { const s = e.target.closest('[data-cid]'); if (s) { App.send({ t: 'dna_pick', sampleEv, cid: s.dataset.cid }); mm.close(); } }; } function printModal(ids, nav) { if (App.state.printSample == null || !ids.length) return; const mm = openModal(`

Дактилоскопія: відбиток зі знаряддя

Зразок ліворуч. Клацніть по власнику відбитка.

← зразок
`, nav); drawPrint(mm.inner.querySelector('#psample').getContext('2d'), App.state.printSample, 0.7); const box = mm.inner.querySelector('#pcands'); for (const id of ids) { const c = App.byId(id); const w = el('div', '', ''); w.style.cursor = 'pointer'; w.style.textAlign = 'center'; const cv = el('canvas'); cv.width = cv.height = 110; cv.style.border = '1px solid var(--line)'; cv.style.borderRadius = '6px'; w.append(cv, el('div', 'small dim', esc(c.name))); drawPrint(cv.getContext('2d'), c.printSeed, 0); w.onclick = () => { App.send({ t: 'print_pick', cid: id }); mm.close(); }; box.append(w); } mm.inner.append(hintBlock('Зразок знято під кутом — подумки поверніть його. Порівнюйте будову: кількість і хід дуг, а не поворот.')); } function triangulateModal(data) { const mm = openModal(`

Тріангуляція номера ${esc(data.number)}

Три вежі, три відстані до апарата — станом на зараз. Клацніть будинок, що лежить на перетині всіх трьох дуг.

`, // Прилітає у відповідь на запит до оператора: два натискання «Тріангуляція» // дали б дві мапи одна на одній. { key: 'triangulate' }); const cv = mm.inner.querySelector('#trcv'); cv.id = 'cv'; fitCanvas(cv); const ctx = cv.getContext('2d'); drawDistrict(ctx); // лишає трансформацію у світових координатах for (const t of data.towers) { ctx.strokeStyle = 'rgba(232,176,75,.55)'; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.arc(t.x, t.y, t.dist, 0, 7); ctx.stroke(); ctx.fillStyle = '#e8b04b'; ctx.beginPath(); ctx.arc(t.x, t.y, 6, 0, 7); ctx.fill(); ctx.fillStyle = '#e8b04b'; ctx.font = '12px Georgia'; ctx.fillText('▲ ' + t.name, t.x + 8, t.y + 4); } mm.inner.querySelector('#trreads').innerHTML = data.towers.map(t => `${esc(t.name)}: ~${t.dist} м`).join(' · '); mm.inner.append(hintBlock('Кожна дуга — коло можливих точок навколо вежі. Апарат там, де всі три кола накладаються на один будинок. Клацніть саме той будинок.')); cv.onclick = e => { const r = cv.getBoundingClientRect(), D = App.init.district.world; const x = (e.clientX - r.left) * D.w / r.width, y = (e.clientY - r.top) * D.h / r.height; const b = App.init.district.buildings.find(b => x > b.pos.x && x < b.pos.x + b.pos.w && y > b.pos.y && y < b.pos.y + b.pos.h); if (b) { App.send({ t: 'triangulate_pick', building: b.id }); mm.wrap.remove(); } }; } App.on('mg', m => { if (App.role !== 'analyst') return; if (m.kind === 'triangulate') triangulateModal(m); }); })();