fixes
This commit is contained in:
parent
89757ffa31
commit
2c887ca451
11 changed files with 563 additions and 208 deletions
|
|
@ -18,8 +18,8 @@ node server.js
|
|||
|
||||
- **Польовий детектив** — ходить районом (WASD/стрілки, E — увійти), обшукує місця,
|
||||
стукає у квартири, говорить з людьми. У приватне житло — лише з ключем або ордером.
|
||||
- **Аналітик** — база мешканців (пошук за точним ім'ям з клавіатури — помилка в написанні
|
||||
дасть порожній результат; фільтри за слідами: взуття, зріст, одяг, відбитки), мапа району
|
||||
- **Аналітик** — база мешканців (пошук за іменем з клавіатури — досить початку слова, регістр
|
||||
не важить; фільтри за слідами: взуття, зріст, одяг, відбитки), мапа району
|
||||
з напарником наживо, системи (оператор, банки, камери, криміналістика, лабораторія)
|
||||
і **шифри**: Цезар, base64, PIN, злам md5-пароля за фактами з досьє.
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,37 +36,41 @@ const COATS = ['чорному пальті', 'сірій куртці', 'син
|
|||
|
||||
// Чому люди брешуть про свій вечір (виправдання на повторному допиті).
|
||||
// Гравці самі вирішують, кому вірити, — гра вердиктів не ставить.
|
||||
// УВАГА: рядки роздаються `pick`ом незалежно від статі, тож усі вони мусять
|
||||
// лишатися БЕЗРІДНИМИ — теперішній час, інфінітив або називна конструкція.
|
||||
// Жодного минулого часу від першої особи («був», «пив», «ходив»): половина
|
||||
// мешканців району — жінки. Тест у test.js це перевіряє.
|
||||
const EXCUSES = [
|
||||
'Я був не сам, і вдома про це знати не мусять. Ви розумієте.',
|
||||
'Пив. Сильно. Не хотів, щоб про це знали на роботі.',
|
||||
'Грав у карти на гроші в гаражах. Це ж не злочин... майже.',
|
||||
'Я боявся: скажеш «був поруч» — і вже підозрюваний. От і збрехав.',
|
||||
'Позичав гроші в людини, про яку краще не говорити вголос.',
|
||||
'Ходив до ворожки. Смійтеся-смійтеся. Спробуйте самі поживіть із моїм щастям.',
|
||||
'Шукав підробіток. Офіційно мені не можна, тож — тихцем.',
|
||||
'Посварився з дружиною і ночував у майстерні. Соромно було казати.',
|
||||
'Здавав пляшки. Так, отакий у мене вечір. Задоволені?',
|
||||
'Був у клініці. Аналізи. Це моя справа і нікого не обходить.',
|
||||
'Зустрічався з колишньою. Якщо дізнається теперішня — мені кінець.',
|
||||
'Продавав дещо з дому, поки рідня не бачить. Борги.',
|
||||
'Сидів у барі до закриття. Мамі кажу, що не п\'ю.',
|
||||
'Ловив рибу без квитка. Інспектор мене вже двічі попереджав.',
|
||||
'Плакав у машині. Буває. Не хотів, щоб хтось бачив.',
|
||||
'Ходив дивитися на квартиру жертви... тобто, я не так висловився. Просто гуляв тим двором.',
|
||||
'Репетирував освідчення. Так, сам із собою. Годину. У дворі.',
|
||||
'Був на співбесіді в іншому місті. Начальник не мусить знати.',
|
||||
'Виносив речі колишнього орендаря. Не питайте.',
|
||||
'Дзвонив у передачу на радіо. Це анонімно, і хай так і лишиться.',
|
||||
'Фотографував птахів. Уночі. Сов, якщо точніше. Це хобі!',
|
||||
'Стежив за власним чоловіком. І ні, не скажу, що вистежила.',
|
||||
'Пробував кинути курити і намотував кола районом, щоб не зірватися.',
|
||||
'Ремонтував чужу машину за готівку. Податкова ж не дізнається?',
|
||||
'Ходив на могилу до брата. Про це не розповідаю нікому.',
|
||||
'Грав у шахи на набережній. На гроші. Пенсії не вистачає.',
|
||||
'Був у перукарки. Не в тій, до якої ходить моя сім\'я. Це складно.',
|
||||
'Тренувався співати в елінгу. Там акустика. І ніхто не чує. Зазвичай.',
|
||||
'Розклеював оголошення про зниклого кота. Кіт не мій. Довга історія.',
|
||||
'Просто збрехав. Звичка з дитинства: менше скажеш — краще спиш.'
|
||||
'Була компанія, і вдома про це знати не мусять. Ви розумієте.',
|
||||
'Пиятика. Сильна. І на роботі про це знати не мусять.',
|
||||
'Карти на гроші в гаражах. Це ж не злочин... майже.',
|
||||
'Страх, розумієте: скажеш «я поруч» — і ти вже підозрюваний. От і брехня.',
|
||||
'Борг у людини, про яку краще не говорити вголос.',
|
||||
'Ворожка. Смійтеся-смійтеся. Спробуйте самі поживіть із моїм щастям.',
|
||||
'Підробіток. Офіційно мені не можна, тож — тихцем.',
|
||||
'Сварка вдома, ніч у майстерні. Соромно казати.',
|
||||
'Пляшки на здачу. Так, отакий у мене вечір. Задоволені?',
|
||||
'Клініка. Аналізи. Це моя справа і нікого не обходить.',
|
||||
'Зустріч із колишнім коханням. Якщо дізнаються удома — мені кінець.',
|
||||
'Дещо з дому — на продаж, поки рідня не бачить. Борги.',
|
||||
'Бар до самого закриття. Мамі кажу, що не п\'ю.',
|
||||
'Риболовля без квитка. Інспектор мене вже двічі попереджав.',
|
||||
'Сльози в машині. Буває. І без свідків, будь ласка.',
|
||||
'Дивлюся іноді на ту квартиру... тобто, я не так кажу. Просто прогулянка тим двором.',
|
||||
'Репетиція освідчення. Так, наодинці. Годину. У дворі.',
|
||||
'Співбесіда в іншому місті. Начальник не мусить знати.',
|
||||
'Речі колишнього орендаря треба було винести. Не питайте.',
|
||||
'Дзвінок у передачу на радіо. Це анонімно, і хай так і лишиться.',
|
||||
'Фотографую птахів. Уночі. Сов, якщо точніше. Це хобі!',
|
||||
'Стеження за власною половиною. І ні, не скажу, що з того вийшло.',
|
||||
'Кидаю курити — і намотую кола районом, щоб не зірватися.',
|
||||
'Ремонт чужої машини за готівку. Податкова ж не дізнається?',
|
||||
'Могила брата. Про це нікому не розповідаю.',
|
||||
'Шахи на набережній. На гроші. Пенсії не вистачає.',
|
||||
'Перукарка. Не та, до якої ходить моя сім\'я. Це складно.',
|
||||
'Вправляюся співати в елінгу. Там акустика. І ніхто не чує. Зазвичай.',
|
||||
'Оголошення про зниклого кота, по всіх стовпах. Кіт не мій. Довга історія.',
|
||||
'Просто брехня. Звичка з дитинства: менше скажеш — краще спиш.'
|
||||
];
|
||||
|
||||
// Хто знайшов тіло / повідомив (для вступів)
|
||||
|
|
|
|||
|
|
@ -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, 'особу жертви можна встановити');
|
||||
|
|
|
|||
|
|
@ -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('Зразок знято під кутом — подумки поверніть його. Порівнюйте будову: кількість і хід дуг, а не поворот.'));
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
|
||||
// ---------- міні-гра «вилучення»: тремтячі руки псують доказ ----------
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
||||
/* ---------- Тости: телеграма ---------- */
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in a new issue