diff --git a/detective-game/README.md b/detective-game/README.md index 0ac6caf..b72ed9b 100644 --- a/detective-game/README.md +++ b/detective-game/README.md @@ -18,8 +18,8 @@ node server.js - **Польовий детектив** — ходить районом (WASD/стрілки, E — увійти), обшукує місця, стукає у квартири, говорить з людьми. У приватне житло — лише з ключем або ордером. -- **Аналітик** — база мешканців (пошук за точним ім'ям з клавіатури — помилка в написанні - дасть порожній результат; фільтри за слідами: взуття, зріст, одяг, відбитки), мапа району +- **Аналітик** — база мешканців (пошук за іменем з клавіатури — досить початку слова, регістр + не важить; фільтри за слідами: взуття, зріст, одяг, відбитки), мапа району з напарником наживо, системи (оператор, банки, камери, криміналістика, лабораторія) і **шифри**: Цезар, base64, PIN, злам md5-пароля за фактами з досьє. diff --git a/detective-game/casegen/evening.js b/detective-game/casegen/evening.js index 2d2155f..6c11701 100644 --- a/detective-game/casegen/evening.js +++ b/detective-game/casegen/evening.js @@ -22,11 +22,12 @@ function setupEvening(ctx) { for (const c of citizens) c.claim = c.liar ? otherB(c.evening) : c.evening; killer.claim = pick(r, coveredB.filter(b => b !== scene.building && b !== 'station')) || otherB(scene.building); + // Виправдання роздаємо тут (споживає RNG). А от свідка алібі (`mate`) рахувати + // рано: мутатори після нас іще переносять людей (спільниця йде на місце злочину), + // і вже роздані посилання протухають — див. assignMates() в кінці генерації. for (const c of citizens) { if (!c.liar || c === killer) continue; c.excuse = pick(r, N.EXCUSES); - const m2 = citizens.find(o => o !== c && o.id !== victim.id && o.id !== killer.id && o.evening === c.evening); - c.mate = m2 ? m2.id : null; } } diff --git a/detective-game/casegen/index.js b/detective-game/casegen/index.js index dc603ee..adb2fbc 100644 --- a/detective-game/casegen/index.js +++ b/detective-game/casegen/index.js @@ -713,6 +713,22 @@ function generateCase(seed) { ? `${scene.name}: сліди боротьби — і жодної людини. Зникла особа: ${victim.name}, ${victim.age} р., прож. ${victim.home}. Заручник, імовірно, ще живий. З'ясуйте, ХТО забрав людину, ДЕ її тримають і ЧОМУ. Викрадача немає в жодному списку підозрюваних — його збирають по слідах і базі мешканців. У вас один штурм: помилка в імені чи місці — і все марно.` : `Ранок. ${scene.name} — тіло без документів. Ніхто не заявляв про зникнення. Акт I: з'ясуйте, ХТО загинув. Акт II: знайдіть, ХТО це зробив, ЯК і ЧОМУ. Вбивці немає у списку підозрюваних — його треба зібрати по слідах і базі мешканців. У вас одне звинувачення. Помилка — і вас звільнять.`; + // -- свідки алібі брехунів. Рахуємо ОСТАННІМИ: до цього моменту мутатори ще + // переносять людей (спільниця йде на місце злочину), і свідок, призначений раніше, + // перетворюється на мертве посилання — «названий свідок сам себе туди не ставить». + // Це і зливало твіст: єдина людина з суперечливим свідком і була спільницею. + // `citizens.find` RNG не споживає, тож наявні сейви лишаються тією ж справою. + const figures = [victim.id, killer.id, accomplice && accomplice.id]; + for (const c of citizens) { + c.mate = null; + if (!c.liar || c === killer || (accomplice && c === accomplice)) continue; + // Свідок мусить бути там само — і не заперечити цього на власному допиті + // (o.claim === o.evening), інакше «спитайте X» веде в порожнечу. + const m2 = citizens.find(o => o !== c && !figures.includes(o.id) && + o.evening === c.evening && o.claim === o.evening); + if (m2) c.mate = m2.id; + } + return { seed: String(seed), title: 'Невпізнаний', district, citizens, puzzles, victim: victim.id, evidence: E, startingFacts, finale, templateId, caseType, introText, diff --git a/detective-game/casegen/mutators.js b/detective-game/casegen/mutators.js index 0c29fb2..98bf65b 100644 --- a/detective-game/casegen/mutators.js +++ b/detective-game/casegen/mutators.js @@ -26,12 +26,9 @@ function applyDoppelganger(ctx) { doppelganger.liar = true; doppelganger.claim = pick(r, district.buildings.filter(b => b.id !== doppelganger.evening && b.id !== 'station').map(b => b.id)); doppelganger.excuse = pick(r, N.EXCUSES); - - // Знаходимо свідка, який може підтвердити справжнє алібі двійника - const mate = citizens.find(o => o.id !== victim.id && o.id !== killer.id && o.id !== herring.id && o.evening === doppelganger.evening); - if (mate) { - doppelganger.mate = mate.id; - } + // Свідка алібі тут НЕ рахуємо: мутатор працює до setupEvening, коли evening + // у всіх ще undefined, — предикат `o.evening === c.evening` зматчив би першого-ліпшого. + // Свідків роздає assignMates() у кінці генерації, коли вечір уже остаточний. return doppelganger; } diff --git a/detective-game/casegen/names.js b/detective-game/casegen/names.js index 06ec57e..8e3582f 100644 --- a/detective-game/casegen/names.js +++ b/detective-game/casegen/names.js @@ -36,37 +36,41 @@ const COATS = ['чорному пальті', 'сірій куртці', 'син // Чому люди брешуть про свій вечір (виправдання на повторному допиті). // Гравці самі вирішують, кому вірити, — гра вердиктів не ставить. +// УВАГА: рядки роздаються `pick`ом незалежно від статі, тож усі вони мусять +// лишатися БЕЗРІДНИМИ — теперішній час, інфінітив або називна конструкція. +// Жодного минулого часу від першої особи («був», «пив», «ходив»): половина +// мешканців району — жінки. Тест у test.js це перевіряє. const EXCUSES = [ - 'Я був не сам, і вдома про це знати не мусять. Ви розумієте.', - 'Пив. Сильно. Не хотів, щоб про це знали на роботі.', - 'Грав у карти на гроші в гаражах. Це ж не злочин... майже.', - 'Я боявся: скажеш «був поруч» — і вже підозрюваний. От і збрехав.', - 'Позичав гроші в людини, про яку краще не говорити вголос.', - 'Ходив до ворожки. Смійтеся-смійтеся. Спробуйте самі поживіть із моїм щастям.', - 'Шукав підробіток. Офіційно мені не можна, тож — тихцем.', - 'Посварився з дружиною і ночував у майстерні. Соромно було казати.', - 'Здавав пляшки. Так, отакий у мене вечір. Задоволені?', - 'Був у клініці. Аналізи. Це моя справа і нікого не обходить.', - 'Зустрічався з колишньою. Якщо дізнається теперішня — мені кінець.', - 'Продавав дещо з дому, поки рідня не бачить. Борги.', - 'Сидів у барі до закриття. Мамі кажу, що не п\'ю.', - 'Ловив рибу без квитка. Інспектор мене вже двічі попереджав.', - 'Плакав у машині. Буває. Не хотів, щоб хтось бачив.', - 'Ходив дивитися на квартиру жертви... тобто, я не так висловився. Просто гуляв тим двором.', - 'Репетирував освідчення. Так, сам із собою. Годину. У дворі.', - 'Був на співбесіді в іншому місті. Начальник не мусить знати.', - 'Виносив речі колишнього орендаря. Не питайте.', - 'Дзвонив у передачу на радіо. Це анонімно, і хай так і лишиться.', - 'Фотографував птахів. Уночі. Сов, якщо точніше. Це хобі!', - 'Стежив за власним чоловіком. І ні, не скажу, що вистежила.', - 'Пробував кинути курити і намотував кола районом, щоб не зірватися.', - 'Ремонтував чужу машину за готівку. Податкова ж не дізнається?', - 'Ходив на могилу до брата. Про це не розповідаю нікому.', - 'Грав у шахи на набережній. На гроші. Пенсії не вистачає.', - 'Був у перукарки. Не в тій, до якої ходить моя сім\'я. Це складно.', - 'Тренувався співати в елінгу. Там акустика. І ніхто не чує. Зазвичай.', - 'Розклеював оголошення про зниклого кота. Кіт не мій. Довга історія.', - 'Просто збрехав. Звичка з дитинства: менше скажеш — краще спиш.' + 'Була компанія, і вдома про це знати не мусять. Ви розумієте.', + 'Пиятика. Сильна. І на роботі про це знати не мусять.', + 'Карти на гроші в гаражах. Це ж не злочин... майже.', + 'Страх, розумієте: скажеш «я поруч» — і ти вже підозрюваний. От і брехня.', + 'Борг у людини, про яку краще не говорити вголос.', + 'Ворожка. Смійтеся-смійтеся. Спробуйте самі поживіть із моїм щастям.', + 'Підробіток. Офіційно мені не можна, тож — тихцем.', + 'Сварка вдома, ніч у майстерні. Соромно казати.', + 'Пляшки на здачу. Так, отакий у мене вечір. Задоволені?', + 'Клініка. Аналізи. Це моя справа і нікого не обходить.', + 'Зустріч із колишнім коханням. Якщо дізнаються удома — мені кінець.', + 'Дещо з дому — на продаж, поки рідня не бачить. Борги.', + 'Бар до самого закриття. Мамі кажу, що не п\'ю.', + 'Риболовля без квитка. Інспектор мене вже двічі попереджав.', + 'Сльози в машині. Буває. І без свідків, будь ласка.', + 'Дивлюся іноді на ту квартиру... тобто, я не так кажу. Просто прогулянка тим двором.', + 'Репетиція освідчення. Так, наодинці. Годину. У дворі.', + 'Співбесіда в іншому місті. Начальник не мусить знати.', + 'Речі колишнього орендаря треба було винести. Не питайте.', + 'Дзвінок у передачу на радіо. Це анонімно, і хай так і лишиться.', + 'Фотографую птахів. Уночі. Сов, якщо точніше. Це хобі!', + 'Стеження за власною половиною. І ні, не скажу, що з того вийшло.', + 'Кидаю курити — і намотую кола районом, щоб не зірватися.', + 'Ремонт чужої машини за готівку. Податкова ж не дізнається?', + 'Могила брата. Про це нікому не розповідаю.', + 'Шахи на набережній. На гроші. Пенсії не вистачає.', + 'Перукарка. Не та, до якої ходить моя сім\'я. Це складно.', + 'Вправляюся співати в елінгу. Там акустика. І ніхто не чує. Зазвичай.', + 'Оголошення про зниклого кота, по всіх стовпах. Кіт не мій. Довга історія.', + 'Просто брехня. Звичка з дитинства: менше скажеш — краще спиш.' ]; // Хто знайшов тіло / повідомив (для вступів) diff --git a/detective-game/casegen/test.js b/detective-game/casegen/test.js index 9e75ad9..00e7939 100644 --- a/detective-game/casegen/test.js +++ b/detective-game/casegen/test.js @@ -59,6 +59,20 @@ function checkCase(seed) { if (c.liar && c.id !== cs.finale.killer && c.id !== cs.accomplice) assert.ok(c.excuse, 'у брехуна є виправдання на повторний допит'); + // -- свідок алібі: мусить бути там само, підтверджувати це на власному допиті + // і не бути фігурантом справи (інакше «спитайте X» зливає, хто в справі). + for (const c of cs.citizens) { + if (!c.mate) continue; + const mt = byId(c.mate); + assert.ok(mt, `${seed}/${c.name}: свідок алібі не існує`); + assert.strictEqual(mt.evening, c.evening, `${seed}/${c.name}: свідок алібі був в іншому місці`); + assert.strictEqual(mt.claim, mt.evening, `${seed}/${c.name}: свідок алібі сам заперечує його покази`); + assert.ok(![cs.victim, cs.finale.killer, cs.accomplice].includes(mt.id), + `${seed}/${c.name}: свідок алібі — фігурант справи`); + assert.ok(c.liar && c.id !== cs.finale.killer && c.id !== cs.accomplice, + `${seed}/${c.name}: свідок алібі є в того, хто не брехав`); + } + // -- зламане алібі: сплячий доказ, недосяжний симулятором, з вагою підтвердження const ab = cs.evidence.alibi_broken; assert.ok(ab && ab.special === 'alibi' && ab.gives.confirms, 'сплячий доказ алібі існує'); @@ -142,6 +156,17 @@ function checkCase(seed) { }; walk(cs, ''); + // -- виправдання брехунів роздаються `pick`ом незалежно від статі, тож у них + // не має бути минулого часу від першої особи («був», «пив», «посварився»): + // жінка на зізнанні сказала б це в чоловічому роді. Сітка вище цього не ловить — + // форма граматично правильна, просто не того роду. + const M_PAST = /(?:^|[.!?…]\s+|(?:^|\s)(?:я|і|й|та|то|вже|не|ще)\s+)[а-яіїєґ']*[аяиіуюе]в(?:ся|сь)?(?=$|[\s.,!?;:»…])/i; + for (const c of cs.citizens) { + if (!c.excuse) continue; + const m = c.excuse.match(M_PAST); + assert.ok(!m, `${seed}/${c.id}: виправдання в чоловічому роді «${m && m[0].trim()}»: ${c.excuse}`); + } + // -- розв'язність від стартових фактів const sol = solve(cs); assert.ok(sol.identity, 'особу жертви можна встановити'); diff --git a/detective-game/public/analyst.js b/detective-game/public/analyst.js index 427cf66..8e80713 100644 --- a/detective-game/public/analyst.js +++ b/detective-game/public/analyst.js @@ -48,9 +48,9 @@ const p = panels.db; p.innerHTML = `
Пошук за точним ім'ям — помилка в написанні дасть порожній результат. Значення для фільтрів беріть зі знайдених слідів і вводьте самі. Реєстр місцями застарілий: одяг і розмір взуття надійніше звіряти очним оглядом (ордер у польового).
+Пошук за іменем: досить початку — «шев» знайде «Шевченко», регістр не важить. Кілька слів звужують (ім'я + прізвище). Значення для фільтрів беріть зі знайдених слідів і вводьте самі. Реєстр місцями застарілий: одяг і розмір взуття надійніше звіряти очним оглядом (ордер у польового).
— Введіть ім\'я або прізвище.
'; return; } - // Розбиваємо введений запит на слова - const words = q.split(/\s+/); - // Фільтруємо: кожне введене слово має збігатися з початком якогось слова в імені громадянина - lastRows = App.init.citizens.filter(c => { - const nameWords = c.name.split(/\s+/); - return words.every(w => nameWords.some(nw => nw.startsWith(w))); - }); + lastRows = matchName(App.init.citizens, q); // спільний пошук, див. client.js if (!lastRows.length) { dbOut.innerHTML = '— Записів не знайдено. Спробуйте інший початок.
'; return; @@ -97,7 +91,7 @@ }; } - // рядки з позначками ✓/✗/…, як картки доказів: викреслюйте людей по одному + // рядки з кольоровими мітками: ведіть кілька версій одночасно, по одній людині function renderRows() { if (!dbOut || !lastRows || !App.state) return; dbOut.innerHTML = `Збігів: ${lastRows.length}
`; @@ -184,28 +178,35 @@Формат номера вільний: пробіли, дужки й дефіси система відкидає сама.
+Перші два запити — архівні, про вечір злочину. Пеленг — живий: показує поточну позицію апарата, і оператор дає його лише за наявності підстав.
Досить початку імені чи прізвища — те саме правило, що й у базі мешканців. Якщо під запит підходить кілька осіб, банк попросить уточнити.
Зразок із місця злочину — ліворуч. Клацніть по профілю, який вважаєте збігом. Памʼятайте: збіг скаже лише, ЧИЯ це ДНК, — не коли і не чому вона там.
-Зразок ліворуч. Клацніть по власнику відбитка.
← зразок - `); + `, nav); drawPrint(mm.inner.querySelector('#psample').getContext('2d'), App.state.printSample, 0.7); const box = mm.inner.querySelector('#pcands'); for (const id of ids) { @@ -435,7 +446,7 @@ const cv = el('canvas'); cv.width = cv.height = 110; cv.style.border = '1px solid var(--line)'; cv.style.borderRadius = '6px'; w.append(cv, el('div', 'small dim', esc(c.name))); drawPrint(cv.getContext('2d'), c.printSeed, 0); - w.onclick = () => { App.send({ t: 'print_pick', cid: id }); mm.wrap.remove(); }; + w.onclick = () => { App.send({ t: 'print_pick', cid: id }); mm.close(); }; box.append(w); } mm.inner.append(hintBlock('Зразок знято під кутом — подумки поверніть його. Порівнюйте будову: кількість і хід дуг, а не поворот.')); diff --git a/detective-game/public/client.js b/detective-game/public/client.js index ae31fba..a30f87e 100644 --- a/detective-game/public/client.js +++ b/detective-game/public/client.js @@ -130,11 +130,31 @@ try { // ---------- Бічна дошка ---------- let sideTab = "ev"; let showHidden = false; // згорнута секція «Приховані» на дні дошки +let evFilter = ""; // текстовий фільтр по картках доказів +let evTag = null; // активний чип-тег (null = всі) // Дошка перемальовується на кожен стан — тож недодруковані нотатки треба зберігати, // інакше broadcast напарника стирає те, що ти зараз друкуєш. Тримаємо чернетку в мапі // (ключ = data-draft-key поля) і відновлюємо фокус/курсор після перемальовки. const noteDraft = {}; +// Але фокус повертаємо, ЛИШЕ якщо гравець справді щойно друкував. Раніше було +// «завжди, якщо колись друкував»: варто польовому один раз клацнути в нотатку, +// і кожен наступний broadcast знову садив фокус у той інпут — а обробник клавіш +// у field.js свідомо ігнорує натискання в INPUT/TEXTAREA. Клавіатура мовчала, +// візуально нічого не змінювалось. +const FOCUS_GRACE_MS = 3000; +let lastTypedAt = 0; +let restoringFocus = false; // наш власний .focus() не має продовжувати вікно +{ + const side = $("#side"); + const touch = () => { + if (!restoringFocus) lastTypedAt = Date.now(); + }; + // capture: фільтр дошки сам кличе renderSide() зі свого oninput, тож позначку + // часу треба поставити ДО того, як обробник цілі встигне перемалювати панель. + side.addEventListener("input", touch, true); + side.addEventListener("focusin", touch, true); +} function wireDraft(input, key) { input.dataset.draftKey = key; if (noteDraft[key]) input.value = noteDraft[key]; @@ -150,20 +170,49 @@ $("#sidetabs").onclick = (e) => { renderSide(); }; +// Мітки. На картках доказів лишається ✓/✗/…, а на людях трьох станів замало — +// треба вести кілька версій одночасно. Тому для ключів 'c:' окрема палітра +// кольорів. Що саме означає кожен колір — вирішують гравці: жодних підписів, +// легенд і тултипів ми не нав'язуємо. «Сховати» лишається в обох: на ньому +// тримається секція «Приховані». +const EV_MARKS = [ + ["tick", "відмітити", null], + ["cross", "викреслити", null], + ["hide", "сховати", null], +]; +const PEOPLE_MARKS = [ + ["green", null, "var(--mk-green)"], + ["red", null, "var(--mk-red)"], + ["blue", null, "var(--mk-blue)"], + ["amber", null, "var(--mk-amber)"], + ["hide", "сховати", null], +]; +const MARK_GLYPH = { tick: "✓", cross: "✗", hide: "…" }; +// Сейви зі старих версій тримають на людях ✓/✗ — читаємо їх як два перші кольори. +const LEGACY_PERSON_MARK = { tick: "green", cross: "red" }; +const isPersonKey = (key) => String(key).startsWith("c:"); + +function markOf(key) { + const m = App.state.board.marks[key]; + if (!m) return null; + return isPersonKey(key) ? LEGACY_PERSON_MARK[m] || m : m; +} + function markButtons(key) { - const marks = App.state.board.marks; + const cur = markOf(key); const mk = el("div", "mk"); - for (const [kind, label] of [ - ["tick", "✓"], - ["cross", "✗"], - ["hide", "…"], - ]) { - const b = el("button", marks[key] === kind ? "on" : "", label); - b.title = { - tick: "відмітити", - cross: "викреслити", - hide: "сховати", - }[kind]; + for (const [kind, title, colour] of isPersonKey(key) + ? PEOPLE_MARKS + : EV_MARKS) { + const b = el( + "button", + (colour ? "swatch " : "") + (cur === kind ? "on" : ""), + colour + ? `` + : MARK_GLYPH[kind], + ); + if (title) b.title = title; // кольори лишаємо без підписів — значення за гравцями + if (colour) b.style.color = colour; b.onclick = (ev) => { ev.stopPropagation(); App.send({ t: "mark", key, kind }); @@ -174,8 +223,16 @@ function markButtons(key) { } function cardClasses(key) { - const m = App.state.board.marks[key]; - return m && m !== "hide" ? " " + m : ""; + const m = markOf(key); + if (!m || m === "hide") return ""; + return isPersonKey(key) ? " mk-" + m : " " + m; +} + +// Свотч кольору для місць, де мітку лише показують (база польового). +function markSwatch(kind) { + const found = PEOPLE_MARKS.find(([k]) => k === kind); + if (!found || !found[2]) return ""; + return ``; } // Прокрутити дошку до доказу за id і коротко підсвітити. Якщо він у згорнутій @@ -207,38 +264,74 @@ function renderSide() { if (sideTab === "ev") { const pins = App.state.board.pins; + const found = App.state.found; + const tagOf = (e) => e.tag || "запит"; body.append( el( "div", "dim small", - "Закріплене 📌 піде в справу для прокурора. Знайдено: " + - App.state.found.length, + "Закріплене 📌 піде в справу для прокурора. Знайдено: " + found.length, ), ); - const found = App.state.found; + + // Пошук + чипи-теги. До фіналу на дошці 30+ карток одним стовпчиком, і знайти + // потрібну можна лише прокруткою. Чипи будуємо з тегів, що реально є на дошці, — + // так жоден доказ не лишається поза жодним зрізом. + const q = el("input", "small evfilter"); + q.placeholder = "🔎 фільтр за текстом…"; + q.value = evFilter; + q.dataset.draftKey = "evfilter"; // щоб фокус/курсор пережили перемальовку + q.oninput = () => { + evFilter = q.value; + renderSide(); + }; + body.append(q); + + const chips = el("div", "chips"); + const allTags = [...new Set(found.map(tagOf))].sort((a, b) => + a.localeCompare(b, "uk"), + ); + for (const t of [null, ...allTags]) { + const b = el("button", "chip" + (evTag === t ? " on" : ""), t || "всі"); + b.onclick = () => { + evTag = evTag === t ? null : t; + renderSide(); + }; + chips.append(b); + } + body.append(chips); + + // Фільтр застосовуємо ДО поділу на закріплені/решту, щоб 📌 не зникали з очей. + const low = evFilter.trim().toLowerCase(); + const passes = (e) => + (!evTag || tagOf(e) === evTag) && + (!low || + `${e.title} ${e.detail} ${e.from || ""}`.toLowerCase().includes(low)); + const shown = found.filter(passes); + // Закріплені — зверху, від останнього запіненого до першого; далі решта доказів, // від найновішого знайденого до найстарішого. (pins — у порядку закріплення.) const pinnedCards = [...pins] .reverse() - .map((id) => found.find((e) => e.id === id)) + .map((id) => shown.find((e) => e.id === id)) .filter(Boolean); - const rest = [...found].reverse().filter((e) => !pins.includes(e.id)); - const ordered = [...pinnedCards, ...rest]; + const rest = [...shown].reverse().filter((e) => !pins.includes(e.id)); const buildCard = (e) => { const key = "e:" + e.id; - const c = el("div", "card" + cardClasses(key)); + const isPinned = pins.includes(e.id); + const c = el("div", "card" + cardClasses(key) + (isPinned ? " pinned" : "")); c.dataset.ev = e.id; // якір для прокрутки-до-доказу const t = el("div", "t"); - t.append(el("b", "", esc(e.title))); - const pin = el( - "button", - "pinbtn" + (pins.includes(e.id) ? " on" : ""), - "📌", - ); + t.append(el("b", "", esc(e.title)), el("span", "tag", esc(tagOf(e)))); + const pin = el("button", "pinbtn" + (isPinned ? " on" : ""), "📌"); + pin.title = isPinned ? "відкріпити" : "закріпити для прокурора"; pin.onclick = () => App.send({ t: "pin", evId: e.id }); t.append(pin); - c.append(t, el("div", "small", esc(e.detail))); + c.append(t); + // Звідки знахідка — лише для фізичних: решта прийшла з термінала чи розмови. + if (e.from) c.append(el("div", "small dim orig", esc(e.from))); + c.append(el("div", "small", esc(e.detail))); // Експертиза називає доказ, який розбирала, і веде до нього (прокрутка + підсвітка). if (e.ref) { const src = found.find((x) => x.id === e.ref); @@ -278,13 +371,31 @@ function renderSide() { }; const hidden = []; - for (const e of ordered) { - if (marks["e:" + e.id] === "hide") { - hidden.push(e); - continue; - } - body.append(buildCard(e)); - } + const visible = (arr) => + arr.filter((e) => { + if (marks["e:" + e.id] === "hide") { + hidden.push(e); + return false; + } + return true; + }); + const pinnedVisible = visible(pinnedCards); + const restVisible = visible(rest); + for (const e of pinnedVisible) body.append(buildCard(e)); + // Межа теки: видно, де закінчується те, що піде прокуророві. + if (pinnedVisible.length && restVisible.length) + body.append(el("div", "divider small dim", "— решта доказів —")); + for (const e of restVisible) body.append(buildCard(e)); + if (!pinnedVisible.length && !restVisible.length && !hidden.length) + body.append( + el( + "div", + "dim small", + found.length + ? "За цим фільтром нічого немає." + : "Дошка порожня — знахідки з'являться тут.", + ), + ); // Приховані — окрема згорнута секція на дні, кожен доказ окремо (не «одна кнопка = все») if (hidden.length) { const head = el( @@ -330,15 +441,19 @@ function renderSide() { } } - // повернути фокус і курсор у поле, де друкували до перемальовки - if (activeKey) { + // Повернути фокус і курсор у поле, де друкували до перемальовки, — але лише + // якщо друкували щойно (див. FOCUS_GRACE_MS): інакше поле перехоплює клавіатуру + // польового назавжди. + if (activeKey && Date.now() - lastTypedAt < FOCUS_GRACE_MS) { const again = body.querySelector(`[data-draft-key="${activeKey}"]`); if (again) { + restoringFocus = true; again.focus(); if (caret != null) try { again.setSelectionRange(caret, caret); } catch {} + restoringFocus = false; } } } @@ -380,7 +495,7 @@ function logSection(cid) { for (const e of log.slice(-12)) { const key = "q:" + e.id; let cur = App.state.board.marks[key]; - const row = el("div", "small"); + const row = el("div", "small logrow"); row.style.cssText = "padding-left:7px;border-left:2px solid transparent;margin:5px 0"; const apply = (mk) => { row.style.textDecoration = mk === "cross" ? "line-through" : "none"; @@ -417,22 +532,59 @@ function bName(bid) { return b ? b.name : bid; } +// Пошук за іменем — ОДИН на обидві ролі. Кожне слово запиту має бути початком +// якогось слова в імені; регістр не важить. Раніше це були дві копії (база +// аналітика і база польового), і аналітикова загубила .toLowerCase() з обох +// боків — «поліна» не знаходило «Поліна». Сервер шукає банк і реєстри так само. +function matchName(citizens, query) { + const words = String(query || "") + .trim() + .toLowerCase() + .split(/\s+/) + .filter(Boolean); + if (!words.length) return []; + return citizens.filter((c) => { + const nameWords = c.name.toLowerCase().split(/\s+/); + return words.every((w) => nameWords.some((nw) => nw.startsWith(w))); + }); +} + // ---------- Модалки ---------- +// Кроки більше не заміщають один одного: батьківська модалка лишається під +// поточною, тож скасування picker'а повертає до розмови, а не в порожнечу. +// opts.back — явне «← Назад» там, де попередній крок треба перезібрати +// (порожня тека ордера, шортліст лабораторії); opts.onClose — прибирання за собою. function openModal(html, opts = {}) { const wrap = el("div", "modal"); const inner = el("div", "", html); wrap.append(inner); + const close = () => { + wrap.remove(); + if (opts.onClose) opts.onClose(); + }; if (!opts.locked) { wrap.onclick = (e) => { - if (e.target === wrap) wrap.remove(); + if (e.target === wrap) close(); }; + const foot = el("div", "modalfoot"); + if (opts.back) { + const bk = el("button", "small", "← Назад"); + bk.onclick = () => { + wrap.remove(); + opts.back(); + }; + foot.append(bk); + } const x = el("button", "small", "Закрити"); - x.style.marginTop = "12px"; - x.onclick = () => wrap.remove(); - inner.append(x); + x.onclick = close; + foot.append(x); + inner.append(foot); + // Автори модалок дописують вміст уже після openModal (журнал допитів, підказки). + // Мікрозадача переносить кнопки в кінець, коли всі синхронні append'и минули. + queueMicrotask(() => inner.append(foot)); } document.body.append(wrap); - return { wrap, inner }; + return { wrap, inner, close }; } function showIntro() { @@ -450,7 +602,7 @@ function showIntro() { const analystHow = `Ваша база: дошка справи і термінал аналітика синхронізовані. Тут-таки сидить прокурор — ордер на обшук житла подають особисто, з доказом, який прямо вказує на людину.
`); - m.inner.querySelector("#db").onclick = () => { m.wrap.remove(); residentsModal(); }; - m.inner.querySelector("#warr").onclick = () => { - m.wrap.remove(); - pickCitizen("Ордер: на кого?", (cid) => { - const c = App.byId(cid); - // до прокурора йде лише закріплене 📌 — тека справи, а не вся дошка - const evs = App.state.found.filter((e) => - App.state.board.pins.includes(e.id), - ); - if (!evs.length) - return openModal( - `Тека порожня. Спершу закріпіть 📌 на дошці доказ, який прямо вказує на цю особу, — з усім підряд до прокурора не ходять.
`, - ); - const mm = openModal( + m.inner.querySelector("#db").onclick = residentsModal; + m.inner.querySelector("#warr").onclick = warrantFlow; + } + + // Вибір людини → вибір підстави. «← Назад» на обох кроках повертає до вибору + // людини: раніше порожня тека була глухим кутом, а помилка в імені коштувала + // всього ланцюга заново. + function warrantFlow() { + pickCitizen("Ордер: на кого?", (cid) => { + const c = App.byId(cid); + // до прокурора йде лише закріплене 📌 — тека справи, а не вся дошка + const evs = App.state.found.filter((e) => + App.state.board.pins.includes(e.id), + ); + if (!evs.length) + return openModal( `Прокурор підпише, лише якщо доказ прямо вказує на цю особу. У теці — закріплене 📌.
-Тека порожня. Спершу закріпіть 📌 на дошці доказ, який прямо вказує на цю особу, — з усім підряд до прокурора не ходять.
`, + { 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(); - }; - }); - }; + const mm = openModal( + `Прокурор підпише, лише якщо доказ прямо вказує на цю особу. У теці — закріплене 📌.
+Термінал аналітика, тільки перегляд. Мітки (✓/✗/…) ставить аналітик — тут вони лише видно. Реєстр місцями застарілий: одяг і взуття надійніше звіряти очним оглядом за ордером.
+Термінал аналітика, тільки перегляд. Кольорові мітки ставить аналітик — тут вони лише видно; що означає кожен колір, домовляєтесь ви двоє. Реєстр місцями застарілий: одяг і взуття надійніше звіряти очним оглядом за ордером.
Задайте пошук або фільтр.
Збігів: ${rows.length}
`; 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), - `— Введіть ім\'я або прізвище.
'; 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 = '— Записів не знайдено. Спробуйте інший початок.
'; @@ -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 @@${home.length ? "Мешканці: " + home.map((c) => esc(c.name)).join(", ") : "За реєстром тут ніхто не живе."}
`); + // Двері лишаються на екрані: «Замкнено» тепер не викидає в порожнечу — + // тост прилітає, а спробувати постукати чи піти назад можна одразу. 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("") + 'Сірі теми вже в доказах на дошці.
' : ""; + if (talkModal) { + const old = talkModal; + talkModal = null; // прибираємо самі — onClose не має скидати нову модалку + old.wrap.remove(); + } const mm = openModal( `Відповіді можуть бути правдою, брехнею або «не знаю» — перевіряйте камерами і чужими словами.
` + +Відповіді можуть бути правдою, брехнею або «не знаю» — перевіряйте камерами і чужими словами. Модалка лишається відкритою: питайте далі, «Закрити» завершує розмову.
` + 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( '${esc(m.body)}
`, - ); + openModal(`${esc(m.body)}
`); }); - // відповідь допитуваного + // Відповідь допитуваного. Сироту-модалку не відкриваємо: просимо теми ще раз — + // розмова перемалюється вже з новим записом у журналі, і можна питати далі. App.on("answer", (m) => { - const c = App.byId(m.cid); - openModal( - `${esc(m.q)}
${esc(m.a)}
-Записано в журнал допитів (досьє в аналітика і картка розмови).
`, - ); + flashCid = m.cid; + App.send({ t: "talk", cid: m.cid }); }); // ---------- міні-гра «вилучення»: тремтячі руки псують доказ ---------- diff --git a/detective-game/public/index.html b/detective-game/public/index.html index ba54b03..58ba195 100644 --- a/detective-game/public/index.html +++ b/detective-game/public/index.html @@ -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,"); } * { 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; } /* ---------- Тости: телеграма ---------- */ diff --git a/detective-game/server.js b/detective-game/server.js index eab50fc..46577a4 100644 --- a/detective-game/server.js +++ b/detective-game/server.js @@ -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();