From 2c887ca451ad16a66cf0418ef2b9a8c4f961d314 Mon Sep 17 00:00:00 2001 From: UndecimoDia Date: Sat, 15 Aug 2026 15:37:54 +0300 Subject: [PATCH] fixes --- detective-game/README.md | 4 +- detective-game/casegen/evening.js | 5 +- detective-game/casegen/index.js | 16 ++ detective-game/casegen/mutators.js | 9 +- detective-game/casegen/names.js | 64 ++++---- detective-game/casegen/test.js | 25 +++ detective-game/public/analyst.js | 75 +++++---- detective-game/public/client.js | 240 +++++++++++++++++++++++------ detective-game/public/field.js | 192 +++++++++++++---------- detective-game/public/index.html | 52 ++++++- detective-game/server.js | 89 +++++++++-- 11 files changed, 563 insertions(+), 208 deletions(-) diff --git a/detective-game/README.md b/detective-game/README.md index 0ac6caf..b72ed9b 100644 --- a/detective-game/README.md +++ b/detective-game/README.md @@ -18,8 +18,8 @@ node server.js - **Польовий детектив** — ходить районом (WASD/стрілки, E — увійти), обшукує місця, стукає у квартири, говорить з людьми. У приватне житло — лише з ключем або ордером. -- **Аналітик** — база мешканців (пошук за точним ім'ям з клавіатури — помилка в написанні - дасть порожній результат; фільтри за слідами: взуття, зріст, одяг, відбитки), мапа району +- **Аналітик** — база мешканців (пошук за іменем з клавіатури — досить початку слова, регістр + не важить; фільтри за слідами: взуття, зріст, одяг, відбитки), мапа району з напарником наживо, системи (оператор, банки, камери, криміналістика, лабораторія) і **шифри**: Цезар, base64, PIN, злам md5-пароля за фактами з досьє. diff --git a/detective-game/casegen/evening.js b/detective-game/casegen/evening.js index 2d2155f..6c11701 100644 --- a/detective-game/casegen/evening.js +++ b/detective-game/casegen/evening.js @@ -22,11 +22,12 @@ function setupEvening(ctx) { for (const c of citizens) c.claim = c.liar ? otherB(c.evening) : c.evening; killer.claim = pick(r, coveredB.filter(b => b !== scene.building && b !== 'station')) || otherB(scene.building); + // Виправдання роздаємо тут (споживає RNG). А от свідка алібі (`mate`) рахувати + // рано: мутатори після нас іще переносять людей (спільниця йде на місце злочину), + // і вже роздані посилання протухають — див. assignMates() в кінці генерації. for (const c of citizens) { if (!c.liar || c === killer) continue; c.excuse = pick(r, N.EXCUSES); - const m2 = citizens.find(o => o !== c && o.id !== victim.id && o.id !== killer.id && o.evening === c.evening); - c.mate = m2 ? m2.id : null; } } diff --git a/detective-game/casegen/index.js b/detective-game/casegen/index.js index dc603ee..adb2fbc 100644 --- a/detective-game/casegen/index.js +++ b/detective-game/casegen/index.js @@ -713,6 +713,22 @@ function generateCase(seed) { ? `${scene.name}: сліди боротьби — і жодної людини. Зникла особа: ${victim.name}, ${victim.age} р., прож. ${victim.home}. Заручник, імовірно, ще живий. З'ясуйте, ХТО забрав людину, ДЕ її тримають і ЧОМУ. Викрадача немає в жодному списку підозрюваних — його збирають по слідах і базі мешканців. У вас один штурм: помилка в імені чи місці — і все марно.` : `Ранок. ${scene.name} — тіло без документів. Ніхто не заявляв про зникнення. Акт I: з'ясуйте, ХТО загинув. Акт II: знайдіть, ХТО це зробив, ЯК і ЧОМУ. Вбивці немає у списку підозрюваних — його треба зібрати по слідах і базі мешканців. У вас одне звинувачення. Помилка — і вас звільнять.`; + // -- свідки алібі брехунів. Рахуємо ОСТАННІМИ: до цього моменту мутатори ще + // переносять людей (спільниця йде на місце злочину), і свідок, призначений раніше, + // перетворюється на мертве посилання — «названий свідок сам себе туди не ставить». + // Це і зливало твіст: єдина людина з суперечливим свідком і була спільницею. + // `citizens.find` RNG не споживає, тож наявні сейви лишаються тією ж справою. + const figures = [victim.id, killer.id, accomplice && accomplice.id]; + for (const c of citizens) { + c.mate = null; + if (!c.liar || c === killer || (accomplice && c === accomplice)) continue; + // Свідок мусить бути там само — і не заперечити цього на власному допиті + // (o.claim === o.evening), інакше «спитайте X» веде в порожнечу. + const m2 = citizens.find(o => o !== c && !figures.includes(o.id) && + o.evening === c.evening && o.claim === o.evening); + if (m2) c.mate = m2.id; + } + return { seed: String(seed), title: 'Невпізнаний', district, citizens, puzzles, victim: victim.id, evidence: E, startingFacts, finale, templateId, caseType, introText, diff --git a/detective-game/casegen/mutators.js b/detective-game/casegen/mutators.js index 0c29fb2..98bf65b 100644 --- a/detective-game/casegen/mutators.js +++ b/detective-game/casegen/mutators.js @@ -26,12 +26,9 @@ function applyDoppelganger(ctx) { doppelganger.liar = true; doppelganger.claim = pick(r, district.buildings.filter(b => b.id !== doppelganger.evening && b.id !== 'station').map(b => b.id)); doppelganger.excuse = pick(r, N.EXCUSES); - - // Знаходимо свідка, який може підтвердити справжнє алібі двійника - const mate = citizens.find(o => o.id !== victim.id && o.id !== killer.id && o.id !== herring.id && o.evening === doppelganger.evening); - if (mate) { - doppelganger.mate = mate.id; - } + // Свідка алібі тут НЕ рахуємо: мутатор працює до setupEvening, коли evening + // у всіх ще undefined, — предикат `o.evening === c.evening` зматчив би першого-ліпшого. + // Свідків роздає assignMates() у кінці генерації, коли вечір уже остаточний. return doppelganger; } diff --git a/detective-game/casegen/names.js b/detective-game/casegen/names.js index 06ec57e..8e3582f 100644 --- a/detective-game/casegen/names.js +++ b/detective-game/casegen/names.js @@ -36,37 +36,41 @@ const COATS = ['чорному пальті', 'сірій куртці', 'син // Чому люди брешуть про свій вечір (виправдання на повторному допиті). // Гравці самі вирішують, кому вірити, — гра вердиктів не ставить. +// УВАГА: рядки роздаються `pick`ом незалежно від статі, тож усі вони мусять +// лишатися БЕЗРІДНИМИ — теперішній час, інфінітив або називна конструкція. +// Жодного минулого часу від першої особи («був», «пив», «ходив»): половина +// мешканців району — жінки. Тест у test.js це перевіряє. const EXCUSES = [ - 'Я був не сам, і вдома про це знати не мусять. Ви розумієте.', - 'Пив. Сильно. Не хотів, щоб про це знали на роботі.', - 'Грав у карти на гроші в гаражах. Це ж не злочин... майже.', - 'Я боявся: скажеш «був поруч» — і вже підозрюваний. От і збрехав.', - 'Позичав гроші в людини, про яку краще не говорити вголос.', - 'Ходив до ворожки. Смійтеся-смійтеся. Спробуйте самі поживіть із моїм щастям.', - 'Шукав підробіток. Офіційно мені не можна, тож — тихцем.', - 'Посварився з дружиною і ночував у майстерні. Соромно було казати.', - 'Здавав пляшки. Так, отакий у мене вечір. Задоволені?', - 'Був у клініці. Аналізи. Це моя справа і нікого не обходить.', - 'Зустрічався з колишньою. Якщо дізнається теперішня — мені кінець.', - 'Продавав дещо з дому, поки рідня не бачить. Борги.', - 'Сидів у барі до закриття. Мамі кажу, що не п\'ю.', - 'Ловив рибу без квитка. Інспектор мене вже двічі попереджав.', - 'Плакав у машині. Буває. Не хотів, щоб хтось бачив.', - 'Ходив дивитися на квартиру жертви... тобто, я не так висловився. Просто гуляв тим двором.', - 'Репетирував освідчення. Так, сам із собою. Годину. У дворі.', - 'Був на співбесіді в іншому місті. Начальник не мусить знати.', - 'Виносив речі колишнього орендаря. Не питайте.', - 'Дзвонив у передачу на радіо. Це анонімно, і хай так і лишиться.', - 'Фотографував птахів. Уночі. Сов, якщо точніше. Це хобі!', - 'Стежив за власним чоловіком. І ні, не скажу, що вистежила.', - 'Пробував кинути курити і намотував кола районом, щоб не зірватися.', - 'Ремонтував чужу машину за готівку. Податкова ж не дізнається?', - 'Ходив на могилу до брата. Про це не розповідаю нікому.', - 'Грав у шахи на набережній. На гроші. Пенсії не вистачає.', - 'Був у перукарки. Не в тій, до якої ходить моя сім\'я. Це складно.', - 'Тренувався співати в елінгу. Там акустика. І ніхто не чує. Зазвичай.', - 'Розклеював оголошення про зниклого кота. Кіт не мій. Довга історія.', - 'Просто збрехав. Звичка з дитинства: менше скажеш — краще спиш.' + 'Була компанія, і вдома про це знати не мусять. Ви розумієте.', + 'Пиятика. Сильна. І на роботі про це знати не мусять.', + 'Карти на гроші в гаражах. Це ж не злочин... майже.', + 'Страх, розумієте: скажеш «я поруч» — і ти вже підозрюваний. От і брехня.', + 'Борг у людини, про яку краще не говорити вголос.', + 'Ворожка. Смійтеся-смійтеся. Спробуйте самі поживіть із моїм щастям.', + 'Підробіток. Офіційно мені не можна, тож — тихцем.', + 'Сварка вдома, ніч у майстерні. Соромно казати.', + 'Пляшки на здачу. Так, отакий у мене вечір. Задоволені?', + 'Клініка. Аналізи. Це моя справа і нікого не обходить.', + 'Зустріч із колишнім коханням. Якщо дізнаються удома — мені кінець.', + 'Дещо з дому — на продаж, поки рідня не бачить. Борги.', + 'Бар до самого закриття. Мамі кажу, що не п\'ю.', + 'Риболовля без квитка. Інспектор мене вже двічі попереджав.', + 'Сльози в машині. Буває. І без свідків, будь ласка.', + 'Дивлюся іноді на ту квартиру... тобто, я не так кажу. Просто прогулянка тим двором.', + 'Репетиція освідчення. Так, наодинці. Годину. У дворі.', + 'Співбесіда в іншому місті. Начальник не мусить знати.', + 'Речі колишнього орендаря треба було винести. Не питайте.', + 'Дзвінок у передачу на радіо. Це анонімно, і хай так і лишиться.', + 'Фотографую птахів. Уночі. Сов, якщо точніше. Це хобі!', + 'Стеження за власною половиною. І ні, не скажу, що з того вийшло.', + 'Кидаю курити — і намотую кола районом, щоб не зірватися.', + 'Ремонт чужої машини за готівку. Податкова ж не дізнається?', + 'Могила брата. Про це нікому не розповідаю.', + 'Шахи на набережній. На гроші. Пенсії не вистачає.', + 'Перукарка. Не та, до якої ходить моя сім\'я. Це складно.', + 'Вправляюся співати в елінгу. Там акустика. І ніхто не чує. Зазвичай.', + 'Оголошення про зниклого кота, по всіх стовпах. Кіт не мій. Довга історія.', + 'Просто брехня. Звичка з дитинства: менше скажеш — краще спиш.' ]; // Хто знайшов тіло / повідомив (для вступів) diff --git a/detective-game/casegen/test.js b/detective-game/casegen/test.js index 9e75ad9..00e7939 100644 --- a/detective-game/casegen/test.js +++ b/detective-game/casegen/test.js @@ -59,6 +59,20 @@ function checkCase(seed) { if (c.liar && c.id !== cs.finale.killer && c.id !== cs.accomplice) assert.ok(c.excuse, 'у брехуна є виправдання на повторний допит'); + // -- свідок алібі: мусить бути там само, підтверджувати це на власному допиті + // і не бути фігурантом справи (інакше «спитайте X» зливає, хто в справі). + for (const c of cs.citizens) { + if (!c.mate) continue; + const mt = byId(c.mate); + assert.ok(mt, `${seed}/${c.name}: свідок алібі не існує`); + assert.strictEqual(mt.evening, c.evening, `${seed}/${c.name}: свідок алібі був в іншому місці`); + assert.strictEqual(mt.claim, mt.evening, `${seed}/${c.name}: свідок алібі сам заперечує його покази`); + assert.ok(![cs.victim, cs.finale.killer, cs.accomplice].includes(mt.id), + `${seed}/${c.name}: свідок алібі — фігурант справи`); + assert.ok(c.liar && c.id !== cs.finale.killer && c.id !== cs.accomplice, + `${seed}/${c.name}: свідок алібі є в того, хто не брехав`); + } + // -- зламане алібі: сплячий доказ, недосяжний симулятором, з вагою підтвердження const ab = cs.evidence.alibi_broken; assert.ok(ab && ab.special === 'alibi' && ab.gives.confirms, 'сплячий доказ алібі існує'); @@ -142,6 +156,17 @@ function checkCase(seed) { }; walk(cs, ''); + // -- виправдання брехунів роздаються `pick`ом незалежно від статі, тож у них + // не має бути минулого часу від першої особи («був», «пив», «посварився»): + // жінка на зізнанні сказала б це в чоловічому роді. Сітка вище цього не ловить — + // форма граматично правильна, просто не того роду. + const M_PAST = /(?:^|[.!?…]\s+|(?:^|\s)(?:я|і|й|та|то|вже|не|ще)\s+)[а-яіїєґ']*[аяиіуюе]в(?:ся|сь)?(?=$|[\s.,!?;:»…])/i; + for (const c of cs.citizens) { + if (!c.excuse) continue; + const m = c.excuse.match(M_PAST); + assert.ok(!m, `${seed}/${c.id}: виправдання в чоловічому роді «${m && m[0].trim()}»: ${c.excuse}`); + } + // -- розв'язність від стартових фактів const sol = solve(cs); assert.ok(sol.identity, 'особу жертви можна встановити'); diff --git a/detective-game/public/analyst.js b/detective-game/public/analyst.js index 427cf66..8e80713 100644 --- a/detective-game/public/analyst.js +++ b/detective-game/public/analyst.js @@ -48,9 +48,9 @@ const p = panels.db; p.innerHTML = `

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

-

Пошук за точним ім'ям — помилка в написанні дасть порожній результат. Значення для фільтрів беріть зі знайдених слідів і вводьте самі. Реєстр місцями застарілий: одяг і розмір взуття надійніше звіряти очним оглядом (ордер у польового).

+

Пошук за іменем: досить початку — «шев» знайде «Шевченко», регістр не важить. Кілька слів звужують (ім'я + прізвище). Значення для фільтрів беріть зі знайдених слідів і вводьте самі. Реєстр місцями застарілий: одяг і розмір взуття надійніше звіряти очним оглядом (ордер у польового).

- +
@@ -69,13 +69,7 @@ p.querySelector('#q-go').onclick = () => { const q = p.querySelector('#q-name').value.trim(); if (!q) { lastRows = null; dbOut.innerHTML = '

— Введіть ім\'я або прізвище.

'; return; } - // Розбиваємо введений запит на слова - const words = q.split(/\s+/); - // Фільтруємо: кожне введене слово має збігатися з початком якогось слова в імені громадянина - lastRows = App.init.citizens.filter(c => { - const nameWords = c.name.split(/\s+/); - return words.every(w => nameWords.some(nw => nw.startsWith(w))); - }); + lastRows = matchName(App.init.citizens, q); // спільний пошук, див. client.js if (!lastRows.length) { dbOut.innerHTML = '

— Записів не знайдено. Спробуйте інший початок.

'; return; @@ -97,7 +91,7 @@ }; } - // рядки з позначками ✓/✗/…, як картки доказів: викреслюйте людей по одному + // рядки з кольоровими мітками: ведіть кілька версій одночасно, по одній людині function renderRows() { if (!dbOut || !lastRows || !App.state) return; dbOut.innerHTML = `

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

`; @@ -184,28 +178,35 @@
Оператор зв'язку
- - - - +
+

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

+
+ + +
+
+ +
+

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

Банк
- +
+

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

Реєстри
- +
Криміналістика
- +
@@ -293,21 +294,25 @@ 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', '', label + (foundId(doneId) ? ' ✓' : '')); + 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); }; - const short = t => t ? t.split(' ').slice(0, 3).join(' ') + '…' : ''; - mk(App.state.dnaLabel ? `ДНК: ${short(App.state.dnaLabel)}` : 'Порівняти ДНК зі зразком', + mk(App.state.dnaLabel ? `ДНК: ${App.state.dnaLabel}` : 'Порівняти ДНК зі зразком', App.state.dnaSample, 'lab_dna_match', 'Немає зразка ДНК: його ще не знайшли. Хай польовий огляне місце злочину.', - () => pickCompare('Гель-електрофорез: кого порівнюємо?', ids => dnaModal(ids, 'sf_dna', App.state.dnaSample))); + () => pickCompare('Гель-електрофорез: кого порівнюємо?', (ids, nav) => dnaModal(ids, 'sf_dna', App.state.dnaSample, nav))); if (App.state.dnaSample2) - mk(`ДНК: ${short(App.state.dna2Label)}`, App.state.dnaSample2, 'lab_dna_match2', + mk(`ДНК (2): ${App.state.dna2Label}`, App.state.dnaSample2, 'lab_dna_match2', '', - () => pickCompare('Другий зразок: кого порівнюємо?', ids => dnaModal(ids, 'sf_dna2', App.state.dnaSample2))); + () => pickCompare('Другий зразок: кого порівнюємо?', (ids, nav) => dnaModal(ids, 'sf_dna2', App.state.dnaSample2, nav))); mk('Звірити відбиток зі знаряддя', App.state.printSample, 'lab_print_match', 'Немає повного відбитка: спершу треба знайти знаряддя злочину (підказку дає розтин).', () => pickCompare('Дактилоскопія: чиї картки підняти?', printModal)); @@ -369,10 +374,10 @@ } const laneHtml = (dna, name) => - `
+ `
${dna.map(v => `
`).join('')} -
${name}
`; +
${name}
`; // Ручний шортліст: аналітик сам вирішує, кого нести в лабораторію. // ponytail: сервер не гейтить ДНК — перебір усієї бази можливий, але по 6 і руками @@ -404,29 +409,35 @@ q.oninput = draw; draw(); mm.inner.querySelector('#pk-go').onclick = () => { if (!sel.size) return; - mm.wrap.remove(); go([...sel]); + // Шортліст не викидаємо, а ховаємо: «← Назад» із гелю повертає до вже + // обраних шести профілів, а не до нуля. + mm.wrap.style.display = 'none'; + go([...sel], { + back: () => { mm.wrap.style.display = ''; }, + onClose: () => mm.wrap.remove() + }); }; } - function dnaModal(ids, sampleEv, sample) { + 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('')}
`); +
${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.wrap.remove(); } + if (s) { App.send({ t: 'dna_pick', sampleEv, cid: s.dataset.cid }); mm.close(); } }; } - function printModal(ids) { + 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) { @@ -435,7 +446,7 @@ 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(); }; + w.onclick = () => { App.send({ t: 'print_pick', cid: id }); mm.close(); }; box.append(w); } mm.inner.append(hintBlock('Зразок знято під кутом — подумки поверніть його. Порівнюйте будову: кількість і хід дуг, а не поворот.')); diff --git a/detective-game/public/client.js b/detective-game/public/client.js index ae31fba..a30f87e 100644 --- a/detective-game/public/client.js +++ b/detective-game/public/client.js @@ -130,11 +130,31 @@ try { // ---------- Бічна дошка ---------- 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]; @@ -150,20 +170,49 @@ $("#sidetabs").onclick = (e) => { 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 marks = App.state.board.marks; + const cur = markOf(key); 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]; + for (const [kind, title, colour] of isPersonKey(key) + ? PEOPLE_MARKS + : EV_MARKS) { + const b = el( + "button", + (colour ? "swatch " : "") + (cur === kind ? "on" : ""), + colour + ? `` + : 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 }); @@ -174,8 +223,16 @@ function markButtons(key) { } function cardClasses(key) { - const m = App.state.board.marks[key]; - return m && m !== "hide" ? " " + m : ""; + 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 ``; } // Прокрутити дошку до доказу за id і коротко підсвітити. Якщо він у згорнутій @@ -207,38 +264,74 @@ function renderSide() { 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", - "Закріплене 📌 піде в справу для прокурора. Знайдено: " + - App.state.found.length, + "Закріплене 📌 піде в справу для прокурора. Знайдено: " + found.length, ), ); - const found = App.state.found; + + // Пошук + чипи-теги. До фіналу на дошці 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) => found.find((e) => e.id === id)) + .map((id) => shown.find((e) => e.id === id)) .filter(Boolean); - const rest = [...found].reverse().filter((e) => !pins.includes(e.id)); - const ordered = [...pinnedCards, ...rest]; + const rest = [...shown].reverse().filter((e) => !pins.includes(e.id)); const buildCard = (e) => { const key = "e:" + e.id; - const c = el("div", "card" + cardClasses(key)); + 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))); - const pin = el( - "button", - "pinbtn" + (pins.includes(e.id) ? " on" : ""), - "📌", - ); + 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, el("div", "small", esc(e.detail))); + 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); @@ -278,13 +371,31 @@ function renderSide() { }; const hidden = []; - for (const e of ordered) { - if (marks["e:" + e.id] === "hide") { - hidden.push(e); - continue; - } - body.append(buildCard(e)); - } + 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( @@ -330,15 +441,19 @@ function renderSide() { } } - // повернути фокус і курсор у поле, де друкували до перемальовки - if (activeKey) { + // Повернути фокус і курсор у поле, де друкували до перемальовки, — але лише + // якщо друкували щойно (див. 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; } } } @@ -380,7 +495,7 @@ function logSection(cid) { for (const e of log.slice(-12)) { const key = "q:" + e.id; let cur = App.state.board.marks[key]; - const row = el("div", "small"); + 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"; @@ -417,22 +532,59 @@ function bName(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) wrap.remove(); + 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.style.marginTop = "12px"; - x.onclick = () => wrap.remove(); - inner.append(x); + x.onclick = close; + foot.append(x); + inner.append(foot); + // Автори модалок дописують вміст уже після openModal (журнал допитів, підказки). + // Мікрозадача переносить кнопки в кінець, коли всі синхронні append'и минули. + queueMicrotask(() => inner.append(foot)); } document.body.append(wrap); - return { wrap, inner }; + return { wrap, inner, close }; } function showIntro() { @@ -450,7 +602,7 @@ function showIntro() { const analystHow = `
Аналітик