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 — увійти), обшукує місця,
|
- **Польовий детектив** — ходить районом (WASD/стрілки, E — увійти), обшукує місця,
|
||||||
стукає у квартири, говорить з людьми. У приватне житло — лише з ключем або ордером.
|
стукає у квартири, говорить з людьми. У приватне житло — лише з ключем або ордером.
|
||||||
- **Аналітик** — база мешканців (пошук за точним ім'ям з клавіатури — помилка в написанні
|
- **Аналітик** — база мешканців (пошук за іменем з клавіатури — досить початку слова, регістр
|
||||||
дасть порожній результат; фільтри за слідами: взуття, зріст, одяг, відбитки), мапа району
|
не важить; фільтри за слідами: взуття, зріст, одяг, відбитки), мапа району
|
||||||
з напарником наживо, системи (оператор, банки, камери, криміналістика, лабораторія)
|
з напарником наживо, системи (оператор, банки, камери, криміналістика, лабораторія)
|
||||||
і **шифри**: Цезар, base64, PIN, злам md5-пароля за фактами з досьє.
|
і **шифри**: Цезар, base64, PIN, злам md5-пароля за фактами з досьє.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,11 +22,12 @@ function setupEvening(ctx) {
|
||||||
for (const c of citizens) c.claim = c.liar ? otherB(c.evening) : c.evening;
|
for (const c of citizens) c.claim = c.liar ? otherB(c.evening) : c.evening;
|
||||||
killer.claim = pick(r, coveredB.filter(b => b !== scene.building && b !== 'station')) || otherB(scene.building);
|
killer.claim = pick(r, coveredB.filter(b => b !== scene.building && b !== 'station')) || otherB(scene.building);
|
||||||
|
|
||||||
|
// Виправдання роздаємо тут (споживає RNG). А от свідка алібі (`mate`) рахувати
|
||||||
|
// рано: мутатори після нас іще переносять людей (спільниця йде на місце злочину),
|
||||||
|
// і вже роздані посилання протухають — див. assignMates() в кінці генерації.
|
||||||
for (const c of citizens) {
|
for (const c of citizens) {
|
||||||
if (!c.liar || c === killer) continue;
|
if (!c.liar || c === killer) continue;
|
||||||
c.excuse = pick(r, N.EXCUSES);
|
c.excuse = pick(r, N.EXCUSES);
|
||||||
const m2 = citizens.find(o => o !== c && o.id !== victim.id && o.id !== killer.id && o.evening === c.evening);
|
|
||||||
c.mate = m2 ? m2.id : null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -713,6 +713,22 @@ function generateCase(seed) {
|
||||||
? `${scene.name}: сліди боротьби — і жодної людини. Зникла особа: ${victim.name}, ${victim.age} р., прож. ${victim.home}. Заручник, імовірно, ще живий. З'ясуйте, ХТО забрав людину, ДЕ її тримають і ЧОМУ. Викрадача немає в жодному списку підозрюваних — його збирають по слідах і базі мешканців. У вас один штурм: помилка в імені чи місці — і все марно.`
|
? `${scene.name}: сліди боротьби — і жодної людини. Зникла особа: ${victim.name}, ${victim.age} р., прож. ${victim.home}. Заручник, імовірно, ще живий. З'ясуйте, ХТО забрав людину, ДЕ її тримають і ЧОМУ. Викрадача немає в жодному списку підозрюваних — його збирають по слідах і базі мешканців. У вас один штурм: помилка в імені чи місці — і все марно.`
|
||||||
: `Ранок. ${scene.name} — тіло без документів. Ніхто не заявляв про зникнення. Акт I: з'ясуйте, ХТО загинув. Акт II: знайдіть, ХТО це зробив, ЯК і ЧОМУ. Вбивці немає у списку підозрюваних — його треба зібрати по слідах і базі мешканців. У вас одне звинувачення. Помилка — і вас звільнять.`;
|
: `Ранок. ${scene.name} — тіло без документів. Ніхто не заявляв про зникнення. Акт I: з'ясуйте, ХТО загинув. Акт II: знайдіть, ХТО це зробив, ЯК і ЧОМУ. Вбивці немає у списку підозрюваних — його треба зібрати по слідах і базі мешканців. У вас одне звинувачення. Помилка — і вас звільнять.`;
|
||||||
|
|
||||||
|
// -- свідки алібі брехунів. Рахуємо ОСТАННІМИ: до цього моменту мутатори ще
|
||||||
|
// переносять людей (спільниця йде на місце злочину), і свідок, призначений раніше,
|
||||||
|
// перетворюється на мертве посилання — «названий свідок сам себе туди не ставить».
|
||||||
|
// Це і зливало твіст: єдина людина з суперечливим свідком і була спільницею.
|
||||||
|
// `citizens.find` RNG не споживає, тож наявні сейви лишаються тією ж справою.
|
||||||
|
const figures = [victim.id, killer.id, accomplice && accomplice.id];
|
||||||
|
for (const c of citizens) {
|
||||||
|
c.mate = null;
|
||||||
|
if (!c.liar || c === killer || (accomplice && c === accomplice)) continue;
|
||||||
|
// Свідок мусить бути там само — і не заперечити цього на власному допиті
|
||||||
|
// (o.claim === o.evening), інакше «спитайте X» веде в порожнечу.
|
||||||
|
const m2 = citizens.find(o => o !== c && !figures.includes(o.id) &&
|
||||||
|
o.evening === c.evening && o.claim === o.evening);
|
||||||
|
if (m2) c.mate = m2.id;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
seed: String(seed), title: 'Невпізнаний', district, citizens, puzzles,
|
seed: String(seed), title: 'Невпізнаний', district, citizens, puzzles,
|
||||||
victim: victim.id, evidence: E, startingFacts, finale, templateId, caseType, introText,
|
victim: victim.id, evidence: E, startingFacts, finale, templateId, caseType, introText,
|
||||||
|
|
|
||||||
|
|
@ -26,12 +26,9 @@ function applyDoppelganger(ctx) {
|
||||||
doppelganger.liar = true;
|
doppelganger.liar = true;
|
||||||
doppelganger.claim = pick(r, district.buildings.filter(b => b.id !== doppelganger.evening && b.id !== 'station').map(b => b.id));
|
doppelganger.claim = pick(r, district.buildings.filter(b => b.id !== doppelganger.evening && b.id !== 'station').map(b => b.id));
|
||||||
doppelganger.excuse = pick(r, N.EXCUSES);
|
doppelganger.excuse = pick(r, N.EXCUSES);
|
||||||
|
// Свідка алібі тут НЕ рахуємо: мутатор працює до setupEvening, коли evening
|
||||||
// Знаходимо свідка, який може підтвердити справжнє алібі двійника
|
// у всіх ще undefined, — предикат `o.evening === c.evening` зматчив би першого-ліпшого.
|
||||||
const mate = citizens.find(o => o.id !== victim.id && o.id !== killer.id && o.id !== herring.id && o.evening === doppelganger.evening);
|
// Свідків роздає assignMates() у кінці генерації, коли вечір уже остаточний.
|
||||||
if (mate) {
|
|
||||||
doppelganger.mate = mate.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
return doppelganger;
|
return doppelganger;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,37 +36,41 @@ const COATS = ['чорному пальті', 'сірій куртці', 'син
|
||||||
|
|
||||||
// Чому люди брешуть про свій вечір (виправдання на повторному допиті).
|
// Чому люди брешуть про свій вечір (виправдання на повторному допиті).
|
||||||
// Гравці самі вирішують, кому вірити, — гра вердиктів не ставить.
|
// Гравці самі вирішують, кому вірити, — гра вердиктів не ставить.
|
||||||
|
// УВАГА: рядки роздаються `pick`ом незалежно від статі, тож усі вони мусять
|
||||||
|
// лишатися БЕЗРІДНИМИ — теперішній час, інфінітив або називна конструкція.
|
||||||
|
// Жодного минулого часу від першої особи («був», «пив», «ходив»): половина
|
||||||
|
// мешканців району — жінки. Тест у test.js це перевіряє.
|
||||||
const EXCUSES = [
|
const EXCUSES = [
|
||||||
'Я був не сам, і вдома про це знати не мусять. Ви розумієте.',
|
'Була компанія, і вдома про це знати не мусять. Ви розумієте.',
|
||||||
'Пив. Сильно. Не хотів, щоб про це знали на роботі.',
|
'Пиятика. Сильна. І на роботі про це знати не мусять.',
|
||||||
'Грав у карти на гроші в гаражах. Це ж не злочин... майже.',
|
'Карти на гроші в гаражах. Це ж не злочин... майже.',
|
||||||
'Я боявся: скажеш «був поруч» — і вже підозрюваний. От і збрехав.',
|
'Страх, розумієте: скажеш «я поруч» — і ти вже підозрюваний. От і брехня.',
|
||||||
'Позичав гроші в людини, про яку краще не говорити вголос.',
|
'Борг у людини, про яку краще не говорити вголос.',
|
||||||
'Ходив до ворожки. Смійтеся-смійтеся. Спробуйте самі поживіть із моїм щастям.',
|
'Ворожка. Смійтеся-смійтеся. Спробуйте самі поживіть із моїм щастям.',
|
||||||
'Шукав підробіток. Офіційно мені не можна, тож — тихцем.',
|
'Підробіток. Офіційно мені не можна, тож — тихцем.',
|
||||||
'Посварився з дружиною і ночував у майстерні. Соромно було казати.',
|
'Сварка вдома, ніч у майстерні. Соромно казати.',
|
||||||
'Здавав пляшки. Так, отакий у мене вечір. Задоволені?',
|
'Пляшки на здачу. Так, отакий у мене вечір. Задоволені?',
|
||||||
'Був у клініці. Аналізи. Це моя справа і нікого не обходить.',
|
'Клініка. Аналізи. Це моя справа і нікого не обходить.',
|
||||||
'Зустрічався з колишньою. Якщо дізнається теперішня — мені кінець.',
|
'Зустріч із колишнім коханням. Якщо дізнаються удома — мені кінець.',
|
||||||
'Продавав дещо з дому, поки рідня не бачить. Борги.',
|
'Дещо з дому — на продаж, поки рідня не бачить. Борги.',
|
||||||
'Сидів у барі до закриття. Мамі кажу, що не п\'ю.',
|
'Бар до самого закриття. Мамі кажу, що не п\'ю.',
|
||||||
'Ловив рибу без квитка. Інспектор мене вже двічі попереджав.',
|
'Риболовля без квитка. Інспектор мене вже двічі попереджав.',
|
||||||
'Плакав у машині. Буває. Не хотів, щоб хтось бачив.',
|
'Сльози в машині. Буває. І без свідків, будь ласка.',
|
||||||
'Ходив дивитися на квартиру жертви... тобто, я не так висловився. Просто гуляв тим двором.',
|
'Дивлюся іноді на ту квартиру... тобто, я не так кажу. Просто прогулянка тим двором.',
|
||||||
'Репетирував освідчення. Так, сам із собою. Годину. У дворі.',
|
'Репетиція освідчення. Так, наодинці. Годину. У дворі.',
|
||||||
'Був на співбесіді в іншому місті. Начальник не мусить знати.',
|
'Співбесіда в іншому місті. Начальник не мусить знати.',
|
||||||
'Виносив речі колишнього орендаря. Не питайте.',
|
'Речі колишнього орендаря треба було винести. Не питайте.',
|
||||||
'Дзвонив у передачу на радіо. Це анонімно, і хай так і лишиться.',
|
'Дзвінок у передачу на радіо. Це анонімно, і хай так і лишиться.',
|
||||||
'Фотографував птахів. Уночі. Сов, якщо точніше. Це хобі!',
|
'Фотографую птахів. Уночі. Сов, якщо точніше. Це хобі!',
|
||||||
'Стежив за власним чоловіком. І ні, не скажу, що вистежила.',
|
'Стеження за власною половиною. І ні, не скажу, що з того вийшло.',
|
||||||
'Пробував кинути курити і намотував кола районом, щоб не зірватися.',
|
'Кидаю курити — і намотую кола районом, щоб не зірватися.',
|
||||||
'Ремонтував чужу машину за готівку. Податкова ж не дізнається?',
|
'Ремонт чужої машини за готівку. Податкова ж не дізнається?',
|
||||||
'Ходив на могилу до брата. Про це не розповідаю нікому.',
|
'Могила брата. Про це нікому не розповідаю.',
|
||||||
'Грав у шахи на набережній. На гроші. Пенсії не вистачає.',
|
'Шахи на набережній. На гроші. Пенсії не вистачає.',
|
||||||
'Був у перукарки. Не в тій, до якої ходить моя сім\'я. Це складно.',
|
'Перукарка. Не та, до якої ходить моя сім\'я. Це складно.',
|
||||||
'Тренувався співати в елінгу. Там акустика. І ніхто не чує. Зазвичай.',
|
'Вправляюся співати в елінгу. Там акустика. І ніхто не чує. Зазвичай.',
|
||||||
'Розклеював оголошення про зниклого кота. Кіт не мій. Довга історія.',
|
'Оголошення про зниклого кота, по всіх стовпах. Кіт не мій. Довга історія.',
|
||||||
'Просто збрехав. Звичка з дитинства: менше скажеш — краще спиш.'
|
'Просто брехня. Звичка з дитинства: менше скажеш — краще спиш.'
|
||||||
];
|
];
|
||||||
|
|
||||||
// Хто знайшов тіло / повідомив (для вступів)
|
// Хто знайшов тіло / повідомив (для вступів)
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,20 @@ function checkCase(seed) {
|
||||||
if (c.liar && c.id !== cs.finale.killer && c.id !== cs.accomplice)
|
if (c.liar && c.id !== cs.finale.killer && c.id !== cs.accomplice)
|
||||||
assert.ok(c.excuse, 'у брехуна є виправдання на повторний допит');
|
assert.ok(c.excuse, 'у брехуна є виправдання на повторний допит');
|
||||||
|
|
||||||
|
// -- свідок алібі: мусить бути там само, підтверджувати це на власному допиті
|
||||||
|
// і не бути фігурантом справи (інакше «спитайте X» зливає, хто в справі).
|
||||||
|
for (const c of cs.citizens) {
|
||||||
|
if (!c.mate) continue;
|
||||||
|
const mt = byId(c.mate);
|
||||||
|
assert.ok(mt, `${seed}/${c.name}: свідок алібі не існує`);
|
||||||
|
assert.strictEqual(mt.evening, c.evening, `${seed}/${c.name}: свідок алібі був в іншому місці`);
|
||||||
|
assert.strictEqual(mt.claim, mt.evening, `${seed}/${c.name}: свідок алібі сам заперечує його покази`);
|
||||||
|
assert.ok(![cs.victim, cs.finale.killer, cs.accomplice].includes(mt.id),
|
||||||
|
`${seed}/${c.name}: свідок алібі — фігурант справи`);
|
||||||
|
assert.ok(c.liar && c.id !== cs.finale.killer && c.id !== cs.accomplice,
|
||||||
|
`${seed}/${c.name}: свідок алібі є в того, хто не брехав`);
|
||||||
|
}
|
||||||
|
|
||||||
// -- зламане алібі: сплячий доказ, недосяжний симулятором, з вагою підтвердження
|
// -- зламане алібі: сплячий доказ, недосяжний симулятором, з вагою підтвердження
|
||||||
const ab = cs.evidence.alibi_broken;
|
const ab = cs.evidence.alibi_broken;
|
||||||
assert.ok(ab && ab.special === 'alibi' && ab.gives.confirms, 'сплячий доказ алібі існує');
|
assert.ok(ab && ab.special === 'alibi' && ab.gives.confirms, 'сплячий доказ алібі існує');
|
||||||
|
|
@ -142,6 +156,17 @@ function checkCase(seed) {
|
||||||
};
|
};
|
||||||
walk(cs, '');
|
walk(cs, '');
|
||||||
|
|
||||||
|
// -- виправдання брехунів роздаються `pick`ом незалежно від статі, тож у них
|
||||||
|
// не має бути минулого часу від першої особи («був», «пив», «посварився»):
|
||||||
|
// жінка на зізнанні сказала б це в чоловічому роді. Сітка вище цього не ловить —
|
||||||
|
// форма граматично правильна, просто не того роду.
|
||||||
|
const M_PAST = /(?:^|[.!?…]\s+|(?:^|\s)(?:я|і|й|та|то|вже|не|ще)\s+)[а-яіїєґ']*[аяиіуюе]в(?:ся|сь)?(?=$|[\s.,!?;:»…])/i;
|
||||||
|
for (const c of cs.citizens) {
|
||||||
|
if (!c.excuse) continue;
|
||||||
|
const m = c.excuse.match(M_PAST);
|
||||||
|
assert.ok(!m, `${seed}/${c.id}: виправдання в чоловічому роді «${m && m[0].trim()}»: ${c.excuse}`);
|
||||||
|
}
|
||||||
|
|
||||||
// -- розв'язність від стартових фактів
|
// -- розв'язність від стартових фактів
|
||||||
const sol = solve(cs);
|
const sol = solve(cs);
|
||||||
assert.ok(sol.identity, 'особу жертви можна встановити');
|
assert.ok(sol.identity, 'особу жертви можна встановити');
|
||||||
|
|
|
||||||
|
|
@ -48,9 +48,9 @@
|
||||||
const p = panels.db;
|
const p = panels.db;
|
||||||
p.innerHTML = `
|
p.innerHTML = `
|
||||||
<h3>База мешканців району</h3>
|
<h3>База мешканців району</h3>
|
||||||
<p class="dim small">Пошук за точним ім'ям — помилка в написанні дасть порожній результат. Значення для фільтрів беріть зі знайдених слідів і вводьте самі. Реєстр місцями застарілий: одяг і розмір взуття надійніше звіряти очним оглядом (ордер у польового).</p>
|
<p class="dim small">Пошук за іменем: досить початку — «шев» знайде «Шевченко», регістр не важить. Кілька слів звужують (ім'я + прізвище). Значення для фільтрів беріть зі знайдених слідів і вводьте самі. Реєстр місцями застарілий: одяг і розмір взуття надійніше звіряти очним оглядом (ордер у польового).</p>
|
||||||
<div class="filters">
|
<div class="filters">
|
||||||
<input id="q-name" placeholder="Ім'я Прізвище" style="width:220px">
|
<input id="q-name" placeholder="Ім'я Прізвище" style="min-width:220px">
|
||||||
<button id="q-go" class="primary">Знайти</button>
|
<button id="q-go" class="primary">Знайти</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="filters">
|
<div class="filters">
|
||||||
|
|
@ -69,13 +69,7 @@
|
||||||
p.querySelector('#q-go').onclick = () => {
|
p.querySelector('#q-go').onclick = () => {
|
||||||
const q = p.querySelector('#q-name').value.trim();
|
const q = p.querySelector('#q-name').value.trim();
|
||||||
if (!q) { lastRows = null; dbOut.innerHTML = '<p class="dim">— Введіть ім\'я або прізвище.</p>'; return; }
|
if (!q) { lastRows = null; dbOut.innerHTML = '<p class="dim">— Введіть ім\'я або прізвище.</p>'; return; }
|
||||||
// Розбиваємо введений запит на слова
|
lastRows = matchName(App.init.citizens, q); // спільний пошук, див. client.js
|
||||||
const words = q.split(/\s+/);
|
|
||||||
// Фільтруємо: кожне введене слово має збігатися з початком якогось слова в імені громадянина
|
|
||||||
lastRows = App.init.citizens.filter(c => {
|
|
||||||
const nameWords = c.name.split(/\s+/);
|
|
||||||
return words.every(w => nameWords.some(nw => nw.startsWith(w)));
|
|
||||||
});
|
|
||||||
if (!lastRows.length) {
|
if (!lastRows.length) {
|
||||||
dbOut.innerHTML = '<p class="dim">— Записів не знайдено. Спробуйте інший початок.</p>';
|
dbOut.innerHTML = '<p class="dim">— Записів не знайдено. Спробуйте інший початок.</p>';
|
||||||
return;
|
return;
|
||||||
|
|
@ -97,7 +91,7 @@
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// рядки з позначками ✓/✗/…, як картки доказів: викреслюйте людей по одному
|
// рядки з кольоровими мітками: ведіть кілька версій одночасно, по одній людині
|
||||||
function renderRows() {
|
function renderRows() {
|
||||||
if (!dbOut || !lastRows || !App.state) return;
|
if (!dbOut || !lastRows || !App.state) return;
|
||||||
dbOut.innerHTML = `<p class="dim small">Збігів: ${lastRows.length}</p>`;
|
dbOut.innerHTML = `<p class="dim small">Збігів: ${lastRows.length}</p>`;
|
||||||
|
|
@ -184,28 +178,35 @@
|
||||||
|
|
||||||
<div class="syshead">Оператор зв'язку</div>
|
<div class="syshead">Оператор зв'язку</div>
|
||||||
<div class="filters">
|
<div class="filters">
|
||||||
<input id="ph-n" placeholder="+380…" style="width:180px">
|
<input id="ph-n" placeholder="+380…" style="min-width:200px">
|
||||||
<button data-q="owner">Власник номера</button>
|
|
||||||
<button data-q="calls">Журнал дзвінків</button>
|
|
||||||
<button data-q="locate">Тріангуляція (де зараз)</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p class="dim small">Формат номера вільний: пробіли, дужки й дефіси система відкидає сама.</p>
|
||||||
|
<div class="filters">
|
||||||
|
<button data-q="owner">Архів: власник номера</button>
|
||||||
|
<button data-q="calls">Архів: журнал дзвінків за вечір</button>
|
||||||
|
</div>
|
||||||
|
<div class="filters">
|
||||||
|
<button data-q="locate">📍 Тріангуляція — де апарат ЗАРАЗ</button>
|
||||||
|
</div>
|
||||||
|
<p class="dim small">Перші два запити — архівні, про вечір злочину. Пеленг — живий: показує поточну позицію апарата, і оператор дає його лише за наявності підстав.</p>
|
||||||
|
|
||||||
<div class="syshead">Банк</div>
|
<div class="syshead">Банк</div>
|
||||||
<div class="filters">
|
<div class="filters">
|
||||||
<input id="bk-n" placeholder="Ім'я Прізвище або заклад" style="width:220px">
|
<input id="bk-n" placeholder="Ім'я Прізвище" style="min-width:220px">
|
||||||
<button id="bk-go">Запросити виписку</button>
|
<button id="bk-go">Запросити виписку</button>
|
||||||
</div>
|
</div>
|
||||||
|
<p class="dim small">Досить початку імені чи прізвища — те саме правило, що й у базі мешканців. Якщо під запит підходить кілька осіб, банк попросить уточнити.</p>
|
||||||
|
|
||||||
<div class="syshead">Реєстри</div>
|
<div class="syshead">Реєстри</div>
|
||||||
<div class="filters">
|
<div class="filters">
|
||||||
<input id="db-n" placeholder="ім'я / тату / кличка собаки…" style="width:220px">
|
<input id="db-n" placeholder="ім'я / тату / кличка собаки…" style="min-width:220px">
|
||||||
<button id="db-go">Пошук у реєстрах</button>
|
<button id="db-go">Пошук у реєстрах</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="syshead">Криміналістика</div>
|
<div class="syshead">Криміналістика</div>
|
||||||
<div class="filters">
|
<div class="filters">
|
||||||
<button id="cr-exp" class="primary">Експертиза місця/тіла</button>
|
<button id="cr-exp" class="primary">Експертиза місця/тіла</button>
|
||||||
<input id="cr-n" placeholder="архів: рік, слово, прізвище…" style="width:200px">
|
<input id="cr-n" placeholder="архів: рік, слово, прізвище…" style="min-width:220px">
|
||||||
<button id="cr-go">Пошук в архіві справ</button>
|
<button id="cr-go">Пошук в архіві справ</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -293,21 +294,25 @@
|
||||||
if (!labBox || !App.state) return;
|
if (!labBox || !App.state) return;
|
||||||
labBox.innerHTML = '';
|
labBox.innerHTML = '';
|
||||||
const foundId = id => App.state.found.some(f => f.id === id);
|
const foundId = id => App.state.found.some(f => f.id === id);
|
||||||
|
// Обрізаємо з кінця по символах, а не по трьох словах: два описи ДНК часто
|
||||||
|
// починаються однаково («волосся, вирване з коренем…»), і кнопки ставали
|
||||||
|
// нерозрізненними. Повний текст лишається в title=.
|
||||||
|
const short = (t, n = 40) => !t ? '' : t.length > n ? t.slice(0, n - 1).trimEnd() + '…' : t;
|
||||||
const mk = (label, sample, doneId, noSampleMsg, run) => {
|
const mk = (label, sample, doneId, noSampleMsg, run) => {
|
||||||
const b = el('button', '', label + (foundId(doneId) ? ' ✓' : ''));
|
const b = el('button', '', short(label) + (foundId(doneId) ? ' ✓' : ''));
|
||||||
|
b.title = label;
|
||||||
b.disabled = foundId(doneId);
|
b.disabled = foundId(doneId);
|
||||||
b.onclick = () => sample == null ? toast(noSampleMsg) : run();
|
b.onclick = () => sample == null ? toast(noSampleMsg) : run();
|
||||||
labBox.append(b);
|
labBox.append(b);
|
||||||
};
|
};
|
||||||
const short = t => t ? t.split(' ').slice(0, 3).join(' ') + '…' : '';
|
mk(App.state.dnaLabel ? `ДНК: ${App.state.dnaLabel}` : 'Порівняти ДНК зі зразком',
|
||||||
mk(App.state.dnaLabel ? `ДНК: ${short(App.state.dnaLabel)}` : 'Порівняти ДНК зі зразком',
|
|
||||||
App.state.dnaSample, 'lab_dna_match',
|
App.state.dnaSample, 'lab_dna_match',
|
||||||
'Немає зразка ДНК: його ще не знайшли. Хай польовий огляне місце злочину.',
|
'Немає зразка ДНК: його ще не знайшли. Хай польовий огляне місце злочину.',
|
||||||
() => pickCompare('Гель-електрофорез: кого порівнюємо?', ids => dnaModal(ids, 'sf_dna', App.state.dnaSample)));
|
() => pickCompare('Гель-електрофорез: кого порівнюємо?', (ids, nav) => dnaModal(ids, 'sf_dna', App.state.dnaSample, nav)));
|
||||||
if (App.state.dnaSample2)
|
if (App.state.dnaSample2)
|
||||||
mk(`ДНК: ${short(App.state.dna2Label)}`, App.state.dnaSample2, 'lab_dna_match2',
|
mk(`ДНК (2): ${App.state.dna2Label}`, App.state.dnaSample2, 'lab_dna_match2',
|
||||||
'',
|
'',
|
||||||
() => pickCompare('Другий зразок: кого порівнюємо?', ids => dnaModal(ids, 'sf_dna2', App.state.dnaSample2)));
|
() => pickCompare('Другий зразок: кого порівнюємо?', (ids, nav) => dnaModal(ids, 'sf_dna2', App.state.dnaSample2, nav)));
|
||||||
mk('Звірити відбиток зі знаряддя', App.state.printSample, 'lab_print_match',
|
mk('Звірити відбиток зі знаряддя', App.state.printSample, 'lab_print_match',
|
||||||
'Немає повного відбитка: спершу треба знайти знаряддя злочину (підказку дає розтин).',
|
'Немає повного відбитка: спершу треба знайти знаряддя злочину (підказку дає розтин).',
|
||||||
() => pickCompare('Дактилоскопія: чиї картки підняти?', printModal));
|
() => pickCompare('Дактилоскопія: чиї картки підняти?', printModal));
|
||||||
|
|
@ -369,10 +374,10 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
const laneHtml = (dna, name) =>
|
const laneHtml = (dna, name) =>
|
||||||
`<div style="display:inline-block;text-align:center;margin:0 7px;vertical-align:top">
|
`<div style="text-align:center;width:86px">
|
||||||
<div style="position:relative;width:26px;height:180px;background:#0d1116;border:1px solid var(--line);margin:0 auto">
|
<div style="position:relative;width:26px;height:180px;background:#0d1116;border:1px solid var(--line);margin:0 auto">
|
||||||
${dna.map(v => `<div style="position:absolute;left:2px;right:2px;top:${8 + v * 10}px;height:4px;background:var(--blue)"></div>`).join('')}
|
${dna.map(v => `<div style="position:absolute;left:2px;right:2px;top:${8 + v * 10}px;height:4px;background:var(--blue)"></div>`).join('')}
|
||||||
</div><div class="small dim" style="max-width:86px">${name}</div></div>`;
|
</div><div class="small dim">${name}</div></div>`;
|
||||||
|
|
||||||
// Ручний шортліст: аналітик сам вирішує, кого нести в лабораторію.
|
// Ручний шортліст: аналітик сам вирішує, кого нести в лабораторію.
|
||||||
// ponytail: сервер не гейтить ДНК — перебір усієї бази можливий, але по 6 і руками
|
// ponytail: сервер не гейтить ДНК — перебір усієї бази можливий, але по 6 і руками
|
||||||
|
|
@ -404,29 +409,35 @@
|
||||||
q.oninput = draw; draw();
|
q.oninput = draw; draw();
|
||||||
mm.inner.querySelector('#pk-go').onclick = () => {
|
mm.inner.querySelector('#pk-go').onclick = () => {
|
||||||
if (!sel.size) return;
|
if (!sel.size) return;
|
||||||
mm.wrap.remove(); go([...sel]);
|
// Шортліст не викидаємо, а ховаємо: «← Назад» із гелю повертає до вже
|
||||||
|
// обраних шести профілів, а не до нуля.
|
||||||
|
mm.wrap.style.display = 'none';
|
||||||
|
go([...sel], {
|
||||||
|
back: () => { mm.wrap.style.display = ''; },
|
||||||
|
onClose: () => mm.wrap.remove()
|
||||||
|
});
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function dnaModal(ids, sampleEv, sample) {
|
function dnaModal(ids, sampleEv, sample, nav) {
|
||||||
if (!sample || !ids.length) return;
|
if (!sample || !ids.length) return;
|
||||||
const mm = openModal(`<h3>Гель-електрофорез</h3>
|
const mm = openModal(`<h3>Гель-електрофорез</h3>
|
||||||
<p class="dim small">Зразок із місця злочину — ліворуч. Клацніть по профілю, який вважаєте збігом. Памʼятайте: збіг скаже лише, ЧИЯ це ДНК, — не коли і не чому вона там.</p>
|
<p class="dim small">Зразок із місця злочину — ліворуч. Клацніть по профілю, який вважаєте збігом. Памʼятайте: збіг скаже лише, ЧИЯ це ДНК, — не коли і не чому вона там.</p>
|
||||||
<div style="overflow-x:auto;white-space:nowrap">${laneHtml(sample, '<b>ЗРАЗОК</b>')}<span style="display:inline-block;width:16px;border-left:1px solid var(--line);height:180px;vertical-align:top"></span>${ids.map(id => { const c = App.byId(id); return `<span data-cid="${id}" style="cursor:pointer">${laneHtml(c.dna, esc(c.name))}</span>`; }).join('')}</div>`);
|
<div style="display:flex;flex-wrap:wrap;align-items:flex-start;gap:8px 6px">${laneHtml(sample, '<b>ЗРАЗОК</b>')}<span style="width:0;border-left:1px solid var(--line);height:180px"></span>${ids.map(id => { const c = App.byId(id); return `<span data-cid="${id}" style="cursor:pointer">${laneHtml(c.dna, esc(c.name))}</span>`; }).join('')}</div>`, nav);
|
||||||
mm.inner.append(hintBlock('Збіг — це коли ВСІ смуги стоять на тих самих висотах, що у зразка. Помилковий вибір чесно скаже «ні» і викреслить людину.'));
|
mm.inner.append(hintBlock('Збіг — це коли ВСІ смуги стоять на тих самих висотах, що у зразка. Помилковий вибір чесно скаже «ні» і викреслить людину.'));
|
||||||
mm.inner.onclick = e => {
|
mm.inner.onclick = e => {
|
||||||
const s = e.target.closest('[data-cid]');
|
const s = e.target.closest('[data-cid]');
|
||||||
if (s) { App.send({ t: 'dna_pick', sampleEv, cid: s.dataset.cid }); mm.wrap.remove(); }
|
if (s) { App.send({ t: 'dna_pick', sampleEv, cid: s.dataset.cid }); mm.close(); }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function printModal(ids) {
|
function printModal(ids, nav) {
|
||||||
if (App.state.printSample == null || !ids.length) return;
|
if (App.state.printSample == null || !ids.length) return;
|
||||||
const mm = openModal(`<h3>Дактилоскопія: відбиток зі знаряддя</h3>
|
const mm = openModal(`<h3>Дактилоскопія: відбиток зі знаряддя</h3>
|
||||||
<p class="dim small">Зразок ліворуч. Клацніть по власнику відбитка.</p>
|
<p class="dim small">Зразок ліворуч. Клацніть по власнику відбитка.</p>
|
||||||
<canvas id="psample" width="150" height="150" style="border:1px solid var(--acc);border-radius:6px"></canvas>
|
<canvas id="psample" width="150" height="150" style="border:1px solid var(--acc);border-radius:6px"></canvas>
|
||||||
<span class="dim small"> ← зразок</span>
|
<span class="dim small"> ← зразок</span>
|
||||||
<div id="pcands" style="display:flex;gap:12px;flex-wrap:wrap;margin-top:10px"></div>`);
|
<div id="pcands" style="display:flex;gap:12px;flex-wrap:wrap;margin-top:10px"></div>`, nav);
|
||||||
drawPrint(mm.inner.querySelector('#psample').getContext('2d'), App.state.printSample, 0.7);
|
drawPrint(mm.inner.querySelector('#psample').getContext('2d'), App.state.printSample, 0.7);
|
||||||
const box = mm.inner.querySelector('#pcands');
|
const box = mm.inner.querySelector('#pcands');
|
||||||
for (const id of ids) {
|
for (const id of ids) {
|
||||||
|
|
@ -435,7 +446,7 @@
|
||||||
const cv = el('canvas'); cv.width = cv.height = 110; cv.style.border = '1px solid var(--line)'; cv.style.borderRadius = '6px';
|
const cv = el('canvas'); cv.width = cv.height = 110; cv.style.border = '1px solid var(--line)'; cv.style.borderRadius = '6px';
|
||||||
w.append(cv, el('div', 'small dim', esc(c.name)));
|
w.append(cv, el('div', 'small dim', esc(c.name)));
|
||||||
drawPrint(cv.getContext('2d'), c.printSeed, 0);
|
drawPrint(cv.getContext('2d'), c.printSeed, 0);
|
||||||
w.onclick = () => { App.send({ t: 'print_pick', cid: id }); mm.wrap.remove(); };
|
w.onclick = () => { App.send({ t: 'print_pick', cid: id }); mm.close(); };
|
||||||
box.append(w);
|
box.append(w);
|
||||||
}
|
}
|
||||||
mm.inner.append(hintBlock('Зразок знято під кутом — подумки поверніть його. Порівнюйте будову: кількість і хід дуг, а не поворот.'));
|
mm.inner.append(hintBlock('Зразок знято під кутом — подумки поверніть його. Порівнюйте будову: кількість і хід дуг, а не поворот.'));
|
||||||
|
|
|
||||||
|
|
@ -130,11 +130,31 @@ try {
|
||||||
// ---------- Бічна дошка ----------
|
// ---------- Бічна дошка ----------
|
||||||
let sideTab = "ev";
|
let sideTab = "ev";
|
||||||
let showHidden = false; // згорнута секція «Приховані» на дні дошки
|
let showHidden = false; // згорнута секція «Приховані» на дні дошки
|
||||||
|
let evFilter = ""; // текстовий фільтр по картках доказів
|
||||||
|
let evTag = null; // активний чип-тег (null = всі)
|
||||||
|
|
||||||
// Дошка перемальовується на кожен стан — тож недодруковані нотатки треба зберігати,
|
// Дошка перемальовується на кожен стан — тож недодруковані нотатки треба зберігати,
|
||||||
// інакше broadcast напарника стирає те, що ти зараз друкуєш. Тримаємо чернетку в мапі
|
// інакше broadcast напарника стирає те, що ти зараз друкуєш. Тримаємо чернетку в мапі
|
||||||
// (ключ = data-draft-key поля) і відновлюємо фокус/курсор після перемальовки.
|
// (ключ = data-draft-key поля) і відновлюємо фокус/курсор після перемальовки.
|
||||||
const noteDraft = {};
|
const noteDraft = {};
|
||||||
|
// Але фокус повертаємо, ЛИШЕ якщо гравець справді щойно друкував. Раніше було
|
||||||
|
// «завжди, якщо колись друкував»: варто польовому один раз клацнути в нотатку,
|
||||||
|
// і кожен наступний broadcast знову садив фокус у той інпут — а обробник клавіш
|
||||||
|
// у field.js свідомо ігнорує натискання в INPUT/TEXTAREA. Клавіатура мовчала,
|
||||||
|
// візуально нічого не змінювалось.
|
||||||
|
const FOCUS_GRACE_MS = 3000;
|
||||||
|
let lastTypedAt = 0;
|
||||||
|
let restoringFocus = false; // наш власний .focus() не має продовжувати вікно
|
||||||
|
{
|
||||||
|
const side = $("#side");
|
||||||
|
const touch = () => {
|
||||||
|
if (!restoringFocus) lastTypedAt = Date.now();
|
||||||
|
};
|
||||||
|
// capture: фільтр дошки сам кличе renderSide() зі свого oninput, тож позначку
|
||||||
|
// часу треба поставити ДО того, як обробник цілі встигне перемалювати панель.
|
||||||
|
side.addEventListener("input", touch, true);
|
||||||
|
side.addEventListener("focusin", touch, true);
|
||||||
|
}
|
||||||
function wireDraft(input, key) {
|
function wireDraft(input, key) {
|
||||||
input.dataset.draftKey = key;
|
input.dataset.draftKey = key;
|
||||||
if (noteDraft[key]) input.value = noteDraft[key];
|
if (noteDraft[key]) input.value = noteDraft[key];
|
||||||
|
|
@ -150,20 +170,49 @@ $("#sidetabs").onclick = (e) => {
|
||||||
renderSide();
|
renderSide();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Мітки. На картках доказів лишається ✓/✗/…, а на людях трьох станів замало —
|
||||||
|
// треба вести кілька версій одночасно. Тому для ключів 'c:' окрема палітра
|
||||||
|
// кольорів. Що саме означає кожен колір — вирішують гравці: жодних підписів,
|
||||||
|
// легенд і тултипів ми не нав'язуємо. «Сховати» лишається в обох: на ньому
|
||||||
|
// тримається секція «Приховані».
|
||||||
|
const EV_MARKS = [
|
||||||
|
["tick", "відмітити", null],
|
||||||
|
["cross", "викреслити", null],
|
||||||
|
["hide", "сховати", null],
|
||||||
|
];
|
||||||
|
const PEOPLE_MARKS = [
|
||||||
|
["green", null, "var(--mk-green)"],
|
||||||
|
["red", null, "var(--mk-red)"],
|
||||||
|
["blue", null, "var(--mk-blue)"],
|
||||||
|
["amber", null, "var(--mk-amber)"],
|
||||||
|
["hide", "сховати", null],
|
||||||
|
];
|
||||||
|
const MARK_GLYPH = { tick: "✓", cross: "✗", hide: "…" };
|
||||||
|
// Сейви зі старих версій тримають на людях ✓/✗ — читаємо їх як два перші кольори.
|
||||||
|
const LEGACY_PERSON_MARK = { tick: "green", cross: "red" };
|
||||||
|
const isPersonKey = (key) => String(key).startsWith("c:");
|
||||||
|
|
||||||
|
function markOf(key) {
|
||||||
|
const m = App.state.board.marks[key];
|
||||||
|
if (!m) return null;
|
||||||
|
return isPersonKey(key) ? LEGACY_PERSON_MARK[m] || m : m;
|
||||||
|
}
|
||||||
|
|
||||||
function markButtons(key) {
|
function markButtons(key) {
|
||||||
const marks = App.state.board.marks;
|
const cur = markOf(key);
|
||||||
const mk = el("div", "mk");
|
const mk = el("div", "mk");
|
||||||
for (const [kind, label] of [
|
for (const [kind, title, colour] of isPersonKey(key)
|
||||||
["tick", "✓"],
|
? PEOPLE_MARKS
|
||||||
["cross", "✗"],
|
: EV_MARKS) {
|
||||||
["hide", "…"],
|
const b = el(
|
||||||
]) {
|
"button",
|
||||||
const b = el("button", marks[key] === kind ? "on" : "", label);
|
(colour ? "swatch " : "") + (cur === kind ? "on" : ""),
|
||||||
b.title = {
|
colour
|
||||||
tick: "відмітити",
|
? `<span class="dot" style="background:${colour}"></span>`
|
||||||
cross: "викреслити",
|
: MARK_GLYPH[kind],
|
||||||
hide: "сховати",
|
);
|
||||||
}[kind];
|
if (title) b.title = title; // кольори лишаємо без підписів — значення за гравцями
|
||||||
|
if (colour) b.style.color = colour;
|
||||||
b.onclick = (ev) => {
|
b.onclick = (ev) => {
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
App.send({ t: "mark", key, kind });
|
App.send({ t: "mark", key, kind });
|
||||||
|
|
@ -174,8 +223,16 @@ function markButtons(key) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function cardClasses(key) {
|
function cardClasses(key) {
|
||||||
const m = App.state.board.marks[key];
|
const m = markOf(key);
|
||||||
return m && m !== "hide" ? " " + m : "";
|
if (!m || m === "hide") return "";
|
||||||
|
return isPersonKey(key) ? " mk-" + m : " " + m;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Свотч кольору для місць, де мітку лише показують (база польового).
|
||||||
|
function markSwatch(kind) {
|
||||||
|
const found = PEOPLE_MARKS.find(([k]) => k === kind);
|
||||||
|
if (!found || !found[2]) return "";
|
||||||
|
return `<span class="dot" style="background:${found[2]}"></span>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Прокрутити дошку до доказу за id і коротко підсвітити. Якщо він у згорнутій
|
// Прокрутити дошку до доказу за id і коротко підсвітити. Якщо він у згорнутій
|
||||||
|
|
@ -207,38 +264,74 @@ function renderSide() {
|
||||||
|
|
||||||
if (sideTab === "ev") {
|
if (sideTab === "ev") {
|
||||||
const pins = App.state.board.pins;
|
const pins = App.state.board.pins;
|
||||||
|
const found = App.state.found;
|
||||||
|
const tagOf = (e) => e.tag || "запит";
|
||||||
body.append(
|
body.append(
|
||||||
el(
|
el(
|
||||||
"div",
|
"div",
|
||||||
"dim small",
|
"dim small",
|
||||||
"Закріплене 📌 піде в справу для прокурора. Знайдено: " +
|
"Закріплене 📌 піде в справу для прокурора. Знайдено: " + found.length,
|
||||||
App.state.found.length,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const found = App.state.found;
|
|
||||||
|
// Пошук + чипи-теги. До фіналу на дошці 30+ карток одним стовпчиком, і знайти
|
||||||
|
// потрібну можна лише прокруткою. Чипи будуємо з тегів, що реально є на дошці, —
|
||||||
|
// так жоден доказ не лишається поза жодним зрізом.
|
||||||
|
const q = el("input", "small evfilter");
|
||||||
|
q.placeholder = "🔎 фільтр за текстом…";
|
||||||
|
q.value = evFilter;
|
||||||
|
q.dataset.draftKey = "evfilter"; // щоб фокус/курсор пережили перемальовку
|
||||||
|
q.oninput = () => {
|
||||||
|
evFilter = q.value;
|
||||||
|
renderSide();
|
||||||
|
};
|
||||||
|
body.append(q);
|
||||||
|
|
||||||
|
const chips = el("div", "chips");
|
||||||
|
const allTags = [...new Set(found.map(tagOf))].sort((a, b) =>
|
||||||
|
a.localeCompare(b, "uk"),
|
||||||
|
);
|
||||||
|
for (const t of [null, ...allTags]) {
|
||||||
|
const b = el("button", "chip" + (evTag === t ? " on" : ""), t || "всі");
|
||||||
|
b.onclick = () => {
|
||||||
|
evTag = evTag === t ? null : t;
|
||||||
|
renderSide();
|
||||||
|
};
|
||||||
|
chips.append(b);
|
||||||
|
}
|
||||||
|
body.append(chips);
|
||||||
|
|
||||||
|
// Фільтр застосовуємо ДО поділу на закріплені/решту, щоб 📌 не зникали з очей.
|
||||||
|
const low = evFilter.trim().toLowerCase();
|
||||||
|
const passes = (e) =>
|
||||||
|
(!evTag || tagOf(e) === evTag) &&
|
||||||
|
(!low ||
|
||||||
|
`${e.title} ${e.detail} ${e.from || ""}`.toLowerCase().includes(low));
|
||||||
|
const shown = found.filter(passes);
|
||||||
|
|
||||||
// Закріплені — зверху, від останнього запіненого до першого; далі решта доказів,
|
// Закріплені — зверху, від останнього запіненого до першого; далі решта доказів,
|
||||||
// від найновішого знайденого до найстарішого. (pins — у порядку закріплення.)
|
// від найновішого знайденого до найстарішого. (pins — у порядку закріплення.)
|
||||||
const pinnedCards = [...pins]
|
const pinnedCards = [...pins]
|
||||||
.reverse()
|
.reverse()
|
||||||
.map((id) => found.find((e) => e.id === id))
|
.map((id) => shown.find((e) => e.id === id))
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
const rest = [...found].reverse().filter((e) => !pins.includes(e.id));
|
const rest = [...shown].reverse().filter((e) => !pins.includes(e.id));
|
||||||
const ordered = [...pinnedCards, ...rest];
|
|
||||||
|
|
||||||
const buildCard = (e) => {
|
const buildCard = (e) => {
|
||||||
const key = "e:" + e.id;
|
const key = "e:" + e.id;
|
||||||
const c = el("div", "card" + cardClasses(key));
|
const isPinned = pins.includes(e.id);
|
||||||
|
const c = el("div", "card" + cardClasses(key) + (isPinned ? " pinned" : ""));
|
||||||
c.dataset.ev = e.id; // якір для прокрутки-до-доказу
|
c.dataset.ev = e.id; // якір для прокрутки-до-доказу
|
||||||
const t = el("div", "t");
|
const t = el("div", "t");
|
||||||
t.append(el("b", "", esc(e.title)));
|
t.append(el("b", "", esc(e.title)), el("span", "tag", esc(tagOf(e))));
|
||||||
const pin = el(
|
const pin = el("button", "pinbtn" + (isPinned ? " on" : ""), "📌");
|
||||||
"button",
|
pin.title = isPinned ? "відкріпити" : "закріпити для прокурора";
|
||||||
"pinbtn" + (pins.includes(e.id) ? " on" : ""),
|
|
||||||
"📌",
|
|
||||||
);
|
|
||||||
pin.onclick = () => App.send({ t: "pin", evId: e.id });
|
pin.onclick = () => App.send({ t: "pin", evId: e.id });
|
||||||
t.append(pin);
|
t.append(pin);
|
||||||
c.append(t, el("div", "small", esc(e.detail)));
|
c.append(t);
|
||||||
|
// Звідки знахідка — лише для фізичних: решта прийшла з термінала чи розмови.
|
||||||
|
if (e.from) c.append(el("div", "small dim orig", esc(e.from)));
|
||||||
|
c.append(el("div", "small", esc(e.detail)));
|
||||||
// Експертиза називає доказ, який розбирала, і веде до нього (прокрутка + підсвітка).
|
// Експертиза називає доказ, який розбирала, і веде до нього (прокрутка + підсвітка).
|
||||||
if (e.ref) {
|
if (e.ref) {
|
||||||
const src = found.find((x) => x.id === e.ref);
|
const src = found.find((x) => x.id === e.ref);
|
||||||
|
|
@ -278,13 +371,31 @@ function renderSide() {
|
||||||
};
|
};
|
||||||
|
|
||||||
const hidden = [];
|
const hidden = [];
|
||||||
for (const e of ordered) {
|
const visible = (arr) =>
|
||||||
if (marks["e:" + e.id] === "hide") {
|
arr.filter((e) => {
|
||||||
hidden.push(e);
|
if (marks["e:" + e.id] === "hide") {
|
||||||
continue;
|
hidden.push(e);
|
||||||
}
|
return false;
|
||||||
body.append(buildCard(e));
|
}
|
||||||
}
|
return true;
|
||||||
|
});
|
||||||
|
const pinnedVisible = visible(pinnedCards);
|
||||||
|
const restVisible = visible(rest);
|
||||||
|
for (const e of pinnedVisible) body.append(buildCard(e));
|
||||||
|
// Межа теки: видно, де закінчується те, що піде прокуророві.
|
||||||
|
if (pinnedVisible.length && restVisible.length)
|
||||||
|
body.append(el("div", "divider small dim", "— решта доказів —"));
|
||||||
|
for (const e of restVisible) body.append(buildCard(e));
|
||||||
|
if (!pinnedVisible.length && !restVisible.length && !hidden.length)
|
||||||
|
body.append(
|
||||||
|
el(
|
||||||
|
"div",
|
||||||
|
"dim small",
|
||||||
|
found.length
|
||||||
|
? "За цим фільтром нічого немає."
|
||||||
|
: "Дошка порожня — знахідки з'являться тут.",
|
||||||
|
),
|
||||||
|
);
|
||||||
// Приховані — окрема згорнута секція на дні, кожен доказ окремо (не «одна кнопка = все»)
|
// Приховані — окрема згорнута секція на дні, кожен доказ окремо (не «одна кнопка = все»)
|
||||||
if (hidden.length) {
|
if (hidden.length) {
|
||||||
const head = el(
|
const head = el(
|
||||||
|
|
@ -330,15 +441,19 @@ function renderSide() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// повернути фокус і курсор у поле, де друкували до перемальовки
|
// Повернути фокус і курсор у поле, де друкували до перемальовки, — але лише
|
||||||
if (activeKey) {
|
// якщо друкували щойно (див. FOCUS_GRACE_MS): інакше поле перехоплює клавіатуру
|
||||||
|
// польового назавжди.
|
||||||
|
if (activeKey && Date.now() - lastTypedAt < FOCUS_GRACE_MS) {
|
||||||
const again = body.querySelector(`[data-draft-key="${activeKey}"]`);
|
const again = body.querySelector(`[data-draft-key="${activeKey}"]`);
|
||||||
if (again) {
|
if (again) {
|
||||||
|
restoringFocus = true;
|
||||||
again.focus();
|
again.focus();
|
||||||
if (caret != null)
|
if (caret != null)
|
||||||
try {
|
try {
|
||||||
again.setSelectionRange(caret, caret);
|
again.setSelectionRange(caret, caret);
|
||||||
} catch {}
|
} catch {}
|
||||||
|
restoringFocus = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -380,7 +495,7 @@ function logSection(cid) {
|
||||||
for (const e of log.slice(-12)) {
|
for (const e of log.slice(-12)) {
|
||||||
const key = "q:" + e.id;
|
const key = "q:" + e.id;
|
||||||
let cur = App.state.board.marks[key];
|
let cur = App.state.board.marks[key];
|
||||||
const row = el("div", "small");
|
const row = el("div", "small logrow");
|
||||||
row.style.cssText = "padding-left:7px;border-left:2px solid transparent;margin:5px 0";
|
row.style.cssText = "padding-left:7px;border-left:2px solid transparent;margin:5px 0";
|
||||||
const apply = (mk) => {
|
const apply = (mk) => {
|
||||||
row.style.textDecoration = mk === "cross" ? "line-through" : "none";
|
row.style.textDecoration = mk === "cross" ? "line-through" : "none";
|
||||||
|
|
@ -417,22 +532,59 @@ function bName(bid) {
|
||||||
return b ? b.name : bid;
|
return b ? b.name : bid;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Пошук за іменем — ОДИН на обидві ролі. Кожне слово запиту має бути початком
|
||||||
|
// якогось слова в імені; регістр не важить. Раніше це були дві копії (база
|
||||||
|
// аналітика і база польового), і аналітикова загубила .toLowerCase() з обох
|
||||||
|
// боків — «поліна» не знаходило «Поліна». Сервер шукає банк і реєстри так само.
|
||||||
|
function matchName(citizens, query) {
|
||||||
|
const words = String(query || "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter(Boolean);
|
||||||
|
if (!words.length) return [];
|
||||||
|
return citizens.filter((c) => {
|
||||||
|
const nameWords = c.name.toLowerCase().split(/\s+/);
|
||||||
|
return words.every((w) => nameWords.some((nw) => nw.startsWith(w)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- Модалки ----------
|
// ---------- Модалки ----------
|
||||||
|
// Кроки більше не заміщають один одного: батьківська модалка лишається під
|
||||||
|
// поточною, тож скасування picker'а повертає до розмови, а не в порожнечу.
|
||||||
|
// opts.back — явне «← Назад» там, де попередній крок треба перезібрати
|
||||||
|
// (порожня тека ордера, шортліст лабораторії); opts.onClose — прибирання за собою.
|
||||||
function openModal(html, opts = {}) {
|
function openModal(html, opts = {}) {
|
||||||
const wrap = el("div", "modal");
|
const wrap = el("div", "modal");
|
||||||
const inner = el("div", "", html);
|
const inner = el("div", "", html);
|
||||||
wrap.append(inner);
|
wrap.append(inner);
|
||||||
|
const close = () => {
|
||||||
|
wrap.remove();
|
||||||
|
if (opts.onClose) opts.onClose();
|
||||||
|
};
|
||||||
if (!opts.locked) {
|
if (!opts.locked) {
|
||||||
wrap.onclick = (e) => {
|
wrap.onclick = (e) => {
|
||||||
if (e.target === wrap) wrap.remove();
|
if (e.target === wrap) close();
|
||||||
};
|
};
|
||||||
|
const foot = el("div", "modalfoot");
|
||||||
|
if (opts.back) {
|
||||||
|
const bk = el("button", "small", "← Назад");
|
||||||
|
bk.onclick = () => {
|
||||||
|
wrap.remove();
|
||||||
|
opts.back();
|
||||||
|
};
|
||||||
|
foot.append(bk);
|
||||||
|
}
|
||||||
const x = el("button", "small", "Закрити");
|
const x = el("button", "small", "Закрити");
|
||||||
x.style.marginTop = "12px";
|
x.onclick = close;
|
||||||
x.onclick = () => wrap.remove();
|
foot.append(x);
|
||||||
inner.append(x);
|
inner.append(foot);
|
||||||
|
// Автори модалок дописують вміст уже після openModal (журнал допитів, підказки).
|
||||||
|
// Мікрозадача переносить кнопки в кінець, коли всі синхронні append'и минули.
|
||||||
|
queueMicrotask(() => inner.append(foot));
|
||||||
}
|
}
|
||||||
document.body.append(wrap);
|
document.body.append(wrap);
|
||||||
return { wrap, inner };
|
return { wrap, inner, close };
|
||||||
}
|
}
|
||||||
|
|
||||||
function showIntro() {
|
function showIntro() {
|
||||||
|
|
@ -450,7 +602,7 @@ function showIntro() {
|
||||||
const analystHow = `
|
const analystHow = `
|
||||||
<div class="syshead">Аналітик</div>
|
<div class="syshead">Аналітик</div>
|
||||||
<ul class="small">
|
<ul class="small">
|
||||||
<li><b>База мешканців:</b> фільтри вводите руками — значення беріть із доказів. Викреслюйте людей по одному (✓/✗/…). Реєстр місцями застарілий: одяг/взуття надійніше звіряти очним оглядом польового.</li>
|
<li><b>База мешканців:</b> фільтри вводите руками — значення беріть із доказів. Мітьте людей кольорами — чотири кольори плюс «…» (сховати); що означає кожен, вирішуєте ви з напарником, гра не підказує. Реєстр місцями застарілий: одяг/взуття надійніше звіряти очним оглядом польового.</li>
|
||||||
<li><b>Системи:</b> оператор (власник, дзвінки, тріангуляція «де зараз»), банк, реєстри, криміналістика (експертиза + архів), камери. Дані для запитів шукайте в доказах. <b>Кожен результат лягає на дошку</b> — навіть дрібниця; що з того справа, вирішуєте ви.</li>
|
<li><b>Системи:</b> оператор (власник, дзвінки, тріангуляція «де зараз»), банк, реєстри, криміналістика (експертиза + архів), камери. Дані для запитів шукайте в доказах. <b>Кожен результат лягає на дошку</b> — навіть дрібниця; що з того справа, вирішуєте ви.</li>
|
||||||
<li><b>Журнал дзвінків</b> — це покази, які не брешуть: хто з ким говорив і о котрій у вечір злочину. Звіряйте з тим, що люди кажуть на допиті. Порожній вечір теж промовистий: апарат могли вимкнути.</li>
|
<li><b>Журнал дзвінків</b> — це покази, які не брешуть: хто з ким говорив і о котрій у вечір злочину. Звіряйте з тим, що люди кажуть на допиті. Порожній вечір теж промовистий: апарат могли вимкнути.</li>
|
||||||
<li><b>Вилучені пристрої</b> потрапляють у лабораторію заблокованими: PIN — це зазвичай рік народження власника (є в реєстрі), пароль ноутбука — або щось особисте латиницею, або словниковий перебір. Усередині буває історія браузера й чати — а буває порожньо.</li>
|
<li><b>Вилучені пристрої</b> потрапляють у лабораторію заблокованими: PIN — це зазвичай рік народження власника (є в реєстрі), пароль ноутбука — або щось особисте латиницею, або словниковий перебір. Усередині буває історія браузера й чати — а буває порожньо.</li>
|
||||||
|
|
|
||||||
|
|
@ -164,6 +164,11 @@
|
||||||
cv.id = "cv";
|
cv.id = "cv";
|
||||||
cv.width = m.district.world.w;
|
cv.width = m.district.world.w;
|
||||||
cv.height = m.district.world.h;
|
cv.height = m.district.world.h;
|
||||||
|
// Канвас — повноцінна ціль фокуса: клік по району забирає клавіатуру назад
|
||||||
|
// у польового, а Esc із будь-якого поля вводу повертає її сюди (див. keydown).
|
||||||
|
cv.tabIndex = 0;
|
||||||
|
cv.style.outline = "none";
|
||||||
|
cv.addEventListener("mousedown", () => cv.focus());
|
||||||
hint = document.createElement("div");
|
hint = document.createElement("div");
|
||||||
hint.id = "fieldhint";
|
hint.id = "fieldhint";
|
||||||
stage.append(cv, hint);
|
stage.append(cv, hint);
|
||||||
|
|
@ -181,8 +186,16 @@
|
||||||
addEventListener("resize", () => fitCanvas(cv));
|
addEventListener("resize", () => fitCanvas(cv));
|
||||||
// e.code — фізична клавіша, працює на будь-якій розкладці (укр/лат)
|
// e.code — фізична клавіша, працює на будь-якій розкладці (укр/лат)
|
||||||
addEventListener("keydown", (e) => {
|
addEventListener("keydown", (e) => {
|
||||||
if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA")
|
const typing =
|
||||||
|
e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA";
|
||||||
|
// Esc — аварійний вихід із поля вводу назад до району. Без нього клавіатура
|
||||||
|
// лишалася «зайнятою» нотаткою на дошці, і рух мовчки не працював.
|
||||||
|
if (typing && e.code === "Escape") {
|
||||||
|
e.target.blur();
|
||||||
|
cv.focus();
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
|
if (typing) return;
|
||||||
keys[e.code] = true;
|
keys[e.code] = true;
|
||||||
if (e.code === "KeyE") tryEnter();
|
if (e.code === "KeyE") tryEnter();
|
||||||
});
|
});
|
||||||
|
|
@ -254,9 +267,14 @@
|
||||||
ctx.fillStyle = vg;
|
ctx.fillStyle = vg;
|
||||||
ctx.fillRect(0, 0, D.w, D.h);
|
ctx.fillRect(0, 0, D.w, D.h);
|
||||||
const b = nearBuilding();
|
const b = nearBuilding();
|
||||||
hint.textContent = b
|
const ae = document.activeElement;
|
||||||
? `E — ${OPEN_ZONES.includes(b.id) ? "оглянутися: " : "увійти: "}${b.name}`
|
const typing =
|
||||||
: "WASD/стрілки — рух, E — увійти/оглянутися";
|
ae && (ae.tagName === "INPUT" || ae.tagName === "TEXTAREA");
|
||||||
|
hint.textContent = typing
|
||||||
|
? "клавіатура зайнята полем вводу — Esc або клік по району, щоб повернути керування"
|
||||||
|
: b
|
||||||
|
? `E — ${OPEN_ZONES.includes(b.id) ? "оглянутися: " : "увійти: "}${b.name}`
|
||||||
|
: "WASD/стрілки — рух, E — увійти/оглянутися";
|
||||||
requestAnimationFrame(loop);
|
requestAnimationFrame(loop);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -282,50 +300,58 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function stationModal() {
|
function stationModal() {
|
||||||
|
// Відділок лишається під усім, що з нього відкривають: і база, і ордер —
|
||||||
|
// це кроки, з яких треба вміти повернутись, а не заміщення екрана.
|
||||||
const m = openModal(`<h3>Відділок поліції</h3>
|
const m = openModal(`<h3>Відділок поліції</h3>
|
||||||
<p class="dim small">Ваша база: дошка справи і термінал аналітика синхронізовані. Тут-таки сидить прокурор — ордер на обшук житла подають особисто, з доказом, який прямо вказує на людину.</p>
|
<p class="dim small">Ваша база: дошка справи і термінал аналітика синхронізовані. Тут-таки сидить прокурор — ордер на обшук житла подають особисто, з доказом, який прямо вказує на людину.</p>
|
||||||
<button class="primary" id="warr">📜 Подати на ордер</button>
|
<button class="primary" id="warr">📜 Подати на ордер</button>
|
||||||
<button id="db">🗂 База мешканців</button>`);
|
<button id="db">🗂 База мешканців</button>`);
|
||||||
m.inner.querySelector("#db").onclick = () => { m.wrap.remove(); residentsModal(); };
|
m.inner.querySelector("#db").onclick = residentsModal;
|
||||||
m.inner.querySelector("#warr").onclick = () => {
|
m.inner.querySelector("#warr").onclick = warrantFlow;
|
||||||
m.wrap.remove();
|
}
|
||||||
pickCitizen("Ордер: на кого?", (cid) => {
|
|
||||||
const c = App.byId(cid);
|
// Вибір людини → вибір підстави. «← Назад» на обох кроках повертає до вибору
|
||||||
// до прокурора йде лише закріплене 📌 — тека справи, а не вся дошка
|
// людини: раніше порожня тека була глухим кутом, а помилка в імені коштувала
|
||||||
const evs = App.state.found.filter((e) =>
|
// всього ланцюга заново.
|
||||||
App.state.board.pins.includes(e.id),
|
function warrantFlow() {
|
||||||
);
|
pickCitizen("Ордер: на кого?", (cid) => {
|
||||||
if (!evs.length)
|
const c = App.byId(cid);
|
||||||
return openModal(
|
// до прокурора йде лише закріплене 📌 — тека справи, а не вся дошка
|
||||||
`<h3>Підстава для ордера на: ${esc(c.name)}</h3>
|
const evs = App.state.found.filter((e) =>
|
||||||
<p class="dim">Тека порожня. Спершу закріпіть 📌 на дошці доказ, який прямо вказує на цю особу, — з усім підряд до прокурора не ходять.</p>`,
|
App.state.board.pins.includes(e.id),
|
||||||
);
|
);
|
||||||
const mm = openModal(
|
if (!evs.length)
|
||||||
|
return openModal(
|
||||||
`<h3>Підстава для ордера на: ${esc(c.name)}</h3>
|
`<h3>Підстава для ордера на: ${esc(c.name)}</h3>
|
||||||
<p class="dim small">Прокурор підпише, лише якщо доказ прямо вказує на цю особу. У теці — закріплене 📌.</p>
|
<p class="dim">Тека порожня. Спершу закріпіть 📌 на дошці доказ, який прямо вказує на цю особу, — з усім підряд до прокурора не ходять.</p>`,
|
||||||
<div class="grid" style="grid-template-columns:1fr">` +
|
{ back: warrantFlow },
|
||||||
evs
|
|
||||||
.map((e) => `<button data-ev="${e.id}">${esc(e.title)}</button>`)
|
|
||||||
.join("") +
|
|
||||||
"</div>",
|
|
||||||
);
|
);
|
||||||
mm.inner.onclick = (e) => {
|
const mm = openModal(
|
||||||
if (!e.target.dataset.ev) return;
|
`<h3>Підстава для ордера на: ${esc(c.name)}</h3>
|
||||||
App.send({ t: "warrant", cid, evId: e.target.dataset.ev });
|
<p class="dim small">Прокурор підпише, лише якщо доказ прямо вказує на цю особу. У теці — закріплене 📌.</p>
|
||||||
mm.wrap.remove();
|
<div class="grid" style="grid-template-columns:1fr">` +
|
||||||
};
|
evs
|
||||||
});
|
.map((e) => `<button data-ev="${e.id}">${esc(e.title)}</button>`)
|
||||||
};
|
.join("") +
|
||||||
|
"</div>",
|
||||||
|
{ back: warrantFlow },
|
||||||
|
);
|
||||||
|
mm.inner.onclick = (e) => {
|
||||||
|
if (!e.target.dataset.ev) return;
|
||||||
|
App.send({ t: "warrant", cid, evId: e.target.dataset.ev });
|
||||||
|
mm.wrap.remove();
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// База мешканців для польового: той самий реєстр, що бачить аналітик, з його
|
// База мешканців для польового: той самий реєстр, що бачить аналітик, з його
|
||||||
// мітками (✓/✗/…) — тільки перегляд, мітки ставить аналітик.
|
// кольоровими мітками — тільки перегляд, мітки ставить аналітик.
|
||||||
function residentsModal() {
|
function residentsModal() {
|
||||||
const coats = [...new Set(App.init.citizens.map((c) => c.coat))];
|
const coats = [...new Set(App.init.citizens.map((c) => c.coat))];
|
||||||
const m = openModal(`<h3>База мешканців району</h3>
|
const m = openModal(`<h3>База мешканців району</h3>
|
||||||
<p class="dim small">Термінал аналітика, тільки перегляд. Мітки (✓/✗/…) ставить аналітик — тут вони лише видно. Реєстр місцями застарілий: одяг і взуття надійніше звіряти очним оглядом за ордером.</p>
|
<p class="dim small">Термінал аналітика, тільки перегляд. Кольорові мітки ставить аналітик — тут вони лише видно; що означає кожен колір, домовляєтесь ви двоє. Реєстр місцями застарілий: одяг і взуття надійніше звіряти очним оглядом за ордером.</p>
|
||||||
<div class="filters">
|
<div class="filters">
|
||||||
<input id="rq" placeholder="Ім'я Прізвище" style="width:200px"><button id="rqgo">Знайти</button>
|
<input id="rq" placeholder="Ім'я Прізвище" style="min-width:200px"><button id="rqgo">Знайти</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="filters">
|
<div class="filters">
|
||||||
<select id="rf-sex"><option value="">стать: будь-яка</option><option value="ч">чол.</option><option value="ж">жін.</option></select>
|
<select id="rf-sex"><option value="">стать: будь-яка</option><option value="ч">чол.</option><option value="ж">жін.</option></select>
|
||||||
|
|
@ -340,7 +366,6 @@
|
||||||
</div>
|
</div>
|
||||||
<div id="rout"><p class="dim small">Задайте пошук або фільтр.</p></div>`);
|
<div id="rout"><p class="dim small">Задайте пошук або фільтр.</p></div>`);
|
||||||
const out = m.inner.querySelector("#rout");
|
const out = m.inner.querySelector("#rout");
|
||||||
const MK = { tick: "✓", cross: "✗", hide: "…" };
|
|
||||||
const draw = (rows) => {
|
const draw = (rows) => {
|
||||||
out.innerHTML = `<p class="dim small">Збігів: ${rows.length}</p>`;
|
out.innerHTML = `<p class="dim small">Збігів: ${rows.length}</p>`;
|
||||||
const t = el(
|
const t = el(
|
||||||
|
|
@ -350,7 +375,9 @@
|
||||||
);
|
);
|
||||||
for (const c of rows) {
|
for (const c of rows) {
|
||||||
const key = "c:" + c.id;
|
const key = "c:" + c.id;
|
||||||
const mark = App.state.board.marks[key];
|
// Мітки ставить аналітик — тут вони лише видно: підпис плюс кольорова
|
||||||
|
// ліва грань рядка (див. cardClasses). На папері дот сам по собі губиться.
|
||||||
|
const mark = markOf(key);
|
||||||
const vic =
|
const vic =
|
||||||
App.state.victim === c.id
|
App.state.victim === c.id
|
||||||
? App.init.caseType === "kidnap"
|
? App.init.caseType === "kidnap"
|
||||||
|
|
@ -360,7 +387,7 @@
|
||||||
const tr = el(
|
const tr = el(
|
||||||
"tr",
|
"tr",
|
||||||
"rowlink" + cardClasses(key),
|
"rowlink" + cardClasses(key),
|
||||||
`<td>${esc(c.name)}${vic}</td><td>${c.age}</td><td>${esc(c.home)}</td><td>${esc(c.job)}</td><td>${c.height}</td><td>${c.shoe}</td><td>${esc(c.printType)}</td><td class="dim">${mark ? MK[mark] : ""}</td>`,
|
`<td>${esc(c.name)}${vic}</td><td>${c.age}</td><td>${esc(c.home)}</td><td>${esc(c.job)}</td><td>${c.height}</td><td>${c.shoe}</td><td>${esc(c.printType)}</td><td class="dim">${markSwatch(mark)}</td>`,
|
||||||
);
|
);
|
||||||
tr.onclick = () => showDossier(c);
|
tr.onclick = () => showDossier(c);
|
||||||
t.append(tr);
|
t.append(tr);
|
||||||
|
|
@ -368,21 +395,14 @@
|
||||||
out.append(t);
|
out.append(t);
|
||||||
};
|
};
|
||||||
const byName = () => {
|
const byName = () => {
|
||||||
const q = m.inner.querySelector("#rq").value.trim().toLowerCase();
|
const q = m.inner.querySelector("#rq").value.trim();
|
||||||
|
|
||||||
if (!q) {
|
if (!q) {
|
||||||
out.innerHTML = '<p class="dim small">— Введіть ім\'я або прізвище.</p>';
|
out.innerHTML = '<p class="dim small">— Введіть ім\'я або прізвище.</p>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Розбиваємо введений запит на слова
|
const hits = matchName(App.init.citizens, q); // спільний пошук, див. client.js
|
||||||
const words = q.split(/\s+/);
|
|
||||||
|
|
||||||
// Фільтруємо: кожне введене слово має збігатися з початком якогось слова в імені громадянина
|
|
||||||
const hits = App.init.citizens.filter((c) => {
|
|
||||||
const nameWords = c.name.toLowerCase().split(/\s+/);
|
|
||||||
return words.every((w) => nameWords.some((nw) => nw.startsWith(w)));
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!hits.length) {
|
if (!hits.length) {
|
||||||
out.innerHTML = '<p class="dim small">— Записів не знайдено. Спробуйте інший початок.</p>';
|
out.innerHTML = '<p class="dim small">— Записів не знайдено. Спробуйте інший початок.</p>';
|
||||||
|
|
@ -484,10 +504,9 @@
|
||||||
spot: b.spots[t.dataset.spot],
|
spot: b.spots[t.dataset.spot],
|
||||||
});
|
});
|
||||||
if (t.dataset.cid) talkTo(t.dataset.cid);
|
if (t.dataset.cid) talkTo(t.dataset.cid);
|
||||||
if (t.dataset.unit) {
|
// Двері квартири відкриваються ПОВЕРХ під'їзду: закрив — ти знову в будинку,
|
||||||
m.wrap.remove();
|
// а не на порожньому екрані.
|
||||||
openUnitDoor(b, t.dataset.unit);
|
if (t.dataset.unit) openUnitDoor(b, t.dataset.unit);
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -500,15 +519,14 @@
|
||||||
<p class="dim small">${home.length ? "Мешканці: " + home.map((c) => esc(c.name)).join(", ") : "За реєстром тут ніхто не живе."}</p>
|
<p class="dim small">${home.length ? "Мешканці: " + home.map((c) => esc(c.name)).join(", ") : "За реєстром тут ніхто не живе."}</p>
|
||||||
<button id="knock">Постукати</button>
|
<button id="knock">Постукати</button>
|
||||||
<button id="enter">Увійти${App.state.keys.includes(unit) ? " 🔑" : ""}</button>`);
|
<button id="enter">Увійти${App.state.keys.includes(unit) ? " 🔑" : ""}</button>`);
|
||||||
|
// Двері лишаються на екрані: «Замкнено» тепер не викидає в порожнечу —
|
||||||
|
// тост прилітає, а спробувати постукати чи піти назад можна одразу.
|
||||||
m.inner.querySelector("#knock").onclick = () => {
|
m.inner.querySelector("#knock").onclick = () => {
|
||||||
if (!present.length) return toast("Тиша. Ніхто не відчиняє.");
|
if (!present.length) return toast("Тиша. Ніхто не відчиняє.");
|
||||||
m.wrap.remove();
|
|
||||||
pickPerson(present);
|
pickPerson(present);
|
||||||
};
|
};
|
||||||
m.inner.querySelector("#enter").onclick = () => {
|
m.inner.querySelector("#enter").onclick = () =>
|
||||||
m.wrap.remove();
|
|
||||||
App.send({ t: "enter_unit", unit });
|
App.send({ t: "enter_unit", unit });
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
App.on("unit", (m) => {
|
App.on("unit", (m) => {
|
||||||
|
|
@ -557,6 +575,12 @@
|
||||||
App.send({ t: "talk", cid });
|
App.send({ t: "talk", cid });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Розмова — це цикл «питання → відповідь → наступне питання», а не одноразовий
|
||||||
|
// крок. Модалка розмови завжди одна: сервер віддає теми у відповідь на `talk`,
|
||||||
|
// і кожна нова відповідь просто перемальовує її разом із журналом.
|
||||||
|
let talkModal = null;
|
||||||
|
let flashCid = null; // до кого прилетіла свіжа відповідь — підсвітити останній запис
|
||||||
|
|
||||||
App.on("topics", (m) => {
|
App.on("topics", (m) => {
|
||||||
const c = App.byId(m.cid);
|
const c = App.byId(m.cid);
|
||||||
const gossip =
|
const gossip =
|
||||||
|
|
@ -576,6 +600,11 @@
|
||||||
.join("") +
|
.join("") +
|
||||||
'</div><p class="dim small">Сірі теми вже в доказах на дошці.</p>'
|
'</div><p class="dim small">Сірі теми вже в доказах на дошці.</p>'
|
||||||
: "";
|
: "";
|
||||||
|
if (talkModal) {
|
||||||
|
const old = talkModal;
|
||||||
|
talkModal = null; // прибираємо самі — onClose не має скидати нову модалку
|
||||||
|
old.wrap.remove();
|
||||||
|
}
|
||||||
const mm = openModal(
|
const mm = openModal(
|
||||||
`<h3>${esc(c.name)}</h3>` +
|
`<h3>${esc(c.name)}</h3>` +
|
||||||
topicsHtml +
|
topicsHtml +
|
||||||
|
|
@ -590,10 +619,23 @@
|
||||||
<button id="sh-go">Показати доказ…</button>
|
<button id="sh-go">Показати доказ…</button>
|
||||||
${App.state.warrants.includes(m.cid) ? '<button id="insp-go">Оглянути особисто 📜</button>' : ''}
|
${App.state.warrants.includes(m.cid) ? '<button id="insp-go">Оглянути особисто 📜</button>' : ''}
|
||||||
</div>
|
</div>
|
||||||
<p class="dim small">Відповіді можуть бути правдою, брехнею або «не знаю» — перевіряйте камерами і чужими словами.</p>` +
|
<p class="dim small">Відповіді можуть бути правдою, брехнею або «не знаю» — перевіряйте камерами і чужими словами. Модалка лишається відкритою: питайте далі, «Закрити» завершує розмову.</p>` +
|
||||||
gossip,
|
gossip,
|
||||||
|
{ onClose: () => { if (talkModal === mm) talkModal = null; } },
|
||||||
);
|
);
|
||||||
|
talkModal = mm;
|
||||||
mm.inner.append(logSection(m.cid)); // журнал із мітками (спільний із досьє аналітика)
|
mm.inner.append(logSection(m.cid)); // журнал із мітками (спільний із досьє аналітика)
|
||||||
|
// Свіжа відповідь лежить останнім записом журналу — прокручуємо й підсвічуємо,
|
||||||
|
// інакше її не видно за темами й плітками.
|
||||||
|
if (flashCid === m.cid) {
|
||||||
|
flashCid = null;
|
||||||
|
const rows = mm.inner.querySelectorAll(".logrow");
|
||||||
|
const last = rows[rows.length - 1];
|
||||||
|
if (last) {
|
||||||
|
last.classList.add("flash");
|
||||||
|
last.scrollIntoView({ block: "center" });
|
||||||
|
}
|
||||||
|
}
|
||||||
const sl = mm.inner.querySelector("#al-t"),
|
const sl = mm.inner.querySelector("#al-t"),
|
||||||
out = mm.inner.querySelector("#al-v");
|
out = mm.inner.querySelector("#al-v");
|
||||||
const fmt = (v) =>
|
const fmt = (v) =>
|
||||||
|
|
@ -601,20 +643,17 @@
|
||||||
const upd = () => (out.textContent = fmt(+sl.value));
|
const upd = () => (out.textContent = fmt(+sl.value));
|
||||||
sl.oninput = upd;
|
sl.oninput = upd;
|
||||||
upd();
|
upd();
|
||||||
mm.inner.querySelector("#al-go").onclick = () => {
|
// Жоден крок нижче не знімає модалку розмови: picker'и й результати
|
||||||
|
// відкриваються ПОВЕРХ неї, а скасування повертає до співрозмовника.
|
||||||
|
mm.inner.querySelector("#al-go").onclick = () =>
|
||||||
App.send({ t: "ask_alibi", cid: m.cid, minutes: +sl.value });
|
App.send({ t: "ask_alibi", cid: m.cid, minutes: +sl.value });
|
||||||
mm.wrap.remove();
|
mm.inner.querySelector("#ab-go").onclick = () =>
|
||||||
};
|
|
||||||
mm.inner.querySelector("#ab-go").onclick = () => {
|
|
||||||
mm.wrap.remove();
|
|
||||||
pickCitizen(
|
pickCitizen(
|
||||||
`${esc(c.first)}, що скажете про…`,
|
`${esc(c.first)}, що скажете про…`,
|
||||||
(about) => App.send({ t: "ask_about", cid: m.cid, about }),
|
(about) => App.send({ t: "ask_about", cid: m.cid, about }),
|
||||||
(x) => x.id === m.cid,
|
(x) => x.id === m.cid,
|
||||||
);
|
);
|
||||||
};
|
|
||||||
mm.inner.querySelector("#sh-go").onclick = () => {
|
mm.inner.querySelector("#sh-go").onclick = () => {
|
||||||
mm.wrap.remove();
|
|
||||||
const pm = openModal(
|
const pm = openModal(
|
||||||
'<h3>Показати доказ</h3><div class="grid" style="grid-template-columns:1fr">' +
|
'<h3>Показати доказ</h3><div class="grid" style="grid-template-columns:1fr">' +
|
||||||
App.state.found
|
App.state.found
|
||||||
|
|
@ -630,34 +669,29 @@
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
const insp = mm.inner.querySelector("#insp-go");
|
const insp = mm.inner.querySelector("#insp-go");
|
||||||
if (insp)
|
if (insp) insp.onclick = () => App.send({ t: "inspect", cid: m.cid });
|
||||||
insp.onclick = () => {
|
|
||||||
App.send({ t: "inspect", cid: m.cid });
|
|
||||||
mm.wrap.remove();
|
|
||||||
};
|
|
||||||
mm.inner.onclick = (e) => {
|
mm.inner.onclick = (e) => {
|
||||||
if (e.target.dataset.topic) {
|
if (e.target.dataset.topic) {
|
||||||
App.send({ t: "ask", evId: e.target.dataset.topic });
|
App.send({ t: "ask", evId: e.target.dataset.topic });
|
||||||
mm.wrap.remove();
|
// Тема пішла на дошку — перезбираємо розмову, щоб вона стала сірою ✓,
|
||||||
|
// а решта тем лишилась під рукою: їх зазвичай кілька поспіль.
|
||||||
|
App.send({ t: "talk", cid: m.cid });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// результат очного огляду: нікуди не пишеться — переносьте в нотатки самі
|
// Результат очного огляду: нікуди не пишеться — переносьте в нотатки самі.
|
||||||
|
// Відкривається поверх розмови, «Закрити» повертає до співрозмовника.
|
||||||
App.on("inspect", (m) => {
|
App.on("inspect", (m) => {
|
||||||
const c = App.byId(m.cid);
|
const c = App.byId(m.cid);
|
||||||
openModal(
|
openModal(`<h3>Очний огляд: ${esc(c.name)}</h3><p>${esc(m.body)}</p>`);
|
||||||
`<h3>Очний огляд: ${esc(c.name)}</h3><p>${esc(m.body)}</p>`,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// відповідь допитуваного
|
// Відповідь допитуваного. Сироту-модалку не відкриваємо: просимо теми ще раз —
|
||||||
|
// розмова перемалюється вже з новим записом у журналі, і можна питати далі.
|
||||||
App.on("answer", (m) => {
|
App.on("answer", (m) => {
|
||||||
const c = App.byId(m.cid);
|
flashCid = m.cid;
|
||||||
openModal(
|
App.send({ t: "talk", cid: m.cid });
|
||||||
`<h3>${esc(c.name)}</h3><p class="dim small">${esc(m.q)}</p><p>${esc(m.a)}</p>
|
|
||||||
<p class="dim small">Записано в журнал допитів (досьє в аналітика і картка розмови).</p>`,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------- міні-гра «вилучення»: тремтячі руки псують доказ ----------
|
// ---------- міні-гра «вилучення»: тремтячі руки псують доказ ----------
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
--txt:#cfd6df; --dim:#7d8794; --acc:#e8b04b; --red:#c94f4f; --green:#5aa870; --blue:#5b8dbf;
|
--txt:#cfd6df; --dim:#7d8794; --acc:#e8b04b; --red:#c94f4f; --green:#5aa870; --blue:#5b8dbf;
|
||||||
--paper:#e9ddc0; --paper2:#dcCfae; --ink:#282218; --inkdim:#6f6450; --paperline:#c3b28b;
|
--paper:#e9ddc0; --paper2:#dcCfae; --ink:#282218; --inkdim:#6f6450; --paperline:#c3b28b;
|
||||||
--amber:#ffb454; --amberdim:#8a6a34; --crt:#0e0903;
|
--amber:#ffb454; --amberdim:#8a6a34; --crt:#0e0903;
|
||||||
|
--mk-green:#5aa870; --mk-red:#c94f4f; --mk-blue:#5b8dbf; --mk-amber:#e8b04b;
|
||||||
--noise:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='140' height='140'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='2'/><feColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .05 0'/></filter><rect width='140' height='140' filter='url(%23n)'/></svg>");
|
--noise:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='140' height='140'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='2'/><feColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .05 0'/></filter><rect width='140' height='140' filter='url(%23n)'/></svg>");
|
||||||
}
|
}
|
||||||
* { box-sizing:border-box; margin:0; }
|
* { box-sizing:border-box; margin:0; }
|
||||||
|
|
@ -63,7 +64,10 @@
|
||||||
.badge.on { color:var(--green); border-color:var(--green); }
|
.badge.on { color:var(--green); border-color:var(--green); }
|
||||||
#main { flex:1; display:flex; min-height:0; }
|
#main { flex:1; display:flex; min-height:0; }
|
||||||
#stage { flex:1; min-width:0; display:flex; flex-direction:column; padding:12px; gap:8px; overflow:auto; }
|
#stage { flex:1; min-width:0; display:flex; flex-direction:column; padding:12px; gap:8px; overflow:auto; }
|
||||||
|
/* Дошка — недоторканний шар: модалка (z-index:40) її НЕ накриває, інакше будь-який
|
||||||
|
обшук чи допит відрізав би обох гравців від доказів саме тоді, коли вони потрібні. */
|
||||||
#side { width:400px; border-left:1px solid #1c130c; display:flex; flex-direction:column; min-height:0;
|
#side { width:400px; border-left:1px solid #1c130c; display:flex; flex-direction:column; min-height:0;
|
||||||
|
position:relative; z-index:45;
|
||||||
background:linear-gradient(160deg, #33261a, #2a1f14 60%, #241a10); background-blend-mode:multiply; }
|
background:linear-gradient(160deg, #33261a, #2a1f14 60%, #241a10); background-blend-mode:multiply; }
|
||||||
#side::before { content:''; position:absolute; } /* резерв */
|
#side::before { content:''; position:absolute; } /* резерв */
|
||||||
|
|
||||||
|
|
@ -102,6 +106,21 @@
|
||||||
#side .card.tick { border-color:#4d7a44; box-shadow:0 4px 10px rgba(0,0,0,.55), inset 0 0 0 2px rgba(77,122,68,.35); }
|
#side .card.tick { border-color:#4d7a44; box-shadow:0 4px 10px rgba(0,0,0,.55), inset 0 0 0 2px rgba(77,122,68,.35); }
|
||||||
.pinbtn { background:none; border:none; opacity:.45; font-size:15px; padding:0 4px; }
|
.pinbtn { background:none; border:none; opacity:.45; font-size:15px; padding:0 4px; }
|
||||||
.pinbtn.on { opacity:1; filter:drop-shadow(0 0 4px rgba(232,176,75,.8)); }
|
.pinbtn.on { opacity:1; filter:drop-shadow(0 0 4px rgba(232,176,75,.8)); }
|
||||||
|
/* Закріплене 📌 — третій візуальний канал: рамку вже займає .tick (зелений ring),
|
||||||
|
накладку «✗» — .cross. Лишається бордова грань теки + світліший папір. */
|
||||||
|
#side .card.pinned { border-left:5px solid #7a1f1f; padding-left:9px; background-color:#f4ebd4; }
|
||||||
|
#side .card.pinned > .t > b { color:#5d1717; }
|
||||||
|
.divider { text-align:center; letter-spacing:2px; margin:2px 0 12px; }
|
||||||
|
#side .divider { color:#c9b596; text-shadow:0 1px 0 rgba(0,0,0,.5); }
|
||||||
|
/* Фільтр дошки: рядок пошуку + чипи-теги над списком карток */
|
||||||
|
#side input.evfilter { width:100%; margin-top:6px; background:var(--paper2);
|
||||||
|
border:1px solid #b3a077; border-radius:2px; color:var(--ink); padding:5px 9px; }
|
||||||
|
#side input.evfilter::placeholder { color:#7d7052; }
|
||||||
|
#side input.evfilter:focus { border-color:#7a1f1f; background:var(--paper); }
|
||||||
|
.chips { display:flex; flex-wrap:wrap; gap:4px; margin:6px 0 10px; }
|
||||||
|
.chip { font-size:11.5px; padding:2px 10px; border-radius:11px; }
|
||||||
|
#side .chip.on { border-color:#7a1f1f; color:#7a1f1f; font-weight:bold; background:#e3d3aa; }
|
||||||
|
#side .card .orig { font-style:italic; margin-bottom:3px; }
|
||||||
|
|
||||||
/* ---------- Сцена: польовий ---------- */
|
/* ---------- Сцена: польовий ---------- */
|
||||||
#cv { width:100%; border:1px solid #1c222c; border-radius:10px; background:#0a0e12; display:block;
|
#cv { width:100%; border:1px solid #1c222c; border-radius:10px; background:#0a0e12; display:block;
|
||||||
|
|
@ -142,14 +161,36 @@
|
||||||
tr.cross td { opacity:.45; text-decoration:line-through; }
|
tr.cross td { opacity:.45; text-decoration:line-through; }
|
||||||
tr.tick td { color:var(--green); }
|
tr.tick td { color:var(--green); }
|
||||||
tr.hl td { background:rgba(232,176,75,.1); }
|
tr.hl td { background:rgba(232,176,75,.1); }
|
||||||
|
/* Мітки на людях: чотири кольори замість ✓/✗ — що вони означають, вирішують
|
||||||
|
гравці, тож жодних підписів. Суцільна заливка рядка не читається ні на
|
||||||
|
бурштиновому CRT аналітика, ні на папері модалки польового, тож канал —
|
||||||
|
кольорова ліва грань рядка плюс кружечок-свотч у кнопці. */
|
||||||
|
tr[class*="mk-"] > td:first-child { border-left:4px solid transparent; padding-left:6px; }
|
||||||
|
tr.mk-green > td:first-child { border-left-color:var(--mk-green); }
|
||||||
|
tr.mk-red > td:first-child { border-left-color:var(--mk-red); }
|
||||||
|
tr.mk-blue > td:first-child { border-left-color:var(--mk-blue); }
|
||||||
|
tr.mk-amber > td:first-child { border-left-color:var(--mk-amber); }
|
||||||
|
.dot { display:inline-block; width:10px; height:10px; border-radius:50%; vertical-align:-1px;
|
||||||
|
box-shadow:0 0 0 1px rgba(0,0,0,.45); }
|
||||||
|
.mk button.swatch { padding:2px 7px; }
|
||||||
|
.mk button.swatch.on { border-color:currentColor; box-shadow:inset 0 0 0 1px currentColor; }
|
||||||
.filters { display:flex; gap:8px; flex-wrap:wrap; margin:8px 0; align-items:center; }
|
.filters { display:flex; gap:8px; flex-wrap:wrap; margin:8px 0; align-items:center; }
|
||||||
.filters select, .filters input { width:130px; }
|
/* min-width, а не width: жорсткі 130px різали довгі назви камер («Муніципальна к…»),
|
||||||
|
хоча панель широка і праворуч лишалося порожньо. Ряд і так переноситься. */
|
||||||
|
.filters select, .filters input { flex:1 1 auto; min-width:140px; max-width:260px; }
|
||||||
|
.filters input[type=checkbox] { flex:0 0 auto; min-width:0; width:auto; }
|
||||||
|
.filters label { flex:0 0 auto; white-space:nowrap; }
|
||||||
|
#cc-cam { max-width:min(100%, 460px); }
|
||||||
.syshead { color:var(--dim); text-transform:uppercase; font-size:12px; letter-spacing:2px; margin:14px 0 6px; }
|
.syshead { color:var(--dim); text-transform:uppercase; font-size:12px; letter-spacing:2px; margin:14px 0 6px; }
|
||||||
.syshead::before { content:'— '; opacity:.5; }
|
.syshead::before { content:'— '; opacity:.5; }
|
||||||
|
|
||||||
/* ---------- Модалки: паперовий протокол ---------- */
|
/* ---------- Модалки: паперовий протокол ---------- */
|
||||||
.modal { position:fixed; inset:0; background:rgba(2,4,8,.72); display:flex; align-items:center; justify-content:center; z-index:40;
|
.modal { position:fixed; inset:0; background:rgba(2,4,8,.72); display:flex; align-items:center; justify-content:center; z-index:40;
|
||||||
backdrop-filter:blur(2px); }
|
backdrop-filter:blur(2px); }
|
||||||
|
/* Затемнення не доходить до дошки (400px праворуч) — клік-по-тлу для закриття
|
||||||
|
лишається робочим, але докази й нотатки доступні під час обшуку чи допиту.
|
||||||
|
На вузьких екранах, де 400px — це пів-екрана, повертаємось до старої поведінки. */
|
||||||
|
@media (min-width: 1000px) { .modal { right:400px; } }
|
||||||
.modal > div { background:var(--paper); background-image:var(--noise); color:var(--ink);
|
.modal > div { background:var(--paper); background-image:var(--noise); color:var(--ink);
|
||||||
border:1px solid #b6a67e; border-radius:3px; padding:22px 24px; width:min(600px, 94vw); max-height:88vh; overflow-y:auto;
|
border:1px solid #b6a67e; border-radius:3px; padding:22px 24px; width:min(600px, 94vw); max-height:88vh; overflow-y:auto;
|
||||||
box-shadow:0 30px 90px rgba(0,0,0,.8), inset 0 0 0 6px rgba(0,0,0,.03); font-family:Georgia, serif; }
|
box-shadow:0 30px 90px rgba(0,0,0,.8), inset 0 0 0 6px rgba(0,0,0,.03); font-family:Georgia, serif; }
|
||||||
|
|
@ -167,6 +208,15 @@
|
||||||
.modal .tag { border:1px solid #b3a077; color:var(--inkdim); background:transparent; }
|
.modal .tag { border:1px solid #b3a077; color:var(--inkdim); background:transparent; }
|
||||||
.modal .card { background:rgba(255,255,255,.35); border:1px solid #c3b28b; border-radius:2px; padding:9px 11px; margin-bottom:8px; }
|
.modal .card { background:rgba(255,255,255,.35); border:1px solid #c3b28b; border-radius:2px; padding:9px 11px; margin-bottom:8px; }
|
||||||
.modal label { color:var(--ink); }
|
.modal label { color:var(--ink); }
|
||||||
|
.modalfoot { display:flex; gap:8px; flex-wrap:wrap; margin-top:14px;
|
||||||
|
border-top:1px solid rgba(122,31,31,.25); padding-top:10px; }
|
||||||
|
/* свіжа відповідь на допиті — щоб її було видно серед тем і пліток */
|
||||||
|
@keyframes logflash { 0%,100% { background:transparent; } 30%,70% { background:rgba(122,31,31,.15); } }
|
||||||
|
.logrow.flash { animation:logflash 1.8s ease; border-radius:3px; }
|
||||||
|
/* Глобальне tr.rowlink:hover заливає рядок майже чорним --panel2. Аналітику це
|
||||||
|
перекрито в #stage, але база польового живе в модалці (папір + чорнило) —
|
||||||
|
там рядок ставав нечитабельним. Той самий бордовий акцент, що й решта модалки. */
|
||||||
|
.modal tr.rowlink:hover td { background:rgba(122,31,31,.10); }
|
||||||
.grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(110px,1fr)); gap:8px; margin-top:8px; }
|
.grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(110px,1fr)); gap:8px; margin-top:8px; }
|
||||||
|
|
||||||
/* ---------- Тости: телеграма ---------- */
|
/* ---------- Тости: телеграма ---------- */
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,10 @@ fs.mkdirSync(SAVES, { recursive: true });
|
||||||
const sessions = Object.create(null);
|
const sessions = Object.create(null);
|
||||||
let clientSeq = 0; // стабільний id на кожне з'єднання (для підписів звинувачення)
|
let clientSeq = 0; // стабільний id на кожне з'єднання (для підписів звинувачення)
|
||||||
|
|
||||||
|
// Допустимі мітки на дошці. Клієнт малює їх із того ж поділу (public/client.js).
|
||||||
|
const EV_MARKS = ['tick', 'cross', 'hide']; // докази і записи журналу
|
||||||
|
const PERSON_MARKS = ['green', 'red', 'blue', 'amber', 'hide']; // люди: кольори без нав'язаного значення
|
||||||
|
|
||||||
// Присутність зараз (не плутати з evening — правдою вечора злочину): вдома або на роботі.
|
// Присутність зараз (не плутати з evening — правдою вечора злочину): вдома або на роботі.
|
||||||
// Перетасовується, коли польовий заходить у будівлю, — крім будівлі, куди він зайшов.
|
// Перетасовується, коли польовий заходить у будівлю, — крім будівлі, куди він зайшов.
|
||||||
function reshuffleLocs(cs, s, except) {
|
function reshuffleLocs(cs, s, except) {
|
||||||
|
|
@ -126,16 +130,32 @@ function visiblePuzzles(s) {
|
||||||
}).map(p => ({ id: p.id, kind: p.kind, prompt: p.prompt, payload: p.payload, hint: p.hint, solved: s.st.puzzlesSolved.includes(p.id) }));
|
}).map(p => ({ id: p.id, kind: p.kind, prompt: p.prompt, payload: p.payload, hint: p.hint, solved: s.st.puzzlesSolved.includes(p.id) }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Походження знахідки — короткий рядок «де це взяли», щоб через годину гри
|
||||||
|
// дошка на 30 карток пам'ятала, що звідки. Лише для фізичних знахідок:
|
||||||
|
// sys/lab/person/start приходять із термінала або з розмови, і відсутність
|
||||||
|
// позначки вже означає це саме — дошка не роздувається.
|
||||||
|
function originOf(cs, ev) {
|
||||||
|
const at = ev.at || {};
|
||||||
|
if (at.type === 'spot') return `знайдено: ${bName(cs, at.building)}, ${at.spot}`;
|
||||||
|
if (at.type === 'unit') return `знайдено: кв. ${at.unit}, ${at.slot}`;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function view(s) {
|
function view(s) {
|
||||||
const { cs, st } = s;
|
const { cs, st } = s;
|
||||||
const found = st.found.map(id => {
|
const found = st.found.map(id => {
|
||||||
const ev = cs.evidence[id];
|
const ev = cs.evidence[id];
|
||||||
const card = { id, title: ev.title, detail: ev.detail, side: ev.side };
|
const card = { id, title: ev.title, detail: ev.detail, side: ev.side, tag: ev.tag || 'запит' };
|
||||||
|
// Звідки знахідка — для фільтра й для пам'яті гравця (див. originOf).
|
||||||
|
const from = originOf(cs, ev);
|
||||||
|
if (from) card.from = from;
|
||||||
// Експертиза (crim/expert) розбирає конкретний доказ — його id у requires[0].
|
// Експертиза (crim/expert) розбирає конкретний доказ — його id у requires[0].
|
||||||
// Віддаємо як ref, щоб на дошці було видно, ЧИЙ це аналіз, і клік вів до нього.
|
// Віддаємо як ref, щоб на дошці було видно, ЧИЙ це аналіз, і клік вів до нього.
|
||||||
if (ev.q && ev.q.kind === 'expert' && ev.requires[0]) card.ref = ev.requires[0];
|
if (ev.q && ev.q.kind === 'expert' && ev.requires[0]) card.ref = ev.requires[0];
|
||||||
return card;
|
return card;
|
||||||
}).concat(s.extra);
|
// Динамічні картки (результати запитів) тега не мають — даємо дефолтний,
|
||||||
|
// інакше вони випадали б із будь-якого зрізу по чипах.
|
||||||
|
}).concat(s.extra.map(x => (x.tag ? x : { ...x, tag: 'запит' })));
|
||||||
const killerC = cs.citizens.find(c => c.id === cs.finale.killer);
|
const killerC = cs.citizens.find(c => c.id === cs.finale.killer);
|
||||||
const dnaOwner = cs.citizens.find(c => c.id === (cs.accomplice || cs.finale.killer));
|
const dnaOwner = cs.citizens.find(c => c.id === (cs.accomplice || cs.finale.killer));
|
||||||
return {
|
return {
|
||||||
|
|
@ -326,16 +346,34 @@ function logQA(s, ws, cid, q, a) {
|
||||||
ws.send(JSON.stringify({ t: 'answer', cid, q, a }));
|
ws.send(JSON.stringify({ t: 'answer', cid, q, a }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Один пошук за іменем на всі поля, де вводять людину: кожне слово запиту — початок
|
||||||
|
// слова в імені, регістр не важить. Раніше банк вимагав точний повний рядок, а реєстри
|
||||||
|
// шукали підрядок — два різні правила для однакових на вигляд полів.
|
||||||
|
const WORDSEP = /[^а-яіїєґa-z0-9]+/i;
|
||||||
|
function findByName(cs, q) {
|
||||||
|
const words = String(q || '').trim().toLowerCase().split(WORDSEP).filter(Boolean);
|
||||||
|
if (!words.length) return [];
|
||||||
|
return cs.citizens.filter(c => {
|
||||||
|
const nw = c.name.toLowerCase().split(WORDSEP);
|
||||||
|
return words.every(w => nw.some(x => x.startsWith(w)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// збіг запиту з довідником доказу
|
// збіг запиту з довідником доказу
|
||||||
function matchQ(q, kind, inp) {
|
function matchQ(q, kind, inp) {
|
||||||
const vals = [].concat(q.match || []);
|
const vals = [].concat(q.match || []);
|
||||||
if (!vals.length) return true; // expert-запити без вводу
|
if (!vals.length) return true; // expert-запити без вводу
|
||||||
if (['owner', 'calls', 'locate'].includes(kind)) return vals.some(v => normPhone(v) === normPhone(inp));
|
if (['owner', 'calls', 'locate'].includes(kind)) return vals.some(v => normPhone(v) === normPhone(inp));
|
||||||
const low = String(inp).toLowerCase();
|
const low = String(inp).toLowerCase().trim();
|
||||||
if (low.length < 3) return false;
|
if (low.length < 3) return false;
|
||||||
|
const qWords = low.split(WORDSEP).filter(Boolean);
|
||||||
return vals.some(v => {
|
return vals.some(v => {
|
||||||
const lv = String(v).toLowerCase();
|
const lv = String(v).toLowerCase();
|
||||||
return lv === low || lv.includes(low) || low.includes(lv);
|
if (lv === low || lv.startsWith(low)) return true; // «стріч» → «стрічка»
|
||||||
|
// Довідкове значення мусить стояти в запиті ОКРЕМИМ словом. Раніше було
|
||||||
|
// взаємне входження підрядка в будь-який бік — і довга вставка матчила будь-що.
|
||||||
|
const vWords = lv.split(WORDSEP).filter(Boolean);
|
||||||
|
return vWords.length > 0 && vWords.every(w => qWords.includes(w));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -423,11 +461,22 @@ function handle(s, ws, m) {
|
||||||
return done();
|
return done();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Тріангуляція: чотири геть різні відмови раніше віддавали один і той самий
|
||||||
|
// тост, тож правильна дія до кінця Акту I читалася як зламана механіка.
|
||||||
|
// Порядок перевірок важливий: гейт «особу не встановлено» — ЗАГАЛЬНИЙ, до
|
||||||
|
// звірки номера, інакше окрема відповідь саме на номер убивці видала б його.
|
||||||
if (m.sys === 'phone' && kind === 'locate') {
|
if (m.sys === 'phone' && kind === 'locate') {
|
||||||
const tri = cs.evidence.rec_triangulate;
|
const tri = cs.evidence.rec_triangulate;
|
||||||
const killer = byIdC(cs, cs.finale.killer);
|
const killer = byIdC(cs, cs.finale.killer);
|
||||||
if (tri && !st.found.includes(tri.id) && RT.available(cs, st, tri) &&
|
const num = normPhone(inp);
|
||||||
normPhone(inp) === normPhone(killer.phone)) {
|
if (!num) return toast(ws, 'Оператор: введіть номер. Формат вільний — усе, крім цифр, відкидається.');
|
||||||
|
if (tri && tri.needsIdentity && !st.identity)
|
||||||
|
return toast(ws, 'Оператор: пеленг — за постановою, а підстав немає: особу загиблого ще не встановлено.');
|
||||||
|
if (tri && st.found.includes(tri.id) && num === normPhone(killer.phone))
|
||||||
|
return toast(ws, 'Оператор: цей пеленг уже в матеріалах справи.');
|
||||||
|
const owner = cs.citizens.find(x => normPhone(x.phone) === num);
|
||||||
|
if (!owner) return toast(ws, 'Оператор: номер не обслуговується.');
|
||||||
|
if (tri && !st.found.includes(tri.id) && RT.available(cs, st, tri) && num === normPhone(killer.phone)) {
|
||||||
s.triTarget = s.locs[killer.id];
|
s.triTarget = s.locs[killer.id];
|
||||||
const b = cs.district.buildings.find(x => x.id === s.triTarget);
|
const b = cs.district.buildings.find(x => x.id === s.triTarget);
|
||||||
const cx = b.pos.x + b.pos.w / 2, cy = b.pos.y + b.pos.h / 2;
|
const cx = b.pos.x + b.pos.w / 2, cy = b.pos.y + b.pos.h / 2;
|
||||||
|
|
@ -436,7 +485,13 @@ function handle(s, ws, m) {
|
||||||
towers: cs.district.towers.map(tw => ({ name: tw.name, x: tw.pos.x, y: tw.pos.y, dist: Math.round(Math.hypot(tw.pos.x - cx, tw.pos.y - cy)) }))
|
towers: cs.district.towers.map(tw => ({ name: tw.name, x: tw.pos.x, y: tw.pos.y, dist: Math.round(Math.hypot(tw.pos.x - cx, tw.pos.y - cy)) }))
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
return toast(ws, 'Оператор: номер зараз не в мережі, не існує — або бракує підстав для пеленга.');
|
return toast(ws, 'Оператор: номер у мережі, але підстав пеленгувати саме його немає — потрібен доказ, що апарат стосується справи.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Текстові системи: запит коротший за 3 символи раніше мовчки давав порожньо.
|
||||||
|
if (m.sys === 'bank' || m.sys === 'db' || (m.sys === 'crim' && kind === 'archive')) {
|
||||||
|
if (!inp) return toast(ws, 'Порожній запит.');
|
||||||
|
if (inp.length < 3) return toast(ws, 'Уточніть запит: мінімум 3 символи.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// реальні докази, що відповідають запиту
|
// реальні докази, що відповідають запиту
|
||||||
|
|
@ -467,16 +522,21 @@ function handle(s, ws, m) {
|
||||||
return done();
|
return done();
|
||||||
}
|
}
|
||||||
if (m.sys === 'bank') {
|
if (m.sys === 'bank') {
|
||||||
const c = cs.citizens.find(x => x.name.toLowerCase() === inp.toLowerCase());
|
const hits = findByName(cs, inp);
|
||||||
if (!c) return toast(ws, 'Банк: рахунків за таким запитом не знайдено. Ім\'я — точно як у реєстрі.');
|
if (!hits.length) return toast(ws, 'Банк: рахунків за таким запитом не знайдено.');
|
||||||
|
if (hits.length > 1)
|
||||||
|
return toast(ws, `Банк: під запит підходить осіб — ${hits.length}. Уточніть ім'я.`);
|
||||||
|
const c = hits[0];
|
||||||
extraCard(s, 'bank|' + c.id, `Виписка: ${c.name}`,
|
extraCard(s, 'bank|' + c.id, `Виписка: ${c.name}`,
|
||||||
`${c.job === 'пенсіонер' ? 'Пенсія' : c.job === 'студент' ? 'Стипендія і перекази від рідні' : 'Зарплата'}, комуналка, дрібні витрати. Аномалій не видно.`);
|
`${c.job === 'пенсіонер' ? 'Пенсія' : c.job === 'студент' ? 'Стипендія і перекази від рідні' : 'Зарплата'}, комуналка, дрібні витрати. Аномалій не видно.`);
|
||||||
return done();
|
return done();
|
||||||
}
|
}
|
||||||
if (m.sys === 'db') {
|
if (m.sys === 'db') {
|
||||||
const low = inp.toLowerCase();
|
const low = inp.toLowerCase();
|
||||||
if (low.length < 3) return toast(ws, 'Реєстри: уточніть запит (мінімум 3 символи).');
|
// Ім'я — за тим самим правилом, що й банк і база аналітика (префікси слів);
|
||||||
const rs = cs.citizens.filter(c => c.name.toLowerCase().includes(low) ||
|
// тату й кличка собаки лишаються підрядком, це прикмети, а не імена.
|
||||||
|
const named = new Set(findByName(cs, inp).map(c => c.id));
|
||||||
|
const rs = cs.citizens.filter(c => named.has(c.id) ||
|
||||||
(c.tattoo || '').toLowerCase().includes(low) || (c.dog || '').toLowerCase().includes(low));
|
(c.tattoo || '').toLowerCase().includes(low) || (c.dog || '').toLowerCase().includes(low));
|
||||||
if (!rs.length) return toast(ws, 'Реєстри: збігів немає.');
|
if (!rs.length) return toast(ws, 'Реєстри: збігів немає.');
|
||||||
extraCard(s, 'db|' + low, `Реєстри: «${inp}»`,
|
extraCard(s, 'db|' + low, `Реєстри: «${inp}»`,
|
||||||
|
|
@ -700,7 +760,12 @@ function handle(s, ws, m) {
|
||||||
else if (st.found.includes(m.evId) || s.extra.some(x => x.id === m.evId)) s.board.pins.push(m.evId);
|
else if (st.found.includes(m.evId) || s.extra.some(x => x.id === m.evId)) s.board.pins.push(m.evId);
|
||||||
return done();
|
return done();
|
||||||
}
|
}
|
||||||
case 'mark': { // {key:'e:..'|'c:..', kind}
|
case 'mark': { // {key:'e:..'|'c:..'|'q:..', kind}
|
||||||
|
// Раніше сюди приймався будь-який рядок як kind. Палітри дві: на людях —
|
||||||
|
// кольори (кілька версій одночасно), на доказах і записах журналу — ✓/✗.
|
||||||
|
if (typeof m.key !== 'string') return;
|
||||||
|
const allowed = m.key.startsWith('c:') ? PERSON_MARKS : EV_MARKS;
|
||||||
|
if (!allowed.includes(m.kind)) return;
|
||||||
if (s.board.marks[m.key] === m.kind) delete s.board.marks[m.key];
|
if (s.board.marks[m.key] === m.kind) delete s.board.marks[m.key];
|
||||||
else s.board.marks[m.key] = m.kind;
|
else s.board.marks[m.key] = m.kind;
|
||||||
return done();
|
return done();
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue