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 — увійти), обшукує місця,
стукає у квартири, говорить з людьми. У приватне житло — лише з ключем або ордером.
- **Аналітик** — база мешканців (пошук за точним ім'ям з клавіатури — помилка в написанні
дасть порожній результат; фільтри за слідами: взуття, зріст, одяг, відбитки), мапа району
- **Аналітик** — база мешканців (пошук за іменем з клавіатури — досить початку слова, регістр
не важить; фільтри за слідами: взуття, зріст, одяг, відбитки), мапа району
з напарником наживо, системи (оператор, банки, камери, криміналістика, лабораторія)
і **шифри**: Цезар, 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;
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;
}
}

View file

@ -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,

View file

@ -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;
}

View file

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

View file

@ -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, 'особу жертви можна встановити');

View file

@ -48,9 +48,9 @@
const p = panels.db;
p.innerHTML = `
<h3>База мешканців району</h3>
<p class="dim small">Пошук за точним ім'ям помилка в написанні дасть порожній результат. Значення для фільтрів беріть зі знайдених слідів і вводьте самі. Реєстр місцями застарілий: одяг і розмір взуття надійніше звіряти очним оглядом (ордер у польового).</p>
<p class="dim small">Пошук за іменем: досить початку «шев» знайде «Шевченко», регістр не важить. Кілька слів звужують (ім'я + прізвище). Значення для фільтрів беріть зі знайдених слідів і вводьте самі. Реєстр місцями застарілий: одяг і розмір взуття надійніше звіряти очним оглядом (ордер у польового).</p>
<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>
</div>
<div class="filters">
@ -69,13 +69,7 @@
p.querySelector('#q-go').onclick = () => {
const q = p.querySelector('#q-name').value.trim();
if (!q) { lastRows = null; dbOut.innerHTML = '<p class="dim">— Введіть ім\'я або прізвище.</p>'; 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 = '<p class="dim">— Записів не знайдено. Спробуйте інший початок.</p>';
return;
@ -97,7 +91,7 @@
};
}
// рядки з позначками ✓/✗/…, як картки доказів: викреслюйте людей по одному
// рядки з кольоровими мітками: ведіть кілька версій одночасно, по одній людині
function renderRows() {
if (!dbOut || !lastRows || !App.state) return;
dbOut.innerHTML = `<p class="dim small">Збігів: ${lastRows.length}</p>`;
@ -184,28 +178,35 @@
<div class="syshead">Оператор зв'язку</div>
<div class="filters">
<input id="ph-n" placeholder="+380…" style="width:180px">
<button data-q="owner">Власник номера</button>
<button data-q="calls">Журнал дзвінків</button>
<button data-q="locate">Тріангуляція (де зараз)</button>
<input id="ph-n" placeholder="+380…" style="min-width:200px">
</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="filters">
<input id="bk-n" placeholder="Ім'я Прізвище або заклад" style="width:220px">
<input id="bk-n" placeholder="Ім'я Прізвище" style="min-width:220px">
<button id="bk-go">Запросити виписку</button>
</div>
<p class="dim small">Досить початку імені чи прізвища те саме правило, що й у базі мешканців. Якщо під запит підходить кілька осіб, банк попросить уточнити.</p>
<div class="syshead">Реєстри</div>
<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>
</div>
<div class="syshead">Криміналістика</div>
<div class="filters">
<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>
</div>
@ -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) =>
`<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">
${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 і руками
@ -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(`<h3>Гель-електрофорез</h3>
<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.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(`<h3>Дактилоскопія: відбиток зі знаряддя</h3>
<p class="dim small">Зразок ліворуч. Клацніть по власнику відбитка.</p>
<canvas id="psample" width="150" height="150" style="border:1px solid var(--acc);border-radius:6px"></canvas>
<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);
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('Зразок знято під кутом — подумки поверніть його. Порівнюйте будову: кількість і хід дуг, а не поворот.'));

View file

