// Аналітик: база мешканців, мапа з мітками, системи-форми і лабораторія. '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'); p.querySelector('#q-go').onclick = () => { const q = p.querySelector('#q-name').value.trim().toLowerCase(); const hit = App.init.citizens.find(c => c.name.toLowerCase() === q); if (!hit) { lastRows = null; dbOut.innerHTML = '

— Записів не знайдено. Перевірте написання.

'; return; } lastRows = [hit]; renderRows(); }; p.querySelector('#f-go').onclick = () => { const v = id => p.querySelector(id).value; lastRows = App.init.citizens.filter(c => (!v('#f-sex') || c.sex === v('#f-sex')) && (!v('#f-smin') || c.shoe >= +v('#f-smin')) && (!v('#f-smax') || c.shoe <= +v('#f-smax')) && (!v('#f-hmin') || c.height >= +v('#f-hmin')) && (!v('#f-hmax') || c.height <= +v('#f-hmax')) && (!v('#f-print') || c.printType === v('#f-print')) && (!v('#f-coat') || c.coat === v('#f-coat')) && (!p.querySelector('#f-smoke').checked || c.smoker)); renderRows(); }; } // рядки з позначками ✓/✗/…, як картки доказів: викреслюйте людей по одному function renderRows() { if (!dbOut || !lastRows || !App.state) return; dbOut.innerHTML = `

Збігів: ${lastRows.length}

`; const t = el('table', '', 'Ім\'яВікАдресаРоботаЗрістВзуттяВідбитки'); const hidden = []; for (const c of lastRows) { const key = 'c:' + c.id; if (App.state.board.marks[key] === 'hide') { hidden.push(c); continue; } const tr = el('tr', 'rowlink' + cardClasses(key), `${esc(c.name)}${App.state.victim === c.id ? (App.init.caseType === 'kidnap' ? ' ⚠' : ' †') : ''}${c.age}${c.home}${esc(c.job)}${c.height}${c.shoe}${c.printType}`); const mk = el('td'); mk.onclick = ev => ev.stopPropagation(); mk.append(markButtons(key)); tr.append(mk); tr.onclick = () => showDossier(c); t.append(tr); } dbOut.append(t); if (hidden.length) { const head = el('button', 'small', `${dbShowHidden ? '▾' : '▸'} Приховані (${hidden.length})`); head.style.cssText = 'width:100%;margin-top:10px;text-align:left'; head.onclick = () => { dbShowHidden = !dbShowHidden; renderRows(); }; dbOut.append(head); if (dbShowHidden) { const ht = el('table', '', 'Ім\'яВікАдресаРобота'); for (const c of hidden) { const tr = el('tr', 'rowlink dim', `${esc(c.name)}${c.age}${c.home}${esc(c.job)}`); const showBtn = el('button', 'small', 'Показати'); showBtn.onclick = (ev) => { ev.stopPropagation(); App.send({ t: 'mark', key: 'c:' + c.id, kind: 'hide' }); // зняти hide }; const td = el('td'); td.append(showBtn); tr.append(td); ht.append(tr); } dbOut.append(ht); } } } // ---------- Мапа ---------- function buildMap() { const p = panels.map; p.innerHTML = '

Мапа району

Клік по будівлі — адреси й люди. Клік по порожньому місцю — мітка «перевір тут» для напарника (повторний клік — зняти). Золота точка — напарник.

'; mapCv = el('canvas'); mapCv.id = 'cv'; mapCv.width = App.init.district.world.w; mapCv.height = App.init.district.world.h; p.append(mapCv); mapCtx = mapCv.getContext('2d'); drawDistrict(mapCtx, { partner: true }); mapCv.onclick = e => { const r = mapCv.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) buildingInfo(b); else App.send({ t: 'marker', x, y }); }; } function buildingInfo(b) { const res = App.init.citizens.filter(c => b.units && b.units.includes(c.home)); const work = App.init.citizens.filter(c => c.workplace === b.id); const here = App.init.citizens.filter(c => App.state && App.state.locs[c.id] === b.id && c.id !== App.state.victim); let html = `

${esc(b.name)}

`; if (res.length) html += '
Мешканці
' + res.map(c => `
${c.home}: ${esc(c.name)}
`).join(''); if (work.length) html += '
Працюють
' + work.map(c => `
${esc(c.name)} — ${esc(c.job)}
`).join(''); html += `
Зараз тут
${here.length ? here.map(c => esc(c.name)).join(', ') : 'нікого не зафіксовано'}
`; openModal(html); } // ---------- Системи-форми ---------- // Кожен запит вводиться руками; результат — навіть дрібниця — лягає на дошку. let pzList = null, labBox = null; const q$ = (p, sel) => p.querySelector(sel); const fmtT = v => `${String(Math.floor(v / 60)).padStart(2, '0')}:${String(v % 60).padStart(2, '0')}`; function buildSys() { const p = panels.sys; p.innerHTML = `

Системи й лабораторія

Дані для запитів — номери, імена, прикмети, роки — вичитуйте з доказів і вводьте самі. Кожен результат лягає на дошку: що з нього стосується справи — вирішуєте ви.

Оператор зв'язку
Банк
Реєстри
Криміналістика
Камери — архів ночі злочину
Зведення алібі
Ще нікого не питали, де він був того вечора.
Лабораторія
Інструменти дешифрування (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 cams = App.init.district.cameras.filter(cam => cam.covers.includes(claim)); const cell = cams.length ? cams.map(cam => `${esc(cam.name)}${(App.state.cctvPulled || []).includes(cam.id) ? ' — архів на дошці' : ' — архів не тягнули'}`).join('
') : 'жодна камера туди не дивиться'; t.append(el('tr', '', `${esc(c.name)}${esc(bn(claim))}${cell}`)); } 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); const mk = (label, sample, doneId, noSampleMsg, run) => { const b = el('button', '', label + (foundId(doneId) ? ' ✓' : '')); b.disabled = foundId(doneId); b.onclick = () => sample == null ? toast(noSampleMsg) : run(); labBox.append(b); }; const short = t => t ? t.split(' ').slice(0, 3).join(' ') + '…' : ''; mk(App.state.dnaLabel ? `ДНК: ${short(App.state.dnaLabel)}` : 'Порівняти ДНК зі зразком', App.state.dnaSample, 'lab_dna_match', 'Немає зразка ДНК: його ще не знайшли. Хай польовий огляне місце злочину.', () => pickCompare('Гель-електрофорез: кого порівнюємо?', ids => dnaModal(ids, 'sf_dna', App.state.dnaSample))); if (App.state.dnaSample2) mk(`ДНК: ${short(App.state.dna2Label)}`, App.state.dnaSample2, 'lab_dna_match2', '', () => pickCompare('Другий зразок: кого порівнюємо?', ids => dnaModal(ids, 'sf_dna2', App.state.dnaSample2))); 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.remove(); go([...sel]); }; } function dnaModal(ids, sampleEv, sample) { 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('')}
`); 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.wrap.remove(); } }; } function printModal(ids) { if (App.state.printSample == null || !ids.length) return; const mm = openModal(`

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

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

← зразок
`); 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.wrap.remove(); }; box.append(w); } mm.inner.append(hintBlock('Зразок знято під кутом — подумки поверніть його. Порівнюйте будову: кількість і хід дуг, а не поворот.')); } function triangulateModal(data) { const mm = openModal(`

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

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

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