This commit is contained in:
UndecimoDia 2026-08-15 15:37:54 +03:00
parent 89757ffa31
commit 2c887ca451
11 changed files with 563 additions and 208 deletions

View file

@ -18,8 +18,8 @@ node server.js
- **Польовий детектив** — ходить районом (WASD/стрілки, E — увійти), обшукує місця, - **Польовий детектив** — ходить районом (WASD/стрілки, E — увійти), обшукує місця,
стукає у квартири, говорить з людьми. У приватне житло — лише з ключем або ордером. стукає у квартири, говорить з людьми. У приватне житло — лише з ключем або ордером.
- **Аналітик** — база мешканців (пошук за точним ім'ям з клавіатури — помилка в написанні - **Аналітик** — база мешканців (пошук за іменем з клавіатури — досить початку слова, регістр
дасть порожній результат; фільтри за слідами: взуття, зріст, одяг, відбитки), мапа району не важить; фільтри за слідами: взуття, зріст, одяг, відбитки), мапа району
з напарником наживо, системи (оператор, банки, камери, криміналістика, лабораторія) з напарником наживо, системи (оператор, банки, камери, криміналістика, лабораторія)
і **шифри**: Цезар, base64, PIN, злам md5-пароля за фактами з досьє. і **шифри**: Цезар, base64, PIN, злам md5-пароля за фактами з досьє.

View file

@ -22,11 +22,12 @@ function setupEvening(ctx) {
for (const c of citizens) c.claim = c.liar ? otherB(c.evening) : c.evening; 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); killer.claim = pick(r, coveredB.filter(b => b !== scene.building && b !== 'station')) || otherB(scene.building);
// Виправдання роздаємо тут (споживає RNG). А от свідка алібі (`mate`) рахувати
// рано: мутатори після нас іще переносять людей (спільниця йде на місце злочину),
// і вже роздані посилання протухають — див. assignMates() в кінці генерації.
for (const c of citizens) { for (const c of citizens) {
if (!c.liar || c === killer) continue; if (!c.liar || c === killer) continue;
c.excuse = pick(r, N.EXCUSES); 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;
} }
} }

View file