@ -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
? `<span class="dot" style="background:${colour}"></span>`
: 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 `<span class="dot" style="background:${found[2]}"></span>`;
}
// Прокрутити дошку до доказу за 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) {
const visible = (arr) =>
arr.filter((e) => {
if (marks["e:" + e.id] === "hide") {
hidden.push(e);
continue;
}
body.append(buildCard(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 = `
<div class="syshead">Аналітик</div>
<ul class="small">
<li><b>База мешканців:</b> фільтри вводите руками — значення беріть із доказів. Викреслюйте людей по одному (✓//). Реєстр місцями застарілий: одяг/взуття надійніше звіряти очним оглядом польового.</li>
<li><b>База мешканців:</b> фільтри вводите руками значення беріть із доказів. Мітьте людей кольорами чотири кольори плюс «» (сховати); що означає кожен, вирішуєте ви з напарником, гра не підказує. Реєстр місцями застарілий: одяг/взуття надійніше звіряти очним оглядом польового.</li>
<li><b>Системи:</b> оператор (власник, дзвінки, тріангуляція «де зараз»), банк, реєстри, криміналістика (експертиза + архів), камери. Дані для запитів шукайте в доказах. <b>Кожен результат лягає на дошку</b> навіть дрібниця; що з того справа, вирішуєте ви.</li>
<li><b>Журнал дзвінків</b> це покази, які не брешуть: хто з ким говорив і о котрій у вечір злочину. Звіряйте з тим, що люди кажуть на допиті. Порожній вечір теж промовистий: апарат могли вимкнути.</li>
<li><b>Вилучені пристрої</b> потрапляють у лабораторію заблокованими: PIN це зазвичай рік народження власника (є в реєстрі), пароль ноутбука або щось особисте латиницею, або словниковий перебір. Усередині буває історія браузера й чати а буває порожньо.</li>

View file

@ -164,6 +164,11 @@
cv.id = "cv";
cv.width = m.district.world.w;
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.id = "fieldhint";
stage.append(cv, hint);
@ -181,8 +186,16 @@
addEventListener("resize", () => fitCanvas(cv));
// e.code — фізична клавіша, працює на будь-якій розкладці (укр/лат)
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;
}
if (typing) return;
keys[e.code] = true;
if (e.code === "KeyE") tryEnter();
});
@ -254,7 +267,12 @@
ctx.fillStyle = vg;
ctx.fillRect(0, 0, D.w, D.h);
const b = nearBuilding();
hint.textContent = b
const ae = document.activeElement;
const typing =
ae && (ae.tagName === "INPUT" || ae.tagName === "TEXTAREA");
hint.textContent = typing
? "клавіатура зайнята полем вводу — Esc або клік по району, щоб повернути керування"
: b
? `E — ${OPEN_ZONES.includes(b.id) ? "оглянутися: " : "увійти: "}${b.name}`
: "WASD/стрілки — рух, E — увійти/оглянутися";
requestAnimationFrame(loop);
@ -282,13 +300,20 @@
}
function stationModal() {
// Відділок лишається під усім, що з нього відкривають: і база, і ордер —
// це кроки, з яких треба вміти повернутись, а не заміщення екрана.
const m = openModal(`<h3>Відділок поліції</h3>
<p class="dim small">Ваша база: дошка справи і термінал аналітика синхронізовані. Тут-таки сидить прокурор ордер на обшук житла подають особисто, з доказом, який прямо вказує на людину.</p>
<button class="primary" id="warr">📜 Подати на ордер</button>
<button id="db">🗂 База мешканців</button>`);
m.inner.querySelector("#db").onclick = () => { m.wrap.remove(); residentsModal(); };
m.inner.querySelector("#warr").onclick = () => {
m.wrap.remove();
m.inner.querySelector("#db").onclick = residentsModal;
m.inner.querySelector("#warr").onclick = warrantFlow;
}
// Вибір людини → вибір підстави. «← Назад» на обох кроках повертає до вибору
// людини: раніше порожня тека була глухим кутом, а помилка в імені коштувала
// всього ланцюга заново.
function warrantFlow() {
pickCitizen("Ордер: на кого?", (cid) => {
const c = App.byId(cid);
// до прокурора йде лише закріплене 📌 — тека справи, а не вся дошка
@ -299,6 +324,7 @@
return openModal(
`<h3>Підстава для ордера на: ${esc(c.name)}</h3>
<p class="dim">Тека порожня. Спершу закріпіть 📌 на дошці доказ, який прямо вказує на цю особу, з усім підряд до прокурора не ходять.</p>`,
{ back: warrantFlow },
);
const mm = openModal(
`<h3>Підстава для ордера на: ${esc(c.name)}</h3>
@ -308,6 +334,7 @@
.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;
@ -315,17 +342,16 @@
mm.wrap.remove();
};
});
};
}
// База мешканців для польового: той самий реєстр, що бачить аналітик, з його
// мітками (✓/✗/…) — тільки перегляд, мітки ставить аналітик.
// кольоровими мітками — тільки перегляд, мітки ставить аналітик.
function residentsModal() {
const coats = [...new Set(App.init.citizens.map((c) => c.coat))];
const m = openModal(`<h3>База мешканців району</h3>
<p class="dim small">Термінал аналітика, тільки перегляд. Мітки (//) ставить аналітик тут вони лише видно. Реєстр місцями застарілий: одяг і взуття надійніше звіряти очним оглядом за ордером.</p>
<p class="dim small">Термінал аналітика, тільки перегляд. Кольорові мітки ставить аналітик тут вони лише видно; що означає кожен колір, домовляєтесь ви двоє. Реєстр місцями застарілий: одяг і взуття надійніше звіряти очним оглядом за ордером.</p>
<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 class="filters">
<select id="rf-sex"><option value="">стать: будь-яка</option><option value="ч">чол.</option><option value="ж">жін.</option></select>
@ -340,7 +366,6 @@
</div>
<div id="rout"><p class="dim small">Задайте пошук або фільтр.</p></div>`);
const out = m.inner.querySelector("#rout");
const MK = { tick: "✓", cross: "✗", hide: "…" };
const draw = (rows) => {
out.innerHTML = `<p class="dim small">Збігів: ${rows.length}</p>`;
const t = el(
@ -350,7 +375,9 @@
);
for (const c of rows) {
const key = "c:" + c.id;
const mark = App.state.board.marks[key];
// Мітки ставить аналітик — тут вони лише видно: підпис плюс кольорова
// ліва грань рядка (див. cardClasses). На папері дот сам по собі губиться.
const mark = markOf(key);
const vic =
App.state.victim === c.id
? App.init.caseType === "kidnap"
@ -360,7 +387,7 @@
const tr = el(
"tr",
"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);
t.append(tr);
@ -368,21 +395,14 @@
out.append(t);
};
const byName = () => {
const q = m.inner.querySelector("#rq").value.trim().toLowerCase();
const q = m.inner.querySelector("#rq").value.trim();
if (!q) {
out.innerHTML = '<p class="dim small">— Введіть ім\'я або прізвище.</p>';
return;
}
// Розбиваємо введений запит на слова
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)));
});
const hits = matchName(App.init.citizens, q); // спільний пошук, див. client.js
if (!hits.length) {
out.innerHTML = '<p class="dim small">— Записів не знайдено. Спробуйте інший початок.</p>';
@ -484,10 +504,9 @@
spot: b.spots[t.dataset.spot],
});
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>
<button id="knock">Постукати</button>
<button id="enter">Увійти${App.state.keys.includes(unit) ? " 🔑" : ""}</button>`);
// Двері лишаються на екрані: «Замкнено» тепер не викидає в порожнечу —
// тост прилітає, а спробувати постукати чи піти назад можна одразу.
m.inner.querySelector("#knock").onclick = () => {
if (!present.length) return toast("Тиша. Ніхто не відчиняє.");
m.wrap.remove();
pickPerson(present);
};
m.inner.querySelector("#enter").onclick = () => {
m.wrap.remove();
m.inner.querySelector("#enter").onclick = () =>
App.send({ t: "enter_unit", unit });
};
}
App.on("unit", (m) => {
@ -557,6 +575,12 @@
App.send({ t: "talk", cid });
}
// Розмова — це цикл «питання → відповідь → наступне питання», а не одноразовий
// крок. Модалка розмови завжди одна: сервер віддає теми у відповідь на `talk`,
// і кожна нова відповідь просто перемальовує її разом із журналом.
let talkModal = null;
let flashCid = null; // до кого прилетіла свіжа відповідь — підсвітити останній запис
App.on("topics", (m) => {
const c = App.byId(m.cid);
const gossip =
@ -576,6 +600,11 @@
.join("") +
'</div><p class="dim small">Сірі теми вже в доказах на дошці.</p>'
: "";
if (talkModal) {
const old = talkModal;
talkModal = null; // прибираємо самі — onClose не має скидати нову модалку
old.wrap.remove();
}
const mm = openModal(
`<h3>${esc(c.name)}</h3>` +
topicsHtml +
@ -590,10 +619,23 @@
<button id="sh-go">Показати доказ</button>
${App.state.warrants.includes(m.cid) ? '<button id="insp-go">Оглянути особисто 📜</button>' : ''}
</div>
<p class="dim small">Відповіді можуть бути правдою, брехнею або «не знаю» перевіряйте камерами і чужими словами.</p>` +
<p class="dim small">Відповіді можуть бути правдою, брехнею або «не знаю» перевіряйте камерами і чужими словами. Модалка лишається відкритою: питайте далі, «Закрити» завершує розмову.</p>` +
gossip,
{ onClose: () => { if (talkModal === mm) talkModal = null; } },
);
talkModal = mm;
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"),
out = mm.inner.querySelector("#al-v");
const fmt = (v) =>
@ -601,20 +643,17 @@
const upd = () => (out.textContent = fmt(+sl.value));
sl.oninput = 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 });
mm.wrap.remove();
};
mm.inner.querySelector("#ab-go").onclick = () => {
mm.wrap.remove();
mm.inner.querySelector("#ab-go").onclick = () =>
pickCitizen(
`${esc(c.first)}, що скажете про…`,
(about) => App.send({ t: "ask_about", cid: m.cid, about }),
(x) => x.id === m.cid,
);
};
mm.inner.querySelector("#sh-go").onclick = () => {
mm.wrap.remove();
const pm = openModal(
'<h3>Показати доказ</h3><div class="grid" style="grid-template-columns:1fr">' +
App.state.found
@ -630,34 +669,29 @@
};
};
const insp = mm.inner.querySelector("#insp-go");
if (insp)
insp.onclick = () => {
App.send({ t: "inspect", cid: m.cid });
mm.wrap.remove();
};
if (insp) insp.onclick = () => App.send({ t: "inspect", cid: m.cid });
mm.inner.onclick = (e) => {
if (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) => {
const c = App.byId(m.cid);
openModal(
`<h3>Очний огляд: ${esc(c.name)}</h3><p>${esc(m.body)}</p>`,
);
openModal(`<h3>Очний огляд: ${esc(c.name)}</h3><p>${esc(m.body)}</p>`);
});
// відповідь допитуваного
// Відповідь допитуваного. Сироту-модалку не відкриваємо: просимо теми ще раз —
// розмова перемалюється вже з новим записом у журналі, і можна питати далі.
App.on("answer", (m) => {
const c = App.byId(m.cid);
openModal(
`<h3>${esc(c.name)}</h3><p class="dim small">${esc(m.q)}</p><p>${esc(m.a)}</p>
<p class="dim small">Записано в журнал допитів (досьє в аналітика і картка розмови).</p>`,
);
flashCid = m.cid;
App.send({ t: "talk", cid: m.cid });
});
// ---------- міні-гра «вилучення»: тремтячі руки псують доказ ----------

View file

@ -10,6 +10,7 @@
--txt:#cfd6df; --dim:#7d8794; --acc:#e8b04b; --red:#c94f4f; --green:#5aa870; --blue:#5b8dbf;
--paper:#e9ddc0; --paper2:#dcCfae; --ink:#282218; --inkdim:#6f6450; --paperline:#c3b28b;
--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>");
}
* { box-sizing:border-box; margin:0; }
@ -63,7 +64,10 @@
.badge.on { color:var(--green); border-color:var(--green); }
#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; }
/* Дошка — недоторканний шар: модалка (z-index:40) її НЕ накриває, інакше будь-який
обшук чи допит відрізав би обох гравців від доказів саме тоді, коли вони потрібні. */
#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; }
#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); }
.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)); }
/* Закріплене 📌 — третій візуальний канал: рамку вже займає .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;
@ -142,14 +161,36 @@
tr.cross td { opacity:.45; text-decoration:line-through; }
tr.tick td { color:var(--green); }
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 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::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;
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);
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; }
@ -167,6 +208,15 @@
.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 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; }
/* ---------- Тости: телеграма ---------- */

View file

@ -16,6 +16,10 @@ fs.mkdirSync(SAVES, { recursive: true });
const sessions = Object.create(null);
let clientSeq = 0; // стабільний id на кожне з'єднання (для підписів звинувачення)
// Допустимі мітки на дошці. Клієнт малює їх із того ж поділу (public/client.js).
const EV_MARKS = ['tick', 'cross', 'hide']; // докази і записи журналу
const PERSON_MARKS = ['green', 'red', 'blue', 'amber', 'hide']; // люди: кольори без нав'язаного значення
// Присутність зараз (не плутати з evening — правдою вечора злочину): вдома або на роботі.
// Перетасовується, коли польовий заходить у будівлю, — крім будівлі, куди він зайшов.
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) }));
}
// Походження знахідки — короткий рядок «де це взяли», щоб через годину гри
// дошка на 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) {
const { cs, st } = s;
const found = st.found.map(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].
// Віддаємо як ref, щоб на дошці було видно, ЧИЙ це аналіз, і клік вів до нього.
if (ev.q && ev.q.kind === 'expert' && ev.requires[0]) card.ref = ev.requires[0];
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 dnaOwner = cs.citizens.find(c => c.id === (cs.accomplice || cs.finale.killer));
return {
@ -326,16 +346,34 @@ function logQA(s, ws, 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) {
const vals = [].concat(q.match || []);
if (!vals.length) return true; // expert-запити без вводу
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;
const qWords = low.split(WORDSEP).filter(Boolean);
return vals.some(v => {
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();
}
// Тріангуляція: чотири геть різні відмови раніше віддавали один і той самий
// тост, тож правильна дія до кінця Акту I читалася як зламана механіка.
// Порядок перевірок важливий: гейт «особу не встановлено» — ЗАГАЛЬНИЙ, до
// звірки номера, інакше окрема відповідь саме на номер убивці видала б його.
if (m.sys === 'phone' && kind === 'locate') {
const tri = cs.evidence.rec_triangulate;
const killer = byIdC(cs, cs.finale.killer);
if (tri && !st.found.includes(tri.id) && RT.available(cs, st, tri) &&
normPhone(inp) === normPhone(killer.phone)) {
const num = normPhone(inp);
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];
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;
@ -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)) }))
}));
}
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();
}
if (m.sys === 'bank') {
const c = cs.citizens.find(x => x.name.toLowerCase() === inp.toLowerCase());
if (!c) return toast(ws, 'Банк: рахунків за таким запитом не знайдено. Ім\'я — точно як у реєстрі.');
const hits = findByName(cs, inp);
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}`,
`${c.job === 'пенсіонер' ? 'Пенсія' : c.job === 'студент' ? 'Стипендія і перекази від рідні' : 'Зарплата'}, комуналка, дрібні витрати. Аномалій не видно.`);
return done();
}
if (m.sys === 'db') {
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));
if (!rs.length) return toast(ws, 'Реєстри: збігів немає.');
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);
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];
else s.board.marks[m.key] = m.kind;
return done();