@ -713,6 +713,22 @@ function generateCase(seed) {
? `${scene.name}: сліди боротьби — і жодної людини. Зникла особа: ${victim.name}, ${victim.age} р., прож. ${victim.home}. Заручник, імовірно, ще живий. З'ясуйте, ХТО забрав людину, ДЕ її тримають і ЧОМУ. Викрадача немає в жодному списку підозрюваних — його збирають по слідах і базі мешканців. У вас один штурм: помилка в імені чи місці — і все марно.` ? `${scene.name}: сліди боротьби — і жодної людини. Зникла особа: ${victim.name}, ${victim.age} р., прож. ${victim.home}. Заручник, імовірно, ще живий. З'ясуйте, ХТО забрав людину, ДЕ її тримають і ЧОМУ. Викрадача немає в жодному списку підозрюваних — його збирають по слідах і базі мешканців. У вас один штурм: помилка в імені чи місці — і все марно.`
: `Ранок. ${scene.name} — тіло без документів. Ніхто не заявляв про зникнення. Акт I: з'ясуйте, ХТО загинув. Акт II: знайдіть, ХТО це зробив, ЯК і ЧОМУ. Вбивці немає у списку підозрюваних — його треба зібрати по слідах і базі мешканців. У вас одне звинувачення. Помилка — і вас звільнять.`; : `Ранок. ${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 { return {
seed: String(seed), title: 'Невпізнаний', district, citizens, puzzles, seed: String(seed), title: 'Невпізнаний', district, citizens, puzzles,
victim: victim.id, evidence: E, startingFacts, finale, templateId, caseType, introText, victim: victim.id, evidence: E, startingFacts, finale, templateId, caseType, introText,

View file

@ -26,12 +26,9 @@ function applyDoppelganger(ctx) {
doppelganger.liar = true; doppelganger.liar = true;
doppelganger.claim = pick(r, district.buildings.filter(b => b.id !== doppelganger.evening && b.id !== 'station').map(b => b.id)); 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); doppelganger.excuse = pick(r, N.EXCUSES);
// Свідка алібі тут НЕ рахуємо: мутатор працює до setupEvening, коли evening
// Знаходимо свідка, який може підтвердити справжнє алібі двійника // у всіх ще undefined, — предикат `o.evening === c.evening` зматчив би першого-ліпшого.
const mate = citizens.find(o => o.id !== victim.id && o.id !== killer.id && o.id !== herring.id && o.evening === doppelganger.evening); // Свідків роздає assignMates() у кінці генерації, коли вечір уже остаточний.
if (mate) {
doppelganger.mate = mate.id;
}
return doppelganger; return doppelganger;
} }

View file

@ -36,37 +36,41 @@ const COATS = ['чорному пальті', 'сірій куртці', 'син
// Чому люди брешуть про свій вечір (виправдання на повторному допиті). // Чому люди брешуть про свій вечір (виправдання на повторному допиті).
// Гравці самі вирішують, кому вірити, — гра вердиктів не ставить. // Гравці самі вирішують, кому вірити, — гра вердиктів не ставить.
// УВАГА: рядки роздаються `pick`ом незалежно від статі, тож усі вони мусять
// лишатися БЕЗРІДНИМИ — теперішній час, інфінітив або називна конструкція.
// Жодного минулого часу від першої особи («був», «пив», «ходив»): половина
// мешканців району — жінки. Тест у test.js це перевіряє.
const EXCUSES = [ const EXCUSES = [
'Я був не сам, і вдома про це знати не мусять. Ви розумієте.', 'Була компанія, і вдома про це знати не мусять. Ви розумієте.',
'Пив. Сильно. Не хотів, щоб про це знали на роботі.', 'Пиятика. Сильна. І на роботі про це знати не мусять.',
'Грав у карти на гроші в гаражах. Це ж не злочин... майже.', 'Карти на гроші в гаражах. Це ж не злочин... майже.',
'Я боявся: скажеш «був поруч» — і вже підозрюваний. От і збрехав.', 'Страх, розумієте: скажеш «я поруч» — і ти вже підозрюваний. От і брехня.',
'Позичав гроші в людини, про яку краще не говорити вголос.', 'Борг у людини, про яку краще не говорити вголос.',
'Ходив до ворожки. Смійтеся-смійтеся. Спробуйте самі поживіть із моїм щастям.', 'Ворожка. Смійтеся-смійтеся. Спробуйте самі поживіть із моїм щастям.',
'Шукав підробіток. Офіційно мені не можна, тож — тихцем.', 'Підробіток. Офіційно мені не можна, тож — тихцем.',
'Посварився з дружиною і ночував у майстерні. Соромно було казати.', 'Сварка вдома, ніч у майстерні. Соромно казати.',
'Здавав пляшки. Так, отакий у мене вечір. Задоволені?', 'Пляшки на здачу. Так, отакий у мене вечір. Задоволені?',
'Був у клініці. Аналізи. Це моя справа і нікого не обходить.', 'Клініка. Аналізи. Це моя справа і нікого не обходить.',
'Зустрічався з колишньою. Якщо дізнається теперішня — мені кінець.', 'Зустріч із колишнім коханням. Якщо дізнаються удома — мені кінець.',
'Продавав дещо з дому, поки рідня не бачить. Борги.', 'Дещо з дому — на продаж, поки рідня не бачить. Борги.',
'Сидів у барі до закриття. Мамі кажу, що не п\'ю.', 'Бар до самого закриття. Мамі кажу, що не п\'ю.',
'Ловив рибу без квитка. Інспектор мене вже двічі попереджав.', 'Риболовля без квитка. Інспектор мене вже двічі попереджав.',
'Плакав у машині. Буває. Не хотів, щоб хтось бачив.', 'Сльози в машині. Буває. І без свідків, будь ласка.',
'Ходив дивитися на квартиру жертви... тобто, я не так висловився. Просто гуляв тим двором.', 'Дивлюся іноді на ту квартиру... тобто, я не так кажу. Просто прогулянка тим двором.',
'Репетирував освідчення. Так, сам із собою. Годину. У дворі.', 'Репетиція освідчення. Так, наодинці. Годину. У дворі.',
'Був на співбесіді в іншому місті. Начальник не мусить знати.', 'Співбесіда в іншому місті. Начальник не мусить знати.',
'Виносив речі колишнього орендаря. Не питайте.', 'Речі колишнього орендаря треба було винести. Не питайте.',
'Дзвонив у передачу на радіо. Це анонімно, і хай так і лишиться.', 'Дзвінок у передачу на радіо. Це анонімно, і хай так і лишиться.',
'Фотографував птахів. Уночі. Сов, якщо точніше. Це хобі!', 'Фотографую птахів. Уночі. Сов, якщо точніше. Це хобі!',
'Стежив за власним чоловіком. І ні, не скажу, що вистежила.', 'Стеження за власною половиною. І ні, не скажу, що з того вийшло.',
'Пробував кинути курити і намотував кола районом, щоб не зірватися.', 'Кидаю курити — і намотую кола районом, щоб не зірватися.',
'Ремонтував чужу машину за готівку. Податкова ж не дізнається?', 'Ремонт чужої машини за готівку. Податкова ж не дізнається?',
'Ходив на могилу до брата. Про це не розповідаю нікому.', 'Могила брата. Про це нікому не розповідаю.',
'Грав у шахи на набережній. На гроші. Пенсії не вистачає.', 'Шахи на набережній. На гроші. Пенсії не вистачає.',
'Був у перукарки. Не в тій, до якої ходить моя сім\'я. Це складно.', 'Перукарка. Не та, до якої ходить моя сім\'я. Це складно.',
'Тренувався співати в елінгу. Там акустика. І ніхто не чує. Зазвичай.', 'Вправляюся співати в елінгу. Там акустика. І ніхто не чує. Зазвичай.',
'Розклеював оголошення про зниклого кота. Кіт не мій. Довга історія.', 'Оголошення про зниклого кота, по всіх стовпах. Кіт не мій. Довга історія.',
'Просто збрехав. Звичка з дитинства: менше скажеш — краще спиш.' 'Просто брехня. Звичка з дитинства: менше скажеш — краще спиш.'
]; ];
// Хто знайшов тіло / повідомив (для вступів) // Хто знайшов тіло / повідомив (для вступів)

View file

@ -59,6 +59,20 @@ function checkCase(seed) {
if (c.liar && c.id !== cs.finale.killer && c.id !== cs.accomplice) if (c.liar && c.id !== cs.finale.killer && c.id !== cs.accomplice)
assert.ok(c.excuse, 'у брехуна є виправдання на повторний допит'); 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; const ab = cs.evidence.alibi_broken;
assert.ok(ab && ab.special === 'alibi' && ab.gives.confirms, 'сплячий доказ алібі існує'); assert.ok(ab && ab.special === 'alibi' && ab.gives.confirms, 'сплячий доказ алібі існує');
@ -142,6 +156,17 @@ function checkCase(seed) {
}; };
walk(cs, ''); 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); const sol = solve(cs);
assert.ok(sol.identity, 'особу жертви можна встановити'); assert.ok(sol.identity, 'особу жертви можна встановити');

View file

@ -48,9 +48,9 @@
const p = panels.db; const p = panels.db;
p.innerHTML = ` p.innerHTML = `
<h3>База мешканців району</h3> <h3>База мешканців району</h3>
<p class="dim small">Пошук за точним ім'ям помилка в написанні дасть порожній результат. Значення для фільтрів беріть зі знайдених слідів і вводьте самі. Реєстр місцями застарілий: одяг і розмір взуття надійніше звіряти очним оглядом (ордер у польового).</p> <p class="dim small">Пошук за іменем: досить початку «шев» знайде «Шевченко», регістр не важить. Кілька слів звужують (ім'я + прізвище). Значення для фільтрів беріть зі знайдених слідів і вводьте самі. Реєстр місцями застарілий: одяг і розмір взуття надійніше звіряти очним оглядом (ордер у польового).</p>
<div class="filters"> <div class="filters">
<input id="q-name" placeholder="Ім'я Прізвище" style="width:220px"> <input id="q-name" placeholder="Ім'я Прізвище" style="min-width:220px">
<button id="q-go" class="primary">Знайти</button> <button id="q-go" class="primary">Знайти</button>
</div> </div>
<div class="filters"> <div class="filters">
@ -69,13 +69,7 @@
p.querySelector('#q-go').onclick = () => { p.querySelector('#q-go').onclick = () => {
const q = p.querySelector('#q-name').value.trim(); const q = p.querySelector('#q-name').value.trim();
if (!q) { lastRows = null; dbOut.innerHTML = '<p class="dim">— Введіть ім\'я або прізвище.</p>'; return; } if (!q) { lastRows = null; dbOut.innerHTML = '<p class="dim">— Введіть ім\'я або прізвище.</p>'; return; }
// Розбиваємо введений запит на слова lastRows = matchName(App.init.citizens, q); // спільний пошук, див. client.js
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)));
});
if (!lastRows.length) { if (!lastRows.length) {
dbOut.innerHTML = '<p class="dim">— Записів не знайдено. Спробуйте інший початок.</p>'; dbOut.innerHTML = '<p class="dim">— Записів не знайдено. Спробуйте інший початок.</p>';
return; return;
@ -97,7 +91,7 @@
}; };
} }
// рядки з позначками ✓/✗/…, як картки доказів: викреслюйте людей по одному // рядки з кольоровими мітками: ведіть кілька версій одночасно, по одній людині
function renderRows() { function renderRows() {
if (!dbOut || !lastRows || !App.state) return; if (!dbOut || !lastRows || !App.state) return;
dbOut.innerHTML = `<p class="dim small">Збігів: ${lastRows.length}</p>`; dbOut.innerHTML = `<p class="dim small">Збігів: ${lastRows.length}</p>`;
@ -184,28 +178,35 @@
<div class="syshead">Оператор зв'язку</div> <div class="syshead">Оператор зв'язку</div>
<div class="filters"> <div class="filters">
<input id="ph-n" placeholder="+380…" style="width:180px"> <input id="ph-n" placeholder="+380…" style="min-width:200px">
<button data-q="owner">Власник номера</button>
<button data-q="calls">Журнал дзвінків</button>
<button data-q="locate">Тріангуляція (де зараз)</button>
</div> </div>
<p class="dim small">Формат номера вільний: пробіли, дужки й дефіси система відкидає сама.</p>
<div class="filters">
<button data-q="owner">Архів: власник номера</button>
<button data-q="calls">Архів: журнал дзвінків за вечір</button>
</div>
<div class="filters">
<button data-q="locate">📍 Тріангуляція де апарат ЗАРАЗ</button>
</div>
<p class="dim small">Перші два запити архівні, про вечір злочину. Пеленг живий: показує поточну позицію апарата, і оператор дає його лише за наявності підстав.</p>
<div class="syshead">Банк</div> <div class="syshead">Банк</div>
<div class="filters"> <div class="filters">
<input id="bk-n" placeholder="Ім'я Прізвище або заклад" style="width:220px"> <input id="bk-n" placeholder="Ім'я Прізвище" style="min-width:220px">
<button id="bk-go">Запросити виписку</button> <button id="bk-go">Запросити виписку</button>
</div> </div>
<p class="dim small">Досить початку імені чи прізвища те саме правило, що й у базі мешканців. Якщо під запит підходить кілька осіб, банк попросить уточнити.</p>
<div class="syshead">Реєстри</div> <div class="syshead">Реєстри</div>
<div class="filters"> <div class="filters">
<input id="db-n" placeholder="ім'я / тату / кличка собаки…" style="width:220px"> <input id="db-n" placeholder="ім'я / тату / кличка собаки…" style="min-width:220px">
<button id="db-go">Пошук у реєстрах</button> <button id="db-go">Пошук у реєстрах</button>
</div> </div>
<div class="syshead">Криміналістика</div> <div class="syshead">Криміналістика</div>
<div class="filters"> <div class="filters">
<button id="cr-exp" class="primary">Експертиза місця/тіла</button> <button id="cr-exp" class="primary">Експертиза місця/тіла</button>
<input id="cr-n" placeholder="архів: рік, слово, прізвище…" style="width:200px"> <input id="cr-n" placeholder="архів: рік, слово, прізвище…" style="min-width:220px">
<button id="cr-go">Пошук в архіві справ</button> <button id="cr-go">Пошук в архіві справ</button>
</div> </div>
@ -293,21 +294,25 @@
if (!labBox || !App.state) return; if (!labBox || !App.state) return;
labBox.innerHTML = ''; labBox.innerHTML = '';
const foundId = id => App.state.found.some(f => f.id === id); 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 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.disabled = foundId(doneId);
b.onclick = () => sample == null ? toast(noSampleMsg) : run(); b.onclick = () => sample == null ? toast(noSampleMsg) : run();
labBox.append(b); labBox.append(b);
}; };
const short = t => t ? t.split(' ').slice(0, 3).join(' ') + '…' : ''; mk(App.state.dnaLabel ? `ДНК: ${App.state.dnaLabel}` : 'Порівняти ДНК зі зразком',
mk(App.state.dnaLabel ? `ДНК: ${short(App.state.dnaLabel)}` : 'Порівняти ДНК зі зразком',
App.state.dnaSample, 'lab_dna_match', 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) 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', mk('Звірити відбиток зі знаряддя', App.state.printSample, 'lab_print_match',
'Немає повного відбитка: спершу треба знайти знаряддя злочину (підказку дає розтин).', 'Немає повного відбитка: спершу треба знайти знаряддя злочину (підказку дає розтин).',
() => pickCompare('Дактилоскопія: чиї картки підняти?', printModal)); () => pickCompare('Дактилоскопія: чиї картки підняти?', printModal));
@ -369,10 +374,10 @@
} }
const laneHtml = (dna, name) => const laneHtml = (dna, name) =>
`<div style="display:inline-block;text-align:center;margin:0 7px;vertical-align:top"> `<div style="text-align:center;width:86px">
<div style="position:relative;width:26px;height:180px;background:#0d1116;border:1px solid var(--line);margin:0 auto"> <div style="position:relative;width:26px;height:180px;background:#0d1116;border:1px solid var(--line);margin:0 auto">
${dna.map(v => `<div style="position:absolute;left:2px;right:2px;top:${8 + v * 10}px;height:4px;background:var(--blue)"></div>`).join('')} ${dna.map(v => `<div style="position:absolute;left:2px;right:2px;top:${8 + v * 10}px;height:4px;background:var(--blue)"></div>`).join('')}
</div><div class="small dim" style="max-width:86px">${name}</div></div>`; </div><div class="small dim">${name}</div></div>`;
// Ручний шортліст: аналітик сам вирішує, кого нести в лабораторію. // Ручний шортліст: аналітик сам вирішує, кого нести в лабораторію.
// ponytail: сервер не гейтить ДНК — перебір усієї бази можливий, але по 6 і руками // ponytail: сервер не гейтить ДНК — перебір усієї бази можливий, але по 6 і руками
@ -404,29 +409,35 @@
q.oninput = draw; draw(); q.oninput = draw; draw();
mm.inner.querySelector('#pk-go').onclick = () => { mm.inner.querySelector('#pk-go').onclick = () => {
if (!sel.size) return; 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; if (!sample || !ids.length) return;
const mm = openModal(`<h3>Гель-електрофорез</h3> const mm = openModal(`<h3>Гель-електрофорез</h3>
<p class="dim small">Зразок із місця злочину ліворуч. Клацніть по профілю, який вважаєте збігом. Памʼятайте: збіг скаже лише, ЧИЯ це ДНК, не коли і не чому вона там.</p> <p class="dim small">Зразок із місця злочину ліворуч. Клацніть по профілю, який вважаєте збігом. Памʼятайте: збіг скаже лише, ЧИЯ це ДНК, не коли і не чому вона там.</p>
<div style="overflow-x:auto;white-space:nowrap">${laneHtml(sample, '<b>ЗРАЗОК</b>')}<span style="display:inline-block;width:16px;border-left:1px solid var(--line);height:180px;vertical-align:top"></span>${ids.map(id => { const c = App.byId(id); return `<span data-cid="${id}" style="cursor:pointer">${laneHtml(c.dna, esc(c.name))}</span>`; }).join('')}</div>`); <div style="display:flex;flex-wrap:wrap;align-items:flex-start;gap:8px 6px">${laneHtml(sample, '<b>ЗРАЗОК</b>')}<span style="width:0;border-left:1px solid var(--line);height:180px"></span>${ids.map(id => { const c = App.byId(id); return `<span data-cid="${id}" style="cursor:pointer">${laneHtml(c.dna, esc(c.name))}</span>`; }).join('')}</div>`, nav);
mm.inner.append(hintBlock('Збіг — це коли ВСІ смуги стоять на тих самих висотах, що у зразка. Помилковий вибір чесно скаже «ні» і викреслить людину.')); mm.inner.append(hintBlock('Збіг — це коли ВСІ смуги стоять на тих самих висотах, що у зразка. Помилковий вибір чесно скаже «ні» і викреслить людину.'));
mm.inner.onclick = e => { mm.inner.onclick = e => {
const s = e.target.closest('[data-cid]'); 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; if (App.state.printSample == null || !ids.length) return;
const mm = openModal(`<h3>Дактилоскопія: відбиток зі знаряддя</h3> const mm = openModal(`<h3>Дактилоскопія: відбиток зі знаряддя</h3>
<p class="dim small">Зразок ліворуч. Клацніть по власнику відбитка.</p> <p class="dim small">Зразок ліворуч. Клацніть по власнику відбитка.</p>
<canvas id="psample" width="150" height="150" style="border:1px solid var(--acc);border-radius:6px"></canvas> <canvas id="psample" width="150" height="150" style="border:1px solid var(--acc);border-radius:6px"></canvas>
<span class="dim small"> зразок</span> <span class="dim small"> зразок</span>
<div id="pcands" style="display:flex;gap:12px;flex-wrap:wrap;margin-top:10px"></div>`); <div id="pcands" style="display:flex;gap:12px;flex-wrap:wrap;margin-top:10px"></div>`, nav);
drawPrint(mm.inner.querySelector('#psample').getContext('2d'), App.state.printSample, 0.7); drawPrint(mm.inner.querySelector('#psample').getContext('2d'), App.state.printSample, 0.7);
const box = mm.inner.querySelector('#pcands'); const box = mm.inner.querySelector('#pcands');
for (const id of ids) { 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'; 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))); w.append(cv, el('div', 'small dim', esc(c.name)));
drawPrint(cv.getContext('2d'), c.printSeed, 0); 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); box.append(w);
} }
mm.inner.append(hintBlock('Зразок знято під кутом — подумки поверніть його. Порівнюйте будову: кількість і хід дуг, а не поворот.')); mm.inner.append(hintBlock('Зразок знято під кутом — подумки поверніть його. Порівнюйте будову: кількість і хід дуг, а не поворот.'));

View file

@ -130,11 +130,31 @@ try {
// ---------- Бічна дошка ---------- // ---------- Бічна дошка ----------
let sideTab = "ev"; let sideTab = "ev";
let showHidden = false; // згорнута секція «Приховані» на дні дошки let showHidden = false; // згорнута секція «Приховані» на дні дошки
let evFilter = ""; // текстовий фільтр по картках доказів
let evTag = null; // активний чип-тег (null = всі)
// Дошка перемальовується на кожен стан — тож недодруковані нотатки треба зберігати, // Дошка перемальовується на кожен стан — тож недодруковані нотатки треба зберігати,
// інакше broadcast напарника стирає те, що ти зараз друкуєш. Тримаємо чернетку в мапі // інакше broadcast напарника стирає те, що ти зараз друкуєш. Тримаємо чернетку в мапі
// (ключ = data-draft-key поля) і відновлюємо фокус/курсор після перемальовки. // (ключ = data-draft-key поля) і відновлюємо фокус/курсор після перемальовки.
const noteDraft = {}; 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) { function wireDraft(input, key) {
input.dataset.draftKey = key; input.dataset.draftKey = key;
if (noteDraft[key]) input.value = noteDraft[key]; if (noteDraft[key]) input.value = noteDraft[key];
@ -150,20 +170,49 @@ $("#sidetabs").onclick = (e) => {
renderSide(); 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) { function markButtons(key) {
const marks = App.state.board.marks; const cur = markOf(key);
const mk = el("div", "mk"); const mk = el("div", "mk");
for (const [kind, label] of [ for (const [kind, title, colour] of isPersonKey(key)
["tick", "✓"], ? PEOPLE_MARKS
["cross", "✗"], : EV_MARKS) {
["hide", "…"], const b = el(
]) { "button",
const b = el("button", marks[key] === kind ? "on" : "", label); (colour ? "swatch " : "") + (cur === kind ? "on" : ""),
b.title = { colour
tick: "відмітити", ? `<span class="dot" style="background:${colour}"></span>`
cross: "викреслити", : MARK_GLYPH[kind],
hide: "сховати", );
}[kind]; if (title) b.title = title; // кольори лишаємо без підписів — значення за гравцями
if (colour) b.style.color = colour;
b.onclick = (ev) => { b.onclick = (ev) => {
ev.stopPropagation(); ev.stopPropagation();
App.send({ t: "mark", key, kind }); App.send({ t: "mark", key, kind });
@ -174,8 +223,16 @@ function markButtons(key) {
} }
function cardClasses(key) { function cardClasses(key) {
const m = App.state.board.marks[key]; const m = markOf(key);
return m && m !== "hide" ? " " + m : ""; 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 і коротко підсвітити. Якщо він у згорнутій // Прокрутити дошку до доказу за id і коротко підсвітити. Якщо він у згорнутій
@ -207,38 +264,74 @@ function renderSide() {
if (sideTab === "ev") { if (sideTab === "ev") {
const pins = App.state.board.pins; const pins = App.state.board.pins;
const found = App.state.found;
const tagOf = (e) => e.tag || "запит";
body.append( body.append(
el( el(
"div", "div",
"dim small", "dim small",
"Закріплене 📌 піде в справу для прокурора. Знайдено: " + "Закріплене 📌 піде в справу для прокурора. Знайдено: " + found.length,
App.state.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 — у порядку закріплення.) // від найновішого знайденого до найстарішого. (pins — у порядку закріплення.)
const pinnedCards = [...pins] const pinnedCards = [...pins]
.reverse() .reverse()
.map((id) => found.find((e) => e.id === id)) .map((id) => shown.find((e) => e.id === id))
.filter(Boolean); .filter(Boolean);
const rest = [...found].reverse().filter((e) => !pins.includes(e.id)); const rest = [...shown].reverse().filter((e) => !pins.includes(e.id));
const ordered = [...pinnedCards, ...rest];
const buildCard = (e) => { const buildCard = (e) => {
const key = "e:" + e.id; 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; // якір для прокрутки-до-доказу c.dataset.ev = e.id; // якір для прокрутки-до-доказу
const t = el("div", "t"); const t = el("div", "t");
t.append(el("b", "", esc(e.title))); t.append(el("b", "", esc(e.title)), el("span", "tag", esc(tagOf(e))));
const pin = el( const pin = el("button", "pinbtn" + (isPinned ? " on" : ""), "📌");
"button", pin.title = isPinned ? "відкріпити" : "закріпити для прокурора";
"pinbtn" + (pins.includes(e.id) ? " on" : ""),
"📌",
);
pin.onclick = () => App.send({ t: "pin", evId: e.id }); pin.onclick = () => App.send({ t: "pin", evId: e.id });
t.append(pin); 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) { if (e.ref) {
const src = found.find((x) => x.id === e.ref); const src = found.find((x) => x.id === e.ref);
@ -278,13 +371,31 @@ function renderSide() {
}; };
const hidden = []; const hidden = [];
for (const e of ordered) { const visible = (arr) =>
if (marks["e:" + e.id] === "hide") { arr.filter((e) => {
hidden.push(e); if (marks["e:" + e.id] === "hide") {
continue; hidden.push(e);
} return false;
body.append(buildCard(e)); }
} 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) { if (hidden.length) {
const head = el( 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}"]`); const again = body.querySelector(`[data-draft-key="${activeKey}"]`);
if (again) { if (again) {
restoringFocus = true;
again.focus(); again.focus();
if (caret != null) if (caret != null)
try { try {
again.setSelectionRange(caret, caret); again.setSelectionRange(caret, caret);
} catch {} } catch {}
restoringFocus = false;
} }
} }
} }
@ -380,7 +495,7 @@ function logSection(cid) {
for (const e of log.slice(-12)) { for (const e of log.slice(-12)) {
const key = "q:" + e.id; const key = "q:" + e.id;
let cur = App.state.board.marks[key]; 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"; row.style.cssText = "padding-left:7px;border-left:2px solid transparent;margin:5px 0";
const apply = (mk) => { const apply = (mk) => {
row.style.textDecoration = mk === "cross" ? "line-through" : "none"; row.style.textDecoration = mk === "cross" ? "line-through" : "none";
@ -417,22 +532,59 @@ function bName(bid) {
return b ? b.name : 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 = {}) { function openModal(html, opts = {}) {
const wrap = el("div", "modal"); const wrap = el("div", "modal");
const inner = el("div", "", html); const inner = el("div", "", html);
wrap.append(inner); wrap.append(inner);
const close = () => {
wrap.remove();
if (opts.onClose) opts.onClose();
};
if (!opts.locked) { if (!opts.locked) {
wrap.onclick = (e) => { 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", "Закрити"); const x = el("button", "small", "Закрити");
x.style.marginTop = "12px"; x.onclick = close;
x.onclick = () => wrap.remove(); foot.append(x);
inner.append(x); inner.append(foot);
// Автори модалок дописують вміст уже після openModal (журнал допитів, підказки).
// Мікрозадача переносить кнопки в кінець, коли всі синхронні append'и минули.
queueMicrotask(() => inner.append(foot));
} }
document.body.append(wrap); document.body.append(wrap);
return { wrap, inner }; return { wrap, inner, close };
} }
function showIntro() { function showIntro() {
@ -450,7 +602,7 @@ function showIntro() {
const analystHow = ` const analystHow = `
<div class="syshead">Аналітик</div> <div class="syshead">Аналітик</div>
<ul class="small"> <ul class="small">
<li><b>База мешканців:</b> фільтри вводите руками — значення беріть із доказів. Викреслюйте людей по одному (✓//). Реєстр місцями застарілий: одяг/взуття надійніше звіряти очним оглядом польового.</li> <li><b>База мешканців:</b> фільтри вводите руками значення беріть із доказів. Мітьте людей кольорами чотири кольори плюс «» (сховати); що означає кожен, вирішуєте ви з напарником, гра не підказує. Реєстр місцями застарілий: одяг/взуття надійніше звіряти очним оглядом польового.</li>
<li><b>Системи:</b> оператор (власник, дзвінки, тріангуляція «де зараз»), банк, реєстри, криміналістика (експертиза + архів), камери. Дані для запитів шукайте в доказах. <b>Кожен результат лягає на дошку</b> навіть дрібниця; що з того справа, вирішуєте ви.</li> <li><b>Системи:</b> оператор (власник, дзвінки, тріангуляція «де зараз»), банк, реєстри, криміналістика (експертиза + архів), камери. Дані для запитів шукайте в доказах. <b>Кожен результат лягає на дошку</b> навіть дрібниця; що з того справа, вирішуєте ви.</li>
<li><b>Журнал дзвінків</b> це покази, які не брешуть: хто з ким говорив і о котрій у вечір злочину. Звіряйте з тим, що люди кажуть на допиті. Порожній вечір теж промовистий: апарат могли вимкнути.</li> <li><b>Журнал дзвінків</b> це покази, які не брешуть: хто з ким говорив і о котрій у вечір злочину. Звіряйте з тим, що люди кажуть на допиті. Порожній вечір теж промовистий: апарат могли вимкнути.</li>
<li><b>Вилучені пристрої</b> потрапляють у лабораторію заблокованими: PIN це зазвичай рік народження власника (є в реєстрі), пароль ноутбука або щось особисте латиницею, або словниковий перебір. Усередині буває історія браузера й чати а буває порожньо.</li> <li><b>Вилучені пристрої</b> потрапляють у лабораторію заблокованими: PIN це зазвичай рік народження власника (є в реєстрі), пароль ноутбука або щось особисте латиницею, або словниковий перебір. Усередині буває історія браузера й чати а буває порожньо.</li>

View file

@ -164,6 +164,11 @@
cv.id = "cv"; cv.id = "cv";
cv.width = m.district.world.w; cv.width = m.district.world.w;
cv.height = m.district.world.h; cv.height = m.district.world.h;
// Канвас — повноцінна ціль фокуса: клік по району забирає клавіатуру назад
// у польового, а Esc із будь-якого поля вводу повертає її сюди (див. keydown).
cv.tabIndex = 0;
cv.style.outline = "none";
cv.addEventListener("mousedown", () => cv.focus());
hint = document.createElement("div"); hint = document.createElement("div");
hint.id = "fieldhint"; hint.id = "fieldhint";
stage.append(cv, hint); stage.append(cv, hint);
@ -181,8 +186,16 @@
addEventListener("resize", () => fitCanvas(cv)); addEventListener("resize", () => fitCanvas(cv));
// e.code — фізична клавіша, працює на будь-якій розкладці (укр/лат) // e.code — фізична клавіша, працює на будь-якій розкладці (укр/лат)
addEventListener("keydown", (e) => { addEventListener("keydown", (e) => {
if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA") const typing =
e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA";
// Esc — аварійний вихід із поля вводу назад до району. Без нього клавіатура
// лишалася «зайнятою» нотаткою на дошці, і рух мовчки не працював.
if (typing && e.code === "Escape") {
e.target.blur();
cv.focus();
return; return;
}
if (typing) return;
keys[e.code] = true; keys[e.code] = true;
if (e.code === "KeyE") tryEnter(); if (e.code === "KeyE") tryEnter();
}); });
@ -254,9 +267,14 @@
ctx.fillStyle = vg; ctx.fillStyle = vg;
ctx.fillRect(0, 0, D.w, D.h); ctx.fillRect(0, 0, D.w, D.h);
const b = nearBuilding(); const b = nearBuilding();
hint.textContent = b const ae = document.activeElement;
? `E — ${OPEN_ZONES.includes(b.id) ? "оглянутися: " : "увійти: "}${b.name}` const typing =
: "WASD/стрілки — рух, E — увійти/оглянутися"; ae && (ae.tagName === "INPUT" || ae.tagName === "TEXTAREA");
hint.textContent = typing
? "клавіатура зайнята полем вводу — Esc або клік по району, щоб повернути керування"
: b
? `E — ${OPEN_ZONES.includes(b.id) ? "оглянутися: " : "увійти: "}${b.name}`
: "WASD/стрілки — рух, E — увійти/оглянутися";
requestAnimationFrame(loop); requestAnimationFrame(loop);
} }
@ -282,50 +300,58 @@
} }
function stationModal() { function stationModal() {
// Відділок лишається під усім, що з нього відкривають: і база, і ордер —
// це кроки, з яких треба вміти повернутись, а не заміщення екрана.
const m = openModal(`<h3>Відділок поліції</h3> const m = openModal(`<h3>Відділок поліції</h3>
<p class="dim small">Ваша база: дошка справи і термінал аналітика синхронізовані. Тут-таки сидить прокурор ордер на обшук житла подають особисто, з доказом, який прямо вказує на людину.</p> <p class="dim small">Ваша база: дошка справи і термінал аналітика синхронізовані. Тут-таки сидить прокурор ордер на обшук житла подають особисто, з доказом, який прямо вказує на людину.</p>
<button class="primary" id="warr">📜 Подати на ордер</button> <button class="primary" id="warr">📜 Подати на ордер</button>
<button id="db">🗂 База мешканців</button>`); <button id="db">🗂 База мешканців</button>`);
m.inner.querySelector("#db").onclick = () => { m.wrap.remove(); residentsModal(); }; m.inner.querySelector("#db").onclick = residentsModal;
m.inner.querySelector("#warr").onclick = () => { m.inner.querySelector("#warr").onclick = warrantFlow;
m.wrap.remove(); }
pickCitizen("Ордер: на кого?", (cid) => {
const c = App.byId(cid); // Вибір людини → вибір підстави. «← Назад» на обох кроках повертає до вибору
// до прокурора йде лише закріплене 📌 — тека справи, а не вся дошка // людини: раніше порожня тека була глухим кутом, а помилка в імені коштувала
const evs = App.state.found.filter((e) => // всього ланцюга заново.
App.state.board.pins.includes(e.id), function warrantFlow() {
); pickCitizen("Ордер: на кого?", (cid) => {
if (!evs.length) const c = App.byId(cid);
return openModal( // до прокурора йде лише закріплене 📌 — тека справи, а не вся дошка
`<h3>Підстава для ордера на: ${esc(c.name)}</h3> const evs = App.state.found.filter((e) =>
<p class="dim">Тека порожня. Спершу закріпіть 📌 на дошці доказ, який прямо вказує на цю особу, з усім підряд до прокурора не ходять.</p>`, App.state.board.pins.includes(e.id),
); );
const mm = openModal( if (!evs.length)
return openModal(
`<h3>Підстава для ордера на: ${esc(c.name)}</h3> `<h3>Підстава для ордера на: ${esc(c.name)}</h3>
<p class="dim small">Прокурор підпише, лише якщо доказ прямо вказує на цю особу. У теці закріплене 📌.</p> <p class="dim">Тека порожня. Спершу закріпіть 📌 на дошці доказ, який прямо вказує на цю особу, з усім підряд до прокурора не ходять.</p>`,
<div class="grid" style="grid-template-columns:1fr">` + { back: warrantFlow },
evs
.map((e) => `<button data-ev="${e.id}">${esc(e.title)}</button>`)
.join("") +
"</div>",
); );
mm.inner.onclick = (e) => { const mm = openModal(
if (!e.target.dataset.ev) return; `<h3>Підстава для ордера на: ${esc(c.name)}</h3>
App.send({ t: "warrant", cid, evId: e.target.dataset.ev }); <p class="dim small">Прокурор підпише, лише якщо доказ прямо вказує на цю особу. У теці закріплене 📌.</p>
mm.wrap.remove(); <div class="grid" style="grid-template-columns:1fr">` +
}; evs
}); .map((e) => `<button data-ev="${e.id}">${esc(e.title)}</button>`)
}; .join("") +
"</div>",
{ back: warrantFlow },
);
mm.inner.onclick = (e) => {
if (!e.target.dataset.ev) return;
App.send({ t: "warrant", cid, evId: e.target.dataset.ev });
mm.wrap.remove();
};
});
} }
// База мешканців для польового: той самий реєстр, що бачить аналітик, з його // База мешканців для польового: той самий реєстр, що бачить аналітик, з його
// мітками (✓/✗/…) — тільки перегляд, мітки ставить аналітик. // кольоровими мітками — тільки перегляд, мітки ставить аналітик.
function residentsModal() { function residentsModal() {
const coats = [...new Set(App.init.citizens.map((c) => c.coat))]; const coats = [...new Set(App.init.citizens.map((c) => c.coat))];
const m = openModal(`<h3>База мешканців району</h3> const m = openModal(`<h3>База мешканців району</h3>
<p class="dim small">Термінал аналітика, тільки перегляд. Мітки (//) ставить аналітик тут вони лише видно. Реєстр місцями застарілий: одяг і взуття надійніше звіряти очним оглядом за ордером.</p> <p class="dim small">Термінал аналітика, тільки перегляд. Кольорові мітки ставить аналітик тут вони лише видно; що означає кожен колір, домовляєтесь ви двоє. Реєстр місцями застарілий: одяг і взуття надійніше звіряти очним оглядом за ордером.</p>
<div class="filters"> <div class="filters">
<input id="rq" placeholder="Ім'я Прізвище" style="width:200px"><button id="rqgo">Знайти</button> <input id="rq" placeholder="Ім'я Прізвище" style="min-width:200px"><button id="rqgo">Знайти</button>
</div> </div>
<div class="filters"> <div class="filters">
<select id="rf-sex"><option value="">стать: будь-яка</option><option value="ч">чол.</option><option value="ж">жін.</option></select> <select id="rf-sex"><option value="">стать: будь-яка</option><option value="ч">чол.</option><option value="ж">жін.</option></select>
@ -340,7 +366,6 @@
</div> </div>
<div id="rout"><p class="dim small">Задайте пошук або фільтр.</p></div>`); <div id="rout"><p class="dim small">Задайте пошук або фільтр.</p></div>`);
const out = m.inner.querySelector("#rout"); const out = m.inner.querySelector("#rout");
const MK = { tick: "✓", cross: "✗", hide: "…" };
const draw = (rows) => { const draw = (rows) => {
out.innerHTML = `<p class="dim small">Збігів: ${rows.length}</p>`; out.innerHTML = `<p class="dim small">Збігів: ${rows.length}</p>`;
const t = el( const t = el(
@ -350,7 +375,9 @@
); );
for (const c of rows) { for (const c of rows) {
const key = "c:" + c.id; const key = "c:" + c.id;
const mark = App.state.board.marks[key]; // Мітки ставить аналітик — тут вони лише видно: підпис плюс кольорова
// ліва грань рядка (див. cardClasses). На папері дот сам по собі губиться.
const mark = markOf(key);
const vic = const vic =
App.state.victim === c.id App.state.victim === c.id
? App.init.caseType === "kidnap" ? App.init.caseType === "kidnap"
@ -360,7 +387,7 @@
const tr = el( const tr = el(
"tr", "tr",
"rowlink" + cardClasses(key), "rowlink" + cardClasses(key),
`<td>${esc(c.name)}${vic}</td><td>${c.age}</td><td>${esc(c.home)}</td><td>${esc(c.job)}</td><td>${c.height}</td><td>${c.shoe}</td><td>${esc(c.printType)}</td><td class="dim">${mark ? MK[mark] : ""}</td>`, `<td>${esc(c.name)}${vic}</td><td>${c.age}</td><td>${esc(c.home)}</td><td>${esc(c.job)}</td><td>${c.height}</td><td>${c.shoe}</td><td>${esc(c.printType)}</td><td class="dim">${markSwatch(mark)}</td>`,
); );
tr.onclick = () => showDossier(c); tr.onclick = () => showDossier(c);
t.append(tr); t.append(tr);
@ -368,21 +395,14 @@
out.append(t); out.append(t);
}; };
const byName = () => { const byName = () => {
const q = m.inner.querySelector("#rq").value.trim().toLowerCase(); const q = m.inner.querySelector("#rq").value.trim();
if (!q) { if (!q) {
out.innerHTML = '<p class="dim small">— Введіть ім\'я або прізвище.</p>'; out.innerHTML = '<p class="dim small">— Введіть ім\'я або прізвище.</p>';
return; return;
} }
// Розбиваємо введений запит на слова const hits = matchName(App.init.citizens, q); // спільний пошук, див. client.js
const words = q.split(/\s+/);
// Фільтруємо: кожне введене слово має збігатися з початком якогось слова в імені громадянина
const hits = App.init.citizens.filter((c) => {
const nameWords = c.name.toLowerCase().split(/\s+/);
return words.every((w) => nameWords.some((nw) => nw.startsWith(w)));
});
if (!hits.length) { if (!hits.length) {
out.innerHTML = '<p class="dim small">— Записів не знайдено. Спробуйте інший початок.</p>'; out.innerHTML = '<p class="dim small">— Записів не знайдено. Спробуйте інший початок.</p>';
@ -484,10 +504,9 @@
spot: b.spots[t.dataset.spot], spot: b.spots[t.dataset.spot],
}); });
if (t.dataset.cid) talkTo(t.dataset.cid); if (t.dataset.cid) talkTo(t.dataset.cid);
if (t.dataset.unit) { // Двері квартири відкриваються ПОВЕРХ під'їзду: закрив — ти знову в будинку,
m.wrap.remove(); // а не на порожньому екрані.
openUnitDoor(b, t.dataset.unit); if (t.dataset.unit) openUnitDoor(b, t.dataset.unit);
}
}; };
} }
@ -500,15 +519,14 @@
<p class="dim small">${home.length ? "Мешканці: " + home.map((c) => esc(c.name)).join(", ") : "За реєстром тут ніхто не живе."}</p> <p class="dim small">${home.length ? "Мешканці: " + home.map((c) => esc(c.name)).join(", ") : "За реєстром тут ніхто не живе."}</p>
<button id="knock">Постукати</button> <button id="knock">Постукати</button>
<button id="enter">Увійти${App.state.keys.includes(unit) ? " 🔑" : ""}</button>`); <button id="enter">Увійти${App.state.keys.includes(unit) ? " 🔑" : ""}</button>`);
// Двері лишаються на екрані: «Замкнено» тепер не викидає в порожнечу —
// тост прилітає, а спробувати постукати чи піти назад можна одразу.
m.inner.querySelector("#knock").onclick = () => { m.inner.querySelector("#knock").onclick = () => {
if (!present.length) return toast("Тиша. Ніхто не відчиняє."); if (!present.length) return toast("Тиша. Ніхто не відчиняє.");
m.wrap.remove();
pickPerson(present); pickPerson(present);
}; };
m.inner.querySelector("#enter").onclick = () => { m.inner.querySelector("#enter").onclick = () =>
m.wrap.remove();
App.send({ t: "enter_unit", unit }); App.send({ t: "enter_unit", unit });
};
} }
App.on("unit", (m) => { App.on("unit", (m) => {
@ -557,6 +575,12 @@
App.send({ t: "talk", cid }); App.send({ t: "talk", cid });
} }
// Розмова — це цикл «питання → відповідь → наступне питання», а не одноразовий
// крок. Модалка розмови завжди одна: сервер віддає теми у відповідь на `talk`,
// і кожна нова відповідь просто перемальовує її разом із журналом.
let talkModal = null;
let flashCid = null; // до кого прилетіла свіжа відповідь — підсвітити останній запис
App.on("topics", (m) => { App.on("topics", (m) => {
const c = App.byId(m.cid); const c = App.byId(m.cid);
const gossip = const gossip =
@ -576,6 +600,11 @@
.join("") + .join("") +
'</div><p class="dim small">Сірі теми вже в доказах на дошці.</p>' '</div><p class="dim small">Сірі теми вже в доказах на дошці.</p>'
: ""; : "";
if (talkModal) {
const old = talkModal;
talkModal = null; // прибираємо самі — onClose не має скидати нову модалку
old.wrap.remove();
}
const mm = openModal( const mm = openModal(
`<h3>${esc(c.name)}</h3>` + `<h3>${esc(c.name)}</h3>` +
topicsHtml + topicsHtml +
@ -590,10 +619,23 @@
<button id="sh-go">Показати доказ</button> <button id="sh-go">Показати доказ</button>
${App.state.warrants.includes(m.cid) ? '<button id="insp-go">Оглянути особисто 📜</button>' : ''} ${App.state.warrants.includes(m.cid) ? '<button id="insp-go">Оглянути особисто 📜</button>' : ''}
</div> </div>
<p class="dim small">Відповіді можуть бути правдою, брехнею або «не знаю» перевіряйте камерами і чужими словами.</p>` + <p class="dim small">Відповіді можуть бути правдою, брехнею або «не знаю» перевіряйте камерами і чужими словами. Модалка лишається відкритою: питайте далі, «Закрити» завершує розмову.</p>` +
gossip, gossip,
{ onClose: () => { if (talkModal === mm) talkModal = null; } },
); );
talkModal = mm;
mm.inner.append(logSection(m.cid)); // журнал із мітками (спільний із досьє аналітика) mm.inner.append(logSection(m.cid)); // журнал із мітками (спільний із досьє аналітика)
// Свіжа відповідь лежить останнім записом журналу — прокручуємо й підсвічуємо,
// інакше її не видно за темами й плітками.
if (flashCid === m.cid) {
flashCid = null;
const rows = mm.inner.querySelectorAll(".logrow");
const last = rows[rows.length - 1];
if (last) {
last.classList.add("flash");
last.scrollIntoView({ block: "center" });
}
}
const sl = mm.inner.querySelector("#al-t"), const sl = mm.inner.querySelector("#al-t"),
out = mm.inner.querySelector("#al-v"); out = mm.inner.querySelector("#al-v");
const fmt = (v) => const fmt = (v) =>
@ -601,20 +643,17 @@
const upd = () => (out.textContent = fmt(+sl.value)); const upd = () => (out.textContent = fmt(+sl.value));
sl.oninput = upd; sl.oninput = upd;
upd(); upd();
mm.inner.querySelector("#al-go").onclick = () => { // Жоден крок нижче не знімає модалку розмови: picker'и й результати
// відкриваються ПОВЕРХ неї, а скасування повертає до співрозмовника.
mm.inner.querySelector("#al-go").onclick = () =>
App.send({ t: "ask_alibi", cid: m.cid, minutes: +sl.value }); App.send({ t: "ask_alibi", cid: m.cid, minutes: +sl.value });
mm.wrap.remove(); mm.inner.querySelector("#ab-go").onclick = () =>
};
mm.inner.querySelector("#ab-go").onclick = () => {
mm.wrap.remove();
pickCitizen( pickCitizen(
`${esc(c.first)}, що скажете про…`, `${esc(c.first)}, що скажете про…`,
(about) => App.send({ t: "ask_about", cid: m.cid, about }), (about) => App.send({ t: "ask_about", cid: m.cid, about }),
(x) => x.id === m.cid, (x) => x.id === m.cid,
); );
};
mm.inner.querySelector("#sh-go").onclick = () => { mm.inner.querySelector("#sh-go").onclick = () => {
mm.wrap.remove();
const pm = openModal( const pm = openModal(
'<h3>Показати доказ</h3><div class="grid" style="grid-template-columns:1fr">' + '<h3>Показати доказ</h3><div class="grid" style="grid-template-columns:1fr">' +
App.state.found App.state.found
@ -630,34 +669,29 @@
}; };
}; };
const insp = mm.inner.querySelector("#insp-go"); const insp = mm.inner.querySelector("#insp-go");
if (insp) if (insp) insp.onclick = () => App.send({ t: "inspect", cid: m.cid });
insp.onclick = () => {
App.send({ t: "inspect", cid: m.cid });
mm.wrap.remove();
};
mm.inner.onclick = (e) => { mm.inner.onclick = (e) => {
if (e.target.dataset.topic) { if (e.target.dataset.topic) {
App.send({ t: "ask", evId: e.target.dataset.topic }); App.send({ t: "ask", evId: e.target.dataset.topic });
mm.wrap.remove(); // Тема пішла на дошку — перезбираємо розмову, щоб вона стала сірою ✓,
// а решта тем лишилась під рукою: їх зазвичай кілька поспіль.
App.send({ t: "talk", cid: m.cid });
} }
}; };
}); });
// результат очного огляду: нікуди не пишеться — переносьте в нотатки самі // Результат очного огляду: нікуди не пишеться — переносьте в нотатки самі.
// Відкривається поверх розмови, «Закрити» повертає до співрозмовника.
App.on("inspect", (m) => { App.on("inspect", (m) => {
const c = App.byId(m.cid); const c = App.byId(m.cid);
openModal( openModal(`<h3>Очний огляд: ${esc(c.name)}</h3><p>${esc(m.body)}</p>`);
`<h3>Очний огляд: ${esc(c.name)}</h3><p>${esc(m.body)}</p>`,
);
}); });
// відповідь допитуваного // Відповідь допитуваного. Сироту-модалку не відкриваємо: просимо теми ще раз —
// розмова перемалюється вже з новим записом у журналі, і можна питати далі.
App.on("answer", (m) => { App.on("answer", (m) => {
const c = App.byId(m.cid); flashCid = m.cid;
openModal( App.send({ t: "talk", cid: m.cid });
`<h3>${esc(c.name)}</h3><p class="dim small">${esc(m.q)}</p><p>${esc(m.a)}</p>
<p class="dim small">Записано в журнал допитів (досьє в аналітика і картка розмови).</p>`,
);
}); });
// ---------- міні-гра «вилучення»: тремтячі руки псують доказ ---------- // ---------- міні-гра «вилучення»: тремтячі руки псують доказ ----------

View file

@ -10,6 +10,7 @@
--txt:#cfd6df; --dim:#7d8794; --acc:#e8b04b; --red:#c94f4f; --green:#5aa870; --blue:#5b8dbf; --txt:#cfd6df; --dim:#7d8794; --acc:#e8b04b; --red:#c94f4f; --green:#5aa870; --blue:#5b8dbf;
--paper:#e9ddc0; --paper2:#dcCfae; --ink:#282218; --inkdim:#6f6450; --paperline:#c3b28b; --paper:#e9ddc0; --paper2:#dcCfae; --ink:#282218; --inkdim:#6f6450; --paperline:#c3b28b;
--amber:#ffb454; --amberdim:#8a6a34; --crt:#0e0903; --amber:#ffb454; --amberdim:#8a6a34; --crt:#0e0903;
--mk-green:#5aa870; --mk-red:#c94f4f; --mk-blue:#5b8dbf; --mk-amber:#e8b04b;
--noise:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='140' height='140'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='2'/><feColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .05 0'/></filter><rect width='140' height='140' filter='url(%23n)'/></svg>"); --noise:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='140' height='140'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='2'/><feColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .05 0'/></filter><rect width='140' height='140' filter='url(%23n)'/></svg>");
} }
* { box-sizing:border-box; margin:0; } * { box-sizing:border-box; margin:0; }
@ -63,7 +64,10 @@
.badge.on { color:var(--green); border-color:var(--green); } .badge.on { color:var(--green); border-color:var(--green); }
#main { flex:1; display:flex; min-height:0; } #main { flex:1; display:flex; min-height:0; }
#stage { flex:1; min-width:0; display:flex; flex-direction:column; padding:12px; gap:8px; overflow:auto; } #stage { flex:1; min-width:0; display:flex; flex-direction:column; padding:12px; gap:8px; overflow:auto; }
/* Дошка — недоторканний шар: модалка (z-index:40) її НЕ накриває, інакше будь-який
обшук чи допит відрізав би обох гравців від доказів саме тоді, коли вони потрібні. */
#side { width:400px; border-left:1px solid #1c130c; display:flex; flex-direction:column; min-height:0; #side { width:400px; border-left:1px solid #1c130c; display:flex; flex-direction:column; min-height:0;
position:relative; z-index:45;
background:linear-gradient(160deg, #33261a, #2a1f14 60%, #241a10); background-blend-mode:multiply; } background:linear-gradient(160deg, #33261a, #2a1f14 60%, #241a10); background-blend-mode:multiply; }
#side::before { content:''; position:absolute; } /* резерв */ #side::before { content:''; position:absolute; } /* резерв */
@ -102,6 +106,21 @@
#side .card.tick { border-color:#4d7a44; box-shadow:0 4px 10px rgba(0,0,0,.55), inset 0 0 0 2px rgba(77,122,68,.35); } #side .card.tick { border-color:#4d7a44; box-shadow:0 4px 10px rgba(0,0,0,.55), inset 0 0 0 2px rgba(77,122,68,.35); }
.pinbtn { background:none; border:none; opacity:.45; font-size:15px; padding:0 4px; } .pinbtn { background:none; border:none; opacity:.45; font-size:15px; padding:0 4px; }
.pinbtn.on { opacity:1; filter:drop-shadow(0 0 4px rgba(232,176,75,.8)); } .pinbtn.on { opacity:1; filter:drop-shadow(0 0 4px rgba(232,176,75,.8)); }
/* Закріплене 📌 — третій візуальний канал: рамку вже займає .tick (зелений ring),
накладку «✗» — .cross. Лишається бордова грань теки + світліший папір. */
#side .card.pinned { border-left:5px solid #7a1f1f; padding-left:9px; background-color:#f4ebd4; }
#side .card.pinned > .t > b { color:#5d1717; }
.divider { text-align:center; letter-spacing:2px; margin:2px 0 12px; }
#side .divider { color:#c9b596; text-shadow:0 1px 0 rgba(0,0,0,.5); }
/* Фільтр дошки: рядок пошуку + чипи-теги над списком карток */
#side input.evfilter { width:100%; margin-top:6px; background:var(--paper2);
border:1px solid #b3a077; border-radius:2px; color:var(--ink); padding:5px 9px; }
#side input.evfilter::placeholder { color:#7d7052; }
#side input.evfilter:focus { border-color:#7a1f1f; background:var(--paper); }
.chips { display:flex; flex-wrap:wrap; gap:4px; margin:6px 0 10px; }
.chip { font-size:11.5px; padding:2px 10px; border-radius:11px; }
#side .chip.on { border-color:#7a1f1f; color:#7a1f1f; font-weight:bold; background:#e3d3aa; }
#side .card .orig { font-style:italic; margin-bottom:3px; }
/* ---------- Сцена: польовий ---------- */ /* ---------- Сцена: польовий ---------- */
#cv { width:100%; border:1px solid #1c222c; border-radius:10px; background:#0a0e12; display:block; #cv { width:100%; border:1px solid #1c222c; border-radius:10px; background:#0a0e12; display:block;
@ -142,14 +161,36 @@
tr.cross td { opacity:.45; text-decoration:line-through; } tr.cross td { opacity:.45; text-decoration:line-through; }
tr.tick td { color:var(--green); } tr.tick td { color:var(--green); }
tr.hl td { background:rgba(232,176,75,.1); } tr.hl td { background:rgba(232,176,75,.1); }
/* Мітки на людях: чотири кольори замість ✓/✗ — що вони означають, вирішують
гравці, тож жодних підписів. Суцільна заливка рядка не читається ні на
бурштиновому CRT аналітика, ні на папері модалки польового, тож канал —
кольорова ліва грань рядка плюс кружечок-свотч у кнопці. */
tr[class*="mk-"] > td:first-child { border-left:4px solid transparent; padding-left:6px; }
tr.mk-green > td:first-child { border-left-color:var(--mk-green); }
tr.mk-red > td:first-child { border-left-color:var(--mk-red); }
tr.mk-blue > td:first-child { border-left-color:var(--mk-blue); }
tr.mk-amber > td:first-child { border-left-color:var(--mk-amber); }
.dot { display:inline-block; width:10px; height:10px; border-radius:50%; vertical-align:-1px;
box-shadow:0 0 0 1px rgba(0,0,0,.45); }
.mk button.swatch { padding:2px 7px; }
.mk button.swatch.on { border-color:currentColor; box-shadow:inset 0 0 0 1px currentColor; }
.filters { display:flex; gap:8px; flex-wrap:wrap; margin:8px 0; align-items:center; } .filters { display:flex; gap:8px; flex-wrap:wrap; margin:8px 0; align-items:center; }
.filters select, .filters input { width:130px; } /* min-width, а не width: жорсткі 130px різали довгі назви камер («Муніципальна к…»),
хоча панель широка і праворуч лишалося порожньо. Ряд і так переноситься. */
.filters select, .filters input { flex:1 1 auto; min-width:140px; max-width:260px; }
.filters input[type=checkbox] { flex:0 0 auto; min-width:0; width:auto; }
.filters label { flex:0 0 auto; white-space:nowrap; }
#cc-cam { max-width:min(100%, 460px); }
.syshead { color:var(--dim); text-transform:uppercase; font-size:12px; letter-spacing:2px; margin:14px 0 6px; } .syshead { color:var(--dim); text-transform:uppercase; font-size:12px; letter-spacing:2px; margin:14px 0 6px; }
.syshead::before { content:'— '; opacity:.5; } .syshead::before { content:'— '; opacity:.5; }
/* ---------- Модалки: паперовий протокол ---------- */ /* ---------- Модалки: паперовий протокол ---------- */
.modal { position:fixed; inset:0; background:rgba(2,4,8,.72); display:flex; align-items:center; justify-content:center; z-index:40; .modal { position:fixed; inset:0; background:rgba(2,4,8,.72); display:flex; align-items:center; justify-content:center; z-index:40;
backdrop-filter:blur(2px); } backdrop-filter:blur(2px); }
/* Затемнення не доходить до дошки (400px праворуч) — клік-по-тлу для закриття
лишається робочим, але докази й нотатки доступні під час обшуку чи допиту.
На вузьких екранах, де 400px — це пів-екрана, повертаємось до старої поведінки. */
@media (min-width: 1000px) { .modal { right:400px; } }
.modal > div { background:var(--paper); background-image:var(--noise); color:var(--ink); .modal > div { background:var(--paper); background-image:var(--noise); color:var(--ink);
border:1px solid #b6a67e; border-radius:3px; padding:22px 24px; width:min(600px, 94vw); max-height:88vh; overflow-y:auto; border:1px solid #b6a67e; border-radius:3px; padding:22px 24px; width:min(600px, 94vw); max-height:88vh; overflow-y:auto;
box-shadow:0 30px 90px rgba(0,0,0,.8), inset 0 0 0 6px rgba(0,0,0,.03); font-family:Georgia, serif; } box-shadow:0 30px 90px rgba(0,0,0,.8), inset 0 0 0 6px rgba(0,0,0,.03); font-family:Georgia, serif; }
@ -167,6 +208,15 @@
.modal .tag { border:1px solid #b3a077; color:var(--inkdim); background:transparent; } .modal .tag { border:1px solid #b3a077; color:var(--inkdim); background:transparent; }
.modal .card { background:rgba(255,255,255,.35); border:1px solid #c3b28b; border-radius:2px; padding:9px 11px; margin-bottom:8px; } .modal .card { background:rgba(255,255,255,.35); border:1px solid #c3b28b; border-radius:2px; padding:9px 11px; margin-bottom:8px; }
.modal label { color:var(--ink); } .modal label { color:var(--ink); }
.modalfoot { display:flex; gap:8px; flex-wrap:wrap; margin-top:14px;
border-top:1px solid rgba(122,31,31,.25); padding-top:10px; }
/* свіжа відповідь на допиті — щоб її було видно серед тем і пліток */
@keyframes logflash { 0%,100% { background:transparent; } 30%,70% { background:rgba(122,31,31,.15); } }
.logrow.flash { animation:logflash 1.8s ease; border-radius:3px; }
/* Глобальне tr.rowlink:hover заливає рядок майже чорним --panel2. Аналітику це
перекрито в #stage, але база польового живе в модалці (папір + чорнило) —
там рядок ставав нечитабельним. Той самий бордовий акцент, що й решта модалки. */
.modal tr.rowlink:hover td { background:rgba(122,31,31,.10); }
.grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(110px,1fr)); gap:8px; margin-top:8px; } .grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(110px,1fr)); gap:8px; margin-top:8px; }
/* ---------- Тости: телеграма ---------- */ /* ---------- Тости: телеграма ---------- */

View file

@ -16,6 +16,10 @@ fs.mkdirSync(SAVES, { recursive: true });
const sessions = Object.create(null); const sessions = Object.create(null);
let clientSeq = 0; // стабільний id на кожне з'єднання (для підписів звинувачення) let clientSeq = 0; // стабільний id на кожне з'єднання (для підписів звинувачення)
// Допустимі мітки на дошці. Клієнт малює їх із того ж поділу (public/client.js).
const EV_MARKS = ['tick', 'cross', 'hide']; // докази і записи журналу
const PERSON_MARKS = ['green', 'red', 'blue', 'amber', 'hide']; // люди: кольори без нав'язаного значення
// Присутність зараз (не плутати з evening — правдою вечора злочину): вдома або на роботі. // Присутність зараз (не плутати з evening — правдою вечора злочину): вдома або на роботі.
// Перетасовується, коли польовий заходить у будівлю, — крім будівлі, куди він зайшов. // Перетасовується, коли польовий заходить у будівлю, — крім будівлі, куди він зайшов.
function reshuffleLocs(cs, s, except) { function reshuffleLocs(cs, s, except) {
@ -126,16 +130,32 @@ function visiblePuzzles(s) {
}).map(p => ({ id: p.id, kind: p.kind, prompt: p.prompt, payload: p.payload, hint: p.hint, solved: s.st.puzzlesSolved.includes(p.id) })); }).map(p => ({ id: p.id, kind: p.kind, prompt: p.prompt, payload: p.payload, hint: p.hint, solved: s.st.puzzlesSolved.includes(p.id) }));
} }
// Походження знахідки — короткий рядок «де це взяли», щоб через годину гри
// дошка на 30 карток пам'ятала, що звідки. Лише для фізичних знахідок:
// sys/lab/person/start приходять із термінала або з розмови, і відсутність
// позначки вже означає це саме — дошка не роздувається.
function originOf(cs, ev) {
const at = ev.at || {};
if (at.type === 'spot') return `знайдено: ${bName(cs, at.building)}, ${at.spot}`;
if (at.type === 'unit') return `знайдено: кв. ${at.unit}, ${at.slot}`;
return null;
}
function view(s) { function view(s) {
const { cs, st } = s; const { cs, st } = s;
const found = st.found.map(id => { const found = st.found.map(id => {
const ev = cs.evidence[id]; const ev = cs.evidence[id];
const card = { id, title: ev.title, detail: ev.detail, side: ev.side }; const card = { id, title: ev.title, detail: ev.detail, side: ev.side, tag: ev.tag || 'запит' };
// Звідки знахідка — для фільтра й для пам'яті гравця (див. originOf).
const from = originOf(cs, ev);
if (from) card.from = from;
// Експертиза (crim/expert) розбирає конкретний доказ — його id у requires[0]. // Експертиза (crim/expert) розбирає конкретний доказ — його id у requires[0].
// Віддаємо як ref, щоб на дошці було видно, ЧИЙ це аналіз, і клік вів до нього. // Віддаємо як ref, щоб на дошці було видно, ЧИЙ це аналіз, і клік вів до нього.
if (ev.q && ev.q.kind === 'expert' && ev.requires[0]) card.ref = ev.requires[0]; if (ev.q && ev.q.kind === 'expert' && ev.requires[0]) card.ref = ev.requires[0];
return card; return card;
}).concat(s.extra); // Динамічні картки (результати запитів) тега не мають — даємо дефолтний,
// інакше вони випадали б із будь-якого зрізу по чипах.
}).concat(s.extra.map(x => (x.tag ? x : { ...x, tag: 'запит' })));
const killerC = cs.citizens.find(c => c.id === cs.finale.killer); const killerC = cs.citizens.find(c => c.id === cs.finale.killer);
const dnaOwner = cs.citizens.find(c => c.id === (cs.accomplice || cs.finale.killer)); const dnaOwner = cs.citizens.find(c => c.id === (cs.accomplice || cs.finale.killer));
return { return {
@ -326,16 +346,34 @@ function logQA(s, ws, cid, q, a) {
ws.send(JSON.stringify({ t: 'answer', cid, q, a })); ws.send(JSON.stringify({ t: 'answer', cid, q, a }));
} }
// Один пошук за іменем на всі поля, де вводять людину: кожне слово запиту — початок
// слова в імені, регістр не важить. Раніше банк вимагав точний повний рядок, а реєстри
// шукали підрядок — два різні правила для однакових на вигляд полів.
const WORDSEP = /[^аіїєґa-z0-9]+/i;
function findByName(cs, q) {
const words = String(q || '').trim().toLowerCase().split(WORDSEP).filter(Boolean);
if (!words.length) return [];
return cs.citizens.filter(c => {
const nw = c.name.toLowerCase().split(WORDSEP);
return words.every(w => nw.some(x => x.startsWith(w)));
});
}
// збіг запиту з довідником доказу // збіг запиту з довідником доказу
function matchQ(q, kind, inp) { function matchQ(q, kind, inp) {
const vals = [].concat(q.match || []); const vals = [].concat(q.match || []);
if (!vals.length) return true; // expert-запити без вводу if (!vals.length) return true; // expert-запити без вводу
if (['owner', 'calls', 'locate'].includes(kind)) return vals.some(v => normPhone(v) === normPhone(inp)); if (['owner', 'calls', 'locate'].includes(kind)) return vals.some(v => normPhone(v) === normPhone(inp));
const low = String(inp).toLowerCase(); const low = String(inp).toLowerCase().trim();
if (low.length < 3) return false; if (low.length < 3) return false;
const qWords = low.split(WORDSEP).filter(Boolean);
return vals.some(v => { return vals.some(v => {
const lv = String(v).toLowerCase(); const lv = String(v).toLowerCase();
return lv === low || lv.includes(low) || low.includes(lv); if (lv === low || lv.startsWith(low)) return true; // «стріч» → «стрічка»
// Довідкове значення мусить стояти в запиті ОКРЕМИМ словом. Раніше було
// взаємне входження підрядка в будь-який бік — і довга вставка матчила будь-що.
const vWords = lv.split(WORDSEP).filter(Boolean);
return vWords.length > 0 && vWords.every(w => qWords.includes(w));
}); });
} }
@ -423,11 +461,22 @@ function handle(s, ws, m) {
return done(); return done();
} }
// Тріангуляція: чотири геть різні відмови раніше віддавали один і той самий
// тост, тож правильна дія до кінця Акту I читалася як зламана механіка.
// Порядок перевірок важливий: гейт «особу не встановлено» — ЗАГАЛЬНИЙ, до
// звірки номера, інакше окрема відповідь саме на номер убивці видала б його.
if (m.sys === 'phone' && kind === 'locate') { if (m.sys === 'phone' && kind === 'locate') {
const tri = cs.evidence.rec_triangulate; const tri = cs.evidence.rec_triangulate;
const killer = byIdC(cs, cs.finale.killer); const killer = byIdC(cs, cs.finale.killer);
if (tri && !st.found.includes(tri.id) && RT.available(cs, st, tri) && const num = normPhone(inp);
normPhone(inp) === normPhone(killer.phone)) { if (!num) return toast(ws, 'Оператор: введіть номер. Формат вільний — усе, крім цифр, відкидається.');
if (tri && tri.needsIdentity && !st.identity)
return toast(ws, 'Оператор: пеленг — за постановою, а підстав немає: особу загиблого ще не встановлено.');
if (tri && st.found.includes(tri.id) && num === normPhone(killer.phone))
return toast(ws, 'Оператор: цей пеленг уже в матеріалах справи.');
const owner = cs.citizens.find(x => normPhone(x.phone) === num);
if (!owner) return toast(ws, 'Оператор: номер не обслуговується.');
if (tri && !st.found.includes(tri.id) && RT.available(cs, st, tri) && num === normPhone(killer.phone)) {
s.triTarget = s.locs[killer.id]; s.triTarget = s.locs[killer.id];
const b = cs.district.buildings.find(x => x.id === s.triTarget); const b = cs.district.buildings.find(x => x.id === s.triTarget);
const cx = b.pos.x + b.pos.w / 2, cy = b.pos.y + b.pos.h / 2; const cx = b.pos.x + b.pos.w / 2, cy = b.pos.y + b.pos.h / 2;
@ -436,7 +485,13 @@ function handle(s, ws, m) {
towers: cs.district.towers.map(tw => ({ name: tw.name, x: tw.pos.x, y: tw.pos.y, dist: Math.round(Math.hypot(tw.pos.x - cx, tw.pos.y - cy)) })) towers: cs.district.towers.map(tw => ({ name: tw.name, x: tw.pos.x, y: tw.pos.y, dist: Math.round(Math.hypot(tw.pos.x - cx, tw.pos.y - cy)) }))
})); }));
} }
return toast(ws, 'Оператор: номер зараз не в мережі, не існує — або бракує підстав для пеленга.'); return toast(ws, 'Оператор: номер у мережі, але підстав пеленгувати саме його немає — потрібен доказ, що апарат стосується справи.');
}
// Текстові системи: запит коротший за 3 символи раніше мовчки давав порожньо.
if (m.sys === 'bank' || m.sys === 'db' || (m.sys === 'crim' && kind === 'archive')) {
if (!inp) return toast(ws, 'Порожній запит.');
if (inp.length < 3) return toast(ws, 'Уточніть запит: мінімум 3 символи.');
} }
// реальні докази, що відповідають запиту // реальні докази, що відповідають запиту
@ -467,16 +522,21 @@ function handle(s, ws, m) {
return done(); return done();
} }
if (m.sys === 'bank') { if (m.sys === 'bank') {
const c = cs.citizens.find(x => x.name.toLowerCase() === inp.toLowerCase()); const hits = findByName(cs, inp);
if (!c) return toast(ws, 'Банк: рахунків за таким запитом не знайдено. Ім\'я — точно як у реєстрі.'); if (!hits.length) return toast(ws, 'Банк: рахунків за таким запитом не знайдено.');
if (hits.length > 1)
return toast(ws, `Банк: під запит підходить осіб${hits.length}. Уточніть ім'я.`);
const c = hits[0];
extraCard(s, 'bank|' + c.id, `Виписка: ${c.name}`, extraCard(s, 'bank|' + c.id, `Виписка: ${c.name}`,
`${c.job === 'пенсіонер' ? 'Пенсія' : c.job === 'студент' ? 'Стипендія і перекази від рідні' : 'Зарплата'}, комуналка, дрібні витрати. Аномалій не видно.`); `${c.job === 'пенсіонер' ? 'Пенсія' : c.job === 'студент' ? 'Стипендія і перекази від рідні' : 'Зарплата'}, комуналка, дрібні витрати. Аномалій не видно.`);
return done(); return done();
} }
if (m.sys === 'db') { if (m.sys === 'db') {
const low = inp.toLowerCase(); const low = inp.toLowerCase();
if (low.length < 3) return toast(ws, 'Реєстри: уточніть запит (мінімум 3 символи).'); // Ім'я — за тим самим правилом, що й банк і база аналітика (префікси слів);
const rs = cs.citizens.filter(c => c.name.toLowerCase().includes(low) || // тату й кличка собаки лишаються підрядком, це прикмети, а не імена.
const named = new Set(findByName(cs, inp).map(c => c.id));
const rs = cs.citizens.filter(c => named.has(c.id) ||
(c.tattoo || '').toLowerCase().includes(low) || (c.dog || '').toLowerCase().includes(low)); (c.tattoo || '').toLowerCase().includes(low) || (c.dog || '').toLowerCase().includes(low));
if (!rs.length) return toast(ws, 'Реєстри: збігів немає.'); if (!rs.length) return toast(ws, 'Реєстри: збігів немає.');
extraCard(s, 'db|' + low, `Реєстри: «${inp}»`, extraCard(s, 'db|' + low, `Реєстри: «${inp}»`,
@ -700,7 +760,12 @@ function handle(s, ws, m) {
else if (st.found.includes(m.evId) || s.extra.some(x => x.id === m.evId)) s.board.pins.push(m.evId); else if (st.found.includes(m.evId) || s.extra.some(x => x.id === m.evId)) s.board.pins.push(m.evId);
return done(); return done();
} }
case 'mark': { // {key:'e:..'|'c:..', kind} case 'mark': { // {key:'e:..'|'c:..'|'q:..', kind}
// Раніше сюди приймався будь-який рядок як kind. Палітри дві: на людях —
// кольори (кілька версій одночасно), на доказах і записах журналу — ✓/✗.
if (typeof m.key !== 'string') return;
const allowed = m.key.startsWith('c:') ? PERSON_MARKS : EV_MARKS;
if (!allowed.includes(m.kind)) return;
if (s.board.marks[m.key] === m.kind) delete s.board.marks[m.key]; if (s.board.marks[m.key] === m.kind) delete s.board.marks[m.key];
else s.board.marks[m.key] = m.kind; else s.board.marks[m.key] = m.kind;
return done(); return done();