commit 155bddda0e59e1ab5c3337c30dfa9857ba58e955 Author: UndecimoDia Date: Thu Jul 23 17:03:26 2026 +0300 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0829bff --- /dev/null +++ b/.gitignore @@ -0,0 +1,148 @@ +node_modules/ +data/saves/*.json +chapter3/node_modules/ +chapter3/saves/ + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.* +!.env.example + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist +.output + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp directory +.temp + +# Sveltekit cache directory +.svelte-kit/ + +# vitepress build output +**/.vitepress/dist + +# vitepress cache directory +**/.vitepress/cache + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# Firebase cache directory +.firebase/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# pnpm +.pnpm-store + +# yarn v3 +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/sdks +!.yarn/versions + +# Vite files +vite.config.js.timestamp-* +vite.config.ts.timestamp-* +.vite/ diff --git a/chapter1/README.md b/chapter1/README.md new file mode 100644 index 0000000..ef63c33 --- /dev/null +++ b/chapter1/README.md @@ -0,0 +1,31 @@ +# Nightshade Protocol — детектив на двох + +Кооперативна детективна гра для двох гравців в одній локальній мережі. Без залежностей, потрібен лише Node.js. + +## Запуск + +``` +node server.js +``` + +Сервер надрукує адресу в локальній мережі (наприклад `http://192.168.0.109:3000`). + +1. Обидва гравці відкривають цю адресу в браузері (телефон або ноутбук, той самий Wi-Fi). +2. Вводять **однаковий код сесії** (будь-який, наприклад `NOIR`). +3. Один обирає роль **Польового детектива**, інший — **Аналітика**. + +## Як грати + +- Польовий оглядає локації та допитує людей; аналітик ганяє запити по системах. +- Знайдені докази закріплюйте на **дошці** (📌 у Записнику) — лише закріплене бачить напарник, і лише воно розблоковує нові питання допитів і нові запити. +- Фінал: вкладка **Звинувачення** — хто, яким способом, 3–4 докази з дошки. Помилка дає підказку, яка ланка не сходиться. + +Розрахунок: 60–90 хвилин. Стан гри живе в пам'яті сервера — не вимикайте його до кінця партії. + +## Перевірка кейса + +``` +node test.js +``` + +Перевіряє fair-play: усі докази досяжні, ключове протиріччя вимагає обох ролей, правильна комбінація виграє, а кожна зламана ланка дає підказку. diff --git a/chapter1/case.js b/chapter1/case.js new file mode 100644 index 0000000..ab738f5 --- /dev/null +++ b/chapter1/case.js @@ -0,0 +1,155 @@ +// КЕЙС №1 — "Nightshade Protocol". Всі дані та логіка розв'язання — детерміновані, без AI. +const CASE = { + title: 'Nightshade Protocol', + intro: 'Меридіан, 2087. Маркус Рейз, 44, брокер даних Halcyon Dynamics, знайдений мертвим під балконом свого пентхауса — падіння з 40-го поверху. Офіційна версія: нещасний випадок. Біометричний лог нейроімпланта (незламний) зафіксував смерть о 23:14. У вас одна ніч, щоб довести протилежне.', + roles: { + field: { name: 'Польовий детектив', desc: 'Ти працюєш ногами: місце злочину, фізичні докази, допити віч-на-віч. Цифрові системи для тебе закриті — те, що бачить аналітик, ти дізнаєшся тільки з розмови.' }, + analyst: { name: 'Детектив-аналітик', desc: 'Ти працюєш з терміналу: логи доступу, камери, транзакції, біометрія. На місце злочину тебе не пускають — фізичні знахідки напарника ти дізнаєшся тільки з розмови.' } + }, + howto: 'Знайдені докази закріплюйте на спільній дошці (📌) — тільки закріплені докази бачить напарник, і тільки вони розблоковують нові питання в допитах та нові запити в системах. Говоріть уголос: жодна роль сама не бачить повної картини.', + + suspects: [ + { id: 'elena', name: 'Елена Восс', role: 'Партнерка Рейза по бізнесу', desc: 'Співзасновниця напряму в Halcyon. Холодна, бездоганна, кар\'єра понад усе.' }, + { id: 'kaito', name: 'Кайто Морі', role: 'Екс-коханець', desc: 'Фрилансер-хакер з Нижніх Доків. Розрив із Рейзом був гучним.' }, + { id: 'sable', name: 'Д-р Сейбл', role: 'Біотех-конкурент', desc: 'Голова R&D у конкурентів. Публічно звинувачував Рейза у крадіжці досліджень.' }, + { id: 'rook', name: 'Рук', role: 'Вуличний фіксер, кредитор', desc: 'Тримає майстерню в Доках. Позичає гроші тим, кому банки не позичать.' }, + { id: 'ivy', name: 'Айві Чен', role: 'Асистентка Рейза', desc: 'Повний доступ до пентхауса і графіка жертви. Тиха, виконавча, нервова.' } + ], + + methods: [ + { id: 'push', label: 'Вимкнення силового поля балкона через інженерний override і поштовх' }, + { id: 'poison', label: 'Отруєння нейротоксином' }, + { id: 'hack', label: 'Злам нейроімпланта (примусова зупинка)' }, + { id: 'drone', label: 'Удар найманого бойового дрона' }, + { id: 'accident', label: 'Нещасний випадок / самогубство' } + ], + + // tag: що доводить. requires: докази, які мають бути НА ДОШЦІ, щоб це стало доступним. + evidence: { + // — фізичні (польовий) — + fabric: { role: 'field', title: 'Клапоть тканини', tag: 'контакт', detail: 'Темно-сірий клапоть ділового піджака, зачепився за поручень балкона. Дорога корпоративна тканина — таку носять у Шпилі, не в Доках.' }, + struggle: { role: 'field', title: 'Сліди боротьби', tag: 'контакт', detail: 'Перекинутий столик, подряпини на покритті балкона біля поручня. Рейз не стояв там сам — його штовхали, і він опирався.' }, + fingerprint: { role: 'field', title: 'Частковий відбиток', tag: 'спосіб', detail: 'Частковий відбиток пальця на панелі інженерного override. Потрібна цифрова звірка — закріпи на дошці, щоб аналітик міг прогнати по базі.' }, + guard: { role: 'field', title: 'Свідчення охоронця', tag: 'алібі', detail: 'Нічний охоронець Halcyon: «Пані Восс вийшла о 22:15 — бейдж пікнув, я записав. Назад не бачив, після 22:30 я був на обході».' }, + ledger: { role: 'field', title: 'Тіньовий гросбух', tag: 'мотив', detail: 'У схованці за панеллю в кабінеті Рейза: роздруківки тіньових рахунків з позначками «Е.В.» і нотатка Рейза: «Рада. 9:00. Все їм показати».' }, + elena_lie: { role: 'field', title: 'Зізнання Елени', tag: 'алібі', detail: 'Під тиском доказів Елена визнала, що була в пентхаусі Рейза вночі. Стверджує, що пішла, коли він був ще живий.' }, + // — цифрові (аналітик) — + override_log:{ role: 'analyst', title: 'Лог override панелі', tag: 'спосіб', detail: 'Інженерний override силового поля балкона, 23:10. Авторизація: ID Елени Восс (пропуск-чіп часів її стосунків з Рейзом досі активний).' }, + drone_rec: { role: 'analyst', title: 'Запис патрульного дрона', tag: 'присутність', detail: 'Резервний архів: 22:50, силует на балконі пентхауса. Обличчя не розібрати — зріст і статура дорослої людини в діловому одязі.' }, + entry_log: { role: 'analyst', title: 'Лог входу в будівлю Рейза', tag: 'алібі', detail: 'Бейдж Елени Восс: вхід у будівлю Рейза о 22:47. Сама вона каже, що «була в офісі допізна».' }, + calendar: { role: 'analyst', title: 'Запрошення в календарі', tag: 'мотив', detail: 'Рейз створив подію на 9:00 ранку: «Термінова рада — комплаєнс Е.В.». Розсилка: рада директорів Halcyon.' }, + kaito_wifi: { role: 'analyst', title: 'Мережевий лог Кайто', tag: 'зняття підозри', detail: 'Імплант Кайто Морі підключений до wifi бару "The Hollow Note" безперервно 20:10–23:30. На момент смерті (23:14) він був у барі.' }, + rook_manifest:{ role: 'analyst', title: 'Маніфест доставки', tag: 'зняття підозри', detail: 'Вантажний під\'їзд будівлі Рейза: Рук, 21:30–21:45, накладна на серводеталі для консьєржа. Виїхав за півтори години до смерті.' }, + ivy_cam: { role: 'analyst', title: 'Камера лобі', tag: 'зняття підозри', detail: 'Айві Чен виходить з лобі о 22:30, збігається з логом бейджа. До 23:14 не поверталась.' }, + sable_conf: { role: 'analyst', title: 'Реєстрація конференції', tag: 'зняття підозри', detail: 'Д-р Сейбл: чекін на конференції «Женева-2», виступ 22:40–23:20, сотні свідків і трансляція. Залізне алібі.' }, + biometric: { role: 'analyst', title: 'Біометрія імпланта Рейза', tag: 'факт', detail: 'Смерть: 23:14. Сплеск адреналіну 23:12–23:14 — боротьба або падіння при свідомості. Це не тихий нещасний випадок.' }, + print_match: { role: 'analyst', title: 'Збіг відбитка', tag: 'спосіб', requires: ['fingerprint'], detail: 'Частковий відбиток з панелі override: збіг 87% — Елена Восс.' }, + shadow_tx: { role: 'analyst', title: 'Тіньові транзакції', tag: 'мотив', requires: ['ledger'], detail: 'Рахунки з гросбуха: систематичне виведення коштів Halcyon на офшор. Транзакції підписані корпоративним ключем Е.В. Рейз це знайшов.' } + }, + + // Польовий детектив + locations: [ + { id: 'penthouse', name: 'Пентхаус Рейза (40-й поверх)', desc: 'Місце смерті. Балкон над прірвою Нижніх Доків, кабінет із заблокованим терміналом.', + spots: [ + { id: 's1', label: 'Оглянути поручень балкона', ev: 'fabric' }, + { id: 's2', label: 'Оглянути балкон', ev: 'struggle' }, + { id: 's3', label: 'Оглянути панель override', ev: 'fingerprint' }, + { id: 's4', label: 'Обшукати кабінет', ev: 'ledger' }, + { id: 's5', label: 'Робочий термінал Рейза', flavor: 'Термінал заблоковано корпоративним шифруванням. Цифрові сліди — робота аналітика.' } + ], people: [] }, + { id: 'lobby', name: 'Лобі будівлі Рейза', desc: 'Мармур, дрони-консьєржі, камери в кожному куті.', + spots: [ { id: 's6', label: 'Стійка охорони', flavor: 'Журнал відвідувачів повністю цифровий. Хай аналітик перевірить логи доступу будівлі.' } ], + people: ['ivy'] }, + { id: 'halcyon', name: 'Офіс Halcyon Dynamics', desc: 'Верхній Шпиль. Скло, тиша, нічна зміна охорони.', + spots: [], people: ['elena', 'guard', 'sable'] }, + { id: 'bar', name: 'Бар "The Hollow Note"', desc: 'Нижні Доки. Димно, гучно, ніхто нічого не бачив — офіційно.', + spots: [], people: ['kaito', 'barman'] }, + { id: 'workshop', name: 'Майстерня Рука', desc: 'Гараж у Доках, завалений дронами й запчастинами.', + spots: [ { id: 's7', label: 'Оглянути дрони', flavor: 'Вантажні мули для доставки. Жодних слідів бойових модифікацій.' } ], + people: ['rook'] } + ], + + // Аналітик + systems: [ + { id: 'access', name: 'Логи доступу', queries: [ + { id: 'q1', label: 'Будівля Рейза: входи 20:00–00:00', ev: 'entry_log' }, + { id: 'q2', label: 'Панель безпеки пентхауса', ev: 'override_log' }, + { id: 'q3', label: 'Вантажний під\'їзд будівлі', ev: 'rook_manifest' }, + { id: 'q4', label: 'Офіс Halcyon: нічні логи', flavor: 'Нічний архів Halcyon недоступний — планове обслуговування серверів. Лишається усний журнал охорони на місці.' } + ]}, + { id: 'video', name: 'Відеоархіви', queries: [ + { id: 'q5', label: 'Камери лобі будівлі Рейза', ev: 'ivy_cam' }, + { id: 'q6', label: 'Патрульні дрони: резервний архів', ev: 'drone_rec' }, + { id: 'q7', label: 'Трансляція конференції «Женева-2»', ev: 'sable_conf' } + ]}, + { id: 'net', name: 'Мережа і біометрія', queries: [ + { id: 'q8', label: 'Біометричний лог імпланта жертви', ev: 'biometric' }, + { id: 'q9', label: 'Мережева активність імпланта Кайто Морі', ev: 'kaito_wifi' }, + { id: 'q10', label: 'Звірити відбиток з панелі по базі', ev: 'print_match' } + ]}, + { id: 'finance', name: 'Фінанси і записи', queries: [ + { id: 'q11', label: 'Календар жертви', ev: 'calendar' }, + { id: 'q12', label: 'Рахунки жертви', flavor: 'Регулярні перекази на рахунок, пов\'язаний з Руком: борг ~40 000 кредитів, останній платіж прострочений.' }, + { id: 'q13', label: 'Простежити рахунки з гросбуха', ev: 'shadow_tx' } + ]} + ], + + // Допити (веде польовий). requires: id доказів на дошці. + people: { + elena: { name: 'Елена Восс', suspect: true, topics: [ + { id: 't1', q: 'Де ви були цієї ночі?', a: '«Була в офісі допізна. Квартальний звіт. Пішла десь після опівночі — охорона підтвердить.» Дивиться прямо, не кліпає. Занадто рівно.' }, + { id: 't2', q: 'Ваші стосунки з Рейзом?', a: '«Партнери. Дванадцять років. Останнім часом він став… параноїком. Всюди бачив змови.» На слові «параноїком» — коротка пауза.' }, + { id: 't3', q: 'Що за термінова рада о 9:00?', requires: ['calendar'], a: '«Звичайна процедурна рада. Комплаєнс. Нічого особливого.» Пальці стискають планшет так, що біліють кісточки.' }, + { id: 't4', q: 'Охоронець каже: ваш бейдж вийшов о 22:15.', requires: ['guard'], a: '«Виходила подихати. Повернулась і працювала далі. Це не злочин, детективе.» Погляд уперше йде вбік.' }, + { id: 't5', q: 'Лог будівлі Рейза: ваш бейдж, вхід о 22:47.', requires: ['guard', 'entry_log'], gives: 'elena_lie', a: 'Довга пауза. «…Гаразд. Я була в нього. Хотіла поговорити до ради. Ми посперечались, і я пішла. Він був живий, чуєте? Живий.» Голос зривається — вперше за розмову.' } + ]}, + kaito: { name: 'Кайто Морі', suspect: true, topics: [ + { id: 't6', q: 'Де ви були о 23:14?', a: '«Тут. Весь вечір, до пів на дванадцяту. Спитайте бармена — чи мій імплант, якщо вмієте читати логи.»' }, + { id: 't7', q: 'Рейз злив ваші генетичні дані.', a: 'Стискає склянку. «Продав мою ДНК як рекламну базу. Я його ненавидів. Але між ненавистю і сорока поверхами — велика різниця.»' }, + { id: 't8', q: 'Мережа підтверджує: ви були тут.', requires: ['kaito_wifi'], a: 'Видихає. «Нарешті хоч хтось перевірив. Слухайте… Рейз останній місяць скаржився на Елену. Казав: „вона рахує, що я сліпий". Не знаю, що він знайшов, але воно його зжирало.»' } + ]}, + sable: { name: 'Д-р Сейбл (відеозв\'язок)', suspect: true, topics: [ + { id: 't9', q: 'Де ви були цієї ночі?', a: '«На „Женеві-2". Виступ о 22:40, чотириста свідків і трансляція. Перевірте — заощадите нам обом час.»' }, + { id: 't10', q: 'Рейз украв ваші дослідження.', a: '«Рейз — падальник, і я не шкодуватиму за ним. Але вбивство — інструмент тих, у кого закінчились юристи. У мене вони не закінчились.»' } + ]}, + rook: { name: 'Рук', suspect: true, topics: [ + { id: 't11', q: 'Вас бачили біля будівлі Рейза.', a: '«Був поруч. Проїздом. Нічого не бачив, нікого не чіпав.» Дивиться повз тебе, витирає руки ганчіркою, яка чистіша за його відповіді.' }, + { id: 't12', q: 'Рейз був винен вам гроші.', a: '«Сорок штук. І що? Мертвий він мені не заплатить. Подумай головою, детективе: я кредитор, а не гробар.»' }, + { id: 't13', q: 'Маніфест: доставка о 21:30–21:45.', requires: ['rook_manifest'], a: '«Ну так, завозив серводеталі консьєржу. Поїхав о 21:45. Задоволений?» Пауза. «Одне скажу: асистентка його виходила пізніше — нервова, як дріт під струмом.»' } + ]}, + ivy: { name: 'Айві Чен', suspect: true, topics: [ + { id: 't14', q: 'Коли ви пішли від Рейза?', a: '«О 22:30, як завжди. Камера в лобі підтвердить.» Відповідає миттєво — наче репетирувала.' }, + { id: 't15', q: 'Яким він був останні тижні?', a: '«Напруженим. Замкнув частину файлів навіть від мене. Я… я думала, він щось підозрює.» Затинається на слові «підозрює».' }, + { id: 't16', q: 'Камера підтверджує. Але чому ви так нервуєте?', requires: ['ivy_cam'], a: 'Довго мовчить. «Я передавала документи Сейблу. За гроші. Але я не вбивала! Рейз готував на ранок якесь викриття — не про мене. Він сказав: „це її поховає". Її.»' } + ]}, + guard: { name: 'Нічний охоронець Halcyon', suspect: false, topics: [ + { id: 't17', q: 'Хто виходив з офісу ввечері?', gives: 'guard', a: '«Пані Восс вийшла о 22:15 — бейдж пікнув, я записав у журнал. Назад не бачив: після 22:30 я на обході, а нічні логи в них на обслуговуванні.»' } + ]}, + barman: { name: 'Бармен "The Hollow Note"', suspect: false, topics: [ + { id: 't18', q: 'Кайто Морі був тут уночі?', a: '«Весь вечір, до пів на дванадцяту точно. Сидів он там і пив так, наче хотів забути чиєсь ім\'я.»' } + ]} + }, + + finale: { + suspect: 'elena', method: 'push', maxEvidence: 4, + recap: 'Елена Восс роками виводила гроші Halcyon через тіньові рахунки. Рейз знайшов гросбух і призначив на 9:00 раду, яка б її поховала. О 22:15 вона вийшла з офісу — охоронець це бачив. О 22:47 увійшла в будівлю Рейза старим пропуском. О 23:10 інженерним override вимкнула силове поле балкона. О 23:14 — після короткої боротьби, що лишила клапоть її піджака на поручні, — Маркус Рейз упав з 40-го поверху. Справу закрито.' + } +}; + +// Перевірка звинувачення. Повертає {ok, hint}. +function checkAccusation(suspect, method, evs) { + const has = id => evs.includes(id); + if (suspect !== CASE.finale.suspect) + return { ok: false, hint: 'Не сходиться сам підозрюваний: його алібі на момент смерті підтверджується доказом, який у вас уже є або який можна знайти. Перевірте, кого докази виключають.' }; + if (method !== CASE.finale.method) + return { ok: false, hint: 'Підозрюваний правильний, але спосіб не відповідає слідам на місці смерті. Що саме відкрило вбивці шлях до жертви?' }; + if (!(has('override_log') || has('print_match'))) + return { ok: false, hint: 'Ланка «спосіб/доступ» не доведена: потрібен доказ, ЧИМ вбивця відкрив шлях до жертви.' }; + if (!(has('elena_lie') || (has('guard') && has('entry_log')))) + return { ok: false, hint: 'Ланка «алібі» не зламана: потрібно довести, що підозрюваний бреше про те, де був. Одного доказу тут мало — потрібна суперечність.' }; + if (!(has('fabric') || has('struggle'))) + return { ok: false, hint: 'Ланка «фізичний контакт» не доведена: що пов\'язує вбивцю з боротьбою на балконі?' }; + return { ok: true }; +} + +if (typeof module !== 'undefined') module.exports = { CASE, checkAccusation }; diff --git a/chapter1/index.html b/chapter1/index.html new file mode 100644 index 0000000..e13e55d --- /dev/null +++ b/chapter1/index.html @@ -0,0 +1,360 @@ + + + + + +Nightshade Protocol + + + +
+ + + + + diff --git a/chapter1/server.js b/chapter1/server.js new file mode 100644 index 0000000..127148c --- /dev/null +++ b/chapter1/server.js @@ -0,0 +1,87 @@ +// Zero-dependency sync server: static files + SSE broadcast of session state. +// ponytail: sessions live in memory — рестарт сервера скидає гру. Досить для вечора. +const http = require('http'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +const PORT = process.env.PORT || 3000; +const sessions = Object.create(null); + +function getSession(code) { + return sessions[code] ??= { + state: { found: {}, pinned: [], asked: {}, tries: 0, lastAccusation: null, solved: false, players: {} }, + clients: new Set() + }; +} + +function broadcast(s) { + const msg = `data: ${JSON.stringify(s.state)}\n\n`; + for (const res of s.clients) res.write(msg); +} + +function apply(st, a) { + switch (a.t) { + case 'join': st.players[a.role] = (st.players[a.role] || 0) + 1; break; + case 'find': st.found[a.id] = a.by || true; break; + case 'pin': if (!st.pinned.some(p => p.id === a.id)) st.pinned.push({ id: a.id, s: a.s, by: a.by }); break; + case 'unpin': st.pinned = st.pinned.filter(p => p.id !== a.id); break; + case 'repin': { const p = st.pinned.find(p => p.id === a.id); if (p) p.s = a.s; } break; + case 'ask': { const arr = st.asked[a.s] ??= []; if (!arr.includes(a.q)) arr.push(a.q); } break; + case 'accuse': st.tries++; st.lastAccusation = { suspect: a.suspect, method: a.method, evidence: a.evidence, result: a.result, n: st.tries }; + if (a.result.ok) st.solved = true; break; + } +} + +const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8' }; + +const server = http.createServer((req, res) => { + const u = new URL(req.url, 'http://x'); + + if (u.pathname === '/events') { + const s = getSession(u.searchParams.get('s') || 'DEFAULT'); + const role = u.searchParams.get('role'); + res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive' }); + s.clients.add(res); + const online = s.state.online ??= {}; + if (role) { online[role] = (online[role] || 0) + 1; } + broadcast(s); + req.on('close', () => { + s.clients.delete(res); + if (role) { online[role] = Math.max(0, (online[role] || 1) - 1); broadcast(s); } + }); + return; + } + + if (u.pathname === '/act' && req.method === 'POST') { + let body = ''; + req.on('data', c => { body += c; if (body.length > 1e6) req.destroy(); }); + req.on('end', () => { + try { + const a = JSON.parse(body); + const s = getSession(a.session || 'DEFAULT'); + apply(s.state, a); + broadcast(s); + res.writeHead(200).end('ok'); + } catch { res.writeHead(400).end('bad request'); } + }); + return; + } + + const file = { '/': 'index.html', '/index.html': 'index.html', '/case.js': 'case.js' }[u.pathname]; + if (!file) return void res.writeHead(404).end('not found'); + fs.readFile(path.join(__dirname, file), (err, data) => { + if (err) return void res.writeHead(500).end('error'); + res.writeHead(200, { 'Content-Type': MIME[path.extname(file)] }).end(data); + }); +}); + +server.listen(PORT, () => { + console.log(`Nightshade Protocol — сервер запущено.\n`); + console.log(`На цьому пристрої: http://localhost:${PORT}`); + for (const ifs of Object.values(os.networkInterfaces())) + for (const i of ifs) + if (i.family === 'IPv4' && !i.internal) + console.log(`У локальній мережі: http://${i.address}:${PORT}`); + console.log(`\nОбидва гравці відкривають адресу мережі, вводять однаковий код сесії і обирають різні ролі.`); +}); diff --git a/chapter1/test.js b/chapter1/test.js new file mode 100644 index 0000000..00d5ee3 --- /dev/null +++ b/chapter1/test.js @@ -0,0 +1,54 @@ +// Fair-play перевірка кейса: node test.js +const assert = require('assert'); +const { CASE, checkAccusation } = require('./case.js'); + +// 1. Кожен доказ досяжний рівно одним джерелом (spot / query / topic.gives) +const sources = {}; +for (const l of CASE.locations) for (const sp of l.spots) if (sp.ev) (sources[sp.ev] ??= []).push('spot:' + sp.id); +for (const s of CASE.systems) for (const q of s.queries) if (q.ev) (sources[q.ev] ??= []).push('query:' + q.id); +for (const p of Object.values(CASE.people)) for (const t of p.topics) if (t.gives) (sources[t.gives] ??= []).push('topic:' + t.id); +for (const id of Object.keys(CASE.evidence)) + assert(sources[id]?.length === 1, `evidence "${id}" має ${sources[id]?.length || 0} джерел`); +for (const id of Object.keys(sources)) + assert(CASE.evidence[id], `джерело посилається на неіснуючий доказ "${id}"`); + +// 2. Роль джерела збігається з роллю доказу +for (const l of CASE.locations) for (const sp of l.spots) if (sp.ev) assert(CASE.evidence[sp.ev].role === 'field'); +for (const s of CASE.systems) for (const q of s.queries) if (q.ev) assert(CASE.evidence[q.ev].role === 'analyst'); + +// 3. Усі requires існують і досяжні без циклів (симуляція повного проходження) +const allReq = []; +for (const ev of Object.values(CASE.evidence)) if (ev.requires) allReq.push(...ev.requires); +for (const p of Object.values(CASE.people)) for (const t of p.topics) if (t.requires) allReq.push(...t.requires); +for (const r of allReq) assert(CASE.evidence[r], `requires посилається на неіснуючий доказ "${r}"`); + +const pinned = new Set(); +let progress = true; +while (progress) { + progress = false; + const ok = req => !req || req.every(r => pinned.has(r)); + for (const l of CASE.locations) for (const sp of l.spots) + if (sp.ev && !pinned.has(sp.ev)) { pinned.add(sp.ev); progress = true; } + for (const s of CASE.systems) for (const q of s.queries) + if (q.ev && !pinned.has(q.ev) && ok(q.requires ?? CASE.evidence[q.ev].requires)) { pinned.add(q.ev); progress = true; } + for (const p of Object.values(CASE.people)) for (const t of p.topics) + if (t.gives && !pinned.has(t.gives) && ok(t.requires)) { pinned.add(t.gives); progress = true; } +} +for (const id of Object.keys(CASE.evidence)) assert(pinned.has(id), `доказ "${id}" недосяжний (deadlock у requires)`); + +// 4. Звинувачення: правильна комбінація виграє +assert(checkAccusation('elena', 'push', ['override_log', 'guard', 'entry_log', 'fabric']).ok); +assert(checkAccusation('elena', 'push', ['print_match', 'elena_lie', 'struggle']).ok); +// 5. Кожна зламана ланка дає осмислену підказку, а не перемогу +assert(!checkAccusation('rook', 'push', ['override_log', 'guard', 'entry_log', 'fabric']).ok); +assert(!checkAccusation('elena', 'poison', ['override_log', 'guard', 'entry_log', 'fabric']).ok); +assert(!checkAccusation('elena', 'push', ['guard', 'entry_log', 'fabric']).ok); // без способу +assert(!checkAccusation('elena', 'push', ['override_log', 'guard', 'fabric']).ok); // суперечність неповна +assert(!checkAccusation('elena', 'push', ['override_log', 'guard', 'entry_log']).ok); // без контакту +for (const bad of [['rook','push'],['elena','poison']].map(([s,m]) => checkAccusation(s, m, []))) + assert(bad.hint && bad.hint.length > 10); + +// 6. Ключове протиріччя вимагає обох ролей +assert(CASE.evidence.guard.role === 'field' && CASE.evidence.entry_log.role === 'analyst'); + +console.log('OK — кейс розв\'язний, fair-play дотримано.'); diff --git a/chapter2/README.md b/chapter2/README.md new file mode 100644 index 0000000..6efeece --- /dev/null +++ b/chapter2/README.md @@ -0,0 +1,31 @@ +# Тихий аукціон — детектив на двох, розділ 2 + +Сучасний нуар у два акти. Кооператив для двох гравців в одній локальній мережі. Без залежностей, потрібен лише Node.js. + +## Запуск + +``` +node server.js +``` + +Порт за замовчуванням — **3001** (щоб міг працювати одночасно з розділом 1 на 3000). Сервер надрукує адресу в мережі. + +1. Обидва гравці відкривають адресу в браузері (той самий Wi-Fi). +2. Вводять однаковий код сесії. +3. Один — **Польовий детектив**, інший — **Аналітик**. + +## Що нового проти розділу 1 + +- **Два акти.** Акт I: доведіть, що «серцевий напад» — вбивство. Це відкриває ордери — Акт II: нові локації, запити та питання. +- **Міні-ігри на дії.** Обшук у польового — зафіксуй сканер у зоні сигналу; запит в аналітика — запам'ятай і повтори код доступу. В Акті II складніше; після 3 промахів гра м'якшає. +- **Викреслення підозрюваних** — синхронізовано для обох. +- **Спільні нотатки** з клавіатури — синхронізовано. +- **Зірки ★ на доказах** — позначайте ключове, видно обом. + +## Перевірка + +``` +node test.js +``` + +Fair-play кейса, розв'язність по актах (докази Акту II недосяжні в Акті I), логіка обох міні-ігор, перевірка звинувачення. diff --git a/chapter2/case2.js b/chapter2/case2.js new file mode 100644 index 0000000..5cf760e --- /dev/null +++ b/chapter2/case2.js @@ -0,0 +1,175 @@ +// КЕЙС №2 — "Тихий аукціон". Сучасний нуар, два акти. Все детерміновано, без AI. +const CASE = { + title: 'Тихий аукціон', + intro: 'Місто. Листопад. Третя доба дощу. Віктор Гайдук, 58, власник аукціонного дому «Гайдук і Спадок», знайдений мертвим у кріслі свого кабінету над аукціонною залою — напередодні найбільших торгів року. Лікар швидкої написав «серцевий напад». Страхова компанія вимагає незалежної перевірки. У вас одна ніч і жодного ордера.', + actNote: 'АКТ I: доведіть, що це вбивство — тоді прокурор підпише ордери. АКТ II: доведіть, хто вбивця.', + act2title: 'АКТ II — ОРДЕРИ ПІДПИСАНО', + act2text: 'Токсикологія підтвердила: це вбивство. Прокурор підписав ордери — відкрито доступ до банків, реєстрів і приватних приміщень. Тепер знайдіть того, хто налив отруту.', + roles: { + field: { name: 'Польовий детектив', desc: 'Ти працюєш ногами: місце злочину, обшуки, допити віч-на-віч. Обшук вимагає твердої руки — зафіксуй сканер у зоні сигналу. Цифрові системи для тебе закриті.' }, + analyst: { name: 'Детектив-аналітик', desc: 'Ти працюєш з терміналу: камери, дзвінки, банки, біометрія. Кожен запит захищений — перехопи й повтори код доступу. На місце злочину тебе не пускають.' } + }, + howto: 'Знайдені докази закріплюйте на спільній дошці (📌) — лише закріплене бачить напарник, і лише воно розблоковує нові питання й запити. Відкидайте підозрюваних, зірочте ключові докази, лишайте нотатки — все синхронізується. Говоріть уголос.', + + suspects: [ + { id: 'stryzh', name: 'Богдан Стриж', role: 'Партнер, головний аукціоніст', desc: 'Друга особа дому «Гайдук і Спадок». Бігає марафони, курить дорогі сигарили — «одна на день, для нервів».' }, + { id: 'iryna', name: 'Ірина Гайдук', role: 'Дружина', desc: 'Молодша за чоловіка на двадцять років. Шлюбний контракт, велика страховка — місто вже все порахувало.' }, + { id: 'dana', name: 'Дана Гайдук', role: 'Дочка від першого шлюбу', desc: 'Не розмовляла з батьком два роки. Борги, зйомна квартира, гострий язик.' }, + { id: 'mazur', name: 'Лесь Мазур', role: 'Реставратор', desc: 'П\'ять років тому Гайдук публічно знищив його репутацію. Тепер веде стріми з реставрації — на життя вистачає.' }, + { id: 'pliuta', name: 'Артем Плюта', role: 'Колекціонер', desc: 'Купив у Гайдука «Екстер» за триста тисяч. Експертиза сказала: підробка. Судиться.' } + ], + + methods: [ + { id: 'digoxin', label: 'Передозування дигоксином, розчиненим у коньяку в графині' }, + { id: 'placebo', label: 'Підміна серцевих ліків на плацебо' }, + { id: 'smother', label: 'Задушення з інсценуванням серцевого нападу' }, + { id: 'insulin', label: 'Ін\'єкція інсуліну' }, + { id: 'natural', label: 'Природна смерть — серцевий напад' } + ], + + evidence: { + // ---- АКТ I, фізичні ---- + decanter: { role: 'field', title: 'Осад у графині', tag: 'засіб', detail: 'На дні графина з коньяком — білуватий кристалічний осад. Коньяк такого не лишає. Графин стоїть на звичному місці, біля крісла.' }, + wiped_glass: { role: 'field', title: 'Вимитий келих', tag: 'інсценування', detail: 'Єдиний келих — ретельно вимитий і поставлений догори дном біля мийки. Мертві не миють посуд. Хтось прибирав за ним.' }, + diary: { role: 'field', title: 'Щоденник Гайдука', tag: 'мотив', detail: 'Останній запис, зроблений вчора: «Скасувати торги. Дзвонити К. Нікому в домі не вірити.» Слово «домі» підкреслено двічі.' }, + pills_missing:{ role: 'field', title: 'Зниклі ліки', tag: 'засіб', detail: 'В аптечці — свіжий рецепт на дигоксин (серцевий препарат), виписаний тиждень тому. Самої пляшечки ніде немає. Ні в кабінеті, ні в смітті.' }, + cigarillo: { role: 'field', title: 'Недопалок сигарили', tag: 'доступ', detail: 'Свіжий недопалок дорогої сигарили під дверима службового входу. Двері — на защіпці, камери там немає. Потрібен аналіз ДНК — закріпи на дошці для аналітика.' }, + guard2: { role: 'field', title: 'Свідчення охоронця', tag: 'алібі', detail: 'Охоронець Семен: «Пан Стриж вийшов о 21:03 головним входом, я записав. Службовий вхід на защіпці, камери там нема — економія. На паркінгу камери два тижні як мертві, тільки шлагбаум лог пише».' }, + cleaner_st: { role: 'field', title: 'Свідчення прибиральниці', tag: 'таймлайн', detail: 'Пані Ольга: «Графин я мила о сьомій вечора, як завжди. Пан Віктор наливав перший келих о пів на десяту — ритуал, свята справа. Келих я не мила і догори дном не ставила».' }, + // ---- АКТ II, фізичні ---- + coatcheck: { role: 'field', title: 'Свідчення гардеробниці', tag: 'алібі', act: 2, detail: 'Пані Зоя з фонду «Меценат»: «Пан Стриж? Здав пальто десь після пів на одинадцяту — точно після початку дощу, мокрий був наскрізь. А дощ того вечора почався о десятій». Людська пам\'ять — не документ, але вона впевнена щодо дощу.' }, + blister: { role: 'field', title: 'Блістер у смітті Стрижа', tag: 'засіб', act: 2, detail: 'На дні сміттєвого пакета в офісі Стрижа на складі — порожній блістер дигоксину. У марафонця Стрижа здорове серце. Партію можна простежити — закріпи для аналітика.' }, + fake_canvases:{ role: 'field', title: 'Підробки на складі', tag: 'мотив', act: 2, detail: 'Склад №3: шість полотен «з провенансом» — і поруч шість свіжих копій, ще зі скипидарним запахом. Це лоти 12–18 завтрашніх торгів. Хтось продає фальшаки через дім Гайдука.' }, + dana_confess:{ role: 'field', title: 'Зізнання Дани', tag: 'зняття підозри', detail: 'Дана визнала: заходила о 20:30 забрати картину Марчука з малої зали — «батько мені її обіцяв». Вийшла о 21:12, до кабінету не піднімалась. Камера це підтверджує.' }, + stryzh_lie: { role: 'field', title: 'Злам алібі Стрижа', tag: 'алібі', act: 2, detail: 'Під тиском фактів Стриж визнав: повертався в дім службовим входом «за каталогами», виїхав з паркінгу о 22:14. Стверджує, що Гайдук був живий і «пив свій коньяк».' }, + // ---- АКТ I, цифрові ---- + watch_log: { role: 'analyst', title: 'Лог смарт-годинника', tag: 'спосіб смерті', detail: 'Годинник жертви: 21:52 — аритмія за патерном глікозидної інтоксикації (передозування серцевим препаратом). 22:03 — зупинка серця. Це не «просто напад» — це отруєння.' }, + lobby_cam: { role: 'analyst', title: 'Камера головного входу', tag: 'таймлайн', detail: 'Стриж: вихід 21:03. Дана Гайдук: вхід 20:30, вихід 21:12 — на поверх кабінету не піднімалась, була лише в малій залі. Після 21:12 головним входом ніхто не заходив і не виходив до ранку.' }, + call_log: { role: 'analyst', title: 'Відеодзвінок Ірини', tag: 'зняття підозри', detail: 'Ірина Гайдук: відеодзвінок сестрі в Торонто 21:28–22:36 з домашнього wifi, камера активна, обличчя в кадрі підтверджено. На момент отруєння була вдома.' }, + stream_log: { role: 'analyst', title: 'Стрім Мазура', tag: 'зняття підозри', detail: 'Лесь Мазур: безперервна пряма трансляція реставрації 20:00–23:05, п\'ятсот глядачів, обличчя й руки в кадрі весь час. Залізне алібі.' }, + expert_email:{ role: 'analyst', title: 'Лист експертці', tag: 'мотив', detail: 'Гайдук — експертці Катерині Резник, учора 18:44: «Катерино, перевірте лоти 12–18 до торгів. Особисто. Нікому ні слова».' }, + geo_toll: { role: 'analyst', title: 'Геолокація Плюти', tag: 'зняття підозри', detail: 'Плюта: транзакції на платних дорогах і АЗС — о 21:50 був за 170 км від міста, повертався з Одеси. Фізично не міг бути в кабінеті.' }, + // ---- АКТ II, цифрові ---- + parking: { role: 'analyst', title: 'Лог шлагбаума паркінгу', tag: 'алібі', act: 2, detail: 'Паркінг аукціонного дому: авто Стрижа — виїзд о 22:14. Він каже, що «поїхав о 21:00 і о 21:30 був на вечері фонду». Авто каже інше.' }, + tox_report: { role: 'analyst', title: 'Токсикологія (ордер)', tag: 'спосіб смерті', act: 2, detail: 'Офіційний висновок: дигоксин, семикратна терапевтична доза. У крові жертви — і в залишках коньяку з графина. Отруту розчинили саме там.' }, + dna_match: { role: 'analyst', title: 'ДНК з недопалка', tag: 'доступ', act: 2, requires: ['cigarillo'], detail: 'ДНК зі слини на недопалку сигарили біля службового входу: збіг — Богдан Стриж. Він стояв під дверима без камер того вечора.' }, + bank_shell: { role: 'analyst', title: 'Рахунки-прокладки', tag: 'мотив', act: 2, detail: 'Ордер на банки: ТОВ «Артконсалт-Гарант» отримало 2,3 млн за «атрибуцію» лотів 12–18. Кінцевий бенефіціар прокладки — Богдан Стриж. Гайдук збирався скасувати торги і поховати схему.' }, + pharma_log: { role: 'analyst', title: 'Слід партії дигоксину', tag: 'засіб', act: 2, requires: ['blister'], detail: 'Партія з блістера: онлайн-аптека, замовлення на неіснуючу «О. Кравець», доставка в поштомат за квартал від дому Стрижа, оплата карткою, поповненою готівкою. Ланцюжок веде до нього.' } + }, + + // Умова переходу в Акт II: обидва докази вбивства на дошці. + actGate: ['decanter', 'watch_log'], + + locations: [ + { id: 'office', name: 'Кабінет Гайдука', desc: 'Кабінет над аукціонною залою. Крісло, графин, дощ по склу. Тіло вже забрали.', + spots: [ + { id: 's1', label: 'Оглянути графин', ev: 'decanter' }, + { id: 's2', label: 'Оглянути мийку та келих', ev: 'wiped_glass' }, + { id: 's3', label: 'Обшукати стіл', ev: 'diary' }, + { id: 's4', label: 'Оглянути аптечку', ev: 'pills_missing' }, + { id: 's5', label: 'Службові сходи', ev: 'cigarillo' }, + { id: 's6', label: 'Оглянути крісло', flavor: 'Жодних слідів боротьби. Він просто сів у крісло з келихом — і не встав. Хтось дуже хотів, щоб це виглядало саме так.' } + ], people: ['olga'] }, + { id: 'hall', name: 'Аукціонна зала', desc: 'Порожня зала, накриті лоти, завтра тут мали бути торги року.', + spots: [], people: ['stryzh', 'dana', 'semen'] }, + { id: 'house', name: 'Дім Гайдуків', desc: 'Особняк за містом. Тихо, дорого, самотньо.', + spots: [], people: ['iryna'] }, + { id: 'studio', name: 'Студія Мазура', desc: 'Підвал, лампи, запах лаку. У кутку — кільцева лампа для стрімів.', + spots: [], people: ['mazur'] }, + { id: 'hotel', name: 'Готель «Централь»', desc: 'Плюта зупинився тут «до кінця судового процесу».', + spots: [], people: ['pliuta'] }, + { id: 'fund', name: 'Фонд «Меценат»', desc: 'Зала благодійної вечері, де Стриж «був з 21:30». Гардероб працює до останнього гостя.', act: 2, + spots: [ { id: 's7', label: 'Розпитати розпорядника', flavor: '«Пан Стриж? Був, звісно. Коли саме прийшов? У нас не клуб, детективе, ми квитки не штампуємо. Спитайте в гардеробі — Зоя все пам\'ятає».' } ], + people: ['zoya'] }, + { id: 'depot', name: 'Склад «Гайдук і Спадок» №3', desc: 'Ордер відкрив двері, які Стриж тримав замкненими. Його офіс — у кутку складу.', act: 2, + spots: [ + { id: 's8', label: 'Оглянути ящики з лотами', ev: 'fake_canvases' }, + { id: 's9', label: 'Обшукати сміття в офісі Стрижа', ev: 'blister' }, + { id: 's10', label: 'Сейф Стрижа', flavor: 'Замкнено. Злам сейфа ордер не покриває — та й те, що лежить у смітті, часом говорить голосніше.' } + ], people: [] } + ], + + systems: [ + { id: 'video', name: 'Камери й трансляції', queries: [ + { id: 'q1', label: 'Камера головного входу аукціонного дому', ev: 'lobby_cam' }, + { id: 'q2', label: 'Стрім-платформа: канал Мазура', ev: 'stream_log' }, + { id: 'q3', label: 'Камери паркінгу', flavor: 'Камери паркінгу не працюють два тижні — «на технічному обслуговуванні». Пише лише лог шлагбаума, а він видається тільки з ордером.' }, + { id: 'q4', label: 'Лог шлагбаума паркінгу (ордер)', ev: 'parking', act: 2 } + ]}, + { id: 'comms', name: 'Зв\'язок', queries: [ + { id: 'q5', label: 'Дзвінки та відеозв\'язок Ірини Гайдук', ev: 'call_log' }, + { id: 'q6', label: 'Пошта жертви', ev: 'expert_email' } + ]}, + { id: 'bio', name: 'Біометрія і криміналістика', queries: [ + { id: 'q7', label: 'Смарт-годинник жертви', ev: 'watch_log' }, + { id: 'q8', label: 'Повна токсикологія (ордер)', ev: 'tox_report', act: 2 }, + { id: 'q9', label: 'Аналіз ДНК недопалка', ev: 'dna_match', act: 2 } + ]}, + { id: 'money', name: 'Фінанси і реєстри', queries: [ + { id: 'q10', label: 'Транзакції Плюти: дороги та АЗС', ev: 'geo_toll' }, + { id: 'q11', label: 'Заповіт і страховка', flavor: 'Нотаріус і страхова вимагають ордер. Ордер буде, коли доведете, що це вбивство: потрібні докази отруєння на дошці.' }, + { id: 'q12', label: 'Банківські рахунки, пов\'язані з домом (ордер)', ev: 'bank_shell', act: 2 }, + { id: 'q13', label: 'Простежити партію препарату', ev: 'pharma_log', act: 2 } + ]} + ], + + people: { + stryzh: { name: 'Богдан Стриж', suspect: true, topics: [ + { id: 't1', q: 'Де ви були вчора ввечері?', a: '«Пішов о 21:00 — камера підтвердить. О 21:30 вже був на вечері фонду „Меценат". Сотня свідків, детективе.» Відповідає гладко, як з каталогу.' }, + { id: 't2', q: 'Що тепер буде з торгами?', a: '«Найбільші торги року. Віктор був знервований останній тиждень... Тепер усе на мені.» Скорботи в голосі — рівно нуль.' }, + { id: 't3', q: 'У щоденнику: «Скасувати торги».', requires: ['diary'], a: '«Скасувати?! Вперше чую. У нас зобов\'язання перед клієнтами.» Різкий, як удар молотка. Занадто різкий.' }, + { id: 't4', q: 'Гайдук просив експертку перевірити лоти 12–18.', requires: ['expert_email'], a: '«Рутинна перевірка провенансу. Ці лоти, до речі, готував я.» Поправляє годинник. Пальці не слухаються.' }, + { id: 't5', q: 'Авто виїхало о 22:14. Пальто ви здали о 22:40.', requires: ['coatcheck', 'parking'], gives: 'stryzh_lie', a: 'Довга пауза. «...Гаразд. Я повертався за каталогами. Службовим входом, щоб охорона не бачила — не хотів розмов. Віктор був живий, сидів з коньяком. Це все, що я скажу без адвоката.»' } + ]}, + iryna: { name: 'Ірина Гайдук', suspect: true, topics: [ + { id: 't6', q: 'Де ви були вчора ввечері?', a: '«Вдома. Дзвонила сестрі в Торонто, ми говорили години зо дві.» Дивиться у вікно, на дощ.' }, + { id: 't7', q: 'Ваш шлюб. Страховка. Контракт.', a: '«Місто вже все порахувало, так? Ми десять років разом. Він був складний. Але мій.» Вперше за розмову — живий голос.' }, + { id: 't8', q: 'Дзвінок підтверджено. Що ви не договорюєте?', requires: ['call_log'], a: 'Видихає. «Останній тиждень Віктор замикався з паперами. Одного вечора сказав: „Богдан думає, що я сліпий". Я тоді не зрозуміла. Тепер, здається, розумію.»' } + ]}, + dana: { name: 'Дана Гайдук', suspect: true, topics: [ + { id: 't9', q: 'Що ви робите в залі?', a: '«Забираю речі батька. Маю право.» Тримає порожню теку так, наче там щось є.' }, + { id: 't10', q: 'Ви не розмовляли два роки. Чому?', a: '«Він обрав дім. Аукціонний. Я не вписалась у каталог.» Сухо. Але руки тремтять.' }, + { id: 't11', q: 'Камера: ви зайшли о 20:30 і вийшли о 21:12.', requires: ['lobby_cam'], gives: 'dana_confess', a: 'Мовчить. Потім: «Я взяла картину. Марчука, з малої зали. Батько обіцяв її мені ще до всього. Нагору я не піднімалась. Не змогла. Тепер уже й не зможу.»' } + ]}, + mazur: { name: 'Лесь Мазур', suspect: true, topics: [ + { id: 't12', q: 'Де ви були вчора ввечері?', a: '«Стрім. Три години, п\'ятсот глядачів, реставрація наживо. Таке не змонтуєш, детективе — перевірте платформу.»' }, + { id: 't13', q: 'Гайдук знищив вашу репутацію.', a: '«Назвав мою атрибуцію шахрайством. Публічно. І знаєте що? Він мав рацію — я тоді помилився. Його прокляттям було бачити підробки скрізь. Схоже, одного разу він подивився надто близько до дому.»' } + ]}, + pliuta: { name: 'Артем Плюта', suspect: true, topics: [ + { id: 't14', q: 'Де ви були вчора ввечері?', a: '«Повертався з Одеси. Траса, чеки, платні дороги — усе в банківській виписці. Перевіряйте, я нікуди не поспішаю.»' }, + { id: 't15', q: 'Гайдук продав вам підробку.', a: '«„Екстер" за триста тисяч, який малювали позавчора. Я подав до суду, детективе, а не в морг. Мертвий Гайдук мені суд не виграє.»' } + ]}, + semen: { name: 'Охоронець Семен', suspect: false, topics: [ + { id: 't16', q: 'Хто заходив і виходив увечері?', gives: 'guard2', a: '«Пан Стриж вийшов о 21:03 головним, я записав. Дочка господаря ще була... Службовий вхід? Він на защіпці постійно, камери там нема — економлять. На паркінгу камери два тижні мертві, тільки шлагбаум лог пише.»' } + ]}, + olga: { name: 'Прибиральниця пані Ольга', suspect: false, topics: [ + { id: 't17', q: 'Розкажіть про вчорашній вечір.', gives: 'cleaner_st', a: '«Графин я мила о сьомій, як завжди. Пан Віктор наливав перший келих о пів на десяту — ритуал, свята справа, хоч годинник звіряй. Келих біля мийки? Я його не мила і догори дном не ставила. Не мій почерк.»' } + ]}, + zoya: { name: 'Гардеробниця пані Зоя', suspect: false, topics: [ + { id: 't18', q: 'Коли Стриж здав пальто?', gives: 'coatcheck', a: '«Десь після пів на одинадцяту. Точно після початку дощу — мокрий був наскрізь, аж з рукавів текло. А дощ учора почався рівно о десятій, я ще парасолю з дому не взяла.»' } + ]} + }, + + finale: { + suspect: 'stryzh', method: 'digoxin', maxEvidence: 4, + recap: 'Богдан Стриж роками продавав підробки через дім «Гайдук і Спадок», отримуючи мільйони через прокладку «Артконсалт-Гарант». Гайдук помітив, замовив таємну експертизу лотів 12–18 і зібрався скасувати торги. О 21:03 Стриж демонстративно вийшов головним входом — для камери. Повернувся службовими сходами, де немає камер, викуривши сигарилу під дверима. Розчинив дигоксин у графині, знаючи ритуал партнера: перший келих о пів на десяту. Вимив свій келих, забрав пляшечку ліків — хай виглядає як хворе серце. О 22:14 виїхав з паркінгу, о 22:40, мокрий від дощу, здав пальто на вечері, де «був з 21:30». Годинник на зап\'ясті жертви виявився чеснішим за некролог. Справу закрито.' + } +}; + +// Перевірка звинувачення. Повертає {ok, hint}. +function checkAccusation(suspect, method, evs) { + const has = id => evs.includes(id); + if (suspect !== CASE.finale.suspect) + return { ok: false, hint: 'Не сходиться сам підозрюваний: його місцеперебування на вечір смерті підтверджене доказами, які у вас є або які можна знайти. Кого докази виключають — той не вбивця.' }; + if (method !== CASE.finale.method) + return { ok: false, hint: 'Підозрюваний правильний, але спосіб суперечить біометрії та токсикології. ЩО саме вбило жертву і ДЕ це було розчинено?' }; + if (!(has('blister') || has('pharma_log'))) + return { ok: false, hint: 'Ланка «засіб» не доведена: потрібен доказ, що пов\'язує вбивцю з отрутою, а не лише отруту з жертвою.' }; + if (!(has('stryzh_lie') || (has('coatcheck') && has('parking')))) + return { ok: false, hint: 'Ланка «алібі» не зламана: потрібна суперечність із двох незалежних джерел — або зізнання. Один доказ тут завжди можна відбрехати.' }; + if (!has('dna_match')) + return { ok: false, hint: 'Ланка «доступ» не доведена: як вбивця потрапив у кабінет повз камери? Потрібен доказ його присутності на шляху без камер.' }; + return { ok: true }; +} + +if (typeof module !== 'undefined') module.exports = { CASE, checkAccusation }; diff --git a/chapter2/index.html b/chapter2/index.html new file mode 100644 index 0000000..6529002 --- /dev/null +++ b/chapter2/index.html @@ -0,0 +1,532 @@ + + + + + +Тихий аукціон + + + +
+
+ + + + + + diff --git a/chapter2/minigames.js b/chapter2/minigames.js new file mode 100644 index 0000000..bd862ad --- /dev/null +++ b/chapter2/minigames.js @@ -0,0 +1,36 @@ +// Логіка міні-ігор — чисті функції, без DOM, тестуються в node. +const MG_SYMBOLS = ['▲', '●', '■', '◆', '✚', '★']; + +// Детермінований ГПВЧ (mulberry32) +function mulberry32(seed) { + return function () { + seed |= 0; seed = seed + 0x6D2B79F5 | 0; + let t = Math.imul(seed ^ seed >>> 15, 1 | seed); + t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; + return ((t ^ t >>> 14) >>> 0) / 4294967296; + }; +} + +// Аналітик: згенерувати код доступу довжиною len із MG_SYMBOLS +function seqGen(seed, len) { + const r = mulberry32(seed); + return Array.from({ length: len }, () => Math.floor(r() * MG_SYMBOLS.length)); +} + +// Введене — коректний префікс коду? +function seqPrefixOk(seq, input) { + return input.every((v, i) => v === seq[i]); +} + +// Польовий: позиція маркера 0..100 (трикутна хвиля, період period мс) +function triPos(t, period) { + const ph = ((t % period) + period) % period / period; + return (ph < 0.5 ? ph * 2 : 2 - ph * 2) * 100; +} + +// Маркер у зоні [start, start+width]? +function inZone(pos, start, width) { + return pos >= start && pos <= start + width; +} + +if (typeof module !== 'undefined') module.exports = { MG_SYMBOLS, mulberry32, seqGen, seqPrefixOk, triPos, inZone }; diff --git a/chapter2/server.js b/chapter2/server.js new file mode 100644 index 0000000..4da4f43 --- /dev/null +++ b/chapter2/server.js @@ -0,0 +1,91 @@ +// Zero-dependency sync server (розділ 2): static + SSE broadcast стану сесії. +// ponytail: сесії в пам'яті — рестарт сервера скидає гру. Досить для вечора. +const http = require('http'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +const PORT = process.env.PORT || 3001; +const sessions = Object.create(null); + +function getSession(code) { + return sessions[code] ??= { + state: { found: {}, pinned: [], asked: {}, notes: [], crossed: {}, stars: {}, act: 1, + tries: 0, lastAccusation: null, solved: false, online: {} }, + clients: new Set() + }; +} + +function broadcast(s) { + const msg = `data: ${JSON.stringify(s.state)}\n\n`; + for (const res of s.clients) res.write(msg); +} + +function apply(st, a) { + switch (a.t) { + case 'find': st.found[a.id] = a.by || true; break; + case 'pin': if (!st.pinned.some(p => p.id === a.id)) st.pinned.push({ id: a.id, s: a.s, by: a.by }); break; + case 'unpin': st.pinned = st.pinned.filter(p => p.id !== a.id); break; + case 'repin': { const p = st.pinned.find(p => p.id === a.id); if (p) p.s = a.s; } break; + case 'ask': { const arr = st.asked[a.s] ??= []; if (!arr.includes(a.q)) arr.push(a.q); } break; + case 'note': if (typeof a.txt === 'string' && a.txt.trim()) st.notes.push({ id: Date.now() + Math.random(), txt: a.txt.slice(0, 500), by: a.by }); break; + case 'note_del':st.notes = st.notes.filter(n => n.id !== a.id); break; + case 'cross': st.crossed[a.s] = !st.crossed[a.s]; break; + case 'star': st.stars[a.id] = !st.stars[a.id]; break; + case 'advance': st.act = Math.max(st.act, a.act | 0); break; + case 'accuse': st.tries++; st.lastAccusation = { suspect: a.suspect, method: a.method, evidence: a.evidence, result: a.result, n: st.tries }; + if (a.result.ok) st.solved = true; break; + } +} + +const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8' }; + +const server = http.createServer((req, res) => { + const u = new URL(req.url, 'http://x'); + + if (u.pathname === '/events') { + const s = getSession(u.searchParams.get('s') || 'DEFAULT'); + const role = u.searchParams.get('role'); + res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive' }); + s.clients.add(res); + if (role) s.state.online[role] = (s.state.online[role] || 0) + 1; + broadcast(s); + req.on('close', () => { + s.clients.delete(res); + if (role) { s.state.online[role] = Math.max(0, (s.state.online[role] || 1) - 1); broadcast(s); } + }); + return; + } + + if (u.pathname === '/act' && req.method === 'POST') { + let body = ''; + req.on('data', c => { body += c; if (body.length > 1e6) req.destroy(); }); + req.on('end', () => { + try { + const a = JSON.parse(body); + const s = getSession(a.session || 'DEFAULT'); + apply(s.state, a); + broadcast(s); + res.writeHead(200).end('ok'); + } catch { res.writeHead(400).end('bad request'); } + }); + return; + } + + const file = { '/': 'index.html', '/index.html': 'index.html', '/case2.js': 'case2.js', '/minigames.js': 'minigames.js' }[u.pathname]; + if (!file) return void res.writeHead(404).end('not found'); + fs.readFile(path.join(__dirname, file), (err, data) => { + if (err) return void res.writeHead(500).end('error'); + res.writeHead(200, { 'Content-Type': MIME[path.extname(file)] }).end(data); + }); +}); + +server.listen(PORT, () => { + console.log(`Тихий аукціон (розділ 2) — сервер запущено.\n`); + console.log(`На цьому пристрої: http://localhost:${PORT}`); + for (const ifs of Object.values(os.networkInterfaces())) + for (const i of ifs) + if (i.family === 'IPv4' && !i.internal) + console.log(`У локальній мережі: http://${i.address}:${PORT}`); + console.log(`\nОбидва гравці відкривають адресу мережі, вводять однаковий код сесії і обирають різні ролі.`); +}); diff --git a/chapter2/test.js b/chapter2/test.js new file mode 100644 index 0000000..f922076 --- /dev/null +++ b/chapter2/test.js @@ -0,0 +1,107 @@ +// Перевірки розділу 2: fair-play кейса, логіка актів, логіка міні-ігор. node test.js +const assert = require('assert'); +const { CASE, checkAccusation } = require('./case2.js'); +const { MG_SYMBOLS, seqGen, seqPrefixOk, triPos, inZone } = require('./minigames.js'); + +// ---------- 1. Кожен доказ має рівно одне джерело, ролі збігаються ---------- +const sources = {}; +for (const l of CASE.locations) for (const sp of l.spots) if (sp.ev) (sources[sp.ev] ??= []).push('spot:' + sp.id); +for (const s of CASE.systems) for (const q of s.queries) if (q.ev) (sources[q.ev] ??= []).push('query:' + q.id); +for (const p of Object.values(CASE.people)) for (const t of p.topics) if (t.gives) (sources[t.gives] ??= []).push('topic:' + t.id); +for (const id of Object.keys(CASE.evidence)) + assert(sources[id]?.length === 1, `доказ "${id}" має ${sources[id]?.length || 0} джерел`); +for (const id of Object.keys(sources)) assert(CASE.evidence[id], `джерело посилається на неіснуючий доказ "${id}"`); +for (const l of CASE.locations) for (const sp of l.spots) if (sp.ev) assert(CASE.evidence[sp.ev].role === 'field'); +for (const s of CASE.systems) for (const q of s.queries) if (q.ev) assert(CASE.evidence[q.ev].role === 'analyst'); + +// ---------- 2. requires існують ---------- +for (const ev of Object.values(CASE.evidence)) for (const r of ev.requires || []) assert(CASE.evidence[r], `bad requires ${r}`); +for (const p of Object.values(CASE.people)) for (const t of p.topics) for (const r of t.requires || []) assert(CASE.evidence[r], `bad requires ${r}`); +for (const id of CASE.actGate) assert(CASE.evidence[id], `actGate: неіснуючий доказ ${id}`); + +// ---------- 3. Симуляція проходження з актами ---------- +// Людей можна допитати лише в їхній локації — акт локації гейтить і допит. +const personAct = {}; +for (const l of CASE.locations) for (const pid of l.people) + personAct[pid] = Math.min(personAct[pid] || 9, l.act || 1); +for (const pid of Object.keys(CASE.people)) assert(personAct[pid], `персонаж "${pid}" не стоїть у жодній локації`); + +function simulate(skipIds = new Set()) { + const pinned = new Set(); + let act = 1, progress = true; + while (progress) { + progress = false; + if (act === 1 && CASE.actGate.every(id => pinned.has(id))) { act = 2; progress = true; } + const ok = req => !req || req.every(r => pinned.has(r)); + const grab = id => { if (!skipIds.has(id) && !pinned.has(id)) { pinned.add(id); return true; } return false; }; + for (const l of CASE.locations) if ((l.act || 1) <= act) + for (const sp of l.spots) if (sp.ev && grab(sp.ev)) progress = true; + for (const s of CASE.systems) for (const q of s.queries) + if (q.ev && (q.act || 1) <= act && ok(CASE.evidence[q.ev].requires) && grab(q.ev)) progress = true; + for (const [pid, p] of Object.entries(CASE.people)) if (personAct[pid] <= act) + for (const t of p.topics) + if (t.gives && ok(t.requires) && grab(t.gives)) progress = true; + } + return { pinned, act }; +} + +// повне проходження: все досяжно, акт 2 відкрився +const full = simulate(); +assert(full.act === 2, 'акт 2 не відкрився'); +for (const id of Object.keys(CASE.evidence)) assert(full.pinned.has(id), `доказ "${id}" недосяжний`); + +// без доказів вбивства акт 2 не відкривається, і докази акту 2 недоступні +const stuck = simulate(new Set(['decanter'])); +assert(stuck.act === 1, 'акт 2 відкрився без доказу з actGate'); +for (const [id, ev] of Object.entries(CASE.evidence)) + if ((ev.act || 1) === 2) assert(!stuck.pinned.has(id), `доказ акту 2 "${id}" досяжний в акті 1`); + +// ---------- 4. Звинувачення ---------- +assert(checkAccusation('stryzh', 'digoxin', ['blister', 'coatcheck', 'parking', 'dna_match']).ok); +assert(checkAccusation('stryzh', 'digoxin', ['pharma_log', 'stryzh_lie', 'dna_match']).ok); +assert(!checkAccusation('iryna', 'digoxin', ['blister', 'coatcheck', 'parking', 'dna_match']).ok); +assert(!checkAccusation('stryzh', 'placebo', ['blister', 'coatcheck', 'parking', 'dna_match']).ok); +assert(!checkAccusation('stryzh', 'digoxin', ['coatcheck', 'parking', 'dna_match']).ok); // без засобу +assert(!checkAccusation('stryzh', 'digoxin', ['blister', 'parking', 'dna_match']).ok); // суперечність неповна +assert(!checkAccusation('stryzh', 'digoxin', ['blister', 'coatcheck', 'parking']).ok); // без доступу +for (const r of [checkAccusation('mazur', 'digoxin', []), checkAccusation('stryzh', 'natural', [])]) + assert(!r.ok && r.hint.length > 10); + +// ключова суперечність вимагає обох ролей +assert(CASE.evidence.coatcheck.role === 'field' && CASE.evidence.parking.role === 'analyst'); +// перехід між актами вимагає обох ролей +assert(new Set(CASE.actGate.map(id => CASE.evidence[id].role)).size === 2, 'actGate має вимагати доказів від обох ролей'); + +// ---------- 5. Міні-гра аналітика: код доступу ---------- +assert.deepStrictEqual(seqGen(42, 5), seqGen(42, 5), 'seqGen недетермінований'); +assert.notDeepStrictEqual(seqGen(42, 5), seqGen(43, 5), 'різні seed дають однакові коди'); +for (const len of [4, 5, 6]) { + const s = seqGen(123, len); + assert(s.length === len); + for (const v of s) assert(Number.isInteger(v) && v >= 0 && v < MG_SYMBOLS.length, 'символ поза межами'); +} +const seq = [0, 3, 1, 5]; +assert(seqPrefixOk(seq, [])); +assert(seqPrefixOk(seq, [0])); +assert(seqPrefixOk(seq, [0, 3, 1])); +assert(seqPrefixOk(seq, [0, 3, 1, 5])); +assert(!seqPrefixOk(seq, [1])); +assert(!seqPrefixOk(seq, [0, 3, 2])); + +// ---------- 6. Міні-гра польового: сканер ---------- +const P = 1600; +assert.strictEqual(triPos(0, P), 0); +assert.strictEqual(triPos(P / 2, P), 100); +assert.strictEqual(triPos(P, P), 0); +assert.strictEqual(triPos(P / 4, P), 50); +assert.strictEqual(triPos(3 * P / 4, P), 50); +assert.strictEqual(triPos(P + P / 2, P), 100, 'періодичність зламана'); +for (let t = -5000; t <= 5000; t += 37) { + const p = triPos(t, P); + assert(p >= 0 && p <= 100, `triPos(${t}) = ${p} поза [0,100]`); +} +assert(inZone(50, 40, 20)); +assert(inZone(40, 40, 20) && inZone(60, 40, 20), 'межі зони мають зараховуватись'); +assert(!inZone(39.99, 40, 20) && !inZone(60.01, 40, 20)); + +console.log('OK — кейс розв\'язний, акти працюють, міні-ігри перевірено.'); diff --git a/chapter3/DESIGN.md b/chapter3/DESIGN.md new file mode 100644 index 0000000..b351cc8 --- /dev/null +++ b/chapter3/DESIGN.md @@ -0,0 +1,105 @@ +# Розділ 3 — «Невпізнаний» (working title) + +Co-op LAN detective, 2 players, Node + vanilla JS. +Every playthrough is a **new random case** from a seed-based generator. Inspired by Shadows of Doubt. + +## Premise + +A body with no ID is found in the district. You get only **starting facts** (randomized per case): +2–4 of e.g. a phone number on a scrap of paper, a footprint size, a fabric fiber, a tattoo, +a keyring, a partial fingerprint. From there: + +- **Act I — Who died?** Identify the victim (DB queries, biometrics, missing persons, phone trace). +- **Act II — Who did it, how, and why?** The killer is NOT on any suspect gallery. You build a + profile from traces and cross-reference the citizen database until one person fits. + +## Roles (asymmetric, both required) + +### Польовий детектив — top-down 2D district +- Canvas top-down map of a **fixed hand-crafted compact district**: ~10 buildings + (apartment blocks, bar, diner, offices, shop, clinic, police HQ), walkable with WASD/arrows. +- Enters buildings, searches rooms via hotspots, collects **samples**: DNA swabs, lifted prints, + photos, documents, objects. Samples go to the shared board; analyst processes them. +- **Apartment access is legal-only**: public places open; private doors need a **key** + (found on the body, in a work safe, at a relative's — never just lying around) or a **warrant** + (requires pinned evidence pointing at that person/place). +- **Interrogation of any NPC** — but evidence-gated: topics unlock only when you hold the + relevant pinned evidence (ch2 `requires:` pattern, generated). + +### Аналітик (поліцейський хакер) — the terminal +- **Citizen DB**: ~50 generated residents. Query by **typed name (keyboard — wrong spelling = + wrong/no dossier)** or filter by attributes (blood type, shoe size, employer, address, print type). +- **City map with addresses** — who lives/works where; mirrors the district and shows the + partner's live position. +- **Labs & systems** (each a hands-on minigame, not a button): + - **DNA lab**: gel electrophoresis — pipette sample into lanes, visually match band patterns + against DB candidates. Wrong match = confidently wrong lead. + - **Fingerprints**: overlay lifted print on candidate prints, rotate/align, mark ridge points. + DB narrows only by print *type*; the final match is your eyes. + - **CCTV archive**: scrub recorded footage of the crime night (timestamps, not a live clock), + spot a figure matching the profile (height, coat, gait). + - **Phone records + triangulation**: call logs for a number, triangulate a phone across + cell towers onto the city map. +- **Crypto/logic tasks** (analyst has a tech background — difficulty is allowed): + base64, Caesar/Vigenère and other classical ciphers, binary/hex conversion, **md5 password + cracking** for a suspect's email/laptop (weak password derivable from dossier facts: pet name + + birth year — detective work feeds the wordlist), and **one optional multi-stage Cicada3301-style + puzzle** guarding a bonus master-evidence cache. Puzzles are generated with the case. + +## No clock + +No time mechanic. Citizens have static current locations (at work or at home); all CCTV and +phone data is archival (the crime night). Actions cost nothing but thought. + +## Case generator — standalone library + +`chapter3/casegen/` — pure JS, no DOM/no server deps, reusable anywhere +(`generateCase(seed, opts)`): + +- **Deterministic from seed**: same seed → same case. Needed for saves and both-players sync; + also lets you share a good case with friends. +- Generates: citizens (names, homes, jobs, relationships, biometrics, phones, current positions), + victim, killer, **motive** from template pool — jealousy/love triangle, debt/desperation, + blackmail turned fatal, silenced witness / mistaken identity, kidnapping-gone-wrong, and more + drawn from classic Holmes-style structures — method, and the full **evidence chain planted + backwards** from the solution to the starting facts. +- **Red herrings by design**: every case plants 1–2 plausible false leads (a citizen with motive + and no alibi, a second print at the scene). **Fair-play rule: every false lead is disprovable** + by reachable evidence — the game may mislead, never cheat. +- `opts` reserved for growth: `citizens`, `districts`, `difficulty` (only `seed` used at first). +- **Fairness validator in test.js**: for many random seeds, prove the chain from starting facts to + (victim identity, killer, motive, means) is reachable, and every red herring is refutable. + +## Shared board & sync + +- Server: Node `http` for static files + **`ws`** for real-time sync (state actions AND live + field-player position). Session stores the **seed** + full state, **persisted to a JSON file + per session code** — quit anytime, resume with the same code (the game is sized for 2–3 evenings). +- Evidence list and citizen/suspect list are both **interactive for both players**: tick, + cross out, highlight, hide, keyboard notes — all synced. + +## Finale — one shot + +Form with three pickers fed by the interactive lists: **ХТО** (from citizens), +**ЯК / ЧИМ** (method/weapon), **ЧОМУ** (motive), each link requiring attached evidence. + +**Single accusation.** Submitting asks for explicit confirmation from *both* players. +Wrong → **you're fired**: the real case unfolds on screen, game over. Right → full recap, rank +based on evidence completeness and how few false leads you chased. + +## Dependencies policy + +Deps allowed, but only popular well-maintained packages, pinned versions, and only where stdlib +genuinely falls short. Current plan: **`ws` only.** Explicitly not used: game engines (canvas 2D +suffices), `express` (http.createServer suffices), `seedrandom` (mulberry32 is 5 lines), +md5 libs (`node:crypto`). + +## Milestones + +1. `casegen` lib + fairness tests (the heart — everything else renders its output) +2. Server: `ws` sync, sessions + seed + disk saves +3. Analyst terminal: DB, typed queries, city map +4. Field: top-down 2D district, movement, search, samples, keys/warrants +5. Minigames: DNA gel, fingerprints, CCTV, triangulation +6. Crypto tasks + Cicada puzzle +7. Interrogations, finale form, firing ending, polish, README diff --git a/chapter3/README.md b/chapter3/README.md new file mode 100644 index 0000000..0ac6caf --- /dev/null +++ b/chapter3/README.md @@ -0,0 +1,107 @@ +# Невпізнаний — детектив на двох, розділ 3 + +Процедурний детектив у дусі Shadows of Doubt: тіло без імені, вбивці немає в списку підозрюваних, +і **кожен код сесії — цілком нова справа**. Кооператив для двох у локальній мережі. + +## Запуск + +``` +npm install # один раз (єдина залежність — ws) +node server.js +``` + +Порт за замовчуванням — **3002**. Обидва гравці відкривають адресу з консолі, вводять +**однаковий код сесії** і різні ролі. Код сесії — це seed справи: той самий код — та сама +справа та її автозбереження (грати можна кілька вечорів), новий код — нова справа. + +## Ролі + +- **Польовий детектив** — ходить районом (WASD/стрілки, E — увійти), обшукує місця, + стукає у квартири, говорить з людьми. У приватне житло — лише з ключем або ордером. +- **Аналітик** — база мешканців (пошук за точним ім'ям з клавіатури — помилка в написанні + дасть порожній результат; фільтри за слідами: взуття, зріст, одяг, відбитки), мапа району + з напарником наживо, системи (оператор, банки, камери, криміналістика, лабораторія) + і **шифри**: Цезар, base64, PIN, злам md5-пароля за фактами з досьє. + +## Два типи справ + +- **Вбивство** (~75%): тіло без імені. Акт I — встановіть особу жертви. Акт II — ХТО, ЯК і ЧОМУ. +- **Викрадення** (~25%): людини нема, але вона ще жива. Особа відома із заяви про зникнення; + з'ясуйте, ХТО забрав людину, **ДЕ її тримають** і ЧОМУ. Фінал — рапорт на штурм: + правильне ім'я і місце = заручника рятують. Помилка = штурм іде не туди. + +## Правила + +- У справі-вбивстві: Акт I — встановіть особу жертви. Акт II — хто, як і чому. +- Мотивів у пулі — одинадцять: від ревнощів і боргів до спадку, старих гріхів, одержимого + переслідувача, кримінального бізнесу, викупу і серійного патерну з підписом на тілі. +- Обшуки знаходять і докази, і **дрібниці** — чек, запальничку, чужий записник. Що з цього + справа, а що мотлох — вирішуєте самі (викреслюйте і ховайте зайве на дошці). +- Кожен мешканець має **плітки про район** — здебільшого шум, але серед балачок трапляється правда. +- Сліди з місця злочину — це фільтри для бази мешканців. ДНК порівнюють лише зі звуженим колом. +- Ордер дає прокурор — і лише коли доказ прямо вказує на особу. +- Стережіться хибних слідів: вони правдоподібні, але спростовні. +- **Звинувачення одне.** Хто, чим, чому + закріплені 📌 докази; підписують обоє. + Помилка — звільнення і правда, яку ви не розкрили. + +## Міні-ігри + +- **Польовий**: вилучення доказів твердою рукою (зафіксуй пінцет у зоні; промахи розширюють + зону); **сейф із кодовим замком** — код не підбирається, а виводиться з деталі десь у + квартирі; пошук оголошення-акростиха в місті (див. 3301). +- **Аналітик**: **гель-електрофорез ДНК** — звузьте коло і зіставте смуги доріжок оком; + **дактилоскопія** — відбиток зі знаряддя знято під кутом, порівняйте візерунки; + **архів камер** — оберіть, КОЛИ дивитися плівку (час смерті є в матеріалах); + **тріангуляція** — три вежі, три дуги на мапі: клацніть будинок на їх перетині, щоб + звузити коло до тих, хто там буває. Помилковий вибір у лабораторії чесно каже «ні». +- **Інструменти дешифрування** (вкладка «Шифри»): Base64, перебір усіх зсувів Цезаря, + Hex→текст, Binary→текст. Інструменти механічні — думати, що і куди вставляти, доведеться самим. + +**Шифри — в кожній справі й не лише на ноутбуці.** Крім сюжетних (щоденник під Цезарем, +акаунт у Base64, PIN телефона), кожен кейс має ще два: **пошту жертви під md5-паролем** і +**записку-шифр** (ротація Base64/Цезар/Hex/Binary). Це необов'язкові дрібниці — але саме +там ви набиваєте руку на кожному типі. + +## Підказки — приховані + +Усі підказки сховані за кнопкою **💡 Підказка** (і в шифрах, і в лабораторії, і в сейфі). +Промт описує лише артефакт і дає хеш/рядок — думайте самі, розкривайте підказку, лише +коли справді застрягли. + +## Паролі (крек латиницею) + +Паролі до пристроїв — завжди **латиницею**, у промті показано md5-хеш: беріть свій +`hashcat`/`john` і будь-який словник (напр. `rockyou.txt`). Алгоритм дає два стилі порівну: + +- **Особистий** — виводиться з досьє: кличка собаки чи ім'я жертви, **транслітеровані в + латиницю** (у досьє вони кирилицею — це і є робота), інколи + рік народження. Швидше + здогадатися, ніж брутити (`Барон` → `baron`, `Лола`+рік → `lola1985`). +- **Випадковий** — жодного зв'язку з жертвою, реальний запис зі словника. Тут лише перебір: + `hashcat -m 0 rockyou.txt` знайде `cookie`, `ginger`, `letmein`… + +Пароль флешки 3301 теж латиницею: акростих оголошення транслітеруєте й додаєте рік. + +## CICADA 3301 + +У **кожній** справі захована чотиришарова головоломка в дусі Cicada 3301 (прості числа, +шари шифрів, «оголошення в місті» замість плакатів): +скринька з кодовим замком (код — на гравіюванні десь у квартирі) → картка з рядком +Base64-поверх-Цезаря → інструкція веде до оголошення в місті → перші літери рядків + +чотири цифри = пароль до флешки. Нагорода — особистий архів жертви: найкоротший шлях +до правди. Ланцюжок необов'язковий: справа розв'язується і без нього. + +## Перевірка + +``` +npm test +``` + +Fair-play валідатор ганяє 300 випадкових справ: кожна розв'язна від стартових фактів, +кожен хибний слід спростовний, усі шифри чесні. Інтеграційний тест проходить справжню +справу двома ws-клієнтами до розкриття (і до звільнення). + +## Генератор як бібліотека + +`casegen/` не залежить від сервера й DOM: `generateCase(seed)` → мешканці, жертва, вбивця, +мотив, граф доказів, шифри; `solve(cs)` — симулятор досяжності; `checkAccusation(...)`. +Можна брати в будь-який інший проєкт. diff --git a/chapter3/casegen/district.js b/chapter3/casegen/district.js new file mode 100644 index 0000000..b842479 --- /dev/null +++ b/chapter3/casegen/district.js @@ -0,0 +1,132 @@ +// Генератор району: геометрія, назви і приміщення — свої для кожного сіда. +// Захист від незграбних мап: будівлі не перекривають дороги й одна одну (зазор GAP), +// повністю в межах світу; якщо розкладка не зійшлась — повний перезапуск розкладки. +const N = require('./names'); + +const W = 1000, H = 700, GAP = 30, MARGIN = 24, ROAD_PAD = 10; + +function makeDistrict(r) { + const ri = (a, b) => a + Math.floor(r() * (b - a + 1)); + const pick = arr => arr[Math.floor(r() * arr.length)]; + const shuffle = arr => { + const a = arr.slice(); + for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(r() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } + return a; + }; + + const mainSt = pick(N.STREETS), laneNm = pick(N.LANES); + const hy = ri(280, 330), vx = ri(420, 560); + const roads = [ + { x: 0, y: hy, w: W, h: 64, name: 'вул. ' + mainSt }, + { x: vx, y: 0, w: 56, h: H, name: 'пров. ' + laneNm }, + { x: 0, y: 616, w: W, h: 84, name: 'набережна' } + ]; + + // приміщення: перші — обов'язкові (на них зав'язані справи: місця злочину, + // схованки зброї, лігва), далі — випадковий колорит + const EXTRA = ['задній двір', 'щитова', 'стоянка велосипедів', 'ґанок', 'вентиляційна ніша']; + const spots = (req, n) => [...req, ...shuffle(EXTRA).slice(0, n)]; + const units = (letter, n) => Array.from({ length: n }, (_, i) => letter + (i + 1)); + + const defs = [ + { id: 'station', name: 'Відділок поліції', public: true, spots: [], w: ri(110, 150), h: ri(85, 110) }, + { id: 'apt_a', name: `Будинок «${pick(N.HOUSES)}», вул. ${mainSt} ${ri(3, 30)}`, units: units('A', ri(5, 8)), w: ri(160, 200), h: ri(95, 120) }, + { id: 'apt_b', name: `Будинок «${pick(N.HOUSES)}», вул. ${mainSt} ${ri(31, 60)}`, units: units('B', ri(5, 8)), w: ri(160, 200), h: ri(95, 120) }, + { id: 'apt_c', name: `Гуртожиток «${pick(N.DORMS)}», пров. ${laneNm} ${ri(1, 12)}`, units: units('C', ri(6, 10)), w: ri(170, 220), h: ri(95, 120) }, + { id: 'bar', name: `Бар «${pick(N.BARS)}»`, public: true, spots: spots(['стійка', 'підсобка', 'смітник у дворі'], ri(0, 1)), jobs: ['бармен', 'офіціант', 'кухар'], w: ri(120, 160), h: ri(90, 115) }, + { id: 'diner', name: `Їдальня «${pick(N.DINERS)}»`, public: true, spots: spots(['зала', 'кухня', 'роздягальня персоналу'], ri(0, 1)), jobs: ['кухар', 'касир', 'офіціант'], w: ri(115, 150), h: ri(85, 105) }, + { id: 'office', name: `Бізнес-центр «${pick(N.OFFICES)}»`, public: true, spots: spots(['рецепція', 'офіс «Логістік-Юг»', 'юридичне бюро', 'паркінг'], ri(1, 2)), jobs: ['менеджер з логістики', 'експедитор', 'програміст', 'тестувальник', 'юрист', 'помічник юриста', 'бухгалтер', 'офіс-менеджер'], w: ri(140, 175), h: ri(110, 140) }, + { id: 'shop', name: `Мінімаркет «${pick(N.SHOPS)}»`, public: true, spots: spots(['каса', 'склад', 'камера зовнішнього спостереження'], ri(0, 1)), jobs: ['касир', 'касир', 'товарознавець'], w: ri(105, 140), h: ri(80, 100) }, + { id: 'clinic', name: `Клініка «${pick(N.CLINICS)}»`, public: true, spots: spots(['реєстратура', 'кабінет лікаря', 'аптечний пункт'], ri(0, 1)), jobs: ['лікар', 'медсестра', 'медбрат', 'адміністратор'], w: ri(130, 170), h: ri(90, 110) }, + { id: 'garage', name: `Автомайстерня «${pick(N.GARAGES)}»`, public: true, spots: spots(['яма', 'стелаж з інструментами', 'конторка', 'двір за боксами'], ri(0, 1)), jobs: ['механік', 'механік', 'приймальник'], w: ri(105, 140), h: ri(90, 115) }, + { id: 'park', name: pick(N.PARKS), public: true, spots: spots(['алея', 'кущі біля лавок', 'старий фонтан'], ri(0, 1)), w: ri(230, 280), h: ri(60, 80) }, + { id: 'pier', name: pick(N.PIERS), public: true, spots: spots(['пірс', 'елінг', 'урна біля сходів'], ri(0, 1)), jobs: ['доглядач причалу'], w: ri(230, 280), h: ri(60, 80) } + ]; + + // Розкладка «полицями» у 4 кварталах, які вирізають дороги: гарантований зазор GAP, + // випадковий порядок і джитер дають різні мапи. Не зійшлося — перезапуск із новим порядком. + // ponytail: полиці замість фізики/force-layout — простіше і не ламається + const cells0 = [ + { x0: MARGIN, x1: vx - ROAD_PAD, y0: MARGIN, y1: hy - ROAD_PAD }, + { x0: vx + 56 + ROAD_PAD, x1: W - MARGIN, y0: MARGIN, y1: hy - ROAD_PAD }, + { x0: MARGIN, x1: vx - ROAD_PAD, y0: hy + 64 + ROAD_PAD, y1: 608 }, + { x0: vx + 56 + ROAD_PAD, x1: W - MARGIN, y0: hy + 64 + ROAD_PAD, y1: 608 } + ]; + let buildings; + for (let attempt = 0; ; attempt++) { + if (attempt >= 200) throw new Error('мапа не зійшлася: ' + attempt + ' перезапусків'); + buildings = []; + const cells = cells0.map(c => ({ ...c, cx: c.x0, cy: c.y0, rowH: 0 })); + let ok = true; + for (const d of shuffle(defs)) { + let placedOne = false; + for (const cell of shuffle(cells)) { + // спроба в поточному рядку, інакше — новий рядок + for (let nl = 0; nl < 2 && !placedOne; nl++) { + if (cell.cx + d.w > cell.x1) { cell.cy += cell.rowH + GAP; cell.cx = cell.x0; cell.rowH = 0; } + if (cell.cx + d.w <= cell.x1 && cell.cy + d.h <= cell.y1) { + const jy = ri(0, Math.min(14, cell.y1 - cell.cy - d.h)); + const { w, h, ...rest } = d; + buildings.push({ ...rest, pos: { x: cell.cx, y: cell.cy + jy, w, h } }); + cell.cx += d.w + GAP; + cell.rowH = Math.max(cell.rowH, d.h + jy); + placedOne = true; + } + } + if (placedOne) break; + } + if (!placedOne) { ok = false; break; } + } + if (ok) break; + } + + // камери: три «власні» + муніципальні, щоб кожне можливе місце злочину було під наглядом + const byId = id => buildings.find(b => b.id === id); + const center = b => ({ x: b.pos.x + b.pos.w / 2, y: b.pos.y + b.pos.h / 2 }); + const near = (host, dist) => buildings + .filter(o => Math.hypot(center(o).x - center(host).x, center(o).y - center(host).y) <= dist) + .map(o => o.id); + const cameras = []; + for (const hostId of ['shop', 'bar', 'office']) { + const h = byId(hostId); + cameras.push({ id: 'cam_' + hostId, name: 'Камера біля: ' + h.name.split(',')[0], covers: near(h, 240) }); + } + for (const must of ['park', 'pier', 'bar', 'garage', 'office']) + if (!cameras.some(c => c.covers.includes(must))) + cameras.push({ id: 'cam_' + must, name: 'Муніципальна камера: ' + byId(must).name.split(',')[0], covers: near(byId(must), 200) }); + + const towers = [ + { id: 't1', name: 'Вежа «Північ»', pos: { x: ri(60, 300), y: ri(20, 70) } }, + { id: 't2', name: 'Вежа «Схід»', pos: { x: ri(870, 970), y: ri(200, 450) } }, + { id: 't3', name: 'Вежа «Південь»', pos: { x: ri(250, 600), y: ri(640, 690) } } + ]; + + return { + name: `Район «${pick(N.DISTRICTS)}»`, + world: { w: W, h: H }, + roads, buildings, cameras, towers, + homeSlots: ['письмовий стіл', 'шафа', 'кухня', 'ванна кімната', 'під ліжком', + ...shuffle(['балкон', 'антресолі', 'комора']).slice(0, ri(0, 2))], + dumpSpots: [ + { building: 'bar', spot: 'смітник у дворі' }, + { building: 'park', spot: 'кущі біля лавок' }, + { building: 'pier', spot: 'урна біля сходів' }, + { building: 'park', spot: 'старий фонтан' } + ], + crimeScenes: [ + { building: 'park', spot: 'алея', name: 'алея парку' }, + { building: 'pier', spot: 'пірс', name: 'пірс на набережній' }, + { building: 'bar', spot: 'смітник у дворі', name: 'двір за баром' }, + { building: 'garage', spot: 'двір за боксами', name: 'двір за боксами автомайстерні' }, + { building: 'office', spot: 'паркінг', name: 'нічний паркінг бізнес-центру' } + ] + }; +} + +function unitBuilding(unit) { return unit[0] === 'A' ? 'apt_a' : unit[0] === 'B' ? 'apt_b' : 'apt_c'; } +function allUnits(d) { return d.buildings.flatMap(b => b.units || []); } +function allJobs(d) { + return d.buildings.flatMap(b => (b.jobs || []).map(title => ({ title, building: b.id }))); +} + +module.exports = { makeDistrict, unitBuilding, allUnits, allJobs, GAP, MARGIN }; diff --git a/chapter3/casegen/index.js b/chapter3/casegen/index.js new file mode 100644 index 0000000..fcf6905 --- /dev/null +++ b/chapter3/casegen/index.js @@ -0,0 +1,1437 @@ +// casegen — генератор справ для розділу 3. Чистий JS, без залежностей і DOM. +// generateCase(seed) → детермінована справа: мешканці, жертва, вбивця, мотив, +// граф доказів (спланований від розв'язку назад), хибні сліди (кожен — спростовний), +// шифро-завдання. solve() — симулятор досяжності для fair-play тестів. + +const crypto = require('crypto'); +const { makeDistrict, unitBuilding, allUnits, allJobs } = require('./district'); +const N = require('./names'); + +// ---------- RNG (детермінований від seed-рядка) ---------- +function hashSeed(str) { + let h = 2166136261 >>> 0; + for (const c of String(str)) { h ^= c.codePointAt(0); h = Math.imul(h, 16777619); } + return h >>> 0; +} +function mulberry32(a) { + return function () { + a |= 0; a = (a + 0x6D2B79F5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} +const ri = (r, a, b) => a + Math.floor(r() * (b - a + 1)); +// оцінка «на око»: асиметричний діапазон навколо справжнього значення. +// нижня межа завжди нижча за справжнє, верхня — вища, тож ціль всередині. +const estRange = (r, actual, lo = 3, hi = 5) => `${actual - ri(r, lo, hi)}–${actual + ri(r, lo, hi)}`; +const pick = (r, arr) => arr[Math.floor(r() * arr.length)]; +const chance = (r, p) => r() < p; +function shuffle(r, arr) { + const a = arr.slice(); + for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(r() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } + return a; +} + +// ---------- Шифри ---------- +const UKR = 'абвгґдеєжзиіїйклмнопрстуфхцчшщьюя'; +function caesar(text, k) { + return [...text].map(ch => { + const lower = ch.toLowerCase(), i = UKR.indexOf(lower); + if (i < 0) return ch; + const out = UKR[(i + k + UKR.length) % UKR.length]; + return ch === lower ? out : out.toUpperCase(); + }).join(''); +} +const b64 = t => Buffer.from(t, 'utf8').toString('base64'); +const md5 = t => crypto.createHash('md5').update(t, 'utf8').digest('hex'); + +// ---------- Паролі: транслітерація + алгоритм генерації ---------- +// Люди пишуть паролі латиницею. Кличка собаки й ім'я в досьє — кирилицею +// (рідна мова), тож «особистий» пароль треба ще й транслітерувати — це і є думання. +const TRANSLIT = { а:'a',б:'b',в:'v',г:'h',ґ:'g',д:'d',е:'e',є:'ie',ж:'zh',з:'z',и:'y',і:'i',ї:'i',й:'i',к:'k',л:'l',м:'m',н:'n',о:'o',п:'p',р:'r',с:'s',т:'t',у:'u',ф:'f',х:'kh',ц:'ts',ч:'ch',ш:'sh',щ:'shch',ь:'',ю:'iu',я:'ia' }; +const translit = s => [...String(s).toLowerCase()].map(c => TRANSLIT[c] ?? c).join('').replace(/[^a-z0-9]/g, ''); + +// Реальні паролі з rockyou.txt (усі перевірені grep-ом). «Випадковий» пароль +// не має жодного зв'язку з жертвою — його бере лише перебір за словником. +const COMMON_PASSWORDS = ['sunshine', 'dragon', 'monkey', 'princess', 'football', 'iloveyou', + 'superman', 'batman', 'master', 'hunter', 'freedom', 'trustno1', 'whatever', 'ginger', + 'flower', 'summer', 'winter', 'orange', 'purple', 'silver', 'letmein', 'qwerty', 'pepper', + 'cookie', 'tigger', 'jasmine', 'soccer', 'maggie', 'hannah', 'ashley', 'chelsea', 'butterfly', + 'password', 'shadow', 'michael', 'jessica', 'charlie', 'daniel', 'jordan', 'matrix', 'falcon', + 'phoenix', 'rainbow', 'diamond', 'banana', 'cheese', 'coffee', 'chocolate', 'midnight', + 'october', 'november', 'chester', 'oliver', 'ranger', 'harley', 'buster', 'george', 'sammy', + 'pumpkin', 'muffin', 'peanut', 'smokey', 'bandit', 'lucky', 'angel', 'lovely', 'sunflower', + 'babygirl', 'bubbles', 'snickers', 'chicken', 'turtle', 'rabbit', 'dolphin', 'junior', + 'dexter', 'guitar', 'hammer', 'winner', 'copper', 'marina', 'sierra', 'tucker', 'willow', + 'zombie', 'viking', 'thunder', 'topgun', 'maverick', 'eclipse', 'scorpion', 'panther']; + +// Алгоритм пароля: половина справ — особисте (читай досьє + транслітеруй), +// половина — випадкове (лише вордліст). Повертає {answer, style, hint, personal}. +function genPassword(r, victim) { + const yr = String(victim.birthYear), yr2 = yr.slice(2); + const personal = []; + if (victim.dog) personal.push( + { answer: translit(victim.dog), hint: 'кличка собаки латиницею (див. досьє)' }, + { answer: translit(victim.dog) + yr, hint: 'кличка собаки латиницею + повний рік народження' }); + personal.push( + { answer: translit(victim.first) + yr, hint: `ім'я латиницею + повний рік народження` }, + { answer: translit(victim.last) + yr2, hint: 'прізвище латиницею + дві цифри року народження' }, + { answer: translit(victim.first) + translit(victim.last), hint: `ім'я та прізвище латиницею разом` }); + // 50/50 особисте vs випадкове + if (chance(r, 0.5)) { + const p = pick(r, personal); + return { ...p, style: 'personal', personal: true }; + } + return { answer: pick(r, COMMON_PASSWORDS), style: 'random', personal: false, + hint: 'жодного зв\'язку з жертвою — тільки перебір за словником (rockyou.txt)' }; +} + +// ---------- Мешканці ---------- +const BLOOD = ['O(I) Rh+', 'O(I) Rh+', 'A(II) Rh+', 'A(II) Rh+', 'B(III) Rh+', 'AB(IV) Rh+', 'O(I) Rh−', 'A(II) Rh−']; +const PRINTS = ['дуга', 'петля', 'петля', 'завиток', 'завиток']; + +function makeCitizens(r, district) { + const citizens = []; + const usedNames = new Set(), usedPhones = new Set(), usedDna = new Set(); + let nextId = 1; + + function person(sex, last, ageRange) { + let first, full; + do { first = pick(r, sex === 'ч' ? N.MALE : N.FEMALE); full = `${first} ${last}`; } while (usedNames.has(full)); + usedNames.add(full); + const age = ri(r, ageRange[0], ageRange[1]); + const height = sex === 'ч' ? ri(r, 165, 195) : ri(r, 152, 183); + let phone; + do { phone = `+380${pick(r, ['50', '63', '66', '67', '68', '73', '95', '96', '97', '98', '99'])}${String(ri(r, 0, 9999999)).padStart(7, '0')}`; } while (usedPhones.has(phone)); + usedPhones.add(phone); + let dna; + do { dna = Array.from({ length: 8 }, () => ri(r, 0, 15)); } while (usedDna.has(dna.join(','))); + usedDna.add(dna.join(',')); + return { + id: 'c' + nextId++, name: full, first, last, sex, age, birthYear: 2026 - age, + blood: pick(r, BLOOD), height, + shoe: Math.max(35, Math.min(46, Math.round(height * 0.24) + ri(r, -1, 1))), + printType: pick(r, PRINTS), printSeed: ri(r, 1, 2 ** 30), coat: pick(r, N.COATS), + smoker: chance(r, 0.3), dog: chance(r, 0.2) ? pick(r, N.DOG_NAMES) : null, + tattoo: chance(r, 0.25) ? pick(r, N.TATTOOS) : null, + phone, dna, home: null, job: null, workplace: null, spouse: null + }; + } + + // розміри родин — від кількості квартир у згенерованому районі; + // щонайменше 3 квартири-одинаки (серед них генератор обере жертву) + const unitList = allUnits(district); + const sizes = {}; + shuffle(r, unitList.filter(u => u[0] !== 'C')).forEach((u, i) => sizes[u] = i < 3 ? 1 : pick(r, [1, 2, 2, 3, 3, 4])); + for (const u of unitList) if (u[0] === 'C') sizes[u] = pick(r, [1, 1, 2]); + for (const unit of unitList) { + const dorm = unit[0] === 'C'; + const size = sizes[unit]; + const last = pick(r, N.SURNAMES); + if (size === 1) { + const p = person(pick(r, ['ч', 'ж']), last, dorm ? [19, 45] : [25, 74]); + p.home = unit; citizens.push(p); + } else { + const a = person('ч', last, [26, 68]); + const b = person('ж', chance(r, 0.7) ? last : pick(r, N.SURNAMES), [Math.max(22, a.age - 8), Math.min(70, a.age + 8)]); + if (!dorm) { a.spouse = b.id; b.spouse = a.id; } + a.home = b.home = unit; citizens.push(a, b); + for (let k = 2; k < size; k++) { + const kid = person(pick(r, ['ч', 'ж']), last, [18, Math.max(19, a.age - 22)]); + kid.home = unit; citizens.push(kid); + } + } + } + + const jobs = shuffle(r, allJobs(district)); + const workers = shuffle(r, citizens.filter(c => c.age <= 65)); + workers.slice(0, jobs.length).forEach((c, i) => { c.job = jobs[i].title; c.workplace = jobs[i].building; }); + for (const c of citizens) if (!c.job) + c.job = c.age > 62 ? 'пенсіонер' : c.age < 23 ? 'студент' : 'тимчасово не працює'; + return citizens; +} + +// ---------- Довідники фіналу ---------- +const METHODS = [ + { id: 'stab', label: 'Ніж', autopsy: 'дві колото-різані рани; знаряддя — вузький клинок ~2 см', weapon: 'кухонний ніж із вузьким лезом' }, + { id: 'blunt', label: 'Тупий предмет', autopsy: 'тупа травма голови; слід шестигранної грані', weapon: 'розвідний ключ' }, + { id: 'strangle', label: 'Удушення', autopsy: 'странгуляційна борозна; у рані — синтетичні волокна шнура', weapon: 'капроновий шнур' }, + { id: 'poison', label: 'Отруєння', autopsy: 'слідів боротьби немає; токсикологія — летальна доза барбітуратів у алкоголі', weapon: 'пляшка коньяку з домішкою' } +]; +const METHOD_OPTIONS = ['Ніж', 'Тупий предмет', 'Удушення', 'Отруєння', 'Падіння з висоти']; +const MOTIVE_OPTIONS = ['Ревнощі', 'Борг', 'Шантаж', 'Свідок злочину', 'Помилкова ціль', 'Пограбування', + 'Спадок і страховка', 'Старі гріхи', 'Одержимість', 'Кримінальний бізнес', 'Серійний патерн', 'Викуп']; +// key — слово, за яким «підпис» шукається в архіві справ +const SIGNATURES = [ + { text: 'чорна стрічка, акуратно зав\'язана на зап\'ясті', key: 'стрічка' }, + { text: 'крейдяне коло, дбайливо обведене довкола тіла', key: 'коло' }, + { text: 'дитяча іграшкова машинка, вкладена в кишеню', key: 'машинка' }, + { text: 'гральна карта — піковий валет — під долонею', key: 'валет' }, + { text: 'засушена фіалка, вкладена між пальцями', key: 'фіалка' }, + { text: 'зупинений годинник, переведений на 3:33', key: 'годинник' }, + { text: 'ґудзик, відрізаний і покладений на повіку', key: 'ґудзик' }, + { text: 'аркуш нотного паперу з єдиною нотою', key: 'нота' }, + { text: 'ключ без бородки, вкладений у долоню', key: 'ключ' }]; +// Можливі місця утримання для справ-викрадень (реальні точки на мапі) +const LAIRS = [ + { building: 'pier', spot: 'елінг', label: 'Елінг на причалі' }, + { building: 'shop', spot: 'склад', label: 'Склад мінімаркету' }, + { building: 'bar', spot: 'підсобка', label: 'Підсобка бару' }, + { building: 'garage', spot: 'яма', label: 'Оглядова яма автомайстерні' }, + { building: 'diner', spot: 'роздягальня персоналу', label: 'Підсобні приміщення їдальні' } +]; + +// ---------- Генерація справи ---------- +function generateCase(seed) { + const r = mulberry32(hashSeed(seed)); + const district = makeDistrict(r); + const citizens = makeCitizens(r, district); + const byId = id => citizens.find(c => c.id === id); + const g = (c, m, f) => (c.sex === 'ч' ? m : f); // рід у дієсловах; відмінювання імен обходимо конструкціями з називним + + // -- вибір шаблону мотиву, жертви, вбивці + const singles = citizens.filter(c => !citizens.some(o => o !== c && o.home === c.home) && c.age >= 25); + const married = citizens.filter(c => c.spouse); + + const victim = pick(r, singles); + let killer, tSpouse = null, tTarget = null; + const notHousehold = c => c.id !== victim.id && c.home !== victim.home; + + // тип справи: вбивство або викрадення (людина ще жива — її можна врятувати) + const caseType = chance(r, 0.25) ? 'kidnap' : 'murder'; + + // шаблон мотиву: перший придатний із перетасованого списку + let templateId = 'debt'; + if (caseType === 'kidnap') templateId = pick(r, ['k_ransom', 'k_obsession', 'k_debt']); + else for (const t of shuffle(r, ['jealousy', 'debt', 'blackmail', 'witness', 'mistaken', + 'inherit', 'oldcrime', 'stalker', 'business', 'serial', 'ransom'])) { + if (t === 'jealousy' && !married.some(c => notHousehold(c) && notHousehold(byId(c.spouse)))) continue; + if (t === 'oldcrime' && !citizens.some(c => notHousehold(c) && c.age >= 30)) continue; + templateId = t; break; + } + + if (templateId === 'jealousy') { + killer = pick(r, married.filter(c => notHousehold(c) && notHousehold(byId(c.spouse)))); + tSpouse = byId(killer.spouse); + } else if (templateId === 'oldcrime') { + killer = pick(r, citizens.filter(c => notHousehold(c) && c.age >= 30)); + } else if (templateId === 'mistaken') { + const neighbors = citizens.filter(c => notHousehold(c) && unitBuilding(c.home) === unitBuilding(victim.home) && Math.abs(c.height - victim.height) <= 8); + tTarget = neighbors.length ? pick(r, neighbors) : pick(r, citizens.filter(notHousehold)); + tTarget.coat = victim.coat; // їх плутали через однаковий одяг + killer = pick(r, citizens.filter(c => notHousehold(c) && c.id !== tTarget.id && c.home !== tTarget.home)); + } else { + killer = pick(r, citizens.filter(c => notHousehold(c) && c.age >= 21)); + } + + const method = pick(r, METHODS); + const scene = pick(r, district.crimeScenes); + const lair = caseType === 'kidnap' ? pick(r, LAIRS) : null; + const sceneCam = district.cameras.find(cam => cam.covers.includes(scene.building)); + const crimeTime = `${ri(r, 22, 23)}:${pick(r, ['10', '25', '40'])}`; + const atScene = { type: 'spot', building: scene.building, spot: scene.spot }; + + // свідки не можуть бути фігурантами справи + let accomplice = null; // призначається мутатором нижче; свідком бути не може + const isFigure = c => [victim.id, killer.id, tSpouse && tSpouse.id, tTarget && tTarget.id, + accomplice && accomplice.id].includes(c.id); + const witnessFrom = arr => pick(r, (arr.filter(c => !isFigure(c) && c.id !== herring.id).length ? arr.filter(c => !isFigure(c) && c.id !== herring.id) : citizens.filter(c => !isFigure(c)))); + + // -- сліди вбивці на місці: розмиті. Взуття читається як діапазон ±1, + // тож після самого лише огляду місця коло мусить лишатися широким (≥8). + const nearShoe = c => c.id !== victim.id && Math.abs(c.shoe - killer.shoe) <= 1; + for (const c of shuffle(r, citizens)) { + if (citizens.filter(nearShoe).length >= 8) break; + if (c.id !== victim.id && !nearShoe(c)) c.shoe = Math.max(35, Math.min(46, killer.shoe + ri(r, -1, 1))); + } + + // -- хибний слід: громадянин, що збігається за слідами + const herring = pick(r, citizens.filter(c => c.id !== killer.id && notHousehold(c) && (!tTarget || c.id !== tTarget.id) && c.sex === killer.sex)); + herring.shoe = killer.shoe; herring.coat = killer.coat; + herring.height = Math.min(195, Math.max(150, killer.height + ri(r, -4, 4))); + + // повний набір слідів (взуття ±1, тканина, зріст ±10) лишає 2–6 кандидатів у фіналі + const matchesTraces = c => nearShoe(c) && c.coat === killer.coat && Math.abs(c.height - killer.height) <= 10; + for (const c of citizens) { + if (c === killer || c === herring) continue; + while (matchesTraces(c) && citizens.filter(matchesTraces).length > 4) + c.coat = pick(r, N.COATS); + } + + // -- вечір злочину: де хто був насправді (evening) і що каже (claim). + // Вбивця завжди бреше; ~15% людей брешуть чи «не знають» і без причини. + // Ці поля — таємниця сервера: клієнту в init вони не віддаються. + const coveredB = [...new Set(district.cameras.flatMap(c => c.covers))]; + for (const c of citizens) { + c.evening = c.workplace && chance(r, 0.2) ? c.workplace + : chance(r, 0.2) ? pick(r, ['bar', 'diner']) : unitBuilding(c.home); + c.liar = chance(r, 0.15); + } + victim.evening = scene.building; victim.liar = false; + killer.evening = scene.building; killer.liar = true; + // хибний слід того вечора був у полі зору камер — цим його алібі й доводиться + herring.evening = pick(r, coveredB.filter(b => b !== scene.building && b !== 'station')) || unitBuilding(herring.home); + herring.liar = false; + const otherB = b => pick(r, district.buildings.filter(x => x.id !== b && x.id !== 'station')).id; + 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); + // брехуни: чому збрехали (виправдання на повторному допиті) і хто може підтвердити правду + 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; + } + + // -- мутатор справи: зрідка правила змінюються, щоб маршрут не заучувався + let mutator = null; + if (caseType === 'murder') { if (chance(r, 0.35)) mutator = pick(r, ['accomplice', 'gloves', 'frame']); } + else if (chance(r, 0.25)) mutator = 'frame'; + if (mutator === 'accomplice') { + accomplice = pick(r, citizens.filter(c => !isFigure(c) && c.home !== killer.home && c.home !== victim.home)); + accomplice.evening = scene.building; accomplice.liar = true; + accomplice.claim = otherB(scene.building); accomplice.excuse = null; accomplice.mate = null; + } + + // -- другий ДНК-зразок (~30%): невинна ДНК на місці злочину. ДНК каже лише ЧИЯ + // вона — не КОЛИ і не ЧОМУ. Бійка вдень, очевидець, що знайшов тіло, перехожий. + let dna2 = null; + if (chance(r, 0.3)) { + const why = pick(r, ['brawl', 'brawl', 'finder', 'passer']); + const owner2 = pick(r, citizens.filter(c => !isFigure(c) && c.id !== herring.id && c.home !== victim.home)); + dna2 = { owner: owner2, why }; + if (why === 'brawl') dna2.reason = pick(r, ['через борг у пару сотень', 'через розлите пиво', + 'через давню образу', 'через чергу і гострий язик', 'бо обоє були нетверезі, слово за слово']); + } + + + // -- побутові стосунки поза справою: пул відповідей на «що скажете про X?» + const REL_KINDS = ['давній конфлікт через дрібницю', 'міцна дружба ще зі школи', + 'позичали одне одному гроші', 'разом рибалять вихідними', 'сварка за паркування, не вітаються']; + const relations = []; + for (let i = 0; i < ri(r, 4, 7); i++) { + const a = pick(r, citizens), b = pick(r, citizens); + if (a === b || a.id === victim.id || b.id === victim.id) continue; + if (relations.some(p => (p.a === a.id && p.b === b.id) || (p.a === b.id && p.b === a.id))) continue; + relations.push({ a: a.id, b: b.id, kind: pick(r, REL_KINDS) }); + } + + // -- граф дзвінків вечора злочину: побут між родиною/колегами/парами стосунків. + // Вбивця: тиша — апарат «поза мережею» довкола часу злочину. Спільник: дзвінок вбивці. + const crimeM = (() => { const [h, mi] = crimeTime.split(':').map(Number); return h * 60 + mi; })(); + const calls = []; + const addCall = (a, b, t) => a && b && a.id !== b.id && calls.push({ a: a.id, b: b.id, t }); + for (const c of citizens) { + if (c.id === victim.id || !chance(r, 0.45)) continue; + const other = c.spouse ? byId(c.spouse) + : pick(r, citizens.filter(o => o !== c && o.id !== victim.id && + (o.last === c.last || (o.workplace && o.workplace === c.workplace)))) || null; + if (other) addCall(c, other, ri(r, 1140, 1430)); + } + for (const pr of relations) if (chance(r, 0.5)) addCall(byId(pr.a), byId(pr.b), ri(r, 1140, 1430)); + const phoneOff = { off: crimeM - ri(r, 15, 35), on: crimeM + ri(r, 60, 200) }; + for (let i = calls.length - 1; i >= 0; i--) { + const x = calls[i]; + if ((x.a === killer.id || x.b === killer.id) && x.t >= phoneOff.off - 10 && x.t <= phoneOff.on) + calls.splice(i, 1); + } + if (accomplice) addCall(killer, accomplice, crimeM - ri(r, 80, 140)); + + // -- докази + const E = {}; + const add = ev => { E[ev.id] = Object.assign({ requires: [], gives: {} }, ev); return ev.id; }; + const puzzles = []; + const addPuzzle = p => { puzzles.push(p); return p.id; }; + + // стартові факти + const startingFacts = []; + if (caseType === 'kidnap') { + startingFacts.push(add({ + id: 'sf_scene', side: 'field', at: { type: 'start' }, + title: `Місце викрадення: ${scene.name}`, tag: 'місце', + detail: `${pick(r, N.WEATHER)}. Сумку помітив ${pick(r, N.DISCOVERERS)}: ${scene.name} — перекинута сумка, зламаний ремінець годинника, сліди волочіння до проїзду. Боротьба була короткою — близько ${crimeTime}. Тіла немає, і це добра новина: людину забрали ${g(victim, 'живим', 'живою')}. Сліди самі до відділка не прийдуть — огляньте місце.` + })); + add({ + id: 'sf_footprint', side: 'field', at: atScene, + title: 'Відбиток взуття', tag: 'слід', + detail: `Відбиток підошви поверх слідів волочіння, край розмитий. Розмір ${estRange(r, killer.shoe, 1, 2)}. Взуття зниклої особи — інше.`, + gives: { filter: { attr: 'shoe', val: killer.shoe, tol: 1 } } + }); + } + if (caseType === 'murder') startingFacts.push(add({ + id: 'sf_scene', side: 'field', at: { type: 'start' }, + title: `Місце злочину: ${scene.name}`, tag: 'місце', + detail: `${pick(r, N.WEATHER)}. Тіло ${dna2 && dna2.why === 'finder' ? `${g(dna2.owner, 'помітив', 'помітила')} ${dna2.owner.name} (${g(dna2.owner, 'він', 'вона')} і викликає поліцію щоранку бігаючи тут)` : 'помітив ' + pick(r, N.DISCOVERERS)}: ${scene.name}. Документів немає, телефон розбитий, кишені майже порожні. Смерть настала близько ${crimeTime}. Сліди самі до відділка не прийдуть — огляньте місце.` + })); + if (caseType === 'murder') add({ + id: 'sf_body', side: 'field', at: atScene, + title: 'Огляд тіла', tag: 'жертва', + detail: `${victim.sex === 'ч' ? 'Чоловік' : 'Жінка'}, на вигляд ${estRange(r, victim.age)} років, зріст ${victim.height} см.` + + (victim.tattoo ? ` Особлива прикмета: татуювання — ${victim.tattoo}.` : ' Особливих прикмет немає.'), + gives: { victimFacts: true } + }); + if (caseType === 'murder') add({ + id: 'sf_footprint', side: 'field', at: atScene, + title: 'Відбиток взуття', tag: 'слід', + detail: `Відбиток підошви поруч із тілом, змазаний. Розмір ${estRange(r, killer.shoe, 1, 2)}. Зі взуттям жертви не збігається.`, + gives: { filter: { attr: 'shoe', val: killer.shoe, tol: 1 } } + }); + // -- ДНК на місці: описи знахідок перемішуються між власниками ВИПАДКОВО. + // «Кров на манжеті» може належати і вбивці, і людині з денної бійки в барі: + // походження зразка — не доказ, лише факт присутності. + const cap = t => t.charAt(0).toUpperCase() + t.slice(1); + const DNA_SPOTS = shuffle(r, caseType === 'murder' + ? ['волосина з цибулиною на комірі жертви', 'кров на манжеті сорочки жертви — і група не її', + 'свіжий недопалок зі слідами слини за півкроку від тіла', 'волосся, вирване з коренем, у ремінці годинника', + 'мікрочастки шкіри під нігтями жертви', 'зіжмакана серветка зі слідами поту в урні поруч'] + : ['волосся, вирване з коренем, у зламаному ремінці годинника', 'свіжа кров на розірваній лямці сумки', + 'свіжий недопалок зі слідами слини біля слідів волочіння', 'зіжмакана серветка зі слідами поту в урні поруч']); + const dnaDesc = DNA_SPOTS[0], dna2Desc = DNA_SPOTS[1]; + const dnaAdds = []; + if (!(caseType === 'murder' && mutator === 'gloves')) dnaAdds.push(() => add({ + id: 'sf_dna', side: 'field', at: atScene, title: 'Зразок ДНК', tag: 'слід', + detail: `${cap(dnaDesc)}. Це чиясь ДНК. Лабораторія скаже, чия саме, — а коли і чому вона тут опинилася, з'ясовувати вам.`, + gives: { dnaSample: true } + })); + if (dna2) dnaAdds.push(() => add({ + id: 'sf_dna2', side: 'field', at: atScene, title: 'Зразок ДНК', tag: 'слід', + detail: `${cap(dna2Desc)}. Це чиясь ДНК. Лабораторія скаже, чия саме, — а коли і чому вона тут опинилася, з'ясовувати вам.`, + gives: { dnaSample2: true } + })); + for (const f of shuffle(r, dnaAdds)) f(); + if (caseType === 'murder' && mutator === 'gloves' && !dna2) add({ + id: 'sf_gloves', side: 'field', at: atScene, + title: 'Жодного біологічного сліду', tag: 'слід', + detail: 'Ані волосини, ані недопалка, ані слини. На пряжці — змазані сліди латексних рукавичок. Вбивця знав, що таке ДНК, і не залишив її. Доведеться йти іншими шляхами: відбиток зі знаряддя — або зламане алібі.' + }); + + // -- фейкові сліди на місці: воно публічне, і не кожен слід стосується справи + const passer = pick(r, citizens.filter(c => !isFigure(c) && Math.abs(c.shoe - killer.shoe) > 1)) || + pick(r, citizens.filter(c => !isFigure(c))); + add({ + id: 'dec_footprint', side: 'field', at: atScene, title: 'Відбиток взуття', tag: 'слід', + detail: `Відбиток підошви, напівзатертий, край розмитий. Розмір ${estRange(r, passer.shoe, 1, 2)}. Протектор гладкий, каблук збитий на один бік.` + }); + add({ + id: 'dec_butt', side: 'field', at: atScene, title: 'Старі недопалки', tag: 'слід', + detail: 'Кілька недопалків різних марок біля лавки. Комусь тут не сидиться без цигарки.' + }); + if (mutator === 'frame') { + add({ + id: 'dec_planted', side: 'field', at: atScene, title: 'Гравірована запальничка', tag: 'слід', + detail: `Біля самого місця — доглянута бензинова запальничка з гравіюванням «${herring.first[0]}. ${herring.last[0]}.». Надто чиста як для речі, загубленої надворі.`, + gives: { pointsTo: herring.id } + }); + add({ + id: 'planted_exp', side: 'analyst', at: { type: 'sys', sys: 'crim' }, q: { sys: 'crim', kind: 'expert' }, requires: ['dec_planted'], + title: 'Експертиза запальнички', tag: 'слід', + detail: 'На запальничці немає ЖОДНОГО відбитка — навіть власника. Її ретельно протерли і поклали так, щоб знайшли. Це не загублена річ. Це підкинута річ.' + }); + } + + // -- реєстри місцями застарілі: у кількох людей у базі неактуальні пальто/розмір. + // Правду показує лише очний огляд за ордером. Фігуранти в реєстрі завжди точні — + // інакше фільтри за слідами стали б нечесними. + const protectedIds = new Set([victim.id, killer.id, herring.id, passer.id, + tSpouse && tSpouse.id, tTarget && tTarget.id, accomplice && accomplice.id].filter(Boolean)); + for (const c of shuffle(r, citizens.filter(c => !protectedIds.has(c.id))).slice(0, ri(r, 2, 4))) { + if (chance(r, 0.5)) c.regCoat = pick(r, N.COATS.filter(x => x !== c.coat)); + else c.regShoe = Math.max(35, Math.min(46, c.shoe + pick(r, [-2, -1, 1, 2]))); + } + + // криміналістика і камери — доступні одразу з термінала + if (caseType === 'murder') add({ + id: 'lab_autopsy', side: 'analyst', at: { type: 'sys', sys: 'crim' }, q: { sys: 'crim', kind: 'expert' }, + title: 'Висновок розтину', tag: 'спосіб', + detail: `Причина смерті: ${method.autopsy}. Час смерті: ${crimeTime} ± 20 хв. Група крові жертви: ${victim.blood}. Під нігтями — чужі волокна тканини, як у ${killer.coat}. Жертва була в ${victim.coat}.`, + gives: { method: true, filter: { attr: 'coat', val: killer.coat } } + }); + else add({ + id: 'lab_traces', side: 'analyst', at: { type: 'sys', sys: 'crim' }, q: { sys: 'crim', kind: 'expert' }, + title: 'Аналіз слідів боротьби', tag: 'місце', + detail: `Висновок криміналістів: опір тривав секунди, слідів крові немає — людину знерухомили й потягли до проїзду. На розірваній лямці сумки — чужі волокна, як у ${killer.coat}. Викрадач діяв сам і мав куди везти: місце утримання підготовлене заздалегідь.`, + gives: { filter: { attr: 'coat', val: killer.coat } } + }); + if (sceneCam) add({ + id: 'rec_cctv', side: 'analyst', at: { type: 'sys', sys: 'cctv' }, q: { sys: 'cctv', building: scene.building }, + title: `Архів: ${sceneCam.name}`, tag: 'слід', + detail: caseType === 'murder' + ? `${crimeTime}: постать у темному швидко йде від місця. Обличчя не видно, кадр зернистий. Зріст за розміткою: приблизно ${estRange(r, killer.height, 6, 10)} см.` + : `${crimeTime}: постать у темному веде другу людину, що обвисла на плечі, в бік проїзду. Обличчя не видно, кадр зернистий. Зріст за розміткою: приблизно ${estRange(r, killer.height, 6, 10)} см.`, + gives: { filter: { attr: 'height', val: killer.height, tol: 10 } } + }); + + // -- Акт I: встановлення особи жертви (два незалежні шляхи) — лише для вбивства; + // у викраденні особа відома із заяви про зникнення + const idPaths = caseType === 'murder' + ? shuffle(r, ['keyring', 'phone_scrap', victim.tattoo ? 'tattoo' : 'phone_scrap']) + .filter((v, i, a) => a.indexOf(v) === i).slice(0, 2) + : []; + if (idPaths.includes('keyring')) { + add({ + id: 'sf_keyring', side: 'field', at: atScene, + title: 'В\'язка ключів із биркою', tag: 'особа', + detail: `У внутрішній кишені — ключі з паперовою биркою: «${victim.home}». Схоже на номер квартири. Питання — в якому будинку.`, + gives: { key: victim.home } + }); + add({ + id: 'id_docs', side: 'field', at: { type: 'unit', unit: victim.home, slot: 'письмовий стіл' }, + title: 'Документи в квартирі', tag: 'особа', requires: ['sf_keyring'], + detail: `Паспорт у шухляді: ${victim.name}, ${victim.birthYear} р.н. Ключ підійшов до квартири ${victim.home}. Тепер у жертви є ім'я.`, + gives: { identity: true } + }); + } + if (idPaths.includes('phone_scrap')) { + const contact = witnessFrom(citizens.filter(c => c.home !== victim.home)); + add({ + id: 'sf_phone_scrap', side: 'field', at: atScene, + title: 'Папірець із номером', tag: 'особа', + detail: `У задній кишені — вирваний аркуш: «${contact.phone}» і приписка «подзвонити ввечері».` + }); + add({ + id: 'id_number_owner', side: 'analyst', at: { type: 'sys', sys: 'phone' }, q: { sys: 'phone', kind: 'owner', match: contact.phone }, requires: ['sf_phone_scrap'], + title: 'Власник номера', tag: 'особа', + detail: `Номер зареєстровано: ${contact.name}, прож. ${contact.home}, ${district.buildings.find(b => b.id === unitBuilding(contact.home)).name}. Варто поговорити.` + }); + add({ + id: 'id_contact', side: 'field', at: { type: 'person', cid: contact.id }, requires: ['id_number_owner'], + title: `Розмова: ${contact.name}`, tag: 'особа', + detail: `«Мій номер? Я ${g(contact, 'лишив', 'лишила')} його одній людині. Ім'я тієї людини — ${victim.name}. Ми домовлялися про зустріч, але ${g(victim, 'він так і не подзвонив', 'вона так і не подзвонила')}…» Опис збігається з тілом.`, + gives: { identity: true } + }); + } + if (idPaths.includes('tattoo')) { + for (const c of citizens) if (c !== victim && c.tattoo === victim.tattoo) c.tattoo = null; // «один запис» мусить бути правдою + add({ + id: 'id_clinic', side: 'analyst', at: { type: 'sys', sys: 'db' }, q: { sys: 'db', match: victim.tattoo }, requires: ['sf_body'], + title: 'Пошук за прикметами', tag: 'особа', + detail: `У базі мешканців з позначкою «${victim.tattoo}» — один запис: ${victim.name}, ${victim.birthYear} р.н. Особу встановлено.`, + gives: { identity: true } + }); + } + + // досьє жертви (після ідентифікації) — база для PIN, паролів та ордерів + if (templateId === 'blackmail' && !victim.dog) victim.dog = pick(r, N.DOG_NAMES); + add({ + id: 'id_dossier', side: 'analyst', at: { type: 'sys', sys: 'db' }, q: { sys: 'db', match: victim.name }, needsIdentity: true, + title: `Досьє: ${victim.name}`, tag: 'особа', + detail: `${victim.name}, ${victim.birthYear} р.н., прож. ${victim.home}. ${victim.job === 'тимчасово не працює' ? 'Офіційно не працює.' : 'Робота: ' + victim.job + '.'}${victim.dog ? ` Тримає собаку на кличку ${victim.dog}.` : ''}` + }); + + // -- мотив (за шаблоном) + хибний слід + const motiveEvs = []; + let motiveId, recapWhy, herringLead; + + if (templateId === 'jealousy') { + motiveId = 'Ревнощі'; + const pin = String(victim.birthYear); + const pinPuzzle = addPuzzle({ + id: 'pz_pin', kind: 'pin', prompt: 'Телефон жертви заблоковано 4-значним PIN-кодом.', + hash: md5(pin), answer: pin, hint: 'Люди ставлять те, що не забудуть: спробуйте рік народження з досьє.' + }); + add({ + id: 'sf_phone_locked', side: 'field', at: atScene, + title: 'Телефон жертви (заблокований)', tag: 'мотив', + detail: 'Екран розбитий, але телефон живий. Заблокований PIN-кодом. Аналітик може спробувати підібрати.' + }); + motiveEvs.push(add({ + id: 'mot_messages', side: 'analyst', at: { type: 'lab' }, requires: ['sf_phone_locked'], needsIdentity: true, puzzle: pinPuzzle, + title: 'Листування в телефоні', tag: 'мотив', + detail: `Місяці переписки з номером ${tSpouse.phone}: зустрічі, готелі, «коли ${tSpouse.sex === 'ч' ? 'він' : 'вона'} у відрядженні». Номер зареєстровано: ${tSpouse.name}.`, + gives: { pointsTo: tSpouse.id } + })); + motiveEvs.push(add({ + id: 'mot_confess', side: 'field', at: { type: 'person', cid: tSpouse.id }, requires: ['mot_messages'], + title: `Розмова: ${tSpouse.name}`, tag: 'мотив', + detail: `Довге мовчання. «Так, ми зустрічалися. Тиждень тому ${killer.first} ${g(killer, 'знайшов', 'знайшла')} листування. ${g(killer, 'Сказав', 'Сказала')}: „Я це закінчу". Я ${g(tSpouse, 'думав', 'думала')} — про шлюб…»`, + gives: { pointsTo: killer.id, motive: true } + })); + motiveEvs.push(add({ + id: 'mot_witness', side: 'field', at: { type: 'person', cid: witnessFrom(citizens.filter(c => c.workplace === 'bar')).id }, needsIdentity: true, + title: 'Свідчення бармена', tag: 'мотив', + detail: `«${victim.first}? Бували тут. У четвер ${g(victim, 'сидів', 'сиділа')} за стійкою, коли ${g(killer, 'влетів', 'влетіла')} ${killer.name} — ${g(killer, 'кричав', 'кричала')} щось про «не лізь у мою сім'ю». Ледь розборонили.»`, + gives: { pointsTo: killer.id, motive: true } + })); + recapWhy = `${killer.name} ${g(killer, 'дізнався', 'дізналася')} про роман. Другою стороною роману були ${tSpouse.first} і жертва. Пробачення не сталося.`; + herringLead = `«Та в ${g(victim, 'нього', 'неї')} був конфлікт! ${herring.name} з ${herring.home} місяць тому ${g(herring, 'влаштував', 'влаштувала')} скандал через залите авто — усі чули погрози.»`; + } + + if (templateId === 'debt') { + motiveId = 'Борг'; + const shift = ri(r, 3, 9); + const plain = `${killer.last} ${killer.first[0]}. — 84 000, прострочено. ${herring.last} ${herring.first[0]}. — 9 000, повернуто.`; + const czPuzzle = addPuzzle({ + id: 'pz_caesar', kind: 'caesar', prompt: 'Зошит, у якому кожен рядок — беззмістовний набір літер.', + payload: caesar(plain, shift), answer: plain, shift, hint: 'Схоже на шифр зсуву (Цезар): переберіть усі зсуви абетки.' + }); + motiveEvs.push(add({ + id: 'mot_notebook', side: 'field', at: { type: 'unit', unit: victim.home, slot: 'під ліжком' }, needsIdentity: true, puzzle: czPuzzle, + title: 'Зошит із шифром', tag: 'мотив', + detail: `Під ліжком — бляшана коробка: готівка і зошит, списаний шифром. ${victim.name} давав гроші під відсоток. Розшифруйте — і побачите боржників.`, + gives: { pointsTo: killer.id, motive: true } + })); + motiveEvs.push(add({ + id: 'mot_bank', side: 'analyst', at: { type: 'sys', sys: 'bank' }, q: { sys: 'bank', match: victim.name }, needsIdentity: true, + title: 'Виписка з рахунку жертви', tag: 'мотив', + detail: `Щомісячні перекази по 7 000 (відправник: ${killer.name}) — і тиша останні три місяці. Останнє повідомлення до переказу: «це все, більше нема».`, + gives: { pointsTo: killer.id, motive: true } + })); + motiveEvs.push(add({ + id: 'mot_neighbor', side: 'field', at: { type: 'person', cid: witnessFrom(citizens.filter(c => unitBuilding(c.home) === unitBuilding(victim.home) && c.home !== victim.home)).id }, needsIdentity: true, + title: 'Свідчення сусіда', tag: 'мотив', + detail: `«За стіною кричали про гроші. ${killer.sex === 'ч' ? 'Чоловічий' : 'Жіночий'} голос повторював: „Я не маю чим, зрозумій, не маю чим"».`, + gives: { motive: true } + })); + recapWhy = `Кредитором був не банк: гроші під відсоток давала жертва. Боржник на 84 тисячі — ${killer.name}, віддавати не було чим. Борг закрили інакше.`; + herringLead = `У зошиті жертви є й ${herring.name} — сусіди кажуть, «теж у боргах, теж сварилися». Щоправда, поруч із тією сумою в зошиті стоїть слово, яке варто прочитати уважно.`; + } + + if (templateId === 'blackmail') { + motiveId = 'Шантаж'; + const pw = genPassword(r, victim); + const pwPuzzle = addPuzzle({ + id: 'pz_md5', kind: 'md5', hash: md5(pw.answer), answer: pw.answer, style: pw.style, + prompt: `Ноутбук жертви. Логін відкритий, пароль — ні. Хеш пароля (md5): ${md5(pw.answer)}.`, + hint: (pw.personal + ? 'Схоже на «особистий» пароль — щось про власника, латиницею. Досьє в поміч: ' + : 'Пароль не пов\'язаний із жертвою — лише перебір за словником (rockyou.txt). ') + pw.hint + }); + const secret = pick(r, [ + `у березні ${killer.name} ${g(killer, 'збив', 'збила')} пішохода на трасі й ${g(killer, 'поїхав', 'поїхала')}; бампер ремонтували «без записів»`, + `диплом, за яким ${killer.name} ${g(killer, 'отримав', 'отримала')} роботу (${killer.job}), — куплений`, + `${killer.name} роками ${g(killer, 'зливав', 'зливала')} касу на роботі повз облік` + ]); + motiveEvs.push(add({ + id: 'mot_laptop', side: 'field', at: { type: 'unit', unit: victim.home, slot: 'письмовий стіл' }, needsIdentity: true, puzzle: pwPuzzle, + title: 'Ноутбук жертви', tag: 'мотив', + detail: `Чернетки листів без підпису: «Я знаю, що ${secret}. 10 000 щомісяця — і ніхто не дізнається». Остання чернетка різкіша: «Ціна подвоюється».`, + gives: { pointsTo: killer.id, motive: true } + })); + motiveEvs.push(add({ + id: 'mot_cash', side: 'analyst', at: { type: 'sys', sys: 'bank' }, q: { sys: 'bank', match: killer.name }, requires: ['mot_laptop'], + title: 'Готівкові зняття', tag: 'мотив', + detail: `Рахунок ${killer.name}: десяте число кожного місяця — зняття 10 000 готівкою, банкомат біля ${district.buildings.find(b => b.id === 'shop').name}. Півроку поспіль. Останній місяць — 20 000.`, + gives: { pointsTo: killer.id, motive: true } + })); + recapWhy = `Жертва знала чужу таємницю і брала за мовчання гроші. Таємниця така: ${secret}. Подвоєна «ціна мовчання» стала вироком самому шантажисту.`; + herringLead = `Серед чернеток є і другий адресат: «Я знаю про ваші оборудки», — а хто в районі має «оборудки», всі скажуть одразу: ${herring.name}.`; + } + + if (templateId === 'witness') { + motiveId = 'Свідок злочину'; + const pin = String(victim.birthYear); + const pinPuzzle = addPuzzle({ + id: 'pz_pin', kind: 'pin', prompt: 'Телефон жертви заблоковано 4-значним PIN-кодом.', + hash: md5(pin), answer: pin, hint: 'Люди ставлять те, що не забудуть: спробуйте рік народження з досьє.' + }); + add({ + id: 'sf_phone_locked', side: 'field', at: atScene, + title: 'Телефон жертви (заблокований)', tag: 'мотив', + detail: 'Екран у павутинні тріщин, але апарат вмикається. PIN. Аналітик може спробувати підібрати.' + }); + motiveEvs.push(add({ + id: 'mot_photos', side: 'analyst', at: { type: 'lab' }, requires: ['sf_phone_locked'], needsIdentity: true, puzzle: pinPuzzle, + title: 'Фото в телефоні жертви', tag: 'мотив', + detail: `Нічні знімки: постать у ${killer.coat} заносить велосипеди в елінг на причалі. Обличчя не видно. Дата — за чотири дні до вбивства.`, + gives: { filter: { attr: 'coat', val: killer.coat } } + })); + motiveEvs.push(add({ + id: 'mot_boathouse', side: 'field', at: { type: 'spot', building: 'pier', spot: 'елінг' }, requires: ['mot_photos'], + title: 'Обшук елінгу', tag: 'мотив', + detail: 'Шість велосипедів зі спиляними номерами рам, добрі замки зрізані болторізом. На стіні — оголошення «продам недорого» з номером.', + gives: { motive: true } + })); + motiveEvs.push(add({ + id: 'mot_ads', side: 'analyst', at: { type: 'sys', sys: 'phone' }, q: { sys: 'phone', kind: 'owner', match: killer.phone }, requires: ['mot_boathouse'], + title: 'Номер з оголошення', tag: 'мотив', + detail: `Номер з оголошення в елінгу зареєстровано: ${killer.name}, прож. ${killer.home}. Жертва сфотографувала крадія — і крадій це помітив.`, + gives: { pointsTo: killer.id, motive: true } + })); + recapWhy = `Жертва випадково сфотографувала нічні «рейси» з краденими велосипедами. Крадій — ${killer.name}. Свідка прибрали.`; + herringLead = `«Кажуть, ${herring.name} вечорами вічно біля елінгу — разів п'ять там ${g(herring, 'крутився', 'крутилася')}. Чого — хтозна.»`; + } + + if (templateId === 'mistaken') { + motiveId = 'Помилкова ціль'; + const accName = 'night_owl_' + ri(r, 100, 999); + const b64Puzzle = addPuzzle({ + id: 'pz_b64', kind: 'b64', prompt: 'Експорт бази месенджера: поле власника акаунта — беззмістовний рядок символів.', + payload: b64(`owner=${killer.name}; phone=${killer.phone}`), answer: `owner=${killer.name}; phone=${killer.phone}`, hint: 'Це не шифр, а кодування Base64 — розкодуйте.' + }); + motiveEvs.push(add({ + id: 'mot_lookalike', side: 'field', at: { type: 'person', cid: witnessFrom(citizens.filter(c => unitBuilding(c.home) === unitBuilding(victim.home) && c.home !== victim.home)).id }, needsIdentity: true, + title: 'Свідчення сусідів', tag: 'мотив', + detail: `«${victim.first}? Царство небесне… Знаєте, ${victim.first} і ${tTarget.first} — їх вічно плутали: той самий одяг, той самий зріст, той самий під'їзд. Навіть листоноша плутав.»` + })); + motiveEvs.push(add({ + id: 'mot_target', side: 'field', at: { type: 'person', cid: tTarget.id }, requires: ['mot_lookalike'], + title: `Розмова: ${tTarget.name}`, tag: 'мотив', + detail: `Блідне. «Це мені писали. Місяць погроз з анонімного акаунта „${accName}": „поверни, або пошкодуєш". Я нікому не ${g(tTarget, 'винен', 'винна')}! Ось, дивіться самі» — і простягає телефон.`, + gives: { motive: true } + })); + motiveEvs.push(add({ + id: 'mot_account', side: 'analyst', at: { type: 'sys', sys: 'phone' }, requires: ['mot_target'], puzzle: b64Puzzle, + title: `Деанон акаунта «${accName}»`, tag: 'мотив', + detail: `Запит до платформи повернув експорт. Розкодоване поле власника: ${killer.name}, номер ${killer.phone}. Погрози на адресу ${tTarget.name} писа${g(killer, 'в', 'ла')} саме ${g(killer, 'він', 'вона')} — через давній борг за розбите в ДТП авто.`, + gives: { pointsTo: killer.id, motive: true } + })); + recapWhy = `Справжня ціль — ${tTarget.name}: місяці погроз через давній борг, і автор погроз — ${killer.name}. У темряві ${g(killer, 'вбивця сплутав', 'вбивця сплутала')} жертву з ціллю: той самий одяг, той самий зріст, той самий під'їзд.`; + herringLead = `А ще сусіди шепочуться: «${herring.name} з ${victim.first} давно на ножах — через паркування, через сміття, через усе».`; + } + + if (templateId === 'inherit') { + motiveId = 'Спадок і страховка'; + const kin = g(killer, 'племінник', 'племінниця'); + motiveEvs.push(add({ + id: 'mot_will', side: 'field', at: { type: 'unit', unit: victim.home, slot: 'шафа' }, needsIdentity: true, + title: 'Чернетка нового заповіту', tag: 'мотив', + detail: 'У шафі, під білизною — чернетка заповіту, датована минулим тижнем: усе майно — благодійному фонду. Підпис не завірений нотаріусом. Хтось дуже не хотів, щоб це було завірено.', + gives: { motive: true } + })); + motiveEvs.push(add({ + id: 'mot_notary', side: 'analyst', at: { type: 'sys', sys: 'db' }, q: { sys: 'db', match: victim.name }, needsIdentity: true, + title: 'Реєстр нотаріуса', tag: 'мотив', + detail: `Чинний заповіт від 2022 року: єдиний спадкоємець — ${kin} жертви, ${killer.name}. Запис про нову зустріч у нотаріуса стояв на наступний тиждень. Зустріч не відбулася.`, + gives: { pointsTo: killer.id, motive: true } + })); + motiveEvs.push(add({ + id: 'mot_insur', side: 'analyst', at: { type: 'sys', sys: 'bank' }, q: { sys: 'bank', match: victim.name }, needsIdentity: true, + title: 'Страховий поліс', tag: 'мотив', + detail: `Страхування життя на 900 000, бенефіціар — ${killer.name}. Найцікавіше: останній рік внески платив не власник поліса, а сам бенефіціар. Інвестиція.`, + gives: { pointsTo: killer.id, motive: true } + })); + motiveEvs.push(add({ + id: 'mot_kin_row', side: 'field', at: { type: 'person', cid: witnessFrom(citizens.filter(c => unitBuilding(c.home) === unitBuilding(victim.home) && c.home !== victim.home)).id }, needsIdentity: true, + title: 'Свідчення сусіда', tag: 'мотив', + detail: `«Тижнів зо два тому на сходах кричали. Другий голос: „Викреслиш мене — пошкодуєш!" Гримнули двері. Я не з тих, хто підслуховує, але сходи в нас лункі.»` + })); + recapWhy = `Чинний заповіт 2022 року називав єдиного спадкоємця: ${killer.name}. Новий заповіт, який усе віддавав фонду, лишалося тільки завірити. Спадщина і страховка на 900 000 переважили.`; + herringLead = `«А ${herring.name} усе розпитує, скільки коштують квартири в будинку жертви. І нащо воно ${g(herring, 'йому', 'їй')}?»`; + } + + if (templateId === 'oldcrime') { + motiveId = 'Старі гріхи'; + motiveEvs.push(add({ + id: 'mot_confession', side: 'field', at: { type: 'unit', unit: victim.home, slot: 'письмовий стіл' }, needsIdentity: true, + title: 'Незакінчений лист', tag: 'мотив', + detail: 'Лист без адресата, перекреслений і початий заново: «Я більше не можу мовчати про те, що ми зробили у 2019-му. Сторож нас не виказав навіть у лікарні. Якщо не зізнаєшся ти — зізнаюсь я.»', + gives: { motive: true } + })); + motiveEvs.push(add({ + id: 'mot_arch2019', side: 'analyst', at: { type: 'sys', sys: 'crim' }, q: { sys: 'crim', kind: 'archive', match: ['2019', 'хвиля'] }, requires: ['mot_confession'], + title: 'Архів: справа 2019 року', tag: 'мотив', + detail: `Крадіжка каси артілі «Хвиля»: сторож дістав травму голови, за рік помер. Підозрювали двох вантажників — недоведено, справу закрито. Прізвища в матеріалах: ${victim.last} і ${killer.last}. Адреса другого збігається з нинішньою: ${killer.home}.`, + gives: { pointsTo: killer.id, motive: true } + })); + motiveEvs.push(add({ + id: 'mot_bank_old', side: 'analyst', at: { type: 'sys', sys: 'bank' }, q: { sys: 'bank', match: killer.name }, requires: ['mot_arch2019'], + title: 'Виписки за 2019 рік', tag: 'мотив', + detail: `Архів банку: восени 2019-го на рахунок ${killer.name} тричі заходила готівка — разом близько чверті вкраденої каси. Задекларованих доходів під ці суми немає.`, + gives: { motive: true } + })); + motiveEvs.push(add({ + id: 'mot_oldtimer', side: 'field', at: { type: 'person', cid: witnessFrom(citizens.filter(c => c.job === 'пенсіонер')).id }, needsIdentity: true, + title: 'Пам\'ять старожила', tag: 'мотив', + detail: `«Ці двоє? Нерозлийвода були, разом вантажниками в артілі. А після тієї історії зі сторожем — ані слова одне одному, роками. Таке буває тільки після грошей або гріха. Або обох.»` + })); + recapWhy = `У 2019-му двоє вантажників винесли касу артілі; сторож, що їх упізнав, помер за рік. Жертва вирішила зізнатися — і подзвонила спільнику. Спільник — ${killer.name} — ${g(killer, 'вирішив', 'вирішила')} інакше.`; + herringLead = `«Питаєте про старе? ${herring.name} тоді теж біля артілі ${g(herring, 'крутився', 'крутилася')}, усі знали. Темна історія.»`; + } + + if (templateId === 'stalker') { + motiveId = 'Одержимість'; + motiveEvs.push(add({ + id: 'mot_letters', side: 'field', at: { type: 'unit', unit: victim.home, slot: 'шафа' }, needsIdentity: true, + title: 'Пачка листів без підпису', tag: 'мотив', + detail: `Перев'язана шпагатом пачка — десятки листів, однаковий почерк, без підпису: «я бачу тебе щодня», «ти всміхаєшся не мені», «я вмію чекати». Найстаріший — дворічної давності. Останній закінчується інакше: «чекати я більше не буду».`, + gives: { motive: true } + })); + motiveEvs.push(add({ + id: 'mot_complaint', side: 'analyst', at: { type: 'sys', sys: 'crim' }, q: { sys: 'crim', kind: 'archive', match: [victim.last, victim.name] }, needsIdentity: true, + title: 'Торішня заява жертви', tag: 'мотив', + detail: `Заява про переслідування, торік, руху не дали: «невідома особа стежить, лишає квіти під дверима». Прикмети з заяви: ${killer.coat}, зріст приблизно ${estRange(r, killer.height)} см.`, + gives: { filter: { attr: 'coat', val: killer.coat }, motive: true } + })); + motiveEvs.push(add({ + id: 'mot_flowers', side: 'field', at: { type: 'person', cid: witnessFrom(citizens.filter(c => c.workplace === 'shop')).id }, requires: ['mot_complaint'], + title: 'Свідчення з мінімаркету', tag: 'мотив', + detail: `«Три гвоздики щоп'ятниці, завжди готівкою, завжди мовчки. Хто? Та ${killer.name} же. От, думаю собі, людина постійна.»`, + gives: { pointsTo: killer.id } + })); + motiveEvs.push(add({ + id: 'mot_shrine', side: 'field', at: { type: 'unit', unit: killer.home, slot: 'шафа' }, + title: 'Стіна фотографій', tag: 'мотив', + detail: 'На внутрішньому боці дверцят шафи — десятки фото жертви, знятих здалеку: біля дому, на набережній, крізь вітрину. Дати на звороті — два роки спостережень.', + gives: { motive: true } + })); + recapWhy = `Два роки листів без відповіді, квіти під дверима, фото здалеку. Автор листів — ${killer.name}. Одержимість скінчилась там, де закінчилось «я вмію чекати».`; + herringLead = `«Хочете про дивних? ${herring.name} вічно сидить біля вікна з біноклем. Каже — «за птахами». Ага, за птахами.»`; + } + + if (templateId === 'business') { + motiveId = 'Кримінальний бізнес'; + const shift2 = ri(r, 3, 9); + const plain2 = `Частка ${killer.last} ${killer.first[0]}. — сорок відсотків. Не сплачено двічі. Точка — склад маркету.`; + const czPuzzle2 = addPuzzle({ + id: 'pz_caesar', kind: 'caesar', prompt: 'Зошит обліку, писаний нечитабельними літерами.', + payload: caesar(plain2, shift2), answer: plain2, shift: shift2, hint: 'Схоже на шифр зсуву (Цезар): переберіть усі зсуви абетки.' + }); + motiveEvs.push(add({ + id: 'mot_ledger', side: 'field', at: { type: 'unit', unit: victim.home, slot: 'кухня' }, needsIdentity: true, puzzle: czPuzzle2, + title: 'Зошит подвійної бухгалтерії', tag: 'мотив', + detail: 'У бляшанці з-під кави — зошит: колонки сум без підписів і шифровані нотатки. Легальних доходів на такі колонки жертва не мала. Розшифруйте — і стане видно, з ким ділили.', + gives: { pointsTo: killer.id, motive: true } + })); + motiveEvs.push(add({ + id: 'mot_stash', side: 'field', at: { type: 'spot', building: 'shop', spot: 'склад' }, requires: ['mot_ledger'], + title: 'Тайник на складі', tag: 'мотив', + detail: 'За стелажем із мінералкою — коробки без маркування, лише код маркером. Той самий код — у зошиті жертви. Контрабандний товар ішов через район тихо і давно.', + gives: { motive: true } + })); + motiveEvs.push(add({ + id: 'mot_bank_biz', side: 'analyst', at: { type: 'sys', sys: 'bank' }, q: { sys: 'bank', match: [victim.name, killer.name] }, needsIdentity: true, + title: 'Синхронні внески', tag: 'мотив', + detail: `Два рахунки, однакові готівкові внески в ті самі дати, місяць за місяцем: рахунок жертви — і рахунок ${killer.name}. Партнерство, якого офіційно не існувало. Останні два місяці вносила тільки жертва.`, + gives: { pointsTo: killer.id, motive: true } + })); + motiveEvs.push(add({ + id: 'mot_bar_corner', side: 'field', at: { type: 'person', cid: witnessFrom(citizens.filter(c => c.workplace === 'bar')).id }, needsIdentity: true, + title: 'Свідчення з бару', tag: 'мотив', + detail: `«Ці двоє завжди сідали в кутку, рахували щось у зошиті. Останнім часом — на підвищених тонах. Слово «частка» я чув частіше, ніж «будьмо».»` + })); + recapWhy = `Через район тихо йшов контрабандний товар; частки ділили за зошитом. Коли жертва двічі не заплатила партнерську частку, партнер — ${killer.name} — ${g(killer, 'закрив', 'закрила')} баланс назавжди.`; + herringLead = `«Шукаєте, хто мутить? ${herring.name} на склад маркету заходить частіше, ніж туди завозять хліб. Просто кажу.»`; + } + + if (templateId === 'serial') { + motiveId = 'Серійний патерн'; + if (!killer.tattoo) killer.tattoo = pick(r, N.TATTOOS); + for (const c of citizens) if (c !== killer && c.tattoo === killer.tattoo) c.tattoo = null; + const sign = pick(r, SIGNATURES); + add({ + id: 'sf_signature', side: 'field', at: atScene, + title: 'Підпис на тілі', tag: 'патерн', + detail: `Деталь, яка не дає спокою: ${sign.text}. Це не випадковість і не пограбування. Це підпис. Такі речі повторюються.` + }); + motiveEvs.push(add({ + id: 'mot_arch_serial', side: 'analyst', at: { type: 'sys', sys: 'crim' }, q: { sys: 'crim', kind: 'archive', match: [sign.key, 'підпис'] }, requires: ['sf_signature'], + title: 'Архів: справа 2021 року', tag: 'мотив', + detail: `Пошук за підписом дав збіг: 2021 рік, сусідній район, нерозкрите вбивство — і той самий підпис: ${sign.text}. Тоді вирішили, що це дрібниця, і не внесли в орієнтування. Це не перше його полювання.`, + gives: { motive: true } + })); + motiveEvs.push(add({ + id: 'mot_sketch', side: 'analyst', at: { type: 'sys', sys: 'crim' }, q: { sys: 'crim', kind: 'archive', match: [sign.key, 'підпис', '2021'] }, requires: ['mot_arch_serial'], + title: 'Свідок 2021 року: прикмети', tag: 'мотив', + detail: `У справі 2021-го є свідчення: біля місця бачили постать у ${killer.coat}, а на передпліччі — татуювання: ${killer.tattoo}. Свідка тоді не сприйняли серйозно.`, + gives: { filter: { attr: 'coat', val: killer.coat } } + })); + motiveEvs.push(add({ + id: 'mot_tattoo_db', side: 'analyst', at: { type: 'sys', sys: 'db' }, q: { sys: 'db', match: killer.tattoo }, requires: ['mot_sketch'], + title: 'Пошук за татуюванням', tag: 'вбивця', + detail: `Запит по базі мешканців: «${killer.tattoo}» — єдиний збіг у районі: ${killer.name}, прож. ${killer.home}.`, + gives: { pointsTo: killer.id } + })); + motiveEvs.push(add({ + id: 'mot_trophies', side: 'field', at: { type: 'unit', unit: killer.home, slot: 'шафа' }, + title: 'Коробка з трофеями', tag: 'мотив', + detail: 'Взуттєва коробка на антресолі: жіночий годинник із чужим гравіюванням, стара запальничка, окуляри — і чохол від телефона жертви. Кожна річ — чиясь. Кожна річ — трофей.', + gives: { motive: true } + })); + recapWhy = `Мотиву в людському сенсі не було. Був патерн: підпис (${sign.text}) уже з'являвся у 2021-му, і тоді його не помітили. ${killer.name} ${g(killer, 'обирав', 'обирала')} тих, хто живе сам і повертається затемна.`; + herringLead = `«Моторошне питаєте? ${herring.name} ночами гуляє районом і фотографує чужі вікна. Сусіди двічі таке бачили.»`; + } + + if (templateId === 'ransom') { + motiveId = 'Викуп'; + const relative = witnessFrom(citizens.filter(c => c.home !== victim.home)); + motiveEvs.push(add({ + id: 'mot_lair', side: 'field', at: { type: 'spot', building: 'pier', spot: 'елінг' }, + title: 'Підготовлене лігво', tag: 'мотив', + detail: 'В елінгу за брезентом — матрац, ланцюг із замком, каністра води, відро. Усе нове, з цінниками. Хтось готувався тримати тут людину. Живою.', + gives: { motive: true } + })); + motiveEvs.push(add({ + id: 'mot_supplies', side: 'field', at: { type: 'person', cid: witnessFrom(citizens.filter(c => c.workplace === 'shop')).id }, needsIdentity: true, + title: 'Свідчення з мінімаркету', tag: 'мотив', + detail: `«Дивна закупівля була днями: стяжки, скотч, брезент, ланцюг — усе разом. Хто? Не пригадаю обличчя, але платили карткою, це точно в терміналі лишилось.»` + })); + motiveEvs.push(add({ + id: 'mot_card', side: 'analyst', at: { type: 'sys', sys: 'bank' }, q: { sys: 'bank', match: ['термінал', district.buildings.find(b => b.id === 'shop').name] }, requires: ['mot_supplies'], + title: 'Термінал мінімаркету', tag: 'мотив', + detail: `Транзакція по картці за ту закупівлю: ${killer.name}. Стяжки, скотч, брезент і ланцюг — за три дні до смерті жертви.`, + gives: { pointsTo: killer.id, motive: true } + })); + motiveEvs.push(add({ + id: 'mot_ransom_note', side: 'field', at: { type: 'unit', unit: killer.home, slot: 'письмовий стіл' }, + title: 'Чернетка вимоги викупу', tag: 'мотив', + detail: `Аркуш із друкованими літерами, тричі переписаний: «200 000 за життя. Поліція = труна.» Адресат — рідня жертви. Лист так і не знадобився: викрадення пішло не за планом.`, + gives: { motive: true } + })); + add({ + id: 'mot_relative', side: 'field', at: { type: 'person', cid: relative.id }, needsIdentity: true, + title: `Розмова: ${relative.name}`, tag: 'мотив', + detail: `${g(relative, 'Далекий родич', 'Далека родичка')} жертви. «За день до того був дзвінок із прихованого номера. Мовчали. Дихали. Я ще пожартувала: „Може, викрасти хочуть?" Господи.»` + }); + recapWhy = `План був інший: викрасти живою людину, тримати в елінгу і взяти з рідні 200 000. Але жертва пручалася — і план скінчився в перші ж хвилини. Автор плану — ${killer.name}.`; + herringLead = `«${herring.name} нещодавно в барі розпитува${g(herring, 'в', 'ла')}, хто в районі має гроші і хто живе сам. Тоді здалося балачкою.»`; + } + + if (templateId === 'k_ransom') { + motiveId = 'Викуп'; + const rep = witnessFrom(citizens.filter(c => c.home !== victim.home)); + startingFacts.push(add({ + id: 'sf_report', side: 'field', at: { type: 'start' }, + title: 'Заява про викрадення', tag: 'жертва', + detail: `${rep.name} (рідня зниклої особи) ${g(rep, 'приніс', 'принесла')} записку зі своєї поштової скриньки: «200 000 — і побачите ${g(victim, 'його', 'її')} ${g(victim, 'живим', 'живою')}. Поліція = кінець». Зникла особа: ${victim.name}, ${victim.age} р.`, + gives: { identity: true, motive: true } + })); + motiveEvs.push(add({ + id: 'mot_note_exp', side: 'analyst', at: { type: 'sys', sys: 'crim' }, q: { sys: 'crim', kind: 'expert' }, requires: ['sf_report'], + title: 'Експертиза записки', tag: 'мотив', + detail: 'Друковані літери через трафарет, папір — зі звичайного офісного стосу. Жодних відбитків: писали в рукавичках. Готувалися заздалегідь і не поспішали.', + gives: { motive: true } + })); + motiveEvs.push(add({ + id: 'mot_supplies', side: 'field', at: { type: 'person', cid: witnessFrom(citizens.filter(c => c.workplace === 'shop')).id }, + title: 'Свідчення з мінімаркету', tag: 'мотив', + detail: `«Дивна закупівля була: стяжки, скотч, брезент, навісний замок — усе разом. Обличчя не пригадаю, але платили карткою — термінал пам'ятає все.»` + })); + motiveEvs.push(add({ + id: 'mot_card', side: 'analyst', at: { type: 'sys', sys: 'bank' }, q: { sys: 'bank', match: ['термінал', district.buildings.find(b => b.id === 'shop').name] }, requires: ['mot_supplies'], + title: 'Термінал мінімаркету', tag: 'мотив', + detail: `Транзакція за ту закупівлю: ${killer.name}. Стяжки, скотч і новий замок — за два дні до викрадення.`, + gives: { pointsTo: killer.id, motive: true } + })); + add({ + id: 'mot_lairclue', side: 'field', at: { type: 'unit', unit: killer.home, slot: 'письмовий стіл' }, + title: 'Чек і нотатка', tag: 'місце', + detail: `У кошику для паперів — зіжмаканий чек за замок і аркуш із розрахунком: «200 000 − борги = вистачить». На звороті олівцем — «${lair.label}» і час обходів охорони.`, + gives: { lair: true, motive: true } + }); + recapWhy = `План був простий і холодний: викрасти, сховати, взяти з рідні 200 000. Автор плану — ${killer.name}.`; + herringLead = `«${herring.name} нещодавно в барі розпитува${g(herring, 'в', 'ла')}, хто в районі має гроші і хто живе сам. Тоді здалося балачкою.»`; + } + + if (templateId === 'k_obsession') { + motiveId = 'Одержимість'; + const rep = witnessFrom(victim.workplace ? citizens.filter(c => c.workplace === victim.workplace) : citizens); + startingFacts.push(add({ + id: 'sf_report', side: 'field', at: { type: 'start' }, + title: 'Заява про зникнення', tag: 'жертва', + detail: `${rep.name} ${g(rep, 'заявив', 'заявила')}: ${victim.name} другий день не виходить на зв'язок, телефон вимкнений, двері зачинені. «Це не в ${g(victim, 'його', 'її')} стилі. І ще: останнім часом ${g(victim, 'він скаржився', 'вона скаржилася')}, що за ${g(victim, 'ним', 'нею')} хтось ходить.»`, + gives: { identity: true } + })); + motiveEvs.push(add({ + id: 'mot_letters', side: 'field', at: { type: 'unit', unit: victim.home, slot: 'шафа' }, + title: 'Пачка листів без підпису', tag: 'мотив', + detail: 'Десятки листів, однаковий почерк: «я бачу тебе щодня», «ти всміхаєшся не мені», «я вмію чекати». Останній інакший: «скоро ти зрозумієш, що нам ніхто не потрібен».', + gives: { motive: true } + })); + motiveEvs.push(add({ + id: 'mot_complaint', side: 'analyst', at: { type: 'sys', sys: 'crim' }, q: { sys: 'crim', kind: 'archive', match: [victim.last, victim.name] }, + title: 'Торішня заява зниклої особи', tag: 'мотив', + detail: `Заява про переслідування, руху не дали: «невідома особа стежить, лишає квіти під дверима». Прикмети: ${killer.coat}, зріст приблизно ${estRange(r, killer.height)} см.`, + gives: { filter: { attr: 'coat', val: killer.coat }, motive: true } + })); + motiveEvs.push(add({ + id: 'mot_flowers', side: 'field', at: { type: 'person', cid: witnessFrom(citizens.filter(c => c.workplace === 'shop')).id }, requires: ['mot_complaint'], + title: 'Свідчення з мінімаркету', tag: 'мотив', + detail: `«Три гвоздики щоп'ятниці, завжди готівкою, завжди мовчки. Хто? Та ${killer.name} же. От, думаю собі, людина постійна.»`, + gives: { pointsTo: killer.id } + })); + add({ + id: 'mot_lairclue', side: 'field', at: { type: 'unit', unit: killer.home, slot: 'шафа' }, + title: 'План «нашого дому»', tag: 'місце', + detail: `На внутрішньому боці дверцят — фото ${victim.name}, зняті здалеку. І аркуш, накреслений від руки: матрац, лампа, засув. Підпис під планом: «${lair.label}». Усе обведено сердечком.`, + gives: { lair: true, motive: true } + }); + recapWhy = `Два роки листів, квітів і фото здалеку. Коли «я вмію чекати» скінчилось, ${killer.name} ${g(killer, 'вирішив', 'вирішила')}: якщо не разом — то під замком.`; + herringLead = `«Хочете про дивних? ${herring.name} вічно сидить біля вікна з біноклем. Каже — «за птахами». Ага, за птахами.»`; + } + + if (templateId === 'k_debt') { + motiveId = 'Борг'; + const rep = witnessFrom(citizens.filter(c => c.home !== victim.home)); + const shiftK = ri(r, 3, 9); + const plainK = `Винен ${killer.last} ${killer.first[0]}. — дев'яносто тисяч. Прострочено двічі. Каже: відпрацюєш.`; + const czK = addPuzzle({ + id: 'pz_caesar', kind: 'caesar', prompt: 'Нотатки зниклої особи — суцільний беззмістовний набір літер.', + payload: caesar(plainK, shiftK), answer: plainK, shift: shiftK, hint: 'Схоже на шифр зсуву (Цезар): переберіть усі зсуви абетки.' + }); + startingFacts.push(add({ + id: 'sf_report', side: 'field', at: { type: 'start' }, + title: 'Заява про зникнення', tag: 'жертва', + detail: `${rep.name} ${g(rep, 'заявив', 'заявила')}: ${victim.name} ${g(victim, 'зник', 'зникла')} по дорозі додому. «За день до того ${g(victim, 'він просив', 'вона просила')} у мене грошей — багато і терміново. Сказали тільки: „інакше буде зле".»`, + gives: { identity: true } + })); + motiveEvs.push(add({ + id: 'mot_iou', side: 'field', at: { type: 'unit', unit: victim.home, slot: 'письмовий стіл' }, puzzle: czK, + title: 'Шифровані нотатки боржника', tag: 'мотив', + detail: 'У шухляді — розписка на 90 000 без імені кредитора і нотатник, писаний шифром. Людина боялась навіть власних записів. Розшифруйте — і кредитор матиме ім\'я.', + gives: { pointsTo: killer.id, motive: true } + })); + motiveEvs.push(add({ + id: 'mot_bank_k', side: 'analyst', at: { type: 'sys', sys: 'bank' }, q: { sys: 'bank', match: victim.name }, + title: 'Виписка зниклої особи', tag: 'мотив', + detail: `Щомісячні перекази з рахунку жертви (отримувач: ${killer.name}) припинились три місяці тому. Останній коментар до переказу: «решту віддам, чесно».`, + gives: { pointsTo: killer.id, motive: true } + })); + motiveEvs.push(add({ + id: 'mot_threat', side: 'field', at: { type: 'person', cid: witnessFrom(citizens.filter(c => unitBuilding(c.home) === unitBuilding(victim.home) && c.home !== victim.home)).id }, + title: 'Свідчення сусіда', tag: 'мотив', + detail: `«Тиждень тому під дверима стояв хтось і цідив крізь зуби: „Тиждень. Або поїдеш зі мною і відпрацюєш". Я в чужі справи не лізу, але голос був недобрий.»` + })); + add({ + id: 'mot_lairclue', side: 'field', at: { type: 'unit', unit: killer.home, slot: 'під ліжком' }, + title: 'Ключ із биркою', tag: 'місце', + detail: `Під ліжком — ящик з інструментами, а в ньому ключ від навісного замка зі свіжою паперовою биркою: «${lair.label}». Замок із комплекту — той самий, що зник із полиці маркету.`, + gives: { lair: true } + }); + recapWhy = `Борг у 90 000, прострочений двічі. «Відпрацюєш» виявилось буквальним: ${killer.name} ${g(killer, 'вирішив', 'вирішила')} утримувати боржника, поки рідня не збере гроші.`; + herringLead = `«У зошитах жертви і ${herring.name} десь фігурує — теж, кажуть, позичали одне одному. Хто кому винен — спитайте самі.»`; + } + + // місце утримання: знайти можна лише за зачіпкою, навмання обшукати всі кутки не вийде + if (caseType === 'kidnap') add({ + id: 'lair_signs', side: 'field', at: { type: 'spot', building: lair.building, spot: lair.spot }, requires: ['mot_lairclue'], + title: `Місце утримання: ${lair.label}`, tag: 'місце', + detail: 'Новий навісний замок. Крізь щілину видно матрац, пляшки з водою — і рух. Тут тримають людину. Самим не штурмувати: подавайте рапорт (ХТО / ДЕ / ЧОМУ) — група захоплення готова.', + gives: { lair: true } + }); + + // -- Тріангуляція: запит «де зараз номер» у оператора. Ціль — жива позиція + // вбивці на момент запиту, сервер рахує відстані до веж у реальному часі. + add({ + id: 'rec_triangulate', side: 'analyst', at: { type: 'sys', sys: 'phone' }, q: { sys: 'phone', kind: 'locate', match: killer.phone }, needsIdentity: true, special: 'triangulate', + title: 'Тріангуляція невідомого номера', tag: 'слід', + detail: `Номер ${killer.phone}, що засвітився біля місця події, запеленговано: перетин трьох веж вказав будинок — він у звіті. Коло звузилось до тих, хто там буває. Порівняйте їх зі слідами (взуття, зріст, одяг, ДНК).` + }); + + // -- хибний слід: зачіпка і спростування + add({ + id: 'her_lead', side: 'field', at: { type: 'person', cid: witnessFrom(citizens.filter(c => c.workplace === 'diner' || c.workplace === 'shop')).id }, needsIdentity: true, + title: 'Чутка з району', tag: 'підозра', + detail: herringLead + ` (${herring.name}: ${herring.height} см, взуття ${herring.shoe} — під прикмети пасує.)`, + gives: { pointsTo: herring.id } + }); + // -- хибний мотив: у хибного підозрюваного — ВЛАСНИЙ справжній конфлікт із жертвою, + // з доказами, як у повноцінної теорії. Але він не вбивав. Тип конфлікту завжди + // інший, ніж мотив вбивці, — щоб дві теорії не зливались в одну. + const ded = caseType === 'kidnap' ? 'зникнення' : 'смерті'; + const fmWitness = witnessFrom(citizens.filter(c => + unitBuilding(c.home) === unitBuilding(herring.home) || (c.workplace && c.workplace === herring.workplace))); + const fmSlot = shuffle(r, district.homeSlots).find(sl => + !Object.values(E).some(e => e.at.type === 'unit' && e.at.unit === victim.home && e.at.slot === sl)) || 'кухня'; + const FM_KINDS = [ + { + label: 'Борг', + admit: `я ${g(herring, 'винен був', 'винна була')} ${g(victim, 'йому', 'їй')} гроші і не віддавав`, + lead: () => add({ + id: 'fm_lead', side: 'analyst', at: { type: 'sys', sys: 'bank' }, q: { sys: 'bank', match: herring.name }, needsIdentity: true, + title: `Виписка: ${herring.name}`, tag: 'підозра', + detail: `Щомісячні перекази на рахунок ${victim.name} раптово припинилися пів року тому. Коментар до останнього: «далі не зможу, вибач». Борг, який перестали платити.`, + gives: { pointsTo: herring.id } + }), + talk: () => add({ + id: 'fm_talk', side: 'field', at: { type: 'person', cid: fmWitness.id }, needsIdentity: true, + title: `Про борг: ${fmWitness.name}`, tag: 'підозра', + detail: `«Гроші між ними стояли давно: ${herring.first} позичи${g(herring, 'в', 'ла')} у ${victim.first} чималу суму. Віддавати не було з чого, і обоє про це знали.»` + }) + }, + { + label: 'Ревнощі', + admit: `я писа${g(herring, 'в', 'ла')} ті записки, і остання була злою`, + lead: () => add({ + id: 'fm_lead', side: 'field', at: { type: 'unit', unit: victim.home, slot: fmSlot }, needsIdentity: true, + title: 'Пачка записок без конверта', tag: 'підозра', + detail: `Перев'язані гумкою записки, підписані «${herring.first[0]}.»: спершу ніжні, далі гіркіші, остання — «ти ще пошкодуєш, що сміявся». Почерк рівний, натиск сильний.`, + gives: { pointsTo: herring.id } + }), + talk: () => add({ + id: 'fm_talk', side: 'field', at: { type: 'person', cid: witnessFrom(citizens.filter(c => c.workplace === 'bar')).id }, needsIdentity: true, + title: 'Свідчення з бару', tag: 'підозра', + detail: `«${herring.first}? Та пів бару знало, що ${g(herring, 'він', 'вона')} до ${victim.first} небайдуж${herring.sex === 'ж' ? 'а' : 'ий'}. А ${victim.first} лише відмахувався. Було в цьому щось недобре.»` + }) + }, + { + label: 'Сусідська війна', + admit: `ми справді гризлися через скарги, і я ${g(herring, 'казав', 'казала')} зайве`, + lead: () => add({ + id: 'fm_lead', side: 'analyst', at: { type: 'sys', sys: 'crim' }, q: { sys: 'crim', kind: 'archive', match: [herring.last, herring.name] }, needsIdentity: true, + title: `Скарги: ${herring.last} ↔ ${victim.last}`, tag: 'підозра', + detail: `Сім взаємних скарг за два роки: шум, залиття, сміття. Останню жертва подала за тиждень до ${ded}. У тексті скарги ${herring.name} — фраза «я цьому покладу край».`, + gives: { pointsTo: herring.id } + }), + talk: () => add({ + id: 'fm_talk', side: 'field', at: { type: 'person', cid: witnessFrom(citizens.filter(c => unitBuilding(c.home) === unitBuilding(victim.home) && c.home !== victim.home)).id }, needsIdentity: true, + title: 'Свідчення сусіда', tag: 'підозра', + detail: `«Та вони роками гризлися! Комісії ходили, акти складали. Востаннє ${herring.first} крича${g(herring, 'в', 'ла')} на сходах так, що чув весь під'їзд.»` + }) + }, + { + label: 'Розбите авто', + admit: `${g(victim, 'він', 'вона')} справді розби${g(victim, 'в', 'ла')} мені крило і відмови${g(victim, 'вся', 'лася')} платити`, + lead: () => add({ + id: 'fm_lead', side: 'field', at: { type: 'unit', unit: victim.home, slot: fmSlot }, needsIdentity: true, + title: 'Лист від адвоката', tag: 'підозра', + detail: `Офіційна вимога: компенсувати ремонт авто ${herring.name} після дрібної ДТП у дворі. Поверх листа — розмашисте, рукою жертви: «нічого не платитиму».`, + gives: { pointsTo: herring.id } + }), + talk: () => add({ + id: 'fm_talk', side: 'field', at: { type: 'person', cid: witnessFrom(citizens.filter(c => c.workplace === 'garage')).id }, needsIdentity: true, + title: 'Свідчення з автомайстерні', tag: 'підозра', + detail: `«Привози${g(herring, 'в', 'ла')} авто з пом'ятим крилом і клекот${g(herring, 'ів', 'іла')}: мовляв, ${victim.first} заплатить за все. Ми ще сміялися — хто ж так борги вибиває.»` + }) + } + ]; + const fm = shuffle(r, FM_KINDS).find(f => f.label !== motiveId) || FM_KINDS[0]; + fm.lead(); fm.talk(); + + // -- пристрої фігурантів: польовий вилучає (ордер), аналітик ламає PIN/пароль, + // всередині — шари з шансом ~35% кожен: історія браузера, чати. Може бути й чисто. + const HIST_COMMON = ['погода на тиждень', 'розклад маршруток', 'рецепт як у їдальні', 'дешеві авіаквитки', + 'чому болить спина зранку', 'серіали за вечір список', 'як позбутися мурах', 'гороскоп терези']; + const CHAT_COMMON = ['«Купи хліба» — «Вже йду»', '«Ти де?» — «Затримуюсь, не чекайте»', + '«Завтра зміна з восьмої» — «Прийду»', '«Мама дзвонила?» — «Подзвоню ввечері»']; + const histFor = who => { + if (who === killer) return caseType === 'kidnap' + ? pick(r, [['навісні замки які надійніші', 'скільки людина може без їжі'], ['чи пишуть камери вночі', 'зняти готівку без банкомата']]) + : method.id === 'poison' + ? [['барбітурати дозування', 'через скільки діє снодійне'], ['алкоголь і снодійне сумісність', 'чи виявляє розтин отруту']][ri(r, 0, 1)] + : pick(r, [['чи бачать камери вночі', 'як поводитись на допиті'], ['квитки в один бік дешево', 'строк давності тяжких злочинів']]); + if (who === herring) return { + 'Борг': ['мікропозика без відмови', 'як швидко заробити'], + 'Ревнощі': ['як повернути кохану людину', 'чому мене не помічають'], + 'Сусідська війна': ['зразок скарги на сусіда', 'куди скаржитись на шум'], + 'Розбите авто': ['вартість ремонту крила', 'як стягнути збитки з винуватця'] + }[fm.label]; + if (accomplice && who === accomplice) return ['чи можна відкликати свідчення', 'адвокат недорого консультація']; + return { + jealousy: ['готелі подобово поруч'], debt: ['шаблон розписки про борг'], blackmail: ['анонімна пошта створити'], + witness: ['куди повідомити про крадіжку'], stalker: ['як зафіксувати переслідування'], inherit: ['зразок заповіту'], + oldcrime: ['строк давності злочинів'], mistaken: ['як змінити номер телефону'], business: ['митні правила дрібні партії'], + serial: ['самооборона курси'], ransom: ['страхування життя умови'], k_ransom: ['страхування життя умови'], + k_obsession: ['як зафіксувати переслідування'], k_debt: ['шаблон розписки про борг'] + }[templateId] || []; + }; + const usedDevSlots = new Set(); + const devFigures = [[killer, 'k'], [herring, 'h']]; + if (accomplice) devFigures.push([accomplice, 'a']); + if (caseType === 'murder' && !['jealousy', 'witness', 'blackmail'].includes(templateId)) devFigures.push([victim, 'v']); + for (const [who, suf] of devFigures) { + const isPhone = chance(r, 0.5); + const taken = sl => usedDevSlots.has(who.home + '|' + sl) || + Object.values(E).some(e => e.at.type === 'unit' && e.at.unit === who.home && e.at.slot === sl); + const slot = shuffle(r, district.homeSlots).find(sl => !taken(sl)) || 'комора'; + usedDevSlots.add(who.home + '|' + slot); + let pz; + if (isPhone) { + const pin = String(who.birthYear); + pz = addPuzzle({ + id: 'pz_dev_' + suf, kind: 'pin', hash: md5(pin), answer: pin, + prompt: `Вилучений телефон (${who.name}): заблоковано 4-значним PIN.`, + hint: 'Люди ставлять те, що не забудуть. Рік народження власника є в реєстрі мешканців.' + }); + } else { + const pw = genPassword(r, who); + pz = addPuzzle({ + id: 'pz_dev_' + suf, kind: 'md5', hash: md5(pw.answer), answer: pw.answer, style: pw.style, + prompt: `Вилучений ноутбук (${who.name}): вхід за паролем. Хеш (md5): ${md5(pw.answer)}.`, + hint: (pw.personal ? 'Особистий пароль — щось про власника, латиницею (див. реєстр): ' : 'Пароль випадковий — лише словниковий перебір: ') + pw.hint + }); + } + add({ + id: 'dev_' + suf, side: 'field', at: { type: 'unit', unit: who.home, slot }, + title: `${isPhone ? 'Телефон' : 'Ноутбук'}: ${who.name}`, tag: 'слід', + detail: `${isPhone ? 'Телефон' : 'Ноутбук'} у житлі ${who.name}. Вилучено для лабораторії: ${isPhone ? 'заблокований PIN-кодом' : 'вхід за паролем'} — далі справа аналітика.` + }); + // кожен шар — рідкісний і незалежний: один пристрій майже ніколи не дає всього + const parts = []; + if (chance(r, 0.3)) { + const lines = shuffle(r, [...histFor(who), ...shuffle(r, HIST_COMMON).slice(0, 3)]).slice(0, 4); + parts.push('Історія браузера: ' + lines.map(l => `«${l}»`).join(', ') + '.'); + } + if (chance(r, 0.3)) { + const mateC = who.spouse ? byId(who.spouse) : pick(r, citizens.filter(o => o !== who && o.id !== victim.id)); + parts.push(`Останні чати: побут із ${mateC.name}. ` + pick(r, CHAT_COMMON) + + (who === killer ? ` І одне непрочитане того вечора: «Подзвони, як будеш удома» — без відповіді.` : '')); + } + add({ + id: 'dev_' + suf + '_data', side: 'analyst', at: { type: 'lab' }, requires: ['dev_' + suf], puzzle: pz, + title: `Вміст пристрою: ${who.name}`, tag: 'слід', + detail: parts.length ? parts.join(' ') + : 'Чисто: банк, погода, пасьянс. Або власник обережний, або йому справді нічого ховати.' + }); + } + + add({ + id: 'her_alibi', side: 'analyst', at: { type: 'sys', sys: 'cctv' }, q: { sys: 'cctv', building: herring.evening }, requires: ['her_lead'], + title: `Перевірка: ${herring.name}`, tag: 'алібі', + detail: `Архів камер: ${crimeTime} — ${herring.name} у кадрі біля «${district.buildings.find(b => b.id === herring.evening).name.split(',')[0]}», далеко від місця злочину. Обличчя чітке. Це не ${herring.sex === 'ч' ? 'він' : 'вона'}.`, + gives: { disproves: herring.id } + }); + + // -- знаряддя і підтвердження вбивці (у викраденні знаряддя немає — лише ДНК) + const weaponInHome = chance(r, 0.3); + const dump = pick(r, district.dumpSpots); + if (caseType === 'murder') add({ + id: 'ev_weapon', side: 'field', + at: weaponInHome ? { type: 'unit', unit: killer.home, slot: 'під ліжком' } : { type: 'spot', building: dump.building, spot: dump.spot }, + requires: weaponInHome ? [] : ['lab_autopsy'], + title: `Знаряддя: ${method.weapon}`, tag: 'спосіб', + detail: `${method.weapon.charAt(0).toUpperCase() + method.weapon.slice(1)}${weaponInHome ? ', нашвидкуруч захований удома' : `, викинутий поспіхом (${dump.spot})`}. Сліди відповідають висновку розтину. На поверхні — один повний відбиток пальця.`, + gives: { weapon: true } + }); + if (caseType === 'murder') add({ + id: 'lab_print_match', side: 'analyst', at: { type: 'lab' }, requires: ['ev_weapon'], + title: 'Повний відбиток зі знаряддя', tag: 'вбивця', + detail: `Відбиток зі знаряддя порівняно з базою (звузьте кандидатів і зіставте вручну). Збіг за ${ri(r, 12, 16)} точками: ${killer.name}.`, + gives: { confirms: true } + }); + if (!(caseType === 'murder' && mutator === 'gloves')) add({ + id: 'lab_dna_match', side: 'analyst', at: { type: 'lab' }, requires: ['sf_dna'], special: 'dna', + title: `Збіг ДНК: ${dnaDesc.split(',')[0]}`, tag: 'вбивця', + detail: (mutator === 'accomplice' + ? `Електрофорез (${dnaDesc}): повний збіг доріжок — ${accomplice.name}. Дивно: на кого вказують взуття і тканина — це ІНША людина.` + : `Електрофорез (${dnaDesc}): повний збіг доріжок — ${killer.name}.`) + + ' Коли і чому ДНК потрапила на місце — аналіз не скаже.', + gives: mutator === 'accomplice' ? { pointsTo: accomplice.id } : { confirms: true } + }); + if (dna2) add({ + id: 'lab_dna_match2', side: 'analyst', at: { type: 'lab' }, requires: ['sf_dna2'], special: 'dna2', + title: `Збіг ДНК: ${dna2Desc.split(',')[0]}`, tag: 'слід', + detail: `Електрофорез (${dna2Desc}): повний збіг доріжок — ${dna2.owner.name}. Коли і чому ДНК потрапила на місце — аналіз не скаже.`, + gives: { pointsTo: dna2.owner.id } + }); + if (mutator === 'accomplice') add({ + id: 'acc_confess', side: 'field', at: { type: 'person', cid: accomplice.id }, requires: ['lab_dna_match'], + title: `Зізнання спільника: ${accomplice.name}`, tag: 'вбивця', + detail: `Побачивши висновок ДНК, ${accomplice.name} осідає на стілець. «Я лише ${g(accomplice, 'стояв', 'стояла')} на сторожі і ${g(accomplice, 'допоміг', 'допомогла')} прибрати. Задум і рука — ${killer.name}. Присягаюся.» Свідчення під протокол.`, + gives: { pointsTo: killer.id, confirms: true, motive: true } + }); + + // -- зламане алібі: третій шлях підтвердження. Це «сплячий» доказ: його відкриває + // лише сервер, коли записана на допиті брехня вбивці суперечить витягнутому архіву камер. + // Виглядає так само, як «алібі не сходиться» для будь-кого іншого (сервер відкриває + // такі й для невинних брехунів) — щоб поява картки не спойлерила винного одразу. + add({ + id: 'alibi_broken', side: 'analyst', at: { type: 'lab' }, special: 'alibi', + title: `Алібі не сходиться: ${killer.name}`, tag: 'слід', + detail: `${killer.name} на допиті ${g(killer, 'заявив', 'заявила')}, що ${g(killer, 'був', 'була')} біля «${district.buildings.find(b => b.id === killer.claim).name.split(',')[0]}», але витягнутий архів камер за той вечір ${g(killer, 'його', 'її')} там не показує. Хтось збрехав про свій вечір — з'ясуйте чому. Брехня про алібі ще не робить винним.`, + gives: { pointsTo: killer.id, confirms: true } + }); + + // журнал дзвінків (там, де вбивця контактував із жертвою) + if (['jealousy', 'debt', 'blackmail'].includes(templateId)) add({ + id: 'rec_calls', side: 'analyst', at: { type: 'sys', sys: 'phone' }, q: { sys: 'phone', kind: 'calls', match: victim.phone }, needsIdentity: true, + title: 'Дзвінки жертви за останню добу', tag: 'зв\'язок', + detail: `Три номери. Останній вхідний о ${ri(r, 20, 21)}:4${ri(r, 0, 9)} — 47 секунд, номер ${killer.phone} (${killer.name}).`, + gives: { pointsTo: killer.id } + }); + + // -- дрібниці: у кожному кутку щось лежить; справжнє і мотлох — упереміш, фільтруйте самі + const nb = () => witnessFrom(citizens); // випадковий не-фігурант для згадки в тексті + const dogOwner = citizens.find(c => c.dog && c.id !== victim.id); + const junkTexts = shuffle(r, [ + { t: 'Чек із бару', d: 'Два еспресо і коньяк, 21:47. На звороті олівцем — номер телефону без імені. Половина цифр затерта.' }, + { t: 'Стара запальничка', d: `Бензинова, з гравіюванням «${pick(r, ['С.', 'М.', 'В.', 'Т.'])}». Давно не заправлялась.` }, + { t: 'Флаєр', d: '«Ремонт взуття за годину». Усі відривні номери відірвані — популярний швець.' }, + { t: 'Порожня пляшка', d: 'З-під дешевого вина, ще липка. Поруч — два пластикові стаканчики.' }, + { t: 'Список покупок', d: 'Молоко, батарейки, свічки, скотч. Почерк квапливий.' }, + { t: 'Одна рукавичка', d: 'Шкіряна, права, добротна. Ліва, вочевидь, досі гріє чиюсь кишеню.' }, + { t: 'Прострочена квитанція', d: `Комуналка на ім'я «${nb().name}», третє нагадування. Хтось не любить платити вчасно.` }, + { t: 'Записник із адресами', d: `Половина сторінок вирвана. Серед уцілілих записів — «${nb().name}» і закреслений номер.` }, + { t: 'Білети в кіно', d: 'Два білети двомісячної давності, сеанс 21:30, місця поруч. І обгортка від шоколаду.' }, + { t: 'Іржавий ключ', d: 'Не підходить до жодних дверей поблизу. Хтось носив його «про всяк випадок» роками.' }, + { t: 'Оголошення про пса', d: dogOwner ? `«Загубився пес на кличку ${dogOwner.dog}. Винагорода.» Судячи з дати — пес давно вдома.` : '«Загубився пес. Винагорода.» Без прикмет — оптимістично.' }, + { t: 'Стос старих газет', d: 'В одній обведено оголошення про оренду квартири. Олівець аж продавив папір.' }, + { t: 'Візитка таксі', d: 'Від руки написано час: «23:15». Ні дати, ні імені.' }, + { t: 'Розбитий парасоль', d: 'І чек на новий — того ж дня, з маркету. Практична людина.' }, + { t: 'Гребінець із монограмою', d: 'Кістяний, старий, доглянутий. Літера затерта до нечитабельності.' }, + { t: 'Театральна програмка', d: 'Гастролі обласного театру, третій ряд. На полях — сердечко олівцем.' }, + { t: 'Ключниця з трьома ключами', d: 'Жоден не підписаний. Хтось досі відчиняє двері навпомацки.' }, + { t: 'Обгортка від валідолу', d: 'Свіжа. Комусь тут нещодавно було недобре — або дуже нервово.' }, + { t: 'Дитячий малюнок', d: 'Будинок, сонце і три постаті. Складений учетверо і затертий на згинах — його носили з собою.' }, + { t: 'Лотерейний білет', d: 'Не виграшний. Закреслений хрест-навхрест із таким натиском, що порвався папір.' }, + { t: 'Окуляри з тріснутим склом', d: 'Сильні діоптрії. Власник без них як без рук — але по них не повернувся.' }, + { t: 'Розклад електричок', d: 'Обведено останній нічний рейс. Двічі.' }, + { t: 'Порожній футляр від скрипки', d: 'Оксамит витертий. Або інструмент продали, або носять у ньому щось інше.' }, + { t: 'Пакетик насіння', d: 'Чорнобривці. Строк придатності вийшов три роки тому. Хтось усе не наважиться посадити.' }, + { t: 'Квитанція з ломбарду', d: `Здано «годинник чол. позолоч.». Викуп — до кінця місяця. Сума немала.` }, + { t: 'Зламана рація', d: 'Дитяча, з наліпкою блискавки. Друга з пари десь чекає на звʼязок.' }, + { t: 'Листівка без марки', d: '«Вітаю з ювілеєм!» — написано, підписано, не надіслано. Вже роки як.' }, + { t: 'Шматок пакувального шпагату', d: 'Свіжий зріз, гострим ножем. Таким перевʼязують коробки. Або щось інше.' }, + { t: 'Кишеньковий календарик', d: 'Позаминулий рік. Пʼятниці обведені. Усі до однієї.' }, + { t: 'Металева фляжка', d: 'Порожня, з увігнутим боком. Пахне не чаєм.' } + ]); + const junkSpots = []; + for (const b of district.buildings) for (const sp of (b.spots || [])) junkSpots.push({ type: 'spot', building: b.id, spot: sp }); + const chosen = shuffle(r, junkSpots).slice(0, 8); + const usedSlots = new Set(Object.values(E).filter(e => e.at.type === 'unit').map(e => e.at.unit + '|' + e.at.slot)); + for (const [unit, n] of [[victim.home, 2], [killer.home, 1], [herring.home, 1]]) { + let placed = 0; + for (const slot of shuffle(r, district.homeSlots)) { + if (placed >= n) break; + if (usedSlots.has(unit + '|' + slot)) continue; + usedSlots.add(unit + '|' + slot); chosen.push({ type: 'unit', unit, slot }); placed++; + } + } + chosen.forEach((at, i) => { + const j = junkTexts[i % junkTexts.length]; + add({ id: 'junk_' + i, side: 'field', at, title: j.t, tag: 'дрібниця', detail: j.d }); + }); + + // -- плітки: у кожного є що сказати про район; трохи правди тоне в шумі + const GOSSIP = [ + () => `Кажуть, ${nb().first} ${nb().last} і ${nb().first} не вітаються після сварки за паркування.`, + () => `${nb().name} нібито у великих боргах за комуналку. Але то таке, люди всяке кажуть.`, + () => `Подейкують, ${nb().name} шукає нову роботу — на старій щось не склалося.`, + () => `${nb().name} щовечора в барі. Щовечора!`, + () => `Бачили, як ${nb().name} кричить у телефон просто посеред вулиці. Аж луна йшла.`, + () => `У «Каштані» хтось заливає сусідів знизу, і всі чомусь думають на ${nb().name}.`, + () => 'На складі маркету вночі інколи світиться. Сторож каже — миші. Ага, миші з ліхтариком.', + () => `${nb().name} нібито бачили на набережній о третій ночі. Безсоння, кажуть.`, + () => 'У гуртожитку знову міняли замки — хтось утретє загубив ключі.', + () => `Подейкують, ${nb().name} збирає гроші на авто. Або на переїзд. Або на весілля. Версії різняться.`, + () => `${nb().name} виносить сміття о п'ятій ранку. Щодня. Хто взагалі стільки смітить?`, + () => { const p = nb(); return `Кажуть, ${p.name} писа${g(p, 'в', 'ла')} скарги на пів району. Пише красиво, з посиланнями на закони.`; }, + () => `У ${nb().first} з ${nb().first} щось було. Або є. Або буде. Двір слідкує.`, + () => `${nb().name} тримає на балконі голубів. Сусіди знизу мають окрему думку про це.`, + () => 'На набережній вечорами хтось грає на трубі. Гарно грає. Сумно.', + () => { const p = nb(); return `${p.name} нібито ви${g(p, 'грав', 'грала')} у лотерею і мовчить. Мовчить підозріло добре.`; }, + () => { const p = nb(); return `Бачили, як ${p.name} годує всіх котів району. Клички ${g(p, 'дав', 'дала')} кожному. Веде список.`; }, + () => 'У клініці кажуть, що нічний охоронець спить. Охоронець каже, що медитує.', + () => { const p = nb(); return `${p.name} посвари${g(p, 'вся', 'лася')} з усім будинком через антену на даху. Війна триває.`; }, + () => `Подейкують, ${nb().name} ходить до бару, але не п'є — сидить і слухає чужі розмови.`, + () => 'В автомайстерні беруть «за терміновість» удвічі. Терміново тепер усе.', + () => `${nb().name} щонеділі кудись їздить першим автобусом. Питати незручно, а цікаво.`, + () => 'Хтось ночами переставляє лавку в парку. Комунальники здалися.' + ]; + for (const c of citizens) { + if (c.id === victim.id) { c.gossip = []; continue; } + c.gossip = [pick(r, GOSSIP)()]; + if (chance(r, 0.4)) c.gossip.push(pick(r, GOSSIP)()); + } + const wit = nb(); // ключова плітка — це чиїсь особисті слова, а не голос двору + wit.gossip.push(caseType === 'kidnap' + ? `Пізно того вечора хтось у ${killer.coat} тягнув щось важке в бік проїзду. ${g(wit, 'Подумав', 'Подумала')} — килим. А тепер думаю — не килим.` + : `Пізно того вечора хтось у ${killer.coat} швидко йшов від місця, де потім знайшли тіло. Може, здалося.`); + nb().gossip.push(`У квартирі ${killer.home} останнім часом світло горить до третьої ночі. Комусь не спиться.`); + if (dna2 && dna2.why === 'brawl') + nb().gossip.push(`Удень перед тим бачили, як ${dna2.owner.name} ${g(dna2.owner, 'зчепився', 'зчепилася')} з ${victim.first} ${victim.last} у барі — ${dna2.reason}. Розборонили швидко, обоє розійшлися своїми ногами.`); + + // -- фінал + const finale = caseType === 'kidnap' + ? { + killer: killer.id, where: lair.label, motive: motiveId, + whereOptions: LAIRS.map(l => l.label), motiveOptions: MOTIVE_OPTIONS, + recap: `${recapWhy} Місце утримання: ${lair.label}. Штурм о четвертій ранку: замок зрізали за вісім секунд, ${g(victim, 'заручника винесли живим', 'заручницю винесли живою')}. ${killer.name} — затримання на місці, без опору. ` + + `Сліди не збрехали: відбиток взуття ${killer.shoe} розміру, вирване волосся з ДНК і камера, що впіймала зріст. Людина вдома. Справу закрито.` + } + : { + killer: killer.id, method: method.label, motive: motiveId, + methodOptions: METHOD_OPTIONS, motiveOptions: MOTIVE_OPTIONS, + recap: `${recapWhy} Увечері ${killer.sex === 'ч' ? 'він підстеріг' : 'вона підстерегла'} жертву (${scene.name}), знаряддя — ${method.weapon}. ` + + `Сліди виказали: волокна одягу, відбиток взуття ${killer.shoe} розміру, ` + + `${mutator === 'gloves' ? 'повний відбиток зі знаряддя' : 'ДНК з місця'} і камера, що впіймала зріст. База мешканців зробила решту. Справу закрито.` + }; + + // admit — пряма мова хибного підозрюваного (див. FM_KINDS), а не третьоособовий + // зворот: цитуємо, а не вклеюємо в речення. + finale.recap += ` ${herring.first} визна${g(herring, 'в', 'ла')}: «${fm.admit}» — та до злочину стосунку не ${g(herring, 'мав', 'мала')}.`; + + const introText = caseType === 'kidnap' + ? `${scene.name}: сліди боротьби — і жодної людини. Зникла особа: ${victim.name}, ${victim.age} р., прож. ${victim.home}. Заручник, імовірно, ще живий. З'ясуйте, ХТО забрав людину, ДЕ її тримають і ЧОМУ. Викрадача немає в жодному списку підозрюваних — його збирають по слідах і базі мешканців. У вас один штурм: помилка в імені чи місці — і все марно.` + : `Ранок. ${scene.name} — тіло без документів. Ніхто не заявляв про зникнення. Акт I: з'ясуйте, ХТО загинув. Акт II: знайдіть, ХТО це зробив, ЯК і ЧОМУ. Вбивці немає у списку підозрюваних — його треба зібрати по слідах і базі мешканців. У вас одне звинувачення. Помилка — і вас звільнять.`; + + return { + seed: String(seed), title: 'Невпізнаний', district, citizens, puzzles, + victim: victim.id, evidence: E, startingFacts, finale, templateId, caseType, introText, + herring: herring.id, scene, crimeTime, relations, decoyOwner: passer.id, + mutator, accomplice: accomplice && accomplice.id, + falseMotive: { label: fm.label, admit: fm.admit }, + calls, phoneOff, + dna2: dna2 && { owner: dna2.owner.id, why: dna2.why, reason: dna2.reason || null }, + dnaDesc, dna2Desc: dna2 ? dna2Desc : null + }; +} + +// ---------- Симулятор досяжності (fair-play) ---------- +const RT = require('./runtime'); + +function solve(cs) { + const E = cs.evidence; + const st = RT.newState(); + // симулятор вважає: ордер беруть, щойно прокурор готовий (pointed ⇒ warrant) + let changed = true; + while (changed) { + changed = false; + for (const cid of st.pointed) if (!st.warrants.includes(cid)) { st.warrants.push(cid); changed = true; } + for (const ev of Object.values(E)) { + if (st.found.includes(ev.id) || !RT.available(cs, st, ev, { ignorePuzzles: true })) continue; + RT.applyFound(cs, st, ev); changed = true; + } + } + + const has = pred => Object.values(E).some(ev => st.found.includes(ev.id) && pred(ev)); + return { + reached: new Set(st.found), identity: st.identity, + killerConfirmed: has(ev => ev.gives.confirms), + methodKnown: has(ev => ev.gives.method), + motiveProven: has(ev => ev.gives.motive), + herringDisproved: has(ev => ev.gives.disproves === cs.herring), + herringLeadFound: st.found.includes('her_lead'), + lairProven: has(ev => ev.gives.lair), + finalCandidates: RT.candidates(cs, st) + }; +} + +// ---------- Перевірка звинувачення ---------- +function checkAccusation(cs, ans, pinned) { + const E = cs.evidence, killer = cs.citizens.find(c => c.id === cs.finale.killer); + const has = pred => pinned.some(id => E[id] && pred(E[id])); + const hasConfirmFor = (name) => pinned.some(id => { + const ev = E[id]; + if (!ev || !ev.gives || !ev.gives.confirms) return false; + // Якщо доказ має pointsTo, перевіряємо, чи це обвинувачений + if (ev.gives.pointsTo) { + const target = cs.citizens.find(c => c.id === ev.gives.pointsTo); + return target && target.name.toLowerCase() === name; + } + // Якщо pointsTo немає (старі докази), вважаємо, що confirms — універсальне + return true; + }); + const accused = (ans.who || '').trim().toLowerCase(); + if (accused !== killer.name.toLowerCase()) + return { ok: false, hint: 'Ім\'я не сходиться з доказами: сліди з місця злочину (взуття, тканина, зріст, ДНК) звужують коло — і хтось із перевірених має підтверджене алібі.' }; + if (cs.caseType === 'kidnap') { + if (ans.how !== cs.finale.where) + return { ok: false, hint: 'Місце не сходиться. ДЕ саме вказують знахідки: бирка ключа, план, чек за замок?' }; + if (ans.why !== cs.finale.motive) + return { ok: false, hint: 'Мотив не той. ЩО пов\'язувало викрадача зі зниклою особою?' }; + //if (!has(ev => ev.gives.confirms)) + if (!hasConfirmFor(accused)) + return { ok: false, hint: 'Особу викрадача не підтверджено: потрібен збіг ДНК або спростоване алібі на дошці.' }; + if (!has(ev => ev.gives.motive)) + return { ok: false, hint: 'Мотив не підкріплено жодним закріпленим доказом.' }; + if (!has(ev => ev.gives.lair)) + return { ok: false, hint: 'Місце утримання не підкріплено закріпленим доказом — штурм наосліп ніхто не санкціонує.' }; + return { ok: true }; + } + if (ans.how !== cs.finale.method) + return { ok: false, hint: 'Спосіб суперечить висновку розтину. Перечитайте, що саме сказав патологоанатом.' }; + if (ans.why !== cs.finale.motive) + return { ok: false, hint: 'Мотив не той. ЩО пов\'язувало цих двох людей за тиждень до смерті?' }; + // if (!has(ev => ev.gives.confirms)) + if (!hasConfirmFor(accused)) + return { ok: false, hint: 'Немає прямого підтвердження особи: потрібен збіг ДНК, повний відбиток зі знаряддя або спростоване алібі на дошці.' }; + if (!has(ev => ev.gives.motive)) + return { ok: false, hint: 'Мотив не підкріплено жодним закріпленим доказом.' }; + if (!has(ev => ev.gives.method)) + return { ok: false, hint: 'Спосіб смерті не підкріплено: закріпіть висновок розтину.' }; + return { ok: true }; +} + +module.exports = { generateCase, solve, checkAccusation, caesar, md5, translit, COMMON_PASSWORDS }; diff --git a/chapter3/casegen/names.js b/chapter3/casegen/names.js new file mode 100644 index 0000000..06ec57e --- /dev/null +++ b/chapter3/casegen/names.js @@ -0,0 +1,99 @@ +// Пули імен. Прізвища — нейтральні (однакові для чоловіків і жінок), щоб не відмінювати. + +const MALE = ['Андрій', 'Богдан', 'Василь', 'Віктор', 'Данило', 'Денис', 'Ігор', 'Максим', + 'Мар\'ян', 'Микола', 'Назар', 'Олег', 'Олексій', 'Орест', 'Павло', 'Роман', 'Сергій', + 'Тарас', 'Юрій', 'Ярослав', 'Артем', 'Гліб', 'Євген', 'Лесь', 'Остап', 'Петро', 'Степан', 'Тимур', + 'Аскольд', 'Борис', 'Владислав', 'Гордій', 'Дем\'ян', 'Захар', 'Кирило', 'Любомир', + 'Матвій', 'Нестор', 'Радомир', 'Святослав', 'Устим', 'Юліан']; + +const FEMALE = ['Анна', 'Віра', 'Галина', 'Дарина', 'Єва', 'Ірина', 'Катерина', 'Лариса', + 'Леся', 'Марта', 'Марія', 'Надія', 'Оксана', 'Олена', 'Ольга', 'Соломія', 'Софія', + 'Тетяна', 'Уляна', 'Христина', 'Юлія', 'Яна', 'Зоряна', 'Іванна', 'Люба', 'Наталя', 'Роксолана', + 'Аліна', 'Богдана', 'Владислава', 'Дана', 'Єлизавета', 'Злата', 'Карина', 'Лідія', + 'Мирослава', 'Орися', 'Поліна', 'Стефанія', 'Таїсія', 'Ярина']; + +const SURNAMES = ['Шевченко', 'Бондаренко', 'Ткаченко', 'Кравченко', 'Коваленко', 'Мельник', + 'Шевчук', 'Поліщук', 'Савченко', 'Клименко', 'Кузьменко', 'Мороз', 'Лисенко', 'Романюк', + 'Гончаренко', 'Кравчук', 'Марченко', 'Руденко', 'Литвиненко', 'Соломаха', 'Дяченко', + 'Гриценко', 'Захарченко', 'Костенко', 'Мірошниченко', 'Носенко', 'Одарченко', 'Панченко', + 'Радченко', 'Сидоренко', 'Тимченко', 'Устенко', 'Федоренко', 'Харченко', 'Царенко', + 'Чумаченко', 'Шульга', 'Щербак', 'Юрченко', 'Ярема', + 'Бабенко', 'Величко', 'Гайдук', 'Деркач', 'Жайворонок', 'Зозуля', 'Кулик', 'Лебідь', 'Майстренко', + 'Нечипоренко', 'Орел', 'Пасічник', 'Рибалко', 'Сорока', 'Тесленко', 'Чорновіл', 'Швайка', + 'Щиголь', 'Яценко', 'Гамалія', 'Довбуш']; + +const DOG_NAMES = ['Рекс', 'Джесі', 'Барон', 'Лола', 'Чапа', 'Грей', 'Буся', 'Арчі', + 'Мухтар', 'Белла', 'Цезар', 'Жуля', 'Тайсон', 'Найда', 'Бім', 'Альма', 'Граф', 'Пиріжок', 'Зевс', 'Хатіко']; + +const TATTOOS = ['якір на передпліччі', 'ластівка на ключиці', 'тризуб на плечі', + 'троянда на зап\'ясті', 'напис «Vale» на ребрах', 'компас на литці', 'сова на лопатці', + 'змія довкола зап\'ястя', 'хрест на плечі', 'вовк на грудях', 'дата римськими цифрами на передпліччі', + 'кит на щиколотці', 'меч уздовж передпліччя', 'півмісяць за вухом', 'шахова тура на біцепсі', + 'ключ скрипковий на шиї', 'олень на лопатці', 'маяк на литці']; + +const COATS = ['чорному пальті', 'сірій куртці', 'синій парці', 'зеленому бушлаті', + 'бежевому плащі', 'бордовій куртці', 'коричневій дублянці', 'темно-синьому кітелі']; + +// Чому люди брешуть про свій вечір (виправдання на повторному допиті). +// Гравці самі вирішують, кому вірити, — гра вердиктів не ставить. +const EXCUSES = [ + 'Я був не сам, і вдома про це знати не мусять. Ви розумієте.', + 'Пив. Сильно. Не хотів, щоб про це знали на роботі.', + 'Грав у карти на гроші в гаражах. Це ж не злочин... майже.', + 'Я боявся: скажеш «був поруч» — і вже підозрюваний. От і збрехав.', + 'Позичав гроші в людини, про яку краще не говорити вголос.', + 'Ходив до ворожки. Смійтеся-смійтеся. Спробуйте самі поживіть із моїм щастям.', + 'Шукав підробіток. Офіційно мені не можна, тож — тихцем.', + 'Посварився з дружиною і ночував у майстерні. Соромно було казати.', + 'Здавав пляшки. Так, отакий у мене вечір. Задоволені?', + 'Був у клініці. Аналізи. Це моя справа і нікого не обходить.', + 'Зустрічався з колишньою. Якщо дізнається теперішня — мені кінець.', + 'Продавав дещо з дому, поки рідня не бачить. Борги.', + 'Сидів у барі до закриття. Мамі кажу, що не п\'ю.', + 'Ловив рибу без квитка. Інспектор мене вже двічі попереджав.', + 'Плакав у машині. Буває. Не хотів, щоб хтось бачив.', + 'Ходив дивитися на квартиру жертви... тобто, я не так висловився. Просто гуляв тим двором.', + 'Репетирував освідчення. Так, сам із собою. Годину. У дворі.', + 'Був на співбесіді в іншому місті. Начальник не мусить знати.', + 'Виносив речі колишнього орендаря. Не питайте.', + 'Дзвонив у передачу на радіо. Це анонімно, і хай так і лишиться.', + 'Фотографував птахів. Уночі. Сов, якщо точніше. Це хобі!', + 'Стежив за власним чоловіком. І ні, не скажу, що вистежила.', + 'Пробував кинути курити і намотував кола районом, щоб не зірватися.', + 'Ремонтував чужу машину за готівку. Податкова ж не дізнається?', + 'Ходив на могилу до брата. Про це не розповідаю нікому.', + 'Грав у шахи на набережній. На гроші. Пенсії не вистачає.', + 'Був у перукарки. Не в тій, до якої ходить моя сім\'я. Це складно.', + 'Тренувався співати в елінгу. Там акустика. І ніхто не чує. Зазвичай.', + 'Розклеював оголошення про зниклого кота. Кіт не мій. Довга історія.', + 'Просто збрехав. Звичка з дитинства: менше скажеш — краще спиш.' +]; + +// Хто знайшов тіло / повідомив (для вступів) +const DISCOVERERS = ['двірник, що вийшов о шостій', 'бігун на ранковому колі', 'листоноша з ранковою поштою', + 'рибалка, що йшов на кльов', 'водій хлібовоза', 'сторож із нічної зміни', 'жінка, що вигулювала пса', + 'підліток, що зрізав шлях до школи', 'таксист, що чекав на клієнта']; + +const WEATHER = ['Дощ лив усю ніч і змив половину слідів', 'Туман із ріки стояв до ранку', + 'Вночі мжичило, і бруківка блищить дотепер', 'Вітер із набережної грюкав вивісками до світанку', + 'Ніч була безвітряна й задушлива', 'Перший приморозок посріблив траву', + 'Зливa скінчилася рівно опівночі', 'Ліхтарі півночі блимали — підстанцію знову заливало']; + +// Пули для генератора району +const DISTRICTS = ['Причал', 'Гавань', 'Старий Млин', 'Пороги', 'Затока', 'Слобідка', 'Верф', 'Пакгауз']; +const STREETS = ['Ярова', 'Каштанова', 'Соборна', 'Липова', 'Гончарна', 'Вишнева', 'Портова', 'Заводська', + 'Кобзарська', 'Митна', 'Набережна-Горішня', 'Чумацька']; +const LANES = ['Цегельний', 'Ткацький', 'Млиновий', 'Рибальський', 'Кооперативний', 'Бондарний', 'Солодовий']; +const HOUSES = ['Каштан', 'Липа', 'Ясен', 'Затишок', 'Хвиля', 'Колос', 'Явір', 'Джерело', 'Світанок', 'Берегиня']; +const DORMS = ['Схід', 'Енергія', 'Молодість', 'Портовик', 'Текстильник', 'Хвиля-2']; +const BARS = ['Причал', 'Якір', 'Штурвал', 'Старий Док', 'Сирена', 'Боцман']; +const SHOPS = ['24/7', 'Куток', 'Гастроном №3', 'Мальва', 'Колос', 'Продукти у Петровича']; +const DINERS = ['Смак', 'Обід', 'У Галини', 'Ложка', 'Домашня кухня', 'Вареник']; +const OFFICES = ['Форум', 'Меридіан', 'Індустрія', 'Прогрес', 'Дельта', 'Континент']; +const CLINICS = ['Медіс', 'Астра-Мед', 'Гіппократ', 'Панацея', 'Здоров\'я']; +const GARAGES = ['Поршень', 'Карбюратор', 'Шина+', 'Глушник', 'Автодок']; +const PARKS = ['Парк над рікою', 'Сквер ткачів', 'Старий сад', 'Липова алея', 'Сквер річковиків']; +const PIERS = ['Набережна і причал', 'Річковий вокзал', 'Пристань «Чайка»', 'Старий причал', 'Дебаркадер №2']; + +module.exports = { MALE, FEMALE, SURNAMES, DOG_NAMES, TATTOOS, COATS, EXCUSES, DISCOVERERS, WEATHER, + DISTRICTS, STREETS, LANES, HOUSES, DORMS, BARS, SHOPS, DINERS, OFFICES, CLINICS, GARAGES, PARKS, PIERS }; diff --git a/chapter3/casegen/runtime.js b/chapter3/casegen/runtime.js new file mode 100644 index 0000000..a10876e --- /dev/null +++ b/chapter3/casegen/runtime.js @@ -0,0 +1,44 @@ +// Спільні правила гри: коли доказ досяжний, кого пускають у квартиру, кого +// лишили в колі підозри. Одні й ті самі правила ганяє fair-play тест (solve) +// і живий сервер — розійтися вони не можуть. + +function newState() { + return { found: [], identity: false, keys: [], warrants: [], filters: [], pointed: [], puzzlesSolved: [] }; +} + +function candidates(cs, st) { + return cs.citizens.filter(c => c.id !== cs.victim && st.filters.every(f => + f.tol ? Math.abs(c[f.attr] - f.val) <= f.tol : c[f.attr] === f.val)); +} + +// Чи можна увійти в приватне житло +function unitAccess(cs, st, unit) { + if (st.keys.includes(unit)) return true; + const victim = cs.citizens.find(c => c.id === cs.victim); + if (unit === victim.home && st.identity) return true; // житло жертви — після ідентифікації + return cs.citizens.some(c => c.home === unit && st.warrants.includes(c.id)); +} + +// Чи досяжний доказ за поточного стану. opts.ignorePuzzles — для симулятора. +function available(cs, st, ev, opts = {}) { + // «сплячі» докази (зламане алібі) відкриває лише сервер за фактом суперечності + if (ev.special === 'alibi') return false; + if (ev.requires.some(id => !st.found.includes(id))) return false; + if (ev.needsIdentity && !st.identity) return false; + if (ev.puzzle && !opts.ignorePuzzles && !st.puzzlesSolved.includes(ev.puzzle)) return false; + if (ev.at.type === 'unit') return unitAccess(cs, st, ev.at.unit); + return true; // start / spot / person / sys / lab — гейти вище +} + +// Застосувати наслідки знайденого доказу +function applyFound(cs, st, ev) { + if (st.found.includes(ev.id)) return; + st.found.push(ev.id); + const g = ev.gives; + if (g.identity) st.identity = true; + if (g.key && !st.keys.includes(g.key)) st.keys.push(g.key); + if (g.filter) st.filters.push(g.filter); + if (g.pointsTo && !st.pointed.includes(g.pointsTo)) st.pointed.push(g.pointsTo); +} + +module.exports = { newState, candidates, unitAccess, available, applyFound }; diff --git a/chapter3/casegen/test.js b/chapter3/casegen/test.js new file mode 100644 index 0000000..338c48a --- /dev/null +++ b/chapter3/casegen/test.js @@ -0,0 +1,278 @@ +// Fair-play валідатор генератора: на сотнях сідів кожна справа мусить бути +// розв'язною від стартових фактів, а кожен хибний слід — спростовним. +const assert = require('assert'); +const { generateCase, solve, checkAccusation, caesar, md5, translit, COMMON_PASSWORDS } = require('./index'); + +const SEEDS = 300; +let fails = 0; + +function checkCase(seed) { + const cs = generateCase(seed); + const byId = id => cs.citizens.find(c => c.id === id); + const victim = byId(cs.victim), killer = byId(cs.finale.killer), herring = byId(cs.herring); + + // -- детермінізм + assert.strictEqual(JSON.stringify(cs), JSON.stringify(generateCase(seed)), 'детермінізм: той самий seed — та сама справа'); + + // -- базова консистентність світу + assert.ok(victim && killer && herring, 'фігуранти існують'); + assert.notStrictEqual(victim.id, killer.id, 'жертва не вбивця'); + assert.notStrictEqual(herring.id, killer.id, 'хибний слід не вбивця'); + assert.ok(victim.home !== killer.home, 'вбивця не живе з жертвою'); + assert.ok(!cs.citizens.some(c => c !== victim && c.home === victim.home), 'жертва живе сама (інакше особу впізнали б одразу)'); + const uniq = (arr, what) => assert.strictEqual(new Set(arr).size, arr.length, 'унікальність: ' + what); + uniq(cs.citizens.map(c => c.name), 'імена'); + uniq(cs.citizens.map(c => c.phone), 'телефони'); + uniq(cs.citizens.map(c => c.dna.join(',')), 'ДНК-профілі'); + assert.ok(cs.citizens.length >= 20 && cs.citizens.length <= 60, 'населення в розумних межах: ' + cs.citizens.length); + + // -- геометрія району: все в межах світу, зазори між будівлями, дороги вільні + const inter = (a, b, pad) => + a.x < b.x + b.w + pad && b.x < a.x + a.w + pad && a.y < b.y + b.h + pad && b.y < a.y + a.h + pad; + const bl = cs.district.buildings; + assert.strictEqual(bl.length, 12, 'усі 12 будівель розміщено'); + for (const b of bl) { + assert.ok(b.pos.x >= 20 && b.pos.y >= 20 && b.pos.x + b.pos.w <= 980 && b.pos.y + b.pos.h <= 616, + 'будівля в межах світу: ' + b.id); + for (const rd of cs.district.roads) + assert.ok(!inter(b.pos, rd, 6), 'будівля не на дорозі: ' + b.id); + } + for (let i = 0; i < bl.length; i++) + for (let j = i + 1; j < bl.length; j++) + assert.ok(!inter(bl[i].pos, bl[j].pos, 24), `зазор ≥24 між ${bl[i].id} і ${bl[j].id}`); + assert.strictEqual(cs.district.towers.length, 3, 'три вежі звязку'); + for (const must of ['park', 'pier', 'bar']) + assert.ok(cs.district.cameras.some(c => c.covers.includes(must)), 'камера покриває ' + must); + for (const slot of ['письмовий стіл', 'шафа', 'кухня', 'ванна кімната', 'під ліжком']) + assert.ok(cs.district.homeSlots.includes(slot), 'обовязковий слот житла: ' + slot); + + // -- вечір злочину: правда/покази, брехуни; стосунки для допитів + for (const c of cs.citizens) { + assert.ok(c.evening && c.claim, 'у кожного є evening і claim: ' + c.id); + if (!c.liar && c.id !== cs.finale.killer) assert.strictEqual(c.claim, c.evening, 'чесні кажуть правду'); + } + assert.ok(killer.liar && killer.claim !== cs.scene.building && killer.evening === cs.scene.building, + 'вбивця був на місці злочину, а каже інше'); + assert.ok(cs.district.cameras.some(c => c.covers.includes(killer.claim)), + '«алібі» вбивці під камерою — його можна зламати'); + for (const c of cs.citizens) + if (c.liar && c.id !== cs.finale.killer && c.id !== cs.accomplice) + assert.ok(c.excuse, 'у брехуна є виправдання на повторний допит'); + + // -- зламане алібі: сплячий доказ, недосяжний симулятором, з вагою підтвердження + const ab = cs.evidence.alibi_broken; + assert.ok(ab && ab.special === 'alibi' && ab.gives.confirms, 'сплячий доказ алібі існує'); + + // -- застарілі реєстри: лише в нефігурантів + for (const c of cs.citizens) + if (c.regShoe != null || c.regCoat != null) + assert.ok(![cs.victim, cs.finale.killer, cs.herring, cs.decoyOwner, cs.accomplice].includes(c.id), + 'реєстр фігурантів завжди точний'); + + // -- мутатори: кожен лишає справу чесною + if (cs.mutator === 'gloves') { + assert.ok(!cs.evidence.sf_dna && !cs.evidence.lab_dna_match, 'рукавички: ДНК вбивці немає'); + assert.ok(cs.dna2 ? !cs.evidence.sf_gloves : cs.evidence.sf_gloves, + 'рукавички: або пояснення, або невинний зразок як пастка'); + } + + // -- другий (невинний) ДНК-зразок: чужа присутність, а не доказ вини + if (cs.dna2) { + assert.ok(![cs.victim, cs.finale.killer, cs.herring, cs.accomplice].includes(cs.dna2.owner), + 'власник другого зразка — не фігурант'); + assert.strictEqual(cs.evidence.lab_dna_match2.gives.pointsTo, cs.dna2.owner, 'збіг вказує на власника'); + assert.ok(!cs.evidence.lab_dna_match2.gives.confirms, 'невинний збіг не підтверджує вбивцю'); + if (cs.dna2.why === 'brawl') { + const ow = byId(cs.dna2.owner); + assert.ok(cs.citizens.some(c => (c.gossip || []).some(l => l.includes(ow.name))), + 'про денну бійку є чутка — історію можна перевірити'); + } + assert.notStrictEqual(cs.dnaDesc, cs.dna2Desc, 'описи зразків різні'); + } + if (cs.mutator === 'accomplice') { + assert.strictEqual(cs.evidence.lab_dna_match.gives.pointsTo, cs.accomplice, 'ДНК вказує на спільника'); + assert.ok(!cs.evidence.lab_dna_match.gives.confirms, 'ДНК спільника — не підтвердження вбивці'); + assert.ok(cs.evidence.acc_confess && cs.evidence.acc_confess.gives.confirms, 'зізнання спільника підтверджує вбивцю'); + } + if (cs.mutator === 'frame') { + assert.strictEqual(cs.evidence.dec_planted.gives.pointsTo, cs.herring, 'підкинута річ вказує на хибний слід'); + assert.ok(cs.evidence.planted_exp, 'експертиза викриває підкидання'); + } + assert.ok(cs.district.cameras.some(c => c.covers.includes(herring.evening)), + 'вечір хибного сліду під камерою — алібі доводиться'); + + // -- хибний мотив: повна власна гілка, іншого типу, ніж мотив вбивці, без gives.motive + assert.ok(cs.evidence.fm_lead && cs.evidence.fm_talk, 'у хибного підозрюваного є власна історія'); + assert.strictEqual(cs.evidence.fm_lead.gives.pointsTo, cs.herring, 'хибний мотив вказує на хибного підозрюваного'); + assert.ok(!cs.evidence.fm_lead.gives.motive && !cs.evidence.fm_talk.gives.motive, + 'хибний мотив не зараховується прокурору як доведений мотив'); + assert.notStrictEqual(cs.falseMotive.label, cs.finale.motive, 'тип хибного мотиву інший, ніж у вбивці'); + assert.ok(cs.finale.recap.includes(herring.first), 'рекап пояснює, що хибний підозрюваний не вбивав'); + assert.ok(Array.isArray(cs.relations), 'стосунки згенеровано'); + assert.ok(cs.citizens.some(c => c.id === cs.decoyOwner) && + ![cs.victim, cs.finale.killer, cs.herring].includes(cs.decoyOwner), 'власник фейкового сліду — не фігурант'); + + // -- кожен sys-доказ досяжний: або через запит-форму, або авторозкриттям після шифру + for (const ev of Object.values(cs.evidence)) + if (ev.at.type === 'sys' && !ev.puzzle) + assert.ok(ev.q && ev.q.sys, `${ev.id}: sys-доказ мусить мати довідник запиту`); + + // -- дрібниці та плітки + const junk = Object.values(cs.evidence).filter(e => e.tag === 'дрібниця'); + assert.ok(junk.length >= 9, 'дрібниць вистачає: ' + junk.length); + assert.ok(cs.citizens.filter(c => c.id !== cs.victim).every(c => c.gossip && c.gossip.length >= 1), 'у кожного живого є плітка'); + assert.ok(cs.citizens.some(c => (c.gossip || []).some(l => l.includes(killer.coat))), 'правдива чутка про одяг вбивці існує'); + if (cs.templateId === 'serial') + assert.strictEqual(cs.citizens.filter(c => c.tattoo === killer.tattoo).length, 1, 'тату серійника унікальне в базі'); + + // -- усі свідки/контакти — не фігуранти справи + for (const ev of Object.values(cs.evidence)) + if (ev.at.type === 'person') + assert.ok(![victim.id, killer.id].includes(ev.at.cid), `${seed}/${ev.id}: свідок не може бути жертвою чи вбивцею`); + + // -- узгодження роду: суфікс жіночого роду клеївся до чоловічої форми + // («писав» + «ла» = «писавла»). Ловимо весь клас однією сіткою по всьому тексту. + const walk = (o, path) => { + if (typeof o === 'string') { + // -в + -ла; (?![сдш]) береже «влада/власний/влаштувати» всередині слова + const m = o.match(/[а-яіїєґ]вла(?![сдш])/i); + assert.ok(!m, `${seed}${path}: зліплена форма «${m && m[0]}» у: ${o.slice(0, 90)}`); + } else if (o && typeof o === 'object') + for (const [k, v] of Object.entries(o)) walk(v, `${path}.${k}`); + }; + walk(cs, ''); + + // -- розв'язність від стартових фактів + const sol = solve(cs); + assert.ok(sol.identity, 'особу жертви можна встановити'); + if (cs.caseType === 'murder') assert.ok(sol.methodKnown, 'спосіб смерті доведений'); + else { + assert.ok(sol.lairProven, 'місце утримання можна довести'); + assert.ok(cs.finale.whereOptions.includes(cs.finale.where), 'місце є серед варіантів'); + } + assert.ok(sol.motiveProven, 'мотив доведений'); + assert.ok(sol.killerConfirmed, 'вбивцю можна підтвердити (ДНК або відбиток)'); + assert.ok(sol.herringLeadFound, 'хибний слід справді трапляється гравцям'); + assert.ok(sol.herringDisproved, 'хибний слід можна спростувати'); + + // -- сліди лежать на місці злочину, а не падають у дошку самі + assert.ok(cs.startingFacts.every(id => ['sf_scene', 'sf_report'].includes(id)), + 'стартовий лише брифінг: ' + cs.startingFacts); + for (const id of ['sf_footprint', 'sf_dna', 'sf_dna2', 'sf_gloves']) { + if (!cs.evidence[id]) continue; + assert.strictEqual(cs.evidence[id].at.type, 'spot', id + ' знаходиться обшуком'); + assert.strictEqual(cs.evidence[id].at.building, cs.scene.building, id + ' — саме на місці злочину'); + } + assert.ok(!sol.reached.has('alibi_broken'), 'сплячий доказ не відкривається сам собою'); + if (cs.dna2) assert.ok(sol.reached.has('sf_dna2') && sol.reached.has('lab_dna_match2'), 'другий зразок досяжний'); + assert.ok(sol.reached.has('fm_lead') && sol.reached.has('fm_talk'), 'хибний мотив досяжний'); + + // -- цифровий слід: граф дзвінків, тиша вбивці, пристрої з шаруватим вмістом + assert.ok(Array.isArray(cs.calls) && cs.phoneOff, 'граф дзвінків згенеровано'); + for (const x of cs.calls) { + assert.ok(x.a !== cs.victim && x.b !== cs.victim, 'мертві не дзвонять у графі побуту'); + if (x.a === cs.finale.killer || x.b === cs.finale.killer) + assert.ok(x.t < cs.phoneOff.off - 10 || x.t > cs.phoneOff.on, 'вбивця мовчить довкола часу злочину'); + } + if (cs.accomplice) + assert.ok(cs.calls.some(x => (x.a === cs.finale.killer && x.b === cs.accomplice) || (x.b === cs.finale.killer && x.a === cs.accomplice)), + 'дзвінок вбивця↔спільник існує'); + assert.ok(cs.evidence.dev_k && cs.evidence.dev_k_data && cs.evidence.dev_h && cs.evidence.dev_h_data, + 'пристрої вбивці й хибного підозрюваного вилучаються'); + assert.strictEqual(!!cs.evidence.dev_a, !!cs.accomplice, 'пристрій спільника — лише коли він є'); + for (const suf of ['k', 'h', 'a', 'v']) { + const d = cs.evidence['dev_' + suf + '_data']; + if (!d) continue; + assert.ok(d.puzzle && cs.puzzles.some(pz => pz.id === d.puzzle), 'вміст пристрою за PIN/паролем'); + assert.ok(!Object.keys(d.gives).length, 'вміст пристрою — штрихи, не механіка'); + assert.ok(sol.reached.has('dev_' + suf) && sol.reached.has('dev_' + suf + '_data'), 'пристрій досяжний: ' + suf); + devLayers[d.detail.startsWith('Чисто') ? 'clean' : 'rich'] = (devLayers[d.detail.startsWith('Чисто') ? 'clean' : 'rich'] || 0) + 1; + } + + // -- сліди розмиті: після самого огляду місця коло ще широке + const wide = cs.citizens.filter(c => c.id !== cs.victim && Math.abs(c.shoe - killer.shoe) <= 1); + assert.ok(wide.length >= 8, `після огляду місця підозрюваних ≥8, зараз ${wide.length}`); + assert.strictEqual(cs.evidence.sf_footprint.gives.filter.tol, 1, 'відбиток взуття — діапазон, не точний розмір'); + + // -- фейкові сліди: існують, нічого не дають механіці + assert.ok(cs.evidence.dec_footprint && cs.evidence.dec_butt, 'фейкові сліди на місці існують'); + assert.strictEqual(Object.keys(cs.evidence.dec_footprint.gives).length, 0, 'фейковий відбиток не дає фільтрів'); + assert.strictEqual(Object.keys(cs.evidence.dec_butt.gives).length, 0, 'фейкові недопалки не дають фільтрів'); + + // -- тріангуляція: запит через оператора, ціль сервер рахує на живо + assert.ok(cs.evidence.rec_triangulate && cs.evidence.rec_triangulate.q.kind === 'locate', + 'тріангуляція викликається запитом «де зараз номер»'); + assert.ok(sol.reached.has('rec_triangulate'), 'тріангуляція досяжна'); + + // -- фільтри чесні: вбивця і хибний слід проходять крізь них, коло не завелике + const cand = sol.finalCandidates; + assert.ok(cand.some(c => c.id === killer.id), 'вбивця відповідає всім слідам з місця злочину'); + assert.ok(cand.some(c => c.id === herring.id), 'хибний слід теж проходить фільтри (інакше він не хибний слід)'); + assert.ok(cand.length >= 2 && cand.length <= 6, `фільтри лишають 2–6 кандидатів, зараз ${cand.length}`); + + // -- шифро-завдання чесні й розв'язні (усі типи) + for (const p of cs.puzzles) { + if (p.kind === 'caesar') assert.strictEqual(caesar(p.payload, -p.shift), p.answer, 'Цезар розшифровується'); + if (p.kind === 'b64') assert.strictEqual(Buffer.from(p.payload, 'base64').toString('utf8'), p.answer, 'base64 розкодовується'); + if (p.kind === 'pin' || p.kind === 'md5') assert.strictEqual(md5(p.answer), p.hash, 'хеш відповідає відповіді'); + if (p.kind === 'pin' && p.id === 'pz_pin') assert.strictEqual(p.answer, String(victim.birthYear), 'PIN виводиться з досьє'); + if (p.kind === 'pin' && p.id.startsWith('pz_dev_')) + assert.ok(cs.citizens.some(c => String(c.birthYear) === p.answer), 'PIN пристрою — рік народження власника'); + if (p.kind === 'md5' && p.id === 'pz_md5') { + // пароль завжди латиниця (щоб реально крекнути), стиль — особистий або випадковий + assert.ok(/^[a-z0-9]+$/.test(p.answer), 'пароль ноутбука — латиниця: ' + p.answer); + if (p.style === 'random') + assert.ok(COMMON_PASSWORDS.includes(p.answer), 'випадковий пароль — реальний запис зі словника rockyou'); + else { + // особистий: виводиться з досьє (кличка/ім'я/прізвище латиницею ± рік) + const yr = String(victim.birthYear), parts = [translit(victim.first), translit(victim.last), victim.dog && translit(victim.dog)].filter(Boolean); + assert.ok(parts.some(pt => p.answer.includes(pt)), 'особистий пароль містить транслітероване особисте дане'); + assert.ok(cs.evidence.id_dossier.detail.includes(victim.name.split(' ')[0]), 'ім\'я жертви є в досьє'); + } + } + } + + // -- звинувачення: правильна повна відповідь приймається, кожна зламана ланка — ні + const pinnedAll = [...sol.reached]; + const rightHow = cs.caseType === 'kidnap' ? cs.finale.where : cs.finale.method; + const wrongHow = cs.caseType === 'kidnap' + ? cs.finale.whereOptions.find(w => w !== cs.finale.where) + : (cs.finale.method === 'Отруєння' ? 'Ніж' : 'Отруєння'); + const right = { who: killer.name, how: rightHow, why: cs.finale.motive }; + assert.ok(checkAccusation(cs, right, pinnedAll).ok, 'правильне звинувачення з доказами приймається'); + assert.ok(!checkAccusation(cs, { ...right, who: herring.name }, pinnedAll).ok, 'хибний підозрюваний відхиляється'); + assert.ok(!checkAccusation(cs, { ...right, how: wrongHow }, pinnedAll).ok, 'хибний спосіб/місце відхиляється'); + assert.ok(!checkAccusation(cs, { ...right, why: 'Пограбування' }, pinnedAll).ok, 'хибний мотив відхиляється'); + assert.ok(!checkAccusation(cs, right, []).ok, 'без закріплених доказів прокурор не підписує'); + assert.ok(checkAccusation(cs, { who: killer.name.toUpperCase(), how: right.how, why: right.why }, pinnedAll).ok, 'регістр імені не важливий'); + + // -- варіанти фіналу містять правильні відповіді + if (cs.caseType === 'murder') assert.ok(cs.finale.methodOptions.includes(cs.finale.method), 'спосіб є серед варіантів'); + assert.ok(cs.finale.motiveOptions.includes(cs.finale.motive), 'мотив є серед варіантів'); + const md5pz = cs.puzzles.find(p => p.id === 'pz_md5'); + if (md5pz) pwStyles[md5pz.style]++; + muts[cs.mutator || 'none'] = (muts[cs.mutator || 'none'] || 0) + 1; + if (cs.dna2) muts['dna2:' + cs.dna2.why] = (muts['dna2:' + cs.dna2.why] || 0) + 1; + return cs.caseType + ':' + cs.templateId; +} + +const seen = {}, pwStyles = { personal: 0, random: 0 }, muts = {}, devLayers = {}; +for (let i = 0; i < SEEDS; i++) { + const seed = 'seed-' + i; + try { const t = checkCase(seed); seen[t] = (seen[t] || 0) + 1; } + catch (e) { fails++; console.error(`✗ ${seed}: ${e.message}`); if (fails > 5) break; } +} + +assert.ok(Object.keys(seen).length >= 11, 'генератор реально використовує всі шаблони мотивів: ' + JSON.stringify(seen)); +assert.ok(Object.keys(seen).some(k => k.startsWith('kidnap:')) && Object.keys(seen).some(k => k.startsWith('murder:')), + 'обидва типи справ трапляються: ' + JSON.stringify(seen)); +assert.ok(pwStyles.personal && pwStyles.random, + 'алгоритм пароля дає і особисті, і випадкові варіанти: ' + JSON.stringify(pwStyles)); +if (fails) { console.error(`\n${fails} збоїв — генератор нечесний.`); process.exit(1); } +console.log(`✓ ${SEEDS} сідів: кожна справа розв'язна, хибні сліди спростовні, шифри чесні.`); +console.log(' Розподіл шаблонів:', JSON.stringify(seen)); +console.log(' Стилі паролів (ноутбук):', JSON.stringify(pwStyles)); +console.log(' Мутатори:', JSON.stringify(muts)); +assert.ok(muts.accomplice && muts.gloves && muts.frame && muts.none, 'усі мутатори трапляються'); +assert.ok(muts['dna2:brawl'] && muts['dna2:finder'] && muts['dna2:passer'], 'усі види невинної ДНК трапляються'); +assert.ok(devLayers.clean && devLayers.rich, 'пристрої бувають і чистими, і з вмістом: ' + JSON.stringify(devLayers)); diff --git a/chapter3/package-lock.json b/chapter3/package-lock.json new file mode 100644 index 0000000..b4a0759 --- /dev/null +++ b/chapter3/package-lock.json @@ -0,0 +1,37 @@ +{ + "name": "nevpiznanyi", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "nevpiznanyi", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "ws": "8.21.1" + } + }, + "node_modules/ws": { + "version": "8.21.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/chapter3/package.json b/chapter3/package.json new file mode 100644 index 0000000..fd4afad --- /dev/null +++ b/chapter3/package.json @@ -0,0 +1,14 @@ +{ + "name": "nevpiznanyi", + "version": "1.0.0", + "description": "Розділ 3 — процедурний детектив на двох", + "scripts": { + "test": "node casegen/test.js && node test-server.js", + "start": "node server.js" + }, + "license": "ISC", + "type": "commonjs", + "dependencies": { + "ws": "8.21.1" + } +} \ No newline at end of file diff --git a/chapter3/public/analyst.js b/chapter3/public/analyst.js new file mode 100644 index 0000000..db50d4d --- /dev/null +++ b/chapter3/public/analyst.js @@ -0,0 +1,453 @@ +// Аналітик: база мешканців, мапа з мітками, системи-форми і лабораторія. +'use strict'; +(() => { + let stage, panels = {}, mapCtx, mapCv; + const UKR = 'абвгґдеєжзиіїйклмнопрстуфхцчшщьюя'; + const caesarDec = (text, s) => [...text].map(ch => { + const low = ch.toLowerCase(), i = UKR.indexOf(low); + if (i < 0) return ch; + const out = UKR[(i - s % UKR.length + UKR.length) % UKR.length]; + return ch === low ? out : out.toUpperCase(); + }).join(''); + const b64dec = s => { try { return decodeURIComponent(escape(atob(s.trim()))); } catch { return '⚠ не схоже на base64'; } }; + + App.on('init', m => { + if (App.role !== 'analyst') return; + stage = document.getElementById('stage'); + stage.innerHTML = ''; + const tabs = el('div', 'atabs'); + const names = { db: 'База мешканців', map: 'Мапа району', sys: 'Системи й лабораторія' }; + for (const id of Object.keys(names)) { + const b = el('button', id === 'db' ? 'on' : '', names[id]); + b.onclick = () => { + for (const x of tabs.children) x.classList.toggle('on', x === b); + for (const p of Object.values(panels)) p.classList.remove('on'); + panels[id].classList.add('on'); + }; + tabs.append(b); + panels[id] = el('div', 'apanel' + (id === 'db' ? ' on' : '')); + } + stage.append(tabs, panels.db, panels.map, panels.sys); + buildDb(); buildMap(); buildSys(); + setInterval(() => { + if (!panels.map.classList.contains('on')) return; + if (!mapCv._k) fitCanvas(mapCv); + drawDistrict(mapCtx, { partner: true }); + }, 400); + addEventListener('resize', () => { if (mapCv) { fitCanvas(mapCv); drawDistrict(mapCtx, { partner: true }); } }); + }); + + App.on('pos', () => { if (mapCtx) drawDistrict(mapCtx, { partner: true }); }); + App.on('state', () => { if (App.role !== 'analyst' || !stage) return; renderPuzzles(); renderRows(); renderLab(); renderAlibi(); }); + + // ---------- База мешканців ---------- + // Сліди в базу не підставляються самі: читайте докази і вводьте значення руками. + let dbOut = null, lastRows = null; + let dbShowHidden = false; // згорнута секція прихованих у базі + function buildDb() { + const p = panels.db; + p.innerHTML = ` +

База мешканців району

+

Пошук за точним ім'ям — помилка в написанні дасть порожній результат. Значення для фільтрів беріть зі знайдених слідів і вводьте самі. Реєстр місцями застарілий: одяг і розмір взуття надійніше звіряти очним оглядом (ордер у польового).

+
+ + +
+
+ + + + + + + + + +
+
`; + dbOut = p.querySelector('#db-out'); + p.querySelector('#q-go').onclick = () => { + const q = p.querySelector('#q-name').value.trim().toLowerCase(); + const hit = App.init.citizens.find(c => c.name.toLowerCase() === q); + if (!hit) { lastRows = null; dbOut.innerHTML = '

— Записів не знайдено. Перевірте написання.

'; return; } + lastRows = [hit]; renderRows(); + }; + p.querySelector('#f-go').onclick = () => { + const v = id => p.querySelector(id).value; + lastRows = App.init.citizens.filter(c => + (!v('#f-sex') || c.sex === v('#f-sex')) && + (!v('#f-smin') || c.shoe >= +v('#f-smin')) && + (!v('#f-smax') || c.shoe <= +v('#f-smax')) && + (!v('#f-hmin') || c.height >= +v('#f-hmin')) && + (!v('#f-hmax') || c.height <= +v('#f-hmax')) && + (!v('#f-print') || c.printType === v('#f-print')) && + (!v('#f-coat') || c.coat === v('#f-coat')) && + (!p.querySelector('#f-smoke').checked || c.smoker)); + renderRows(); + }; + } + + // рядки з позначками ✓/✗/…, як картки доказів: викреслюйте людей по одному + function renderRows() { + if (!dbOut || !lastRows || !App.state) return; + dbOut.innerHTML = `

Збігів: ${lastRows.length}

`; + const t = el('table', '', 'Ім\'яВікАдресаРоботаЗрістВзуттяВідбитки'); + const hidden = []; + for (const c of lastRows) { + const key = 'c:' + c.id; + if (App.state.board.marks[key] === 'hide') { hidden.push(c); continue; } + const tr = el('tr', 'rowlink' + cardClasses(key), `${esc(c.name)}${App.state.victim === c.id ? (App.init.caseType === 'kidnap' ? ' ⚠' : ' †') : ''}${c.age}${c.home}${esc(c.job)}${c.height}${c.shoe}${c.printType}`); + const mk = el('td'); + mk.onclick = ev => ev.stopPropagation(); + mk.append(markButtons(key)); + tr.append(mk); + tr.onclick = () => showDossier(c); + t.append(tr); + } + dbOut.append(t); + if (hidden.length) { + const head = el('button', 'small', `${dbShowHidden ? '▾' : '▸'} Приховані (${hidden.length})`); + head.style.cssText = 'width:100%;margin-top:10px;text-align:left'; + head.onclick = () => { + dbShowHidden = !dbShowHidden; + renderRows(); + }; + dbOut.append(head); + if (dbShowHidden) { + const ht = el('table', '', 'Ім\'яВікАдресаРобота'); + for (const c of hidden) { + const tr = el('tr', 'rowlink dim', `${esc(c.name)}${c.age}${c.home}${esc(c.job)}`); + const showBtn = el('button', 'small', 'Показати'); + showBtn.onclick = (ev) => { + ev.stopPropagation(); + App.send({ t: 'mark', key: 'c:' + c.id, kind: 'hide' }); // зняти hide + }; + const td = el('td'); td.append(showBtn); + tr.append(td); + ht.append(tr); + } + dbOut.append(ht); + } + } + } + + // ---------- Мапа ---------- + function buildMap() { + const p = panels.map; + p.innerHTML = '

Мапа району

Клік по будівлі — адреси й люди. Клік по порожньому місцю — мітка «перевір тут» для напарника (повторний клік — зняти). Золота точка — напарник.

'; + mapCv = el('canvas'); mapCv.id = 'cv'; + mapCv.width = App.init.district.world.w; mapCv.height = App.init.district.world.h; + p.append(mapCv); + mapCtx = mapCv.getContext('2d'); + drawDistrict(mapCtx, { partner: true }); + mapCv.onclick = e => { + const r = mapCv.getBoundingClientRect(), D = App.init.district.world; + const x = (e.clientX - r.left) * D.w / r.width, y = (e.clientY - r.top) * D.h / r.height; + const b = App.init.district.buildings.find(b => x > b.pos.x && x < b.pos.x + b.pos.w && y > b.pos.y && y < b.pos.y + b.pos.h); + if (b) buildingInfo(b); + else App.send({ t: 'marker', x, y }); + }; + } + + function buildingInfo(b) { + const res = App.init.citizens.filter(c => b.units && b.units.includes(c.home)); + const work = App.init.citizens.filter(c => c.workplace === b.id); + const here = App.init.citizens.filter(c => App.state && App.state.locs[c.id] === b.id && c.id !== App.state.victim); + let html = `

${esc(b.name)}

`; + if (res.length) html += '
Мешканці
' + res.map(c => `
${c.home}: ${esc(c.name)}
`).join(''); + if (work.length) html += '
Працюють
' + work.map(c => `
${esc(c.name)} — ${esc(c.job)}
`).join(''); + html += `
Зараз тут
${here.length ? here.map(c => esc(c.name)).join(', ') : 'нікого не зафіксовано'}
`; + openModal(html); + } + + // ---------- Системи-форми ---------- + // Кожен запит вводиться руками; результат — навіть дрібниця — лягає на дошку. + let pzList = null, labBox = null; + const q$ = (p, sel) => p.querySelector(sel); + const fmtT = v => `${String(Math.floor(v / 60)).padStart(2, '0')}:${String(v % 60).padStart(2, '0')}`; + + function buildSys() { + const p = panels.sys; + p.innerHTML = ` +

Системи й лабораторія

+

Дані для запитів — номери, імена, прикмети, роки — вичитуйте з доказів і вводьте самі. Кожен результат лягає на дошку: що з нього стосується справи — вирішуєте ви.

+ +
Оператор зв'язку
+
+ + + + +
+ +
Банк
+
+ + +
+ +
Реєстри
+
+ + +
+ +
Криміналістика
+
+ + + +
+ +
Камери — архів ночі злочину
+
+ + + + +
+ +
Зведення алібі
+
Ще нікого не питали, де він був того вечора.
+ +
Лабораторія
+
+
+
Інструменти дешифрування (Base64, Цезар) +
+
Base64
+ + +
+
Цезар — перебір усіх зсувів
+ + +

+        
+
`; + + for (const b of p.querySelectorAll('[data-q]')) + b.onclick = () => App.send({ t: 'sys_query', sys: 'phone', kind: b.dataset.q, input: q$(p, '#ph-n').value }); + q$(p, '#bk-go').onclick = () => App.send({ t: 'sys_query', sys: 'bank', input: q$(p, '#bk-n').value }); + q$(p, '#db-go').onclick = () => App.send({ t: 'sys_query', sys: 'db', input: q$(p, '#db-n').value }); + q$(p, '#cr-exp').onclick = () => App.send({ t: 'sys_query', sys: 'crim', kind: 'expert' }); + q$(p, '#cr-go').onclick = () => App.send({ t: 'sys_query', sys: 'crim', kind: 'archive', input: q$(p, '#cr-n').value }); + const sl = q$(p, '#cc-t'), out = q$(p, '#cc-v'); + const upd = () => out.textContent = fmtT(+sl.value); + sl.oninput = upd; upd(); + q$(p, '#cc-go').onclick = () => App.send({ t: 'sys_query', sys: 'cctv', camera: q$(p, '#cc-cam').value, minutes: +sl.value }); + + q$(p, '#tb64go').onclick = () => q$(p, '#tb64out').textContent = b64dec(q$(p, '#tb64').value); + q$(p, '#tczgo').onclick = () => { + const s = q$(p, '#tcz').value; + q$(p, '#tczout').textContent = s.trim() + ? Array.from({ length: 32 }, (_, i) => `${String(i + 1).padStart(2, ' ')} → ${caesarDec(s, i + 1)}`).join('\n') : ''; + }; + pzList = q$(p, '#pz-list'); + labBox = q$(p, '#lab-btns'); + renderLab(); + } + + // Зведення того, що люди КАЖУТЬ про свій вечір. Суперечності не підсвічуються — + // порівнювати покази з архівами камер гравці мають самі. + function renderAlibi() { + const box = panels.sys && panels.sys.querySelector('#al-table'); + if (!box || !App.state) return; + const claims = Object.entries(App.state.alibiClaims || {}); + if (!claims.length) { box.innerHTML = 'Ще нікого не питали, де він був того вечора.'; box.className = 'small dim'; return; } + box.className = ''; + const bn = id => { const b = App.init.district.buildings.find(x => x.id === id); return b ? b.name.split(',')[0] : id; }; + const t = el('table', '', 'ХтоКаже, що бувКамери над тим місцем'); + for (const [cid, claim] of claims) { + const c = App.byId(cid); + const cams = App.init.district.cameras.filter(cam => cam.covers.includes(claim)); + const cell = cams.length + ? cams.map(cam => `${esc(cam.name)}${(App.state.cctvPulled || []).includes(cam.id) ? ' — архів на дошці' : ' — архів не тягнули'}`).join('
') + : 'жодна камера туди не дивиться'; + t.append(el('tr', '', `${esc(c.name)}${esc(bn(claim))}${cell}`)); + } + box.innerHTML = ''; box.append(t); + } + + function renderLab() { + if (!labBox || !App.state) return; + labBox.innerHTML = ''; + const foundId = id => App.state.found.some(f => f.id === id); + const mk = (label, sample, doneId, noSampleMsg, run) => { + const b = el('button', '', label + (foundId(doneId) ? ' ✓' : '')); + b.disabled = foundId(doneId); + b.onclick = () => sample == null ? toast(noSampleMsg) : run(); + labBox.append(b); + }; + const short = t => t ? t.split(' ').slice(0, 3).join(' ') + '…' : ''; + mk(App.state.dnaLabel ? `ДНК: ${short(App.state.dnaLabel)}` : 'Порівняти ДНК зі зразком', + App.state.dnaSample, 'lab_dna_match', + 'Немає зразка ДНК: його ще не знайшли. Хай польовий огляне місце злочину.', + () => pickCompare('Гель-електрофорез: кого порівнюємо?', ids => dnaModal(ids, 'sf_dna', App.state.dnaSample))); + if (App.state.dnaSample2) + mk(`ДНК: ${short(App.state.dna2Label)}`, App.state.dnaSample2, 'lab_dna_match2', + '', + () => pickCompare('Другий зразок: кого порівнюємо?', ids => dnaModal(ids, 'sf_dna2', App.state.dnaSample2))); + mk('Звірити відбиток зі знаряддя', App.state.printSample, 'lab_print_match', + 'Немає повного відбитка: спершу треба знайти знаряддя злочину (підказку дає розтин).', + () => pickCompare('Дактилоскопія: чиї картки підняти?', printModal)); + } + + // ---------- Шифри (у лабораторії) ---------- + let pzSig = null; + function renderPuzzles() { + if (!pzList) return; + const sig = App.state.puzzles.map(x => x.id + x.solved).join(); + if (sig === pzSig) return; pzSig = sig; + pzList.innerHTML = ''; + if (!App.state.puzzles.length) pzList.append(el('p', 'dim small', 'Зашифрованих знахідок поки немає.')); + for (const z of App.state.puzzles) { + const c = el('div', 'card' + (z.solved ? ' tick' : '')); + c.append(el('div', 't', `${z.solved ? '✓ ' : ''}${esc(z.prompt)}`)); + if (z.solved) { pzList.append(c); continue; } + if (z.kind === 'caesar') { + c.append(el('div', 'small', `${esc(z.payload)}`)); + const out = el('div', 'small', ''); + const sl = el('input'); sl.type = 'range'; sl.min = 1; sl.max = 32; sl.value = 1; sl.style.width = '100%'; + const upd = () => out.innerHTML = `зсув ${sl.value}: ${esc(caesarDec(z.payload, +sl.value))}`; + sl.oninput = upd; upd(); + const b = el('button', 'primary', 'Зафіксувати зсув'); + b.onclick = () => App.send({ t: 'puzzle_try', id: z.id, shift: +sl.value }); + c.append(sl, out, b); + } else if (z.kind === 'b64') { + c.append(el('div', 'small', `${esc(z.payload)}`)); + const inp = el('input'); inp.placeholder = 'розкодований текст (інструменти нижче)'; inp.style.width = '100%'; + const b = el('button', 'primary', 'Надіслати'); + b.onclick = () => App.send({ t: 'puzzle_try', id: z.id, answer: inp.value.trim() }); + c.append(inp, b); + } else { // pin / md5 + const inp = el('input'); inp.placeholder = z.kind === 'pin' ? 'PIN (4 цифри)' : 'пароль'; + const b = el('button', 'primary', 'Спробувати'); + b.onclick = () => App.send({ t: 'puzzle_try', id: z.id, answer: inp.value }); + inp.onkeydown = e => { if (e.key === 'Enter') b.click(); }; + c.append(inp, b); + } + c.append(hintBlock(z.hint)); + pzList.append(c); + } + } + + // ---------- Лабораторні міні-ігри ---------- + const rng32 = seed => { let a = seed | 0; return () => { a |= 0; a = (a + 0x6D2B79F5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; }; + + function drawPrint(ctx, seed, rot) { + const rr = rng32(seed), W = ctx.canvas.width, c = W / 2; + ctx.fillStyle = '#0d1116'; ctx.fillRect(0, 0, W, W); + ctx.save(); ctx.translate(c, c); ctx.rotate(rot || 0); + ctx.strokeStyle = '#9db4c8'; ctx.lineWidth = 1.4; + for (let i = 0; i < 11; i++) { + const rad = 4 + i * (c - 8) / 11 + rr() * 3, a0 = rr() * 6.28, len = 1.2 + rr() * 3.6; + ctx.beginPath(); ctx.arc(0, 0, rad, a0, a0 + len); ctx.stroke(); + if (rr() < 0.4) { ctx.beginPath(); ctx.arc(0, 0, rad * 0.72, a0 + 2, a0 + 2.7); ctx.stroke(); } + } + ctx.restore(); + } + + const laneHtml = (dna, name) => + `
+
+ ${dna.map(v => `
`).join('')} +
${name}
`; + + // Ручний шортліст: аналітик сам вирішує, кого нести в лабораторію. + // ponytail: сервер не гейтить ДНК — перебір усієї бази можливий, але по 6 і руками + function pickCompare(title, go) { + const mm = openModal(`

${esc(title)}

+

Оберіть до 6 профілів для порівняння зі зразком.

+ +
+ `); + const sel = new Set(); + const list = mm.inner.querySelector('#pk-list'), q = mm.inner.querySelector('#pk-q'), n = mm.inner.querySelector('#pk-n'); + const draw = () => { + list.innerHTML = ''; + const f = q.value.trim().toLowerCase(); + for (const c of App.init.citizens) { + if (c.id === App.state.victim) continue; + if (f && !c.name.toLowerCase().includes(f)) continue; + const lb = el('label', 'small'); lb.style.display = 'block'; + const cb = el('input'); cb.type = 'checkbox'; cb.checked = sel.has(c.id); + cb.onchange = () => { + if (cb.checked && sel.size >= 6) { cb.checked = false; return; } + if (cb.checked) sel.add(c.id); else sel.delete(c.id); + n.textContent = `обрано: ${sel.size}/6`; + }; + lb.append(cb, ` ${c.name} — ${c.home}, ${c.job}`); + list.append(lb); + } + }; + q.oninput = draw; draw(); + mm.inner.querySelector('#pk-go').onclick = () => { + if (!sel.size) return; + mm.wrap.remove(); go([...sel]); + }; + } + + function dnaModal(ids, sampleEv, sample) { + if (!sample || !ids.length) return; + const mm = openModal(`

Гель-електрофорез

+

Зразок із місця злочину — ліворуч. Клацніть по профілю, який вважаєте збігом. Памʼятайте: збіг скаже лише, ЧИЯ це ДНК, — не коли і не чому вона там.

+
${laneHtml(sample, 'ЗРАЗОК')}${ids.map(id => { const c = App.byId(id); return `${laneHtml(c.dna, esc(c.name))}`; }).join('')}
`); + mm.inner.append(hintBlock('Збіг — це коли ВСІ смуги стоять на тих самих висотах, що у зразка. Помилковий вибір чесно скаже «ні» і викреслить людину.')); + mm.inner.onclick = e => { + const s = e.target.closest('[data-cid]'); + if (s) { App.send({ t: 'dna_pick', sampleEv, cid: s.dataset.cid }); mm.wrap.remove(); } + }; + } + + function printModal(ids) { + if (App.state.printSample == null || !ids.length) return; + const mm = openModal(`

Дактилоскопія: відбиток зі знаряддя

+

Зразок ліворуч. Клацніть по власнику відбитка.

+ + ← зразок +
`); + drawPrint(mm.inner.querySelector('#psample').getContext('2d'), App.state.printSample, 0.7); + const box = mm.inner.querySelector('#pcands'); + for (const id of ids) { + const c = App.byId(id); + const w = el('div', '', ''); w.style.cursor = 'pointer'; w.style.textAlign = 'center'; + 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(); }; + box.append(w); + } + mm.inner.append(hintBlock('Зразок знято під кутом — подумки поверніть його. Порівнюйте будову: кількість і хід дуг, а не поворот.')); + } + + function triangulateModal(data) { + const mm = openModal(`

Тріангуляція номера ${esc(data.number)}

+

Три вежі, три відстані до апарата — станом на зараз. Клацніть будинок, що лежить на перетині всіх трьох дуг.

+ +
`); + const cv = mm.inner.querySelector('#trcv'); cv.id = 'cv'; + fitCanvas(cv); + const ctx = cv.getContext('2d'); + drawDistrict(ctx); // лишає трансформацію у світових координатах + for (const t of data.towers) { + ctx.strokeStyle = 'rgba(232,176,75,.55)'; ctx.lineWidth = 1.5; + ctx.beginPath(); ctx.arc(t.x, t.y, t.dist, 0, 7); ctx.stroke(); + ctx.fillStyle = '#e8b04b'; ctx.beginPath(); ctx.arc(t.x, t.y, 6, 0, 7); ctx.fill(); + ctx.fillStyle = '#e8b04b'; ctx.font = '12px Georgia'; ctx.fillText('▲ ' + t.name, t.x + 8, t.y + 4); + } + mm.inner.querySelector('#trreads').innerHTML = data.towers.map(t => `${esc(t.name)}: ~${t.dist} м`).join(' · '); + mm.inner.append(hintBlock('Кожна дуга — коло можливих точок навколо вежі. Апарат там, де всі три кола накладаються на один будинок. Клацніть саме той будинок.')); + cv.onclick = e => { + const r = cv.getBoundingClientRect(), D = App.init.district.world; + const x = (e.clientX - r.left) * D.w / r.width, y = (e.clientY - r.top) * D.h / r.height; + const b = App.init.district.buildings.find(b => x > b.pos.x && x < b.pos.x + b.pos.w && y > b.pos.y && y < b.pos.y + b.pos.h); + if (b) { App.send({ t: 'triangulate_pick', building: b.id }); mm.wrap.remove(); } + }; + } + + App.on('mg', m => { + if (App.role !== 'analyst') return; + if (m.kind === 'triangulate') triangulateModal(m); + }); +})(); diff --git a/chapter3/public/client.js b/chapter3/public/client.js new file mode 100644 index 0000000..420534a --- /dev/null +++ b/chapter3/public/client.js @@ -0,0 +1,524 @@ +// Спільне ядро клієнта: мережа, стан, бічна дошка, звинувачення, модалки. +"use strict"; + +const App = (window.App = { + ws: null, + role: null, + session: null, + init: null, // {district, citizens, intro, options...} + state: null, // останній снапшот від сервера + handlers: {}, + on(t, fn) { + (this.handlers[t] ??= []).push(fn); + }, + send(m) { + if (this.ws && this.ws.readyState === 1) this.ws.send(JSON.stringify(m)); + }, + byId(cid) { + return this.init.citizens.find((c) => c.id === cid); + }, +}); + +const $ = (sel) => document.querySelector(sel); +const el = (tag, cls, html) => { + const e = document.createElement(tag); + if (cls) e.className = cls; + if (html != null) e.innerHTML = html; + return e; +}; +const esc = (s) => + String(s).replace( + /[&<>"]/g, + (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c], + ); + +function toast(txt) { + const t = el("div", "toast", esc(txt)); + $("#toasts").append(t); + setTimeout(() => t.remove(), 4200); +} + +// Підказки приховані за замовчуванням — спершу думаємо самі. +function hintBlock(text) { + const wrap = el("div", "hintwrap"); + if (!text) return wrap; + const btn = el("button", "small", "💡 Підказка"); + const body = el("div", "small dim"); + body.style.cssText = "display:none;margin-top:4px"; + body.textContent = text; + btn.onclick = () => { + body.style.display = body.style.display === "none" ? "block" : "none"; + }; + wrap.append(btn, body); + return wrap; +} + +// ---------- Мережа ---------- +function connect(code, role) { + App.noReconnect = false; + const ws = (App.ws = new WebSocket(`ws://${location.host}`)); + ws.onopen = () => + ws.send( + JSON.stringify({ t: "join", session: code, role, pos: App.lastFieldPos }), + ); + ws.onmessage = (e) => { + const m = JSON.parse(e.data); + if (m.t === "reset") { + toast("Сейв видалено — справа починається з нуля."); + setTimeout(() => location.reload(), 1400); + return; + } + if (m.t === "joinerr") { + // сесія заповнена по цій ролі — лишаємось у лобі, без автоперепідключення + App.noReconnect = true; + toast(m.txt); + ws.close(); + return; + } + if (m.t === "pos") App.fieldPositions = m.field || []; + if (m.t === "toast") { + if (m.txt.includes("підписав ордер") && window.Snd) Snd.stamp(); + return toast(m.txt); + } + if (m.t === "init") { + App.init = m; + App.role = m.role; + App.myNum = m.num; + document.body.classList.add("role-" + m.role); + $("#lobby").style.display = "none"; + $("#app").classList.add("on"); + $("#b-session").textContent = "справа: " + App.session; + for (const fn of App.handlers.init) fn(m); + showIntro(); + return; + } + if (m.t === "state") { + App.state = m; + $("#b-field").classList.toggle("on", m.online.field > 0); + $("#b-analyst").classList.toggle("on", m.online.analyst > 0); + $("#b-field").textContent = "поле" + (m.online.field > 1 ? " ×" + m.online.field : ""); + $("#b-analyst").textContent = "аналітик" + (m.online.analyst > 1 ? " ×" + m.online.analyst : ""); + renderSide(); + renderEnd(); + for (const fn of App.handlers.state) fn(m); + return; + } + for (const fn of App.handlers[m.t] || []) fn(m); + }; + ws.onclose = () => { + if (App.noReconnect) return; + toast("Зв'язок втрачено, перепідключення…"); + setTimeout(() => connect(code, role), 1500); + }; +} + +// ---------- Лобі ---------- +for (const b of document.querySelectorAll(".rolebtn")) { + b.onclick = () => { + const code = ($("#code").value.trim() || "DEFAULT").toUpperCase(); + App.session = code; + localStorage.setItem("ch3", JSON.stringify({ code, role: b.dataset.role })); + if (window.Snd) Snd.start(b.dataset.role); + connect(code, b.dataset.role); + }; +} +try { + const prev = JSON.parse(localStorage.getItem("ch3")); + if (prev && prev.code) $("#code").value = prev.code; +} catch {} + +// ---------- Бічна дошка ---------- +let sideTab = "ev"; +let showHidden = false; // згорнута секція «Приховані» на дні дошки + +// Дошка перемальовується на кожен стан — тож недодруковані нотатки треба зберігати, +// інакше broadcast напарника стирає те, що ти зараз друкуєш. Тримаємо чернетку в мапі +// (ключ = data-draft-key поля) і відновлюємо фокус/курсор після перемальовки. +const noteDraft = {}; +function wireDraft(input, key) { + input.dataset.draftKey = key; + if (noteDraft[key]) input.value = noteDraft[key]; + input.addEventListener("input", () => { + noteDraft[key] = input.value; + }); +} +$("#sidetabs").onclick = (e) => { + const b = e.target.closest("button"); + if (!b) return; + sideTab = b.dataset.tab; + for (const x of $("#sidetabs").children) x.classList.toggle("on", x === b); + renderSide(); +}; + +function markButtons(key) { + const marks = App.state.board.marks; + 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]; + b.onclick = (ev) => { + ev.stopPropagation(); + App.send({ t: "mark", key, kind }); + }; + mk.append(b); + } + return mk; +} + +function cardClasses(key) { + const m = App.state.board.marks[key]; + return m && m !== "hide" ? " " + m : ""; +} + +function renderSide() { + if (!App.state) return; + const body = $("#sidebody"); + // запам'ятати, яке поле нотатки в фокусі, щоб повернути його після перемальовки + const act = document.activeElement; + const activeKey = act && act.dataset ? act.dataset.draftKey : null; + const caret = activeKey ? act.selectionStart : null; + body.innerHTML = ""; + const marks = App.state.board.marks; + + if (sideTab === "ev") { + const pins = App.state.board.pins; + body.append( + el( + "div", + "dim small", + "Закріплене 📌 піде в справу для прокурора. Знайдено: " + + App.state.found.length, + ), + ); + const found = App.state.found; + // Закріплені — зверху, від останнього запіненого до першого; далі решта доказів, + // від найновішого знайденого до найстарішого. (pins — у порядку закріплення.) + const pinnedCards = [...pins] + .reverse() + .map((id) => found.find((e) => e.id === id)) + .filter(Boolean); + const rest = [...found].reverse().filter((e) => !pins.includes(e.id)); + const ordered = [...pinnedCards, ...rest]; + + const buildCard = (e) => { + const key = "e:" + e.id; + const c = el("div", "card" + cardClasses(key)); + const t = el("div", "t"); + t.append(el("b", "", esc(e.title))); + const pin = el( + "button", + "pinbtn" + (pins.includes(e.id) ? " on" : ""), + "📌", + ); + pin.onclick = () => App.send({ t: "pin", evId: e.id }); + t.append(pin); + c.append(t, el("div", "small", esc(e.detail)), markButtons(key)); + const notes = (App.state.board.enotes || {})[e.id] || []; + for (const n of notes) { + const row = el( + "div", + "small dim", + `✎ ${esc(n.txt)} ${n.by === "field" ? "польовий" : "аналітик"}`, + ); + const d = el("button", "small", "×"); + d.title = "видалити нотатку"; + d.onclick = () => App.send({ t: "enote_del", evId: e.id, id: n.id }); + row.append(d); + c.append(row); + } + const ni = el("input", "small"); + ni.placeholder = "нотатка до доказу…"; + ni.style.cssText = "width:100%;margin-top:4px"; + wireDraft(ni, "e:" + e.id); + ni.onkeydown = (ev) => { + if (ev.key === "Enter" && ni.value.trim()) { + App.send({ t: "enote", evId: e.id, txt: ni.value }); + ni.value = ""; + delete noteDraft["e:" + e.id]; + } + }; + c.append(ni); + return c; + }; + + const hidden = []; + for (const e of ordered) { + if (marks["e:" + e.id] === "hide") { + hidden.push(e); + continue; + } + body.append(buildCard(e)); + } + // Приховані — окрема згорнута секція на дні, кожен доказ окремо (не «одна кнопка = все») + if (hidden.length) { + const head = el( + "button", + "small", + `${showHidden ? "▾" : "▸"} Приховані (${hidden.length})`, + ); + head.style.cssText = "width:100%;margin-top:10px;text-align:left"; + head.onclick = () => { + showHidden = !showHidden; + renderSide(); + }; + body.append(head); + if (showHidden) for (const e of hidden) body.append(buildCard(e)); + } + } + + if (sideTab === "notes") { + const ta = el("textarea", "", ""); + ta.rows = 2; + ta.style.width = "100%"; + ta.placeholder = "спільна нотатка…"; + wireDraft(ta, "shared"); + const add = el("button", "primary", "Додати"); + add.onclick = () => { + if (ta.value.trim()) { + App.send({ t: "note", txt: ta.value }); + ta.value = ""; + delete noteDraft["shared"]; + } + }; + body.append(ta, add); + for (const n of [...App.state.board.notes].reverse()) { + const c = el("div", "card"); + c.append( + el("div", "", esc(n.txt)), + el("div", "small dim", n.by === "field" ? "польовий" : "аналітик"), + ); + const d = el("button", "small", "видалити"); + d.onclick = () => App.send({ t: "note_del", id: n.id }); + c.append(d); + body.append(c); + } + } + + // повернути фокус і курсор у поле, де друкували до перемальовки + if (activeKey) { + const again = body.querySelector(`[data-draft-key="${activeKey}"]`); + if (again) { + again.focus(); + if (caret != null) + try { + again.setSelectionRange(caret, caret); + } catch {} + } + } +} + +// ---------- Досьє ---------- +function showDossier(c) { + const dnaCells = c.dna + .map( + (v) => + ``, + ) + .join(""); + const m = openModal(` +

${esc(c.name)}

+ + + + + + + + + + +
Вік / стать${c.age} / ${c.sex === "ч" ? "чол." : "жін."} (${c.birthYear} р.н.)
Адреса${esc(c.home)}
Робота${esc(c.job)}${c.workplace ? " — " + esc(bName(c.workplace)) : ""}
Телефон${esc(c.phone)}
Зріст / взуття${c.height} см / ${c.shoe} розмір
Кров / відбитки${esc(c.blood)} / ${esc(c.printType)}
Верхній одяг${esc(c.coat)}
Звички${c.smoker ? "курить; " : ""}${c.dog ? "собака " + esc(c.dog) + "; " : ""}${c.tattoo ? "тату: " + esc(c.tattoo) : ""}
ДНК-профіль${dnaCells}
`); + if (App.state && App.state.warrants.includes(c.id)) + m.inner.append(el("div", "small", "📜 Ордер на обшук житла — отримано")); + m.inner.append(logSection(c.id)); +} + +// Журнал допитів із мітками (✓ виділити / ✗ закреслити) на кожному записі. Мітки +// спільні (board.marks, ключ 'q:'+id) — бачать обидві ролі. Модалка статична, тож +// клік дає оптимістичний відгук одразу; збережений стан підтягнеться при повторному відкритті. +function logSection(cid) { + const wrap = el("div"); + const log = (App.state && App.state.log && App.state.log[cid]) || []; + if (!log.length) return wrap; + wrap.append(el("div", "syshead", "Журнал допитів")); + for (const e of log.slice(-12)) { + const key = "q:" + e.id; + let cur = App.state.board.marks[key]; + const row = el("div", "small"); + 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"; + row.style.opacity = mk === "cross" ? ".5" : "1"; + row.style.borderLeftColor = mk === "tick" ? "var(--green,#5aa870)" : "transparent"; + }; + row.append( + el("div", "", `${esc(e.q)}
${esc(e.a)}`), + ); + const ctrl = el("div", "mk"); + for (const [kind, label] of [ + ["tick", "✓"], + ["cross", "✗"], + ]) { + const b = el("button", cur === kind ? "on" : "", label); + b.title = kind === "tick" ? "виділити" : "закреслити"; + b.onclick = () => { + App.send({ t: "mark", key, kind }); + cur = cur === kind ? undefined : kind; // сервер toggl-ить так само + apply(cur); + for (const bb of ctrl.children) bb.classList.remove("on"); + if (cur) b.classList.add("on"); + }; + ctrl.append(b); + } + row.append(ctrl); + apply(cur); + wrap.append(row); + } + return wrap; +} +function bName(bid) { + const b = App.init.district.buildings.find((x) => x.id === bid); + return b ? b.name : bid; +} + +// ---------- Модалки ---------- +function openModal(html, opts = {}) { + const wrap = el("div", "modal"); + const inner = el("div", "", html); + wrap.append(inner); + if (!opts.locked) { + wrap.onclick = (e) => { + if (e.target === wrap) wrap.remove(); + }; + const x = el("button", "small", "Закрити"); + x.style.marginTop = "12px"; + x.onclick = () => wrap.remove(); + inner.append(x); + } + document.body.append(wrap); + return { wrap, inner }; +} + +function showIntro() { + const i = App.init.intro; + const fieldHow = ` +
Польовий детектив
+ `; + const analystHow = ` +
Аналітик
+ `; + openModal(`

${esc(i.title)}: ${esc(i.scene)}, ~${esc(i.time)}

${esc(i.text)}

+

Роль: ${App.role === "field" ? "польовий детектив — район, обшуки, розмови" : "аналітик — бази, камери, лабораторія"}.

+ ${App.role === "field" ? fieldHow : analystHow} +
Фінал
+

Одне звинувачення на двох: ХТО (ім'я з клавіатури) / ЯК чи ДЕ / ЧОМУ, підписи обох. Прокурор дивиться лише на закріплені 📌 докази: потрібне підтвердження особи (ДНК, відбиток або зламане алібі), мотив і спосіб. Нотатки — спільні: загальні у вкладці, точкові — прямо на картці доказу.

`); +} +$("#introbtn").onclick = showIntro; + +const sndBtn = $("#sndbtn"); +if (sndBtn && window.Snd) { + sndBtn.textContent = Snd.muted ? "🔇" : "🔊"; + sndBtn.onclick = () => (sndBtn.textContent = Snd.toggle() ? "🔇" : "🔊"); +} + +// ---------- Звинувачення ---------- +$("#accusebtn").onclick = () => { + const st = App.state; + if (!st || st.ended) return; + if (st.accuseDraft) return showAccuseStatus(); + const o = App.init.options; + const isK = App.init.caseType === "kidnap"; + const m = openModal(` +

${isK ? "Рапорт на штурм — один шанс" : "Звинувачення — один шанс"}

+

Помилка в будь-якому полі — і ${isK ? "штурм піде не туди" : "вас звільнять"}. Прокурор дивиться лише на закріплені 📌 докази.

+

ХТО (повне ім'я, з клавіатури):

+

${isK ? "ДЕ утримують:" : "ЯК / ЧИМ:"}

+

ЧОМУ:

+

Закріплено доказів: ${st.board.pins.length}

+ `); + m.inner.querySelector("#ac-go").onclick = () => { + const who = m.inner.querySelector("#ac-who").value.trim(); + if (!who) return toast("Впишіть ім'я."); + App.send({ + t: "accuse_draft", + who, + how: m.inner.querySelector("#ac-how").value, + why: m.inner.querySelector("#ac-why").value, + }); + m.wrap.remove(); + }; +}; + +let accuseModal = null; +function showAccuseStatus() { + const d = App.state.accuseDraft; + if (accuseModal) accuseModal.wrap.remove(); + accuseModal = openModal(` +

${App.init.caseType === "kidnap" ? "Рапорт на штурм — на підписі" : "Звинувачення на підписі"}

+ + + + +
ХТО${esc(d.who)}
${App.init.caseType === "kidnap" ? "ДЕ" : "ЯК"}${esc(d.how)}
ЧОМУ${esc(d.why)}
+

Підписано: ${d.signed} з ${d.need}

+

Потрібен підпис КОЖНОГО гравця — і вироку вже не відкликати.

+ + `); + accuseModal.inner.querySelector("#ac-sign").onclick = () => + App.send({ t: "accuse_confirm" }); + accuseModal.inner.querySelector("#ac-cancel").onclick = () => + App.send({ t: "accuse_cancel" }); +} +App.on("state", () => { + if (accuseModal) { + accuseModal.wrap.remove(); + accuseModal = null; + } + if (App.state.accuseDraft && !App.state.ended) showAccuseStatus(); +}); + +// ---------- Фінал ---------- +let endShown = false; +function renderEnd() { + const e = App.state.ended; + if (!e || endShown) return; + endShown = true; + if (window.Snd) Snd.sting(!!e.solved); + if (accuseModal) { + accuseModal.wrap.remove(); + accuseModal = null; + } + openModal( + ` +
+

${e.solved ? (App.init.caseType === "kidnap" ? "ЛЮДИНУ ВРЯТОВАНО" : "СПРАВУ РОЗКРИТО") : "ВАС ЗВІЛЬНЕНО"}

+ ${e.hint ? `

Де ви схибили: ${esc(e.hint)}

` : ""} +

${esc(e.recap)}

+

${e.solved ? "Новий код сесії — цілком нова справа." : "Справжню картину відновив ваш наступник. Новий код сесії — нова спроба в новій справі."}

+
`, + { locked: false }, + ); +} diff --git a/chapter3/public/field.js b/chapter3/public/field.js new file mode 100644 index 0000000..5730943 --- /dev/null +++ b/chapter3/public/field.js @@ -0,0 +1,707 @@ +// Польовий детектив: топ-даун район на канвасі, обшуки, розмови. +"use strict"; +(() => { + let cv, ctx, hint; + let player = { x: 85, y: 290 }; + const keys = {}; + let solids = [], + walkIn = []; // будівлі-перешкоди та відкриті зони (парк/пірс) + const OPEN_ZONES = ["park", "pier"]; + + // ---------- рендер мапи (спільний з аналітиком) ---------- + // канвас під розмір вікна (без вертикального скролу) і під щільність пікселів (без мила) + window.fitCanvas = function (cv) { + const D = App.init.district.world, + dpr = window.devicePixelRatio || 1; + const rect = cv.parentElement.getBoundingClientRect(); + if (rect.width < 50) return; // вкладку ще не видно + const availH = Math.max( + 240, + window.innerHeight - cv.getBoundingClientRect().top - 36, + ); + const w = Math.min(rect.width - 4, (availH * D.w) / D.h); + cv.style.width = w + "px"; + cv.style.height = (w * D.h) / D.w + "px"; + cv._k = (w * dpr) / D.w; + cv.width = Math.round(w * dpr); + cv.height = Math.round(((w * D.h) / D.w) * dpr); + }; + + // ---------- нічний рендер району (спільний з мапою аналітика) ---------- + const hash32 = (str) => { + let h = 2166136261 >>> 0; + for (const c of String(str)) { h ^= c.codePointAt(0); h = Math.imul(h, 16777619); } + return h >>> 0; + }; + const lcg = (seed) => { + let a = seed | 0; + return () => { a = (Math.imul(a, 1103515245) + 12345) | 0; return ((a >>> 8) & 0xffff) / 0x10000; }; + }; + + window.drawDistrict = function (ctx, opts = {}) { + const D = App.init.district; + const k = ctx.canvas._k || 1; + ctx.setTransform(k, 0, 0, k, 0, 0); + ctx.globalCompositeOperation = "source-over"; + // ніч: земля і річка + ctx.fillStyle = "#0b1014"; + ctx.fillRect(0, 0, D.world.w, D.world.h); + const rg = ctx.createLinearGradient(0, 600, 0, 700); + rg.addColorStop(0, "#0b1622"); + rg.addColorStop(1, "#123049"); + ctx.fillStyle = rg; + ctx.fillRect(0, 616, D.world.w, 84); + // дороги: мокрий асфальт з осьовою + for (const rd of D.roads) { + ctx.fillStyle = "#161a20"; + ctx.fillRect(rd.x, rd.y, rd.w, rd.h); + ctx.strokeStyle = "rgba(200,210,220,.07)"; + ctx.setLineDash([14, 18]); + ctx.lineWidth = 2; + ctx.beginPath(); + if (rd.w > rd.h) { ctx.moveTo(rd.x, rd.y + rd.h / 2); ctx.lineTo(rd.x + rd.w, rd.y + rd.h / 2); } + else { ctx.moveTo(rd.x + rd.w / 2, rd.y); ctx.lineTo(rd.x + rd.w / 2, rd.y + rd.h); } + ctx.stroke(); + ctx.setLineDash([]); + ctx.fillStyle = "#3f4854"; + ctx.font = "11px Georgia"; + ctx.fillText(rd.name, rd.x + 8 + (rd.w > rd.h ? 0 : -2), rd.y + (rd.w > rd.h ? 14 : 60)); + } + // будівлі: тінь, фасад, теплі вікна (детерміновані від id) + for (const b of D.buildings) { + const p = b.pos, open = OPEN_ZONES.includes(b.id); + if (open) { + ctx.fillStyle = b.id === "park" ? "#0e1a10" : "#0d151c"; + ctx.fillRect(p.x, p.y, p.w, p.h); + ctx.strokeStyle = "rgba(120,150,120,.25)"; + ctx.strokeRect(p.x + 0.5, p.y + 0.5, p.w, p.h); + const rr = lcg(hash32(b.id)); + ctx.fillStyle = b.id === "park" ? "#14240f" : "#13202b"; + for (let i = 0; i < 14; i++) { + const x = p.x + 10 + rr() * (p.w - 20), y = p.y + 10 + rr() * (p.h - 20); + ctx.beginPath(); ctx.arc(x, y, 3 + rr() * 5, 0, 7); ctx.fill(); + } + } else { + ctx.fillStyle = "#05070a"; + ctx.fillRect(p.x + 3, p.y + 4, p.w, p.h); + ctx.fillStyle = b.id === "station" ? "#1a2433" : "#171d26"; + ctx.fillRect(p.x, p.y, p.w, p.h); + ctx.fillStyle = "rgba(255,255,255,.045)"; + ctx.fillRect(p.x, p.y, p.w, 6); + ctx.strokeStyle = "#2c3642"; + ctx.strokeRect(p.x + 0.5, p.y + 0.5, p.w, p.h); + const rr = lcg(hash32(b.id)); + for (let wy = p.y + 12; wy < p.y + p.h - 12; wy += 16) + for (let wx = p.x + 8; wx < p.x + p.w - 12; wx += 14) + if (rr() < 0.3) { + ctx.fillStyle = rr() < 0.8 ? "rgba(244,201,112,.5)" : "rgba(160,200,255,.28)"; + ctx.fillRect(wx, wy, 5, 7); + } + if (b.id === "station") { ctx.fillStyle = "#7fb4ff"; ctx.fillRect(p.x + p.w / 2 - 2, p.y - 6, 4, 6); } + } + ctx.fillStyle = "#93a1b0"; + ctx.font = "12px Georgia"; + ctx.fillText(b.name.split(",")[0], p.x + 6, p.y + 16, p.w - 12); + if (App.init.sceneBuilding === b.id) { + ctx.strokeStyle = "rgba(208,83,83,.7)"; + ctx.setLineDash([6, 5]); + ctx.strokeRect(p.x - 3.5, p.y - 3.5, p.w + 7, p.h + 7); + ctx.setLineDash([]); + ctx.fillStyle = "#d05353"; + ctx.font = "bold 15px Georgia"; + ctx.fillText(App.init.caseType === "kidnap" ? "✖ місце викрадення" : "✖ місце злочину", p.x + 6, p.y + p.h - 8, p.w - 12); + } + } + // ліхтарі вздовж доріг — тепле світло, адитивно + ctx.globalCompositeOperation = "lighter"; + for (const rd of D.roads) { + const horiz = rd.w > rd.h, len = horiz ? rd.w : rd.h; + for (let i = 80; i < len; i += 170) { + const x = horiz ? rd.x + i : rd.x + rd.w / 2; + const y = horiz ? rd.y + rd.h / 2 : rd.y + i; + const gl = ctx.createRadialGradient(x, y, 2, x, y, 55); + gl.addColorStop(0, "rgba(255,196,110,.15)"); + gl.addColorStop(1, "rgba(255,196,110,0)"); + ctx.fillStyle = gl; + ctx.beginPath(); ctx.arc(x, y, 55, 0, 7); ctx.fill(); + ctx.fillStyle = "rgba(255,220,160,.8)"; + ctx.fillRect(x - 1, y - 1, 2, 2); + } + } + ctx.globalCompositeOperation = "source-over"; + if (opts.partner) { + for (const f of App.fieldPositions || []) { + if (App.role === "field" && f.num === App.myNum) continue; // себе малює loop + ctx.fillStyle = "#e8b04b"; + ctx.beginPath(); + ctx.arc(f.x, f.y, 7, 0, 7); + ctx.fill(); + ctx.font = "11px Georgia"; + ctx.fillText("Польовий" + f.num, f.x + 10, f.y + 4); + } + } + // мітки аналітика «перевір тут» — бачать обидві ролі + for (const mk of (App.state && App.state.markers) || []) { + ctx.strokeStyle = "#d05353"; + ctx.lineWidth = 2.5; + ctx.beginPath(); + ctx.moveTo(mk.x - 8, mk.y - 8); + ctx.lineTo(mk.x + 8, mk.y + 8); + ctx.moveTo(mk.x + 8, mk.y - 8); + ctx.lineTo(mk.x - 8, mk.y + 8); + ctx.stroke(); + } + }; + + App.on("init", (m) => { + if (App.role !== "field") return; + player = { ...m.pos }; + App.lastFieldPos = { ...m.pos }; + const stage = document.getElementById("stage"); + stage.innerHTML = ""; + cv = document.createElement("canvas"); + cv.id = "cv"; + cv.width = m.district.world.w; + cv.height = m.district.world.h; + hint = document.createElement("div"); + hint.id = "fieldhint"; + stage.append(cv, hint); + ctx = cv.getContext("2d"); + fitCanvas(cv); + addEventListener("resize", () => fitCanvas(cv)); + solids = m.district.buildings + .filter((b) => !OPEN_ZONES.includes(b.id)) + .map((b) => b.pos); + walkIn = m.district.buildings.filter((b) => OPEN_ZONES.includes(b.id)); + // e.code — фізична клавіша, працює на будь-якій розкладці (укр/лат) + addEventListener("keydown", (e) => { + if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA") + return; + keys[e.code] = true; + if (e.code === "KeyE") tryEnter(); + }); + addEventListener("keyup", (e) => { + keys[e.code] = false; + }); + addEventListener("blur", () => { + for (const k in keys) keys[k] = false; + }); + requestAnimationFrame(loop); + setInterval(() => { + App.lastFieldPos = { x: player.x, y: player.y }; + App.send({ t: "move", x: player.x, y: player.y }); + }, 150); + }); + + const hit = (x, y) => + solids.some( + (p) => + x > p.x - 9 && x < p.x + p.w + 9 && y > p.y - 9 && y < p.y + p.h + 9, + ); + + let drops = null; + function loop() { + const sp = 3.2; + let dx = + (keys.KeyD || keys.ArrowRight ? sp : 0) - + (keys.KeyA || keys.ArrowLeft ? sp : 0); + let dy = + (keys.KeyS || keys.ArrowDown ? sp : 0) - + (keys.KeyW || keys.ArrowUp ? sp : 0); + const D = App.init.district.world; + if (dx && !hit(player.x + dx, player.y)) + player.x = Math.max(8, Math.min(D.w - 8, player.x + dx)); + if (dy && !hit(player.x, player.y + dy)) + player.y = Math.max(8, Math.min(D.h - 18, player.y + dy)); + drawDistrict(ctx, { partner: true }); + // ліхтарик детектива + ctx.globalCompositeOperation = "lighter"; + const pg = ctx.createRadialGradient(player.x, player.y, 4, player.x, player.y, 85); + pg.addColorStop(0, "rgba(255,230,170,.17)"); + pg.addColorStop(1, "rgba(255,230,170,0)"); + ctx.fillStyle = pg; + ctx.beginPath(); ctx.arc(player.x, player.y, 85, 0, 7); ctx.fill(); + ctx.globalCompositeOperation = "source-over"; + ctx.fillStyle = "#e8b04b"; + ctx.beginPath(); ctx.arc(player.x, player.y, 8, 0, 7); ctx.fill(); + ctx.strokeStyle = "#14100a"; ctx.stroke(); + // дощ — косі штрихи, що падають крізь світло ліхтарів + if (!drops) + drops = Array.from({ length: 130 }, () => ({ + x: Math.random() * D.w, y: Math.random() * D.h, + l: 8 + Math.random() * 10, s: 6 + Math.random() * 5, + })); + ctx.strokeStyle = "rgba(170,190,215,.27)"; + ctx.lineWidth = 1; + ctx.beginPath(); + for (const d of drops) { + ctx.moveTo(d.x, d.y); + ctx.lineTo(d.x - d.l * 0.35, d.y + d.l); + d.x -= d.s * 0.35; d.y += d.s; + if (d.y > D.h) { d.y = -10; d.x = Math.random() * (D.w + 60); } + } + ctx.stroke(); + // віньєтка ночі + const vg = ctx.createRadialGradient(D.w / 2, D.h / 2, D.h / 2.6, D.w / 2, D.h / 2, D.w / 1.35); + vg.addColorStop(0, "rgba(0,0,0,0)"); + vg.addColorStop(1, "rgba(0,0,0,.55)"); + ctx.fillStyle = vg; + ctx.fillRect(0, 0, D.w, D.h); + const b = nearBuilding(); + hint.textContent = b + ? `E — ${OPEN_ZONES.includes(b.id) ? "оглянутися: " : "увійти: "}${b.name}` + : "WASD/стрілки — рух, E — увійти/оглянутися"; + requestAnimationFrame(loop); + } + + function nearBuilding() { + return App.init.district.buildings.find((b) => { + const p = b.pos, + m = 26; + return ( + player.x > p.x - m && + player.x < p.x + p.w + m && + player.y > p.y - m && + player.y < p.y + p.h + m + ); + }); + } + + // ---------- інтер'єри ---------- + function tryEnter() { + const b = nearBuilding(); + if (!b) return; + if (b.id === "station") return stationModal(); + openBuilding(b); + } + + function stationModal() { + const m = openModal(`

Відділок поліції

+

Ваша база: дошка справи і термінал аналітика синхронізовані. Тут-таки сидить прокурор — ордер на обшук житла подають особисто, з доказом, який прямо вказує на людину.

+ + `); + 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( + `

Підстава для ордера на: ${esc(c.name)}

+

Тека порожня. Спершу закріпіть 📌 на дошці доказ, який прямо вказує на цю особу, — з усім підряд до прокурора не ходять.

`, + ); + const mm = openModal( + `

Підстава для ордера на: ${esc(c.name)}

+

Прокурор підпише, лише якщо доказ прямо вказує на цю особу. У теці — закріплене 📌.

+
` + + evs + .map((e) => ``) + .join("") + + "
", + ); + 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() { + const coats = [...new Set(App.init.citizens.map((c) => c.coat))]; + const m = openModal(`

База мешканців району

+

Термінал аналітика, тільки перегляд. Мітки (✓/✗/…) ставить аналітик — тут вони лише видно. Реєстр місцями застарілий: одяг і взуття надійніше звіряти очним оглядом за ордером.

+
+ +
+
+ + + + + + + + + +
+

Задайте пошук або фільтр.

`); + const out = m.inner.querySelector("#rout"); + const MK = { tick: "✓", cross: "✗", hide: "…" }; + const draw = (rows) => { + out.innerHTML = `

Збігів: ${rows.length}

`; + const t = el( + "table", + "", + "Ім'яВікАдресаРоботаЗрістВзуттяВідбитки", + ); + for (const c of rows) { + const key = "c:" + c.id; + const mark = App.state.board.marks[key]; + const vic = + App.state.victim === c.id + ? App.init.caseType === "kidnap" + ? " ⚠" + : " †" + : ""; + const tr = el( + "tr", + "rowlink" + cardClasses(key), + `${esc(c.name)}${vic}${c.age}${esc(c.home)}${esc(c.job)}${c.height}${c.shoe}${esc(c.printType)}${mark ? MK[mark] : ""}`, + ); + tr.onclick = () => showDossier(c); + t.append(tr); + } + out.append(t); + }; + const byName = () => { + const q = m.inner.querySelector("#rq").value.trim().toLowerCase(); + const hit = App.init.citizens.find((c) => c.name.toLowerCase() === q); + out.innerHTML = hit + ? "" + : '

— Записів не знайдено. Перевірте написання.

'; + if (hit) draw([hit]); + }; + const byFilter = () => { + const v = (id) => m.inner.querySelector(id).value; + draw( + App.init.citizens.filter( + (c) => + (!v("#rf-sex") || c.sex === v("#rf-sex")) && + (!v("#rf-smin") || c.shoe >= +v("#rf-smin")) && + (!v("#rf-smax") || c.shoe <= +v("#rf-smax")) && + (!v("#rf-hmin") || c.height >= +v("#rf-hmin")) && + (!v("#rf-hmax") || c.height <= +v("#rf-hmax")) && + (!v("#rf-print") || c.printType === v("#rf-print")) && + (!v("#rf-coat") || c.coat === v("#rf-coat")) && + (!m.inner.querySelector("#rf-smoke").checked || c.smoker), + ), + ); + }; + m.inner.querySelector("#rqgo").onclick = byName; + m.inner.querySelector("#rfgo").onclick = byFilter; + } + + // одиночний вибір мешканця з фільтром за іменем. + // skip(c) — кого не показувати; за замовчуванням жертву (ордер на неї не беруть), + // але про жертву питати можна й треба — там викликач передає своє. + function pickCitizen(title, cb, skip = (c) => c.id === App.state.victim) { + const mm = openModal(`

${esc(title)}

+ +
`); + const list = mm.inner.querySelector("#pc-list"), + q = mm.inner.querySelector("#pc-q"); + const draw = () => { + list.innerHTML = ""; + const f = q.value.trim().toLowerCase(); + for (const c of App.init.citizens) { + if (skip(c)) continue; + if (f && !c.name.toLowerCase().includes(f)) continue; + const b = document.createElement("button"); + b.textContent = c.name; + b.onclick = () => { + mm.wrap.remove(); + cb(c.id); + }; + list.append(b); + } + }; + q.oninput = draw; + draw(); + } + window.pickCitizen = pickCitizen; + + function openBuilding(b) { + App.send({ t: "visit", building: b.id }); // тасує присутність в ІНШИХ будівлях + const ppl = App.init.citizens.filter( + (c) => App.state.locs[c.id] === b.id && c.id !== App.state.victim, + ); + let html = `

${esc(b.name)}

`; + if (b.spots && b.spots.length) { + html += + '
Оглянути
' + + b.spots + .map((s, i) => ``) + .join("") + + "
"; + } + if (b.units) { + html += + '
Квартири
' + + b.units + .map( + (u) => + ``, + ) + .join("") + + "
"; + } + if (ppl.length && !b.units) { + html += + '
Тут зараз
' + + ppl + .map((c) => ``) + .join("") + + "
"; + } + const m = openModal(html); + m.inner.onclick = (e) => { + const t = e.target; + if (t.dataset.spot != null) + App.send({ + t: "search", + building: b.id, + 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); + } + }; + } + + function openUnitDoor(b, unit) { + const home = App.init.citizens.filter( + (c) => c.home === unit && c.id !== App.state.victim, + ); + const present = home.filter((c) => App.state.locs[c.id] === b.id); + const m = openModal(`

${esc(b.name)} — кв. ${unit}

+

${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(); + App.send({ t: "enter_unit", unit }); + }; + } + + App.on("unit", (m) => { + if (!m.allowed) return; + const inside = App.init.citizens.filter( + (c) => + c.home === m.unit && + App.state.locs[c.id] === unitB(m.unit) && + c.id !== App.state.victim, + ); + const mm = openModal(`

Кв. ${m.unit} — обшук

+ ${inside.length ? '

Вдома: ' + inside.map((c) => ``).join(" ") + "

" : ""} +
${m.slots.map((s, i) => ``).join("")}
`); + mm.inner.onclick = (e) => { + if (e.target.dataset.slot != null) + App.send({ + t: "search_unit", + unit: m.unit, + slot: m.slots[e.target.dataset.slot], + }); + if (e.target.dataset.cid) talkTo(e.target.dataset.cid); + }; + }); + + const unitB = (u) => + u[0] === "A" ? "apt_a" : u[0] === "B" ? "apt_b" : "apt_c"; + + function pickPerson(list) { + if (list.length === 1) return talkTo(list[0].id); + const m = openModal( + '

Хто відчинив?

' + + list + .map((c) => ``) + .join("") + + "
", + ); + m.inner.onclick = (e) => { + if (e.target.dataset.cid) { + m.wrap.remove(); + talkTo(e.target.dataset.cid); + } + }; + } + + function talkTo(cid) { + App.send({ t: "talk", cid }); + } + + App.on("topics", (m) => { + const c = App.byId(m.cid); + const gossip = + c.gossip && c.gossip.length + ? '
Балачки про район
' + + c.gossip + .map((l) => `

💬 «${esc(l)}»

`) + .join("") + : ""; + const topicsHtml = m.topics.length + ? '
Теми у справі
' + + m.topics + .map( + (t) => + ``, + ) + .join("") + + '

Сірі теми вже в доказах на дошці.

' + : ""; + const mm = openModal( + `

${esc(c.name)}

` + + topicsHtml + + `
Допит
+
+ + + +
+
+ + + ${App.state.warrants.includes(m.cid) ? '' : ''} +
+

Відповіді можуть бути правдою, брехнею або «не знаю» — перевіряйте камерами і чужими словами.

` + + gossip, + ); + mm.inner.append(logSection(m.cid)); // журнал із мітками (спільний із досьє аналітика) + const sl = mm.inner.querySelector("#al-t"), + out = mm.inner.querySelector("#al-v"); + const fmt = (v) => + `${String(Math.floor(v / 60)).padStart(2, "0")}:${String(v % 60).padStart(2, "0")}`; + const upd = () => (out.textContent = fmt(+sl.value)); + sl.oninput = upd; + upd(); + 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(); + 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( + '

Показати доказ

' + + App.state.found + .map((e) => ``) + .join("") + + "
", + ); + pm.inner.onclick = (e) => { + if (e.target.dataset.ev) { + App.send({ t: "show_ev", cid: m.cid, evId: e.target.dataset.ev }); + pm.wrap.remove(); + } + }; + }; + const insp = mm.inner.querySelector("#insp-go"); + if (insp) + insp.onclick = () => { + App.send({ t: "inspect", cid: m.cid }); + mm.wrap.remove(); + }; + mm.inner.onclick = (e) => { + if (e.target.dataset.topic) { + App.send({ t: "ask", evId: e.target.dataset.topic }); + mm.wrap.remove(); + } + }; + }); + + // результат очного огляду: нікуди не пишеться — переносьте в нотатки самі + App.on("inspect", (m) => { + const c = App.byId(m.cid); + openModal( + `

Очний огляд: ${esc(c.name)}

${esc(m.body)}

`, + ); + }); + + // відповідь допитуваного + App.on("answer", (m) => { + const c = App.byId(m.cid); + openModal( + `

${esc(c.name)}

${esc(m.q)}

${esc(m.a)}

+

Записано в журнал допитів (досьє в аналітика і картка розмови).

`, + ); + }); + + // ---------- міні-гра «вилучення»: тремтячі руки псують доказ ---------- + let mgMisses = 0; + App.on("mg", (m) => { + if (m.kind !== "collect" || App.role !== "field") return; + const mm = openModal( + `

Вилучення: ${esc(m.title)}

+

Зафіксуйте пінцет у зеленій зоні (кнопка або пробіл). Промахи розширюють зону — руки звикають.

+
+
+
+
+ + + `, + { locked: true }, + ); + const q = (sel) => mm.inner.querySelector(sel); + const zone = q("#mgzone"), + mark = q("#mgmark"), + msg = q("#mgmsg"); + let zl = 25 + Math.random() * 45, + zw = 13 + mgMisses * 5; + zone.style.left = zl + "%"; + zone.style.width = zw + "%"; + let alive = true; + const t0 = performance.now(), + speed = 0.075; // % за мс + (function anim(now) { + if (!alive) return; + const p = ((now - t0) * speed) % 192, + x = p < 96 ? p : 192 - p; + mark.style.left = `calc(${x}% - 2px)`; + mark._x = x; + requestAnimationFrame(anim); + })(t0); + const close = () => { + alive = false; + removeEventListener("keydown", keyfix); + mm.wrap.remove(); + }; + const fix = () => { + const x = mark._x || 0; + if (x >= zl && x <= zl + zw) { + App.send({ t: "mg_done", evId: m.evId }); + close(); + } else { + mgMisses++; + zw = 13 + mgMisses * 5; + zone.style.width = zw + "%"; + msg.textContent = `Зірвалось (${mgMisses}). Обережніше.`; + if (mgMisses >= 4) { + App.send({ t: "mg_done", evId: m.evId }); + toast("З четвертої спроби рука не здригнулася."); + close(); + } + } + }; + const keyfix = (e) => { + if (e.code === "Space") { + e.preventDefault(); + fix(); + } + }; + addEventListener("keydown", keyfix); + q("#mgfix").onclick = fix; + q("#mgoff").onclick = close; + }); +})(); diff --git a/chapter3/public/index.html b/chapter3/public/index.html new file mode 100644 index 0000000..114e149 --- /dev/null +++ b/chapter3/public/index.html @@ -0,0 +1,225 @@ + + + + + +Невпізнаний — детектив на двох, розділ 3 + + + + +
+
+

НЕВПІЗНАНИЙ

+
Детектив на двох • розділ 3 • кожна справа — нова
+
+
+ + +
+
Той самий код — та сама справа і її збереження. Новий код — цілком нова справа.
+
+
+ +
+
+ НЕВПІЗНАНИЙ + + поле + аналітик + + + + +
+
+
+
+
+ + +
+
+
+
+
+ +
+ + + + + + diff --git a/chapter3/public/sound.js b/chapter3/public/sound.js new file mode 100644 index 0000000..16c0ad0 --- /dev/null +++ b/chapter3/public/sound.js @@ -0,0 +1,108 @@ +// Легкий звук без файлів: усе синтезується WebAudio. +// Дощ у польового, гудіння термінала в аналітика, кліки, штамп, фінальний стінг. +"use strict"; +window.Snd = (() => { + let ctx = null, + master = null, + started = false, + muted = localStorage.getItem("ch3snd") === "off"; + + function ensure() { + if (ctx) return; + ctx = new (window.AudioContext || window.webkitAudioContext)(); + master = ctx.createGain(); + master.gain.value = muted ? 0 : 1; + master.connect(ctx.destination); + } + + function noiseBuf(secs) { + const b = ctx.createBuffer(1, ctx.sampleRate * secs, ctx.sampleRate); + const d = b.getChannelData(0); + for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1; + return b; + } + + // фоновий шар: дощ (поле) або гудіння ЕПТ (аналітик) + function start(role) { + ensure(); + if (started) return; + started = true; + const src = ctx.createBufferSource(); + src.buffer = noiseBuf(3); + src.loop = true; + const filt = ctx.createBiquadFilter(); + const amb = ctx.createGain(); + if (role === "field") { + filt.type = "bandpass"; + filt.frequency.value = 1100; + filt.Q.value = 0.4; + amb.gain.value = 0.045; + // пориви дощу + const lfo = ctx.createOscillator(), lg = ctx.createGain(); + lfo.frequency.value = 0.11; + lg.gain.value = 0.018; + lfo.connect(lg).connect(amb.gain); + lfo.start(); + } else { + filt.type = "lowpass"; + filt.frequency.value = 130; + amb.gain.value = 0.02; + const hum = ctx.createOscillator(), hg = ctx.createGain(); + hum.frequency.value = 50; + hg.gain.value = 0.006; + hum.connect(hg).connect(master); + hum.start(); + } + src.connect(filt).connect(amb).connect(master); + src.start(); + } + + function blip(freq, dur, vol, type) { + if (!ctx) return; + const o = ctx.createOscillator(), gn = ctx.createGain(); + o.type = type || "square"; + o.frequency.value = freq; + gn.gain.setValueAtTime(vol, ctx.currentTime); + gn.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + dur); + o.connect(gn).connect(master); + o.start(); + o.stop(ctx.currentTime + dur); + } + + const click = () => blip(1300, 0.03, 0.05); + function stamp() { + if (!ctx) return; + blip(85, 0.16, 0.22, "sine"); + const n = ctx.createBufferSource(); + n.buffer = noiseBuf(0.08); + const gn = ctx.createGain(); + gn.gain.setValueAtTime(0.12, ctx.currentTime); + gn.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + 0.09); + n.connect(gn).connect(master); + n.start(); + } + function sting(solved) { + if (!ctx) return; + const seq = solved ? [220, 277, 330, 440] : [196, 185, 147, 98]; + seq.forEach((f, i) => + setTimeout(() => blip(f, 0.5, 0.09, "triangle"), i * 190), + ); + } + function toggle() { + muted = !muted; + localStorage.setItem("ch3snd", muted ? "off" : "on"); + if (master) master.gain.value = muted ? 0 : 1; + return muted; + } + + // клік на будь-якій кнопці — тихий тик + document.addEventListener( + "click", + (e) => { + if (e.target.closest("button")) click(); + }, + true, + ); + + return { start, click, stamp, sting, toggle, get muted() { return muted; } }; +})(); diff --git a/chapter3/server.js b/chapter3/server.js new file mode 100644 index 0000000..3c4f57f --- /dev/null +++ b/chapter3/server.js @@ -0,0 +1,823 @@ +// Розділ 3 «Невпізнаний» — сервер: static + ws-синхронізація + сейви на диск. +// Код сесії = seed справи: той самий код — та сама справа (і її сейв). +const http = require('http'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const { WebSocketServer } = require('ws'); +const { generateCase, checkAccusation, md5 } = require('./casegen'); +const { unitBuilding } = require('./casegen/district'); +const RT = require('./casegen/runtime'); + +const PORT = process.env.PORT || 3002; +const SAVES = path.join(__dirname, 'saves'); +fs.mkdirSync(SAVES, { recursive: true }); + +const sessions = Object.create(null); +let clientSeq = 0; // стабільний id на кожне з'єднання (для підписів звинувачення) + +// Присутність зараз (не плутати з evening — правдою вечора злочину): вдома або на роботі. +// Перетасовується, коли польовий заходить у будівлю, — крім будівлі, куди він зайшов. +function reshuffleLocs(cs, s, except) { + for (const c of cs.citizens) { + if (c.id === cs.victim) continue; + if (except && s.locs[c.id] === except) continue; + s.locs[c.id] = c.workplace && s.rng() < 0.6 ? c.workplace : unitBuilding(c.home); + } +} + +// Seeded PRNG (mulberry32) — детерміновані перетасовки +function seedRand(seed) { + let s = seed | 0; + return function() { + s |= 0; s = s + 0x6D2B79F5 | 0; + let t = Math.imul(s ^ s >>> 15, 1 | s); + t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; + return ((t ^ t >>> 14) >>> 0) / 4294967296; + }; +} + +function getSession(code) { + if (sessions[code]) return sessions[code]; + const cs = generateCase(code); + const rng = seedRand([...code].reduce((a, c) => a * 31 + c.charCodeAt(0), 0)); // детермінований rand + const stn = cs.district.buildings.find(b => b.id === 'station'); + const s = { + code, cs, + rng, + st: RT.newState(), + board: { pins: [], marks: {}, notes: [], enotes: {} }, // marks: {'e:id'|'c:id': 'tick'|'cross'|'hide'} + spawn: { x: stn.pos.x + stn.pos.w / 2, y: stn.pos.y + stn.pos.h + 15 }, // точка появи у відділку + extra: [], xn: 0, // динамічні картки (результати запитів, зізнання) + log: {}, // журнал допитів: cid → [{id, q, a}] + logN: 0, // лічильник id записів журналу (для міток на них) + alibiClaims: {}, // cid → building: що людина заявила про вечір злочину + cracked: {}, // cid → true: брехун уже зізнався в дрібній брехні + markers: [], // мітки аналітика на мапі + locs: {}, + accuseDraft: null, ended: null, + clients: new Set() + }; + reshuffleLocs(cs, s, null); + const file = path.join(SAVES, code + '.json'); + let loaded = false; + if (fs.existsSync(file)) { + try { + const j = JSON.parse(fs.readFileSync(file, 'utf8')); + Object.assign(s, j, { st: RT.newState(), locs: s.locs, board: Object.assign(s.board, j.board) }); + // Сейв міг лишитися від старішої версії генератора: первинний стан — found, + // puzzlesSolved і warrants; решту перераховуємо по нинішній справі. + const old = j.st || {}; + s.st.puzzlesSolved = (old.puzzlesSolved || []).filter(id => cs.puzzles.some(p => p.id === id)); + for (const id of old.found || []) if (cs.evidence[id]) RT.applyFound(cs, s.st, cs.evidence[id]); + s.st.warrants = (old.warrants || []).filter(cid => cs.citizens.some(c => c.id === cid)); + s.board.pins = s.board.pins.filter(id => cs.evidence[id] || (s.extra || []).some(x => x.id === id)); + // лічильник id журналу не має відкотитись нижче вже наявних (сумісність зі старими сейвами) + s.logN = Math.max(s.logN || 0, ...Object.values(s.log).flat().map(e => e.id || 0), 0); + loaded = true; + } catch {} + } + if (!loaded) { + for (const id of cs.startingFacts) RT.applyFound(cs, s.st, cs.evidence[id]); + saveNow(s); // синхронно: файл має існувати одразу, до першого throttle-запису + } + return sessions[code] = s; +} + +// Персистенція «лінива»: дії лише зводять прапорець «брудно», а на диск пишемо +// не частіше ніж раз на SAVE_MS — тисячі швидких дій (4 гравці) схлопуються в +// кілька записів замість запису-на-кожну-дію. Пишемо в тимчасовий файл і атомарно +// перейменовуємо: паралельні гравці ніколи не лишать напівзаписаного (і, отже, +// непарсабельного) JSON. +const SAVE_MS = 300; +function saveData(s) { + const { st, board, accuseDraft, ended, extra, xn, log, logN, markers, alibiClaims, cracked, mgOffered } = s; + return JSON.stringify({ st, board, accuseDraft, ended, extra, xn, log, logN, markers, alibiClaims, cracked, mgOffered }); +} +// Синхронний запис на диск — для першого сейву щойно створеної сесії. Інакше файл +// з'явиться лише через SAVE_MS, і другий гравець, що зайшов раніше, тригерне +// «сейв зник → почати з нуля» і скине сесію, викинувши першого. +function saveNow(s) { try { fs.writeFileSync(path.join(SAVES, s.code + '.json'), saveData(s)); } catch {} } +function save(s) { + s._dirty = true; + if (s._timer || s._writing) return; // запис уже заплановано або триває + s._timer = setTimeout(() => { s._timer = null; flushSave(s); }, SAVE_MS); +} +function flushSave(s) { + if (s._writing || !s._dirty) return; + s._writing = true; s._dirty = false; + const final = path.join(SAVES, s.code + '.json'); + const tmp = final + '.' + process.pid + '.tmp'; + fs.writeFile(tmp, saveData(s), err => { + const done = () => { s._writing = false; if (s._dirty) save(s); }; // хвіст → ще запис + if (err) return done(); + fs.rename(tmp, final, done); + }); +} + +// ---------- Видимість для клієнтів ---------- +function carrierOf(cs, pid) { return Object.values(cs.evidence).find(ev => ev.puzzle === pid); } + +function visiblePuzzles(s) { + return s.cs.puzzles.filter(p => { + const ev = carrierOf(s.cs, p.id); + return s.st.found.includes(ev.id) || RT.available(s.cs, s.st, ev, { ignorePuzzles: true }); + }).map(p => ({ id: p.id, kind: p.kind, prompt: p.prompt, payload: p.payload, hint: p.hint, solved: s.st.puzzlesSolved.includes(p.id) })); +} + +function view(s) { + const { cs, st } = s; + const found = st.found.map(id => { + const ev = cs.evidence[id]; + return { id, title: ev.title, detail: ev.detail, side: ev.side }; + }).concat(s.extra); + 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 { + t: 'state', + online: rolesOnline(s), found, + identity: st.identity, victim: st.identity ? cs.victim : null, + keys: st.keys, warrants: st.warrants, + puzzles: visiblePuzzles(s), + locs: s.locs, markers: s.markers, log: s.log, + alibiClaims: s.alibiClaims, + cctvPulled: s.extra.filter(x => x.key && x.key.startsWith('cctv|')).map(x => x.key.slice(5)), + dnaSample: st.found.includes('sf_dna') ? dnaOwner.dna : null, + dnaLabel: st.found.includes('sf_dna') ? cs.dnaDesc : null, + dnaSample2: cs.dna2 && st.found.includes('sf_dna2') ? byIdC(cs, cs.dna2.owner).dna : null, + dna2Label: cs.dna2 && st.found.includes('sf_dna2') ? cs.dna2Desc : null, + printSample: st.found.includes('ev_weapon') ? killerC.printSeed : null, + board: s.board, accuseDraft: accuseView(s), ended: s.ended + }; +} + +function rolesOnline(s) { + const o = { field: 0, analyst: 0 }; + for (const c of s.clients) if (c.role) o[c.role]++; + return o; +} + +// Розсилку коалесуємо: багато мутацій в одному тіку (злив дій) → один снапшот +// стану на всіх, а не серіалізація+розсилка на кожну дію. +function broadcast(s) { + if (s._bc) return; + s._bc = true; + setImmediate(() => { + s._bc = false; + const msg = JSON.stringify(view(s)); + for (const c of s.clients) { try { c.send(msg); } catch { } } // мертвий сокет не має валити розсилку іншим + }); +} +function toast(ws, txt) { ws.send(JSON.stringify({ t: 'toast', txt })); } + +// позиції польових (у кожного своя точка) — легка розсилка окремо від стану +function fieldPositions(s) { + return [...s.clients].filter(c => c.role === 'field' && c.pos).map(c => ({ num: c.num, x: c.pos.x, y: c.pos.y })); +} +function pushPositions(s) { const msg = JSON.stringify({ t: 'pos', field: fieldPositions(s) }); for (const c of s.clients) { try { c.send(msg); } catch { } } } + +// Звинувачення закривається, лише коли підписалися ВСІ під'єднані гравці. +// Підписи ключуються id з'єднання; рахуємо тільки серед присутніх зараз, тож +// підпис того, хто вийшов, не тримає справу заручником. +function resolveAccusationIfReady(s) { + const d = s.accuseDraft; + if (!d || s.ended) return; + const ids = [...s.clients].map(c => c.id); + if (!ids.length || !ids.every(id => d.confirms[id])) return; + const res = checkAccusation(s.cs, d, s.board.pins); + s.ended = res.ok + ? { solved: true, recap: s.cs.finale.recap } + : { fired: true, hint: res.hint, recap: s.cs.finale.recap }; +} + +// для клієнта: чернетка + скільки з присутніх уже підписали +function accuseView(s) { + const d = s.accuseDraft; + if (!d) return null; + const ids = [...s.clients].map(c => c.id); + return { who: d.who, how: d.how, why: d.why, signed: ids.filter(id => d.confirms[id]).length, need: ids.length }; +} + +// позиція від клієнта (для відновлення на реконекті) — приймаємо лише в межах світу +function validFieldPos(p, cs) { + const W = cs.district.world; + if (!p || typeof p.x !== 'number' || typeof p.y !== 'number') return null; + if (p.x < 0 || p.x > W.w || p.y < 0 || p.y > W.h) return null; + return { x: p.x | 0, y: p.y | 0 }; +} + +function reveal(s, ev) { RT.applyFound(s.cs, s.st, ev); } + +// головоломка розв'язана / нові вимоги виконані → носії відкриваються ланцюжком +function autoUnlockCarriers(s) { + let changed = true; + while (changed) { + changed = false; + for (const p of s.cs.puzzles) { + if (!s.st.puzzlesSolved.includes(p.id)) continue; + const ev = carrierOf(s.cs, p.id); + if (!s.st.found.includes(ev.id) && RT.available(s.cs, s.st, ev)) { reveal(s, ev); changed = true; } + } + } +} + +// динамічна картка на дошку (результат запиту, зізнання); key — від дублів. +// pointsTo (опційно) — на кого картка вказує, щоб з неї теж можна було взяти ордер. +function extraCard(s, key, title, detail, pointsTo) { + if (s.extra.some(x => x.key === key)) return; + s.extra.push({ id: 'x' + (++s.xn), key, title, detail, side: 'analyst', ...(pointsTo ? { pointsTo } : {}) }); +} + +// На кого вказує доказ (для ордера) — і зі статичних доказів, і з динамічних карток. +function evPointsTo(s, evId) { + const ev = s.cs.evidence[evId]; + if (ev) return s.st.found.includes(evId) ? ev.gives.pointsTo || null : null; + const x = s.extra.find(e => e.id === evId); // extra-картки завжди «знайдені» + return x ? x.pointsTo || null : null; +} + +// Польова знахідка: справжній доказ → міні-гра вилучення, дрібниця → одразу +function offerOrReveal(s, ws, ev, done) { + if (ev.tag !== 'дрібниця') { + (s.mgOffered ??= {})[ev.id] = true; + return ws.send(JSON.stringify({ t: 'mg', kind: 'collect', evId: ev.id, title: ev.title })); + } + reveal(s, ev); done(); +} + +// ---------- Дрібні помічники ---------- +const byIdC = (cs, cid) => cs.citizens.find(c => c.id === cid); +const bName = (cs, bid) => { const b = cs.district.buildings.find(x => x.id === bid); return b ? b.name.split(',')[0] : bid; }; +const g = (c, m, f) => (c.sex === 'ч' ? m : f); +const normPhone = v => String(v || '').replace(/\D/g, ''); +const fmtMin = v => `${String(Math.floor(v / 60)).padStart(2, '0')}:${String(v % 60).padStart(2, '0')}`; +const pickBy = (arr, str) => arr[[...String(str)].reduce((a, ch) => a + ch.charCodeAt(0), 0) % arr.length]; + +function crimeMin(cs) { const [h, mi] = cs.crimeTime.split(':').map(Number); return h * 60 + mi; } + +// Чи витягнуто архів камери, що дивиться на назване людиною місце. +const claimCovered = (s, c) => s.cs.district.cameras.some(cam => + cam.covers.includes(c.claim) && s.extra.some(x => x.key === 'cctv|' + cam.id)); + +// Чи людина потрапляє в кадри камери (та ж логіка, що й у списку «хто був у кадрі»: +// жертву й убивцю камери не фіксують — убивця їх уникав). +const shownBy = (cs, cam, c) => c.id !== cs.victim && c.id !== cs.finale.killer && cam.covers.includes(c.evening); + +// Алібі спростоване: витягнутий архів камери над НАЗВАНИМ місцем, де людини в кадрі немає. +// Для чесних (claim === evening) камера їх якраз показує → не спрацьовує. +const alibiBroken = (s, c) => s.cs.district.cameras.some(cam => + cam.covers.includes(c.claim) && s.extra.some(x => x.key === 'cctv|' + cam.id) && !shownBy(s.cs, cam, c)); + +// Цих словами не зламати — тільки доказами. +const hardened = (s, c) => c.id === s.cs.finale.killer || c.id === s.cs.accomplice; + +// Брехуна ламає не питання, а факт: записана заява + архів камери над названим +// місцем, де його немає. Байдуже, чи це повторний допит, чи архів пред'явлено в лоб. +function crackLine(s, c) { + const { cs } = s; + if (!c.liar || hardened(s, c) || s.cracked[c.id]) return null; + if (!s.alibiClaims[c.id] || !claimCovered(s, c)) return null; + s.cracked[c.id] = true; + const mate = c.mate && byIdC(cs, c.mate); + return `Пауза. Погляд у підлогу. «Гаразд... Насправді я ${g(c, 'був', 'була')} біля «${bName(cs, c.evening)}». ${c.excuse}` + + (mate ? ` Спитайте ${mate.name}, ми були там разом.»` : '»'); +} + +// Зламане алібі — для БУДЬ-КОГО, чиї покази суперечать камерам, а не лише вбивці: +// інакше поява картки одразу «спойлерить» винного. Убивці — сплячий доказ-підтвердження +// (alibi_broken), решті брехунів — нейтральний лід «алібі не сходиться», щоб уточнювати далі. +function checkAlibiBreaks(s) { + const { cs, st } = s; + for (const cid of Object.keys(s.alibiClaims)) { + const c = byIdC(cs, cid); + if (!c || c.id === cs.victim || !alibiBroken(s, c)) continue; + if (c.id === cs.finale.killer) { + const ev = cs.evidence.alibi_broken; + if (ev && !st.found.includes(ev.id)) reveal(s, ev); + } else { + const where = c.claim === unitBuilding(c.home) + ? g(c, 'був удома', 'була вдома') + : `${g(c, 'був', 'була')} біля «${bName(cs, c.claim)}»`; + extraCard(s, 'alibibreak|' + c.id, `Алібі не сходиться: ${c.name}`, + `${c.name} на допиті ${g(c, 'заявив', 'заявила')}, що ${where}, але витягнутий архів камер за той вечір ${g(c, 'його', 'її')} там не показує. Хтось збрехав про свій вечір — з'ясуйте чому. Брехня про алібі ще не робить винним.`, + c.id); // теж підстава для ордера — інакше «єдина картка, що дає ордер» видала б вбивцю + } + } +} + +function logQA(s, ws, cid, q, a) { + (s.log[cid] ??= []).push({ id: ++s.logN, q, a }); // id — стабільний ключ для міток на записі + if (s.log[cid].length > 60) { + const removed = s.log[cid].shift(); + const key = 'q:' + removed.id; + if (s.board.marks[key]) delete s.board.marks[key]; + } + ws.send(JSON.stringify({ t: 'answer', cid, q, a })); +} + +// збіг запиту з довідником доказу +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(); + if (low.length < 3) return false; + return vals.some(v => { + const lv = String(v).toLowerCase(); + return lv === low || lv.includes(low) || low.includes(lv); + }); +} + +// стосунок z до x (за реєстровими даними) +function relationOf(z, x) { + if (z.spouse === x.id) return 'подружжя'; + if (z.last === x.last && z.home === x.home) return 'родина, живемо разом'; + if (z.home === x.home) return 'сусіди по кімнаті'; + if (unitBuilding(z.home) === unitBuilding(x.home)) return 'сусіди по будинку'; + if (z.workplace && z.workplace === x.workplace) return 'колеги'; + return null; +} + +// ---------- Обробка дій ---------- +function handle(s, ws, m) { + const { cs, st } = s; + const done = () => { autoUnlockCarriers(s); save(s); broadcast(s); }; + if (s.ended && !['note', 'note_del', 'enote', 'enote_del', 'mark'].includes(m.t)) return; // справу закрито + + switch (m.t) { + case 'search': { // публічна точка: {building, spot} + const ev = Object.values(cs.evidence).find(e => + e.at.type === 'spot' && e.at.building === m.building && e.at.spot === m.spot && !st.found.includes(e.id)); + if (!ev) return toast(ws, 'Нічого цікавого.'); + if (!RT.available(cs, st, ev)) + return toast(ws, ev.puzzle ? 'Тут щось є, але воно під замком — потрібна робота аналітика.' : 'Нічого цікавого.'); + return offerOrReveal(s, ws, ev, done); + } + case 'enter_unit': { // {unit} + const ok = RT.unitAccess(cs, st, m.unit); + ws.send(JSON.stringify({ t: 'unit', unit: m.unit, allowed: ok, slots: ok ? cs.district.homeSlots : [] })); + if (!ok) toast(ws, 'Замкнено. Потрібен ключ або ордер на когось із мешканців.'); + return; + } + case 'search_unit': { // {unit, slot} + if (!RT.unitAccess(cs, st, m.unit)) return toast(ws, 'Замкнено.'); + const ev = Object.values(cs.evidence).find(e => + e.at.type === 'unit' && e.at.unit === m.unit && e.at.slot === m.slot && !st.found.includes(e.id)); + if (!ev) return toast(ws, 'Нічого цікавого.'); + if (!RT.available(cs, st, ev)) + return toast(ws, ev.puzzle ? 'Знахідка заблокована (шифр/пароль) — передайте аналітику.' : 'Нічого цікавого.'); + return offerOrReveal(s, ws, ev, done); + } + case 'mg_done': { // польовий упорався з вилученням + const ev = cs.evidence[m.evId]; + if (!ev || !s.mgOffered || !s.mgOffered[m.evId] || st.found.includes(ev.id)) return; + if (!RT.available(cs, st, ev)) return; + delete s.mgOffered[m.evId]; + reveal(s, ev); return done(); + } + case 'visit': { // польовий зайшов у будівлю → тасуємо присутність у всіх ІНШИХ + if (ws.role !== 'field' || !cs.district.buildings.some(b => b.id === m.building)) return; + reshuffleLocs(cs, s, m.building); + return broadcast(s); + } + + // ---- системи-форми аналітика ---- + case 'sys_query': { // {sys, kind, input, camera, minutes} + const kind = m.kind || 'search'; + const inp = String(m.input || '').trim(); + + if (m.sys === 'cctv') { + const cam = cs.district.cameras.find(c => c.id === m.camera); + if (!cam) return; + const t = m.minutes | 0, crime = crimeMin(cs); + let got = false; + if (Math.abs(t - crime) <= 12) { + for (const id of ['rec_cctv', 'her_alibi']) { + const ev = cs.evidence[id]; + if (ev && ev.q && cam.covers.includes(ev.q.building) && + !st.found.includes(id) && RT.available(cs, st, ev)) { reveal(s, ev); got = true; } + } + } + // хто взагалі потрапляв у кадри того вечора (вбивця камер уникав — його тут нема). + // Порожній архів — теж доказ: там, де хтось «був», його ніхто не зняв. + const seen = cs.citizens.filter(c => + c.id !== cs.victim && c.id !== cs.finale.killer && cam.covers.includes(c.evening)); + extraCard(s, 'cctv|' + cam.id, `Архів за вечір: ${cam.name}`, + seen.length + ? 'У кадрах протягом вечора: ' + seen.map(c => `${c.name} (${bName(cs, c.evening)})`).join(', ') + '.' + : 'За весь вечір у кадрах — нікого. Дощ, ліхтар, порожнеча.'); + if (!got && Math.abs(t - crime) <= 12) + toast(ws, 'На сам час злочину ця камера нічого суттєвого не зняла.'); + checkAlibiBreaks(s); + return done(); + } + + 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)) { + 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; + return ws.send(JSON.stringify({ + t: 'mg', kind: 'triangulate', number: inp, + 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, 'Оператор: номер зараз не в мережі, не існує — або бракує підстав для пеленга.'); + } + + // реальні докази, що відповідають запиту + const hits = Object.values(cs.evidence).filter(ev => ev.q && ev.q.sys === m.sys && + (ev.q.kind || 'search') === kind && !st.found.includes(ev.id) && + RT.available(cs, st, ev) && matchQ(ev.q, kind, inp)); + if (hits.length) { for (const ev of hits) reveal(s, ev); return done(); } + + // філери: навіть дрібниця лягає на дошку — фільтруйте самі + if (m.sys === 'phone' && kind === 'owner') { + const c = cs.citizens.find(x => normPhone(x.phone) === normPhone(inp)); + if (!c) return toast(ws, 'Номер не обслуговується.'); + extraCard(s, 'own|' + c.id, `Власник номера ${inp}`, `${c.name}, прож. ${c.home}, ${bName(cs, unitBuilding(c.home))}.`); + return done(); + } + if (m.sys === 'phone' && kind === 'calls') { + const c = cs.citizens.find(x => normPhone(x.phone) === normPhone(inp)); + if (!c) return toast(ws, 'Номер не обслуговується.'); + const list = (cs.calls || []).filter(x => x.a === c.id || x.b === c.id).sort((x, y) => x.t - y.t); + const lines = list.slice(0, 8).map(x => { + const other = byIdC(cs, x.a === c.id ? x.b : x.a); + return `${fmtMin(x.t)} ${x.a === c.id ? '→ вихідний:' : '← вхідний:'} ${other.name}`; + }); + let txt = lines.length ? 'Вечір злочину: ' + lines.join('; ') + '.' : 'Вечір злочину: жодного дзвінка.'; + if (c.id === cs.finale.killer) + txt += ` З ${fmtMin(cs.phoneOff.off)} апарат поза мережею${cs.phoneOff.on > 1439 ? ' — до самого ранку' : `, знову в мережі о ${fmtMin(cs.phoneOff.on % 1440)}`}.`; + extraCard(s, 'calls|' + c.id, `Дзвінки за вечір: ${c.name}`, txt); + return done(); + } + if (m.sys === 'bank') { + const c = cs.citizens.find(x => x.name.toLowerCase() === inp.toLowerCase()); + if (!c) return toast(ws, 'Банк: рахунків за таким запитом не знайдено. Ім\'я — точно як у реєстрі.'); + 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) || + (c.tattoo || '').toLowerCase().includes(low) || (c.dog || '').toLowerCase().includes(low)); + if (!rs.length) return toast(ws, 'Реєстри: збігів немає.'); + extraCard(s, 'db|' + low, `Реєстри: «${inp}»`, + rs.slice(0, 6).map(c => `${c.name}, прож. ${c.home}${c.tattoo ? ', тату: ' + c.tattoo : ''}${c.dog ? ', собака: ' + c.dog : ''}`).join('; ') + (rs.length > 6 ? ' — і ще записи, уточніть запит.' : '.')); + return done(); + } + return toast(ws, 'За запитом нічого не знайдено.'); + } + + // ---- допит ---- + case 'ask_alibi': { // {cid, minutes} + const c = byIdC(cs, m.cid); + if (!c || c.id === cs.victim) return; + const t = m.minutes | 0, crime = crimeMin(cs); + const q = `Де ви були о ${fmtMin(t)} того вечора?`; + let a; + if (Math.abs(t - crime) <= 90) { + const askedBefore = !!s.alibiClaims[c.id]; + const hard = hardened(s, c); + const crack = crackLine(s, c); // притиснули фактами — брехун ламається + if (s.cracked[c.id] && !crack) { + const mate = c.mate && byIdC(cs, c.mate); + a = `«Я ж уже все розпові${g(c, 'в', 'ла')}: ${g(c, 'був', 'була')} біля «${bName(cs, c.evening)}».` + + (mate ? ` ${mate.name} підтвердить.»` : '»'); + } else if (crack) { + a = crack; + } else { + const honest = !c.liar && !hard; + const mate = honest ? cs.citizens.find(o => o !== c && o.id !== cs.victim && + o.id !== cs.finale.killer && !o.liar && o.evening === c.evening) : null; + a = `«${g(c, 'Був', 'Була')} ${c.claim === unitBuilding(c.home) ? 'удома' : 'біля «' + bName(cs, c.claim) + '»'}. ` + + (mate ? `Питайте ${mate.name} — ми бачилися там.»` : `Підтвердити нікому, ${g(c, 'сам', 'сама')} ${g(c, 'був', 'була')}.»`); + if (hard && askedBefore && claimCovered(s, c)) + a = `«Я вже відповіда${g(c, 'в', 'ла')} на це. Біля «${bName(cs, c.claim)}». Ми закінчили?» — ані м'яза на обличчі.`; + } + s.alibiClaims[c.id] = c.claim; + checkAlibiBreaks(s); + } else { + a = `«${fmtMin(t)}? ` + (c.workplace ? 'На роботі або дорогою з неї' : 'Удома') + ', як завжди. А що сталося?»'; + } + logQA(s, ws, c.id, q, a); + return done(); + } + case 'ask_about': { // {cid, about} — «що скажете про людину X?» + const z = byIdC(cs, m.cid), x = byIdC(cs, m.about); + if (!z || !x || z.id === x.id || z.id === cs.victim) return; + const q = `Що скажете про: ${x.name}?`; + const own = relationOf(z, x); + const close = !!own; + let a; + if (z.id === cs.finale.killer && x.id === cs.victim) { + a = `«${x.first}? Бачив кілька разів у дворі. Майже не знав.»`; // брехня + } else if (z.liar) { + a = pickBy(['«Не знаю таких.»', '«Нічого не скажу, не моя справа.»', '«Уперше чую це ім\'я.»'], z.id + x.id); + } else { + a = own ? `«Ми ${own}.» ` : `«Знаю у обличчя, не більше.» `; + const pair = (cs.relations || []).find(p => p.a === x.id || p.b === x.id); + if (pair && (close || relationOf(z, byIdC(cs, pair.a === x.id ? pair.b : pair.a)))) { + const other = byIdC(cs, pair.a === x.id ? pair.b : pair.a); + a += `«Кажуть, у ${x.first} з ${other.name} — ${pair.kind}.»`; + } else if (close) { + a += `«${g(x, 'Нормальний', 'Нормальна')}. Живе тихо, зайвого не скажу.»`; + } + } + logQA(s, ws, z.id, q, a); + return done(); + } + case 'show_ev': { // {cid, evId} — «що ви скажете на це?» + const c = byIdC(cs, m.cid); + const real = cs.evidence[m.evId]; + const ev = real || s.extra.find(x => x.id === m.evId); + if (!c || !ev || c.id === cs.victim) return; + if (real && !st.found.includes(real.id)) return; + const K = c.id === cs.finale.killer; + const gives = (real && real.gives) || {}; + const q = `Пред'явлено: ${ev.title}`; + // архів камери, що дивиться на назване людиною місце, — це пряма суперечність + // її показам: у кадрах її немає. Пред'явити його = те саме, що притиснути на допиті. + const camId = ev.key && ev.key.startsWith('cctv|') && ev.key.slice(5); + const cam = camId && cs.district.cameras.find(x => x.id === camId); + const vsClaim = cam && s.alibiClaims[c.id] && cam.covers.includes(c.claim); + const crack = vsClaim ? crackLine(s, c) : null; + let a; + if (crack) { + a = crack; + } else if (vsClaim && hardened(s, c)) { + a = `Довгий погляд у список. «Значить, камера мене не зачепила. Я там ${g(c, 'був', 'була')}. Далі що?» — голос рівний.`; + } else if (vsClaim && s.cracked[c.id]) { + a = `«Я ж уже зізна${g(c, 'вся', 'лася')}, що збреха${g(c, 'в', 'ла')}. ${g(c, 'Був', 'Була')} біля «${bName(cs, c.evening)}». Що ще вам треба?»`; + } else if (vsClaim && !c.liar) { + a = `«То придивіться — я ж у цьому списку і є. ${g(c, 'Казав', 'Казала')} же вам.»`; + } else if (m.evId === 'dec_footprint' && c.id === cs.decoyOwner) { + a = `«Розмір ${c.shoe}? Та це ж мій слід — я там ${g(c, 'ходив', 'ходила')} того ж вечора, як і щодня. Вам би хто одразу сказав.»`; + extraCard(s, 'dec_owned', 'Відбиток взуття: власника знайдено', + `${c.name} впізнає слід біля місця злочину: ${g(c, 'проходив', 'проходила')} там того вечора. До справи відношення не має.`); + } else if (K && gives.confirms) { + a = `Довга пауза. «...Гаразд. Ваша взяла.» — і ${c.name} починає говорити.`; + extraCard(s, 'confess', `Зізнання: ${c.name}`, + `Після пред'явлення (${ev.title.toLowerCase()}) ${c.name} ${g(c, 'зізнався', 'зізналася')}. Протокол підписано. Лишилося оформити справу: ХТО / ЯК / ЧОМУ — і закріпити докази для прокурора.`); + } else if (cs.dna2 && c.id === cs.dna2.owner && ['sf_dna2', 'lab_dna_match2'].includes(m.evId)) { + // невинна ДНК: людина пояснює, звідки вона там, — вірити чи ні, вирішують гравці + const vic = byIdC(cs, cs.victim); + a = cs.dna2.why === 'brawl' + ? `«Так, це моє! Ми зчепилися — ${cs.dna2.reason}. Удень, у барі, пів зали бачило! Але коли я ${g(c, 'пішов', 'пішла')}, ${vic.first} стояв біля стійки живий-здоровий. Спитайте будь-кого з бару!»` + : cs.dna2.why === 'finder' + ? `«Я ж і ${g(c, 'знайшов', 'знайшла')}… ${g(c, 'Нахилявся', 'Нахилялася')}, пульс перевіряти, за плече торкався. Тому й слід мій. Я одразу подзвонив у відділок — перевірте час дзвінка.»` + : `«Я там буваю щодня, як і пів району. ДНК на публічному місці? Це ще нічого не значить, і ви це знаєте.»`; + } else if (gives.pointsTo === c.id) { + a = K ? '«Це нічого не доводить. Мало що де написано. Розмову закінчено.»' + : c.id === cs.herring + ? (m.evId.startsWith('fm_') + ? `Мовчанка. Потім, крізь зуби: «Так, це правда — ${cs.falseMotive.admit}. Конфлікт був. Але вбивство?! Перевірте камери за той вечір — мене там і близько не було!»` + : `«Знову я?! Виглядає погано, знаю. Але я тут ні до чого — перевірте камери за той вечір, я ${g(c, 'був', 'була')} зовсім не там!»`) + : '«Я можу це пояснити. Це не те, чим здається...» — і пояснення, схоже, щире.'; + } else if (K && (gives.dnaSample || gives.filter)) { + a = '«І що це мені має сказати?» — але щелепа помітно напружилась.'; + } else { + a = pickBy(['«Вперше бачу.»', '«Нічим не допоможу, на жаль.»', '«Хм. Питайте когось ближчого до цього.»'], c.id + m.evId); + } + logQA(s, ws, c.id, q, a); + return done(); + } + + // ---- лабораторія ---- + case 'dna_pick': { // аналітик обрав доріжку гелю; {sampleEv: 'sf_dna'|'sf_dna2', cid} + const two = m.sampleEv === 'sf_dna2'; + const ev = cs.evidence[two ? 'lab_dna_match2' : 'lab_dna_match']; + const target = two ? (cs.dna2 && cs.dna2.owner) : (cs.accomplice || cs.finale.killer); + if (!ev || !target || st.found.includes(ev.id) || !RT.available(cs, st, ev)) return; + if (m.cid !== target) { + const c = byIdC(cs, m.cid); + return toast(ws, `${c ? c.name : 'Профіль'}: доріжки не збігаються зі зразком. Це не ${c && c.sex === 'ж' ? 'вона' : 'він'}.`); + } + reveal(s, ev); return done(); + } + case 'print_pick': { // аналітик обрав візерунок відбитка + const ev = cs.evidence.lab_print_match; + if (!ev || st.found.includes(ev.id) || !RT.available(cs, st, ev)) return; + if (m.cid !== cs.finale.killer) { + const c = byIdC(cs, m.cid); + return toast(ws, `${c ? c.name : 'Візерунок'}: точки не сходяться. Відбиток не ${c && c.sex === 'ж' ? 'її' : 'його'}.`); + } + reveal(s, ev); return done(); + } + case 'triangulate_pick': { // {building} — клік по будинку на перетині дуг + const ev = cs.evidence.rec_triangulate; + if (!ev || st.found.includes(ev.id) || !RT.available(cs, st, ev) || !s.triTarget) return; + if (m.building !== s.triTarget) + return toast(ws, 'Сота не сходиться: жоден будинок не лежить на перетині всіх трьох дуг. Придивіться уважніше.'); + reveal(s, ev); return done(); + } + + case 'talk': { // {cid} → список тем у справі + const topics = Object.values(cs.evidence) + .filter(e => e.at.type === 'person' && e.at.cid === m.cid) + .filter(e => st.found.includes(e.id) || RT.available(cs, st, e)) + .map(e => ({ id: e.id, title: e.title, found: st.found.includes(e.id) })); + return ws.send(JSON.stringify({ t: 'topics', cid: m.cid, topics })); + } + case 'ask': { // {evId} + const ev = cs.evidence[m.evId]; + if (!ev || ev.at.type !== 'person' || st.found.includes(ev.id)) return; + if (!RT.available(cs, st, ev)) return toast(ws, 'Людина знизує плечима. Немає з чим до неї йти.'); + reveal(s, ev); return done(); + } + + case 'inspect': { // {cid} — очний огляд за ордером; результат нікуди не пишеться + if (ws.role !== 'field') return; + const c = byIdC(cs, m.cid); + if (!c || c.id === cs.victim) return; + if (!st.warrants.includes(c.id)) return toast(ws, 'Без ордера людина має право послати вас куди завгодно. І пошле.'); + ws.send(JSON.stringify({ + t: 'inspect', cid: c.id, + body: `Зріст ${c.height} см. Взуття ${c.shoe} розміру. Зараз у ${c.coat}.` + + `${c.tattoo ? ' Татуювання: ' + c.tattoo + '.' : ''}${c.smoker ? ' Пальці курця.' : ''}` + + ' Запишіть у нотатки, що важливо, — у протокол огляд не йде.' + })); + return; + } + case 'warrant': { // {cid, evId} — лише польовий, лише у відділку, лише з доказом + if (ws.role !== 'field') return toast(ws, 'Ордери подає польовий — особисто у відділку.'); + const stn = cs.district.buildings.find(b => b.id === 'station').pos; + const p = ws.pos || s.spawn; + if (p.x < stn.x - 45 || p.x > stn.x + stn.w + 45 || p.y < stn.y - 45 || p.y > stn.y + stn.h + 45) + return toast(ws, 'За ордером — до відділка, до прокурора.'); + if (st.warrants.includes(m.cid)) return; + if (evPointsTo(s, m.evId) !== m.cid) + return toast(ws, 'Прокурор повертає подання: цей доказ прямо не вказує на цю особу.'); + st.warrants.push(m.cid); + toast(ws, 'Прокурор підписав ордер.'); + return done(); + } + + case 'puzzle_try': { // {id, answer|shift} + const p = cs.puzzles.find(x => x.id === m.id); + if (!p || st.puzzlesSolved.includes(p.id)) return; + const ok = + p.kind === 'caesar' ? Number(m.shift) === p.shift : + p.kind === 'b64' ? String(m.answer || '').trim() === p.answer : + md5(String(m.answer || '').trim()) === p.hash; + if (!ok) return toast(ws, 'Не підходить.'); + st.puzzlesSolved.push(p.id); + toast(ws, 'Розшифровано!'); return done(); + } + + // ---- дошка ---- + case 'pin': { + const i = s.board.pins.indexOf(m.evId); + if (i >= 0) s.board.pins.splice(i, 1); + 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} + if (s.board.marks[m.key] === m.kind) delete s.board.marks[m.key]; + else s.board.marks[m.key] = m.kind; + return done(); + } + case 'note': { + if (typeof m.txt === 'string' && m.txt.trim()) + s.board.notes.push({ id: Date.now() + Math.random(), txt: m.txt.slice(0, 500), by: ws.role }); + return done(); + } + case 'note_del': { s.board.notes = s.board.notes.filter(n => n.id !== m.id); return done(); } + case 'enote': { // нотатка на конкретному доказі + if (typeof m.txt !== 'string' || !m.txt.trim()) return; + (s.board.enotes[m.evId] ??= []).push({ id: Date.now() + Math.random(), txt: m.txt.slice(0, 300), by: ws.role }); + return done(); + } + case 'enote_del': { + const l = s.board.enotes[m.evId]; + if (l) s.board.enotes[m.evId] = l.filter(n => n.id !== m.id); + return done(); + } + case 'marker': { // {x, y} — мітка аналітика на мапі (повторний клік поруч — зняти) + const near = s.markers.findIndex(mk => Math.hypot(mk.x - m.x, mk.y - m.y) < 26); + if (near >= 0) s.markers.splice(near, 1); + else if (s.markers.length < 8) s.markers.push({ x: m.x | 0, y: m.y | 0 }); + return done(); + } + + case 'move': { // позиція польового — без сейву, легка розсилка + if (ws.role !== 'field' || !ws.pos) return; + ws.pos.x = m.x | 0; ws.pos.y = m.y | 0; + pushPositions(s); + return; + } + case 'accuse_draft': { + s.accuseDraft = { who: String(m.who || '').slice(0, 60), how: m.how, why: m.why, confirms: {} }; + return done(); + } + case 'accuse_cancel': { s.accuseDraft = null; return done(); } + case 'accuse_confirm': { + if (!s.accuseDraft) return; + s.accuseDraft.confirms[ws.id] = true; + resolveAccusationIfReady(s); + return done(); + } + } +} + +// ---------- HTTP: статика ---------- +const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8' }; +const server = http.createServer((req, res) => { + const u = new URL(req.url, 'http://x'); + const file = u.pathname === '/' ? 'index.html' : u.pathname.replace(/^\/+/, '').replace(/\.\./g, ''); + const full = path.join(__dirname, 'public', file); + fs.readFile(full, (err, data) => { + if (err) return void res.writeHead(404).end('not found'); + res.writeHead(200, { 'Content-Type': MIME[path.extname(full)] || 'application/octet-stream' }).end(data); + }); +}); + +// ---------- WS ---------- +// evening/claim/liar — таємниці слідства: у клієнтський init не потрапляють +function pubCitizens(cs) { + return cs.citizens.map(({ evening, claim, liar, excuse, mate, regShoe, regCoat, ...c }) => ({ + ...c, + shoe: regShoe ?? c.shoe, // реєстр місцями застарілий: клієнт бачить те, що в базі, + coat: regCoat ?? c.coat // а правду показує лише очний огляд за ордером + })); +} + +const wss = new WebSocketServer({ server }); +wss.on('connection', ws => { + ws.id = ++clientSeq; + ws.on('message', raw => { + let m; try { m = JSON.parse(raw); } catch { return; } + if (m.t === 'join') { + const code = String(m.session || 'DEFAULT').trim().toUpperCase().slice(0, 24) || 'DEFAULT'; + // сейв видалили з диска — це наказ почати справу з нуля + if (sessions[code] && !fs.existsSync(path.join(SAVES, code + '.json'))) { + clearTimeout(sessions[code]._timer); // щоб відкладений запис не воскресив видалений сейв + for (const c of sessions[code].clients) c.send(JSON.stringify({ t: 'reset' })); + delete sessions[code]; + } + const role = m.role === 'analyst' ? 'analyst' : 'field'; + const s = getSession(code); + // макс 2 на роль; новому — найменший вільний номер (Польовий1/2, Аналітик1/2) + const nums = new Set([...s.clients].filter(c => c.role === role).map(c => c.num)); + if (nums.size >= 2) { + // ворота не пройдено: НЕ закріплюємо сокет за сесією, тож жодна його дія + // далі не виконається (`if (!ws.session) return`). + ws.send(JSON.stringify({ t: 'joinerr', txt: `Ліміт ${role === 'field' ? 'польових' : 'аналітиків'}: у цій справі вже двоє.` })); + return; + } + ws.role = role; + ws.session = code; + ws.num = nums.has(1) ? 2 : 1; + s.clients.add(ws); + // на реконекті польовий повертається туди, де був (клієнт шле останню позицію) + if (role === 'field') ws.pos = validFieldPos(m.pos, s.cs) || { ...s.spawn }; + const cs = s.cs; + ws.send(JSON.stringify({ + t: 'init', role, num: ws.num, + district: cs.district, citizens: pubCitizens(cs), + sceneBuilding: cs.scene.building, + caseType: cs.caseType, + options: { + methods: cs.caseType === 'kidnap' ? cs.finale.whereOptions : cs.finale.methodOptions, + motives: cs.finale.motiveOptions + }, + intro: { title: cs.title, scene: cs.scene.name, time: cs.crimeTime, text: cs.introText }, + pos: ws.pos || s.spawn + })); + pushPositions(s); + broadcast(s); + return; + } + if (!ws.session) { return; } + handle(getSession(ws.session), ws, m); + }); + ws.on('close', () => { + if (!ws.session || !sessions[ws.session]) return; + const s = sessions[ws.session]; + s.clients.delete(ws); + // якщо пішов саме останній, хто не підписав, — звинувачення тепер готове. + // Зберігаємо ЛИШЕ коли цей вихід сам завершив справу (інакше пост-фінальні + // закриття сокетів переписували б сейв уже після його видалення). + const wasEnded = s.ended; + resolveAccusationIfReady(s); + if (s.ended && !wasEnded) save(s); + pushPositions(s); + broadcast(s); + }); +}); + +server.listen(PORT, () => { + console.log('Невпізнаний (розділ 3) — сервер запущено.\n'); + console.log(`На цьому пристрої: http://localhost:${PORT}`); + for (const ifs of Object.values(os.networkInterfaces())) + for (const i of ifs) + if (i.family === 'IPv4' && !i.internal) + console.log(`У локальній мережі: http://${i.address}:${PORT}`); + console.log('\nОбидва гравці відкривають адресу, вводять однаковий код сесії (він же — seed справи) і різні ролі.'); +}); diff --git a/chapter3/test-server.js b/chapter3/test-server.js new file mode 100644 index 0000000..46cf5e4 --- /dev/null +++ b/chapter3/test-server.js @@ -0,0 +1,215 @@ +// Інтеграційний тест: реальний сервер + два ws-клієнти проходять справу до кінця +// новими механіками: обшук місця, запити-форми, ордер у відділку, ручна лабораторія. +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const { spawn } = require('child_process'); +const WebSocket = require('ws'); +const { generateCase } = require('./casegen'); + +const PORT = +process.env.PORT || 3102; +const NO_SPAWN = !!process.env.NO_SPAWN; // підключитись до вже запущеного сервера (для дебагу з логами) +const SEED = 'TEST1'; +const sleep = ms => new Promise(r => setTimeout(r, ms)); +// чекаємо на умову замість фіксованого сну — сервер асинхронний, а після +// «зливу» 16 проходів його черга повідомлень може бути ще не розібрана +const waitFor = async (fn, ms = 4000) => { + for (const t0 = Date.now(); Date.now() - t0 < ms; await sleep(40)) if (fn()) return true; + return false; +}; + +function client(role) { + const ws = new WebSocket(`ws://127.0.0.1:${PORT}`); + const c = { ws, role, state: null, init: null, ended: null }; + ws.on('message', raw => { + const m = JSON.parse(raw); + if (m.t === 'init') c.init = m; + if (m.t === 'state') { c.state = m; c.ended = m.ended; } + }); + c.open = new Promise(r => ws.on('open', () => { ws.send(JSON.stringify({ t: 'join', session: c.session, role })); r(); })); + c.send = m => ws.send(JSON.stringify(m)); + return c; +} + +// будинок, чий центр збігається з трьома дугами тріангуляції +function solveTriangulate(cs, towers) { + let best = null, bestErr = Infinity; + for (const b of cs.district.buildings) { + const cx = b.pos.x + b.pos.w / 2, cy = b.pos.y + b.pos.h / 2; + const err = towers.reduce((a, t) => a + Math.abs(Math.hypot(t.x - cx, t.y - cy) - t.dist), 0); + if (err < bestErr) { bestErr = err; best = b.id; } + } + return best; +} + +async function playToTheEnd(session, accusation) { + const cs = generateCase(session); + const killer = cs.citizens.find(c => c.id === cs.finale.killer); + const field = client('field'); field.session = session; + const analyst = client('analyst'); analyst.session = session; + await field.open; await analyst.open; await sleep(300); + + // міні-ігри: тест «грає» їх одразу правильно + const react = c => c.ws.on('message', raw => { + const m = JSON.parse(raw); + if (m.t === 'mg') { + if (m.kind === 'collect') c.send({ t: 'mg_done', evId: m.evId }); + if (m.kind === 'triangulate') c.send({ t: 'triangulate_pick', building: solveTriangulate(cs, m.towers) }); + } + }); + react(field); react(analyst); + + assert.ok(field.init && analyst.init, 'обидва отримали init'); + assert.strictEqual(field.init.citizens.length, cs.citizens.length, 'мешканці на місці'); + assert.ok(field.init.citizens.every(c => + !('evening' in c) && !('liar' in c) && !('excuse' in c) && !('regShoe' in c) && !('regCoat' in c)), + 'таємниці слідства не течуть у клієнт'); + assert.ok(field.state.found.length >= 1, 'брифінг уже на дошці'); + + const stn = cs.district.buildings.find(b => b.id === 'station'); + const realFound = () => analyst.state.found.filter(f => cs.evidence[f.id]); + + // ганяємо всі можливі дії по колу, як робили б гравці + for (let pass = 0; pass < 16; pass++) { + const found = new Set(realFound().map(f => f.id)); + for (const p of cs.puzzles) + analyst.send({ t: 'puzzle_try', id: p.id, shift: p.shift, answer: p.answer }); + + // ордери: польовий іде у відділок і підкладає доказ, що прямо вказує + field.send({ t: 'move', x: stn.pos.x + stn.pos.w / 2 | 0, y: stn.pos.y + stn.pos.h / 2 | 0 }); + for (const id of found) { + const ev = cs.evidence[id]; + if (ev.gives.pointsTo) field.send({ t: 'warrant', cid: ev.gives.pointsTo, evId: id }); + } + + for (const ev of Object.values(cs.evidence)) { + if (found.has(ev.id)) continue; + if (ev.at.type === 'spot') field.send({ t: 'search', building: ev.at.building, spot: ev.at.spot }); + if (ev.at.type === 'unit') field.send({ t: 'search_unit', unit: ev.at.unit, slot: ev.at.slot }); + if (ev.at.type === 'person') field.send({ t: 'ask', evId: ev.id }); + if (ev.at.type === 'sys' && ev.q) { + const q = ev.q; + if (q.sys === 'cctv') { + const cam = cs.district.cameras.find(c => c.covers.includes(q.building)); + const [h, mi] = cs.crimeTime.split(':').map(Number); + analyst.send({ t: 'sys_query', sys: 'cctv', camera: cam.id, minutes: h * 60 + mi }); + } else { + analyst.send({ t: 'sys_query', sys: q.sys, kind: q.kind || 'search', input: [].concat(q.match || [''])[0] }); + } + } + } + // лабораторія: порівняння вручну (тест одразу тицяє правильних власників) + analyst.send({ t: 'dna_pick', sampleEv: 'sf_dna', cid: cs.accomplice || cs.finale.killer }); + if (cs.dna2) analyst.send({ t: 'dna_pick', sampleEv: 'sf_dna2', cid: cs.dna2.owner }); + analyst.send({ t: 'print_pick', cid: cs.finale.killer }); + await sleep(250); + } + + // зламане алібі: допит вбивці + архів камери над його «алібі» будить сплячий доказ + const killerClaim = cs.citizens.find(c => c.id === cs.finale.killer).claim; + const [ch, cm] = cs.crimeTime.split(':').map(Number); + field.send({ t: 'ask_alibi', cid: cs.finale.killer, minutes: ch * 60 + cm }); + // спершу дочекатись, що алібі вбивці ЗАПИСАНЕ (видно в стані), і лише тоді + // тягнути архів — інакше camera-запит спрацює до запису й нічого не розбудить + await waitFor(() => analyst.state && analyst.state.alibiClaims && analyst.state.alibiClaims[cs.finale.killer] != null); + const claimCam = cs.district.cameras.find(c => c.covers.includes(killerClaim)); + analyst.send({ t: 'sys_query', sys: 'cctv', camera: claimCam.id, minutes: 1230 }); + await waitFor(() => realFound().some(f => f.id === 'alibi_broken')); + assert.ok(realFound().some(f => f.id === 'alibi_broken'), 'брехня вбивці + камери = доказ'); + + assert.strictEqual(realFound().length, Object.keys(cs.evidence).length, + `зібрано все: ${realFound().length}/${Object.keys(cs.evidence).length}`); + assert.ok(analyst.state.identity, 'особа жертви встановлена'); + + // брехун ламається і від пред'явленого архіву, не лише від повторного питання: + // заявив, що був там, куди дивиться камера, а в кадрах його нема. + const liar = cs.citizens.find(c => c.liar && c.id !== cs.finale.killer && c.id !== cs.accomplice && + cs.district.cameras.some(cam => cam.covers.includes(c.claim))); + if (liar) { + const liarCam = cs.district.cameras.find(cam => cam.covers.includes(liar.claim)); + field.send({ t: 'ask_alibi', cid: liar.id, minutes: ch * 60 + cm }); + await waitFor(() => analyst.state.alibiClaims[liar.id] != null); + analyst.send({ t: 'sys_query', sys: 'cctv', camera: liarCam.id, minutes: 1230 }); + await waitFor(() => analyst.state.found.some(f => f.title === `Архів за вечір: ${liarCam.name}`)); + const arch = analyst.state.found.find(f => f.title === `Архів за вечір: ${liarCam.name}`); + field.send({ t: 'show_ev', cid: liar.id, evId: arch.id }); + await waitFor(() => (analyst.state.log[liar.id] || []).some(e => e.a.includes(liar.excuse))); + const said = analyst.state.log[liar.id].at(-1).a; + assert.ok(said.includes(liar.excuse), 'пред\'явлений архів ламає брехуна так само, як повторний допит'); + } + + // допити: алібі, «про людину», пред'явлення доказу — відповіді пишуться в журнал + field.send({ t: 'ask_about', cid: cs.herring, about: cs.finale.killer }); + field.send({ t: 'show_ev', cid: cs.decoyOwner, evId: 'dec_footprint' }); + await sleep(300); + assert.ok((analyst.state.log[cs.finale.killer] || []).length >= 1, 'алібі вбивці в журналі'); + assert.ok(analyst.state.found.some(f => f.id.startsWith('x') && f.title.toLowerCase().includes('відбиток')), + 'фейковий слід закрито карткою на дошці'); + + // мітка аналітика видна в стані + analyst.send({ t: 'marker', x: 500, y: 100 }); + await sleep(200); + assert.strictEqual(analyst.state.markers.length, 1, 'мітка на мапі'); + + for (const f of analyst.state.found) field.send({ t: 'pin', evId: f.id }); + await sleep(300); + + const how = cs.caseType === 'kidnap' ? cs.finale.where : cs.finale.method; + const acc = accusation === 'right' + ? { who: killer.name, how, why: cs.finale.motive } + : { who: cs.citizens.find(c => c.id === cs.herring).name, how, why: cs.finale.motive }; + field.send({ t: 'accuse_draft', ...acc }); + await waitFor(() => field.state.accuseDraft && field.state.accuseDraft.who === acc.who); + field.send({ t: 'accuse_confirm' }); + analyst.send({ t: 'accuse_confirm' }); + await waitFor(() => analyst.ended); + + const ended = analyst.ended; + assert.ok(ended, 'гра завершилась після двох підписів'); + if (accusation === 'right') assert.ok(ended.solved, 'правильне звинувачення = справу розкрито'); + else { assert.ok(ended.fired, 'хибне звинувачення = звільнення'); assert.ok(ended.recap, 'справжня картина показана'); } + + field.ws.close(); analyst.ws.close(); + return ended; +} + +(async () => { + for (const f of [SEED, 'TEST2', 'TEST3']) { try { fs.unlinkSync(path.join(__dirname, 'saves', f + '.json')); } catch {} } + const srv = NO_SPAWN ? null : spawn('node', ['server.js'], { cwd: __dirname, env: { ...process.env, PORT } }); + if (srv) await new Promise(r => srv.stdout.on('data', d => { if (String(d).includes('localhost')) r(); })); + else console.error('[test] NO_SPAWN: підключаюсь до сервера на PORT=' + PORT); + try { + await playToTheEnd(SEED, 'right'); + const savePath = path.join(__dirname, 'saves', SEED + '.json'); + // дочекатись, поки фінальний стан ДОПИСАЛО на диск (жодних відкладених записів), + // і лише тоді видаляти — інакше запис-«хвіст» відтворив би файл після unlink + await waitFor(() => { try { return !!JSON.parse(fs.readFileSync(savePath, 'utf8')).ended; } catch { return false; } }); + assert.ok(fs.existsSync(savePath), 'сейв на диску'); + // видалення сейву з диска = чиста справа після перепідключення + fs.unlinkSync(savePath); + const fresh = client('field'); fresh.session = SEED; + await fresh.open; + await waitFor(() => fresh.state != null); + assert.ok(fresh.state && !fresh.state.ended && fresh.state.found.length <= 2, + 'видалений сейв = справа з нуля, на дошці лише брифінг: ' + (fresh.state && fresh.state.found.length)); + fresh.ws.close(); + await playToTheEnd('TEST2', 'wrong'); + // TEST3 — перевіряємо, що і викрадення проходиться (якщо сід дає kidnap — тим краще) + const k = await playToTheEnd('TEST3', 'right'); + assert.ok(k.solved, 'третя справа теж розкривається'); + + // -- мультіплеєр: до 2 на роль, свій номер кожному, 3-й дістає помилку ліміту + const grab = ws => new Promise(r => { const o = {}; ws.on('message', raw => { const m = JSON.parse(raw); if (m.t === 'init') { o.num = m.num; r(o); } if (m.t === 'joinerr') { o.err = m.txt; r(o); } }); }); + const mk = () => { const ws = new WebSocket(`ws://127.0.0.1:${PORT}`); const g = grab(ws); ws.on('open', () => ws.send(JSON.stringify({ t: 'join', session: 'MPTEST', role: 'field' }))); return { ws, g }; }; + // послідовно: кожен join опрацьовується до наступного, щоб номери були детерміновані + const f1 = mk(); assert.strictEqual((await f1.g).num, 1, 'перший польовий — №1'); + const f2 = mk(); assert.strictEqual((await f2.g).num, 2, 'другий польовий — №2'); + const f3 = mk(); const r3 = await f3.g; + assert.ok(r3.err && !r3.num, '3-й польовий дістає помилку ліміту, а не вхід'); + // звільнили слот — новий займає найменший вільний номер + f1.ws.close(); await sleep(200); + const f4 = mk(); assert.strictEqual((await f4.g).num, 1, 'після виходу №1 звільняється — новий стає №1'); + for (const x of [f2, f4]) x.ws.close(); + console.log('✓ інтеграція: справи розкриваються новими механіками, хибне звинувачення = звільнення, сейви пишуться, мультіплеєр тримає ліміт по 2 на роль.'); + } finally { if (srv) srv.kill(); } +})().catch(e => { console.error('✗', e.message); process.exit(1); }); diff --git a/detective-coop-game-prompt.md b/detective-coop-game-prompt.md new file mode 100644 index 0000000..3a33e71 --- /dev/null +++ b/detective-coop-game-prompt.md @@ -0,0 +1,141 @@ +## Що будуємо + +Інтерактивну веб-гру (single HTML/React artifact), детектив на двох гравців, кооп, нелінійна структура — досліджуємо локації, допитуємо підозрюваних, збираємо докази, зіставляємо їх на дошці, в кінці називаємо вбивцю і доводимо чому. Формат натхненний тим, як влаштовані справи в Shadows of Doubt: відкрита структура розслідування, а не лінійний ланцюжок квестів. + +**Жорсткий принцип: без живого AI всередині гри.** Все — діалоги, докази, логіка розв'язання — прописано наперед і детерміноване. Гра має логічно "закриватись": є один винний, і його провину можна довести конкретним набором доказів без вгадування. + +## Формат і платформа + +- Один HTML-файл (React/vanilla JS), відкривається в браузері з телефону чи ноутбука. +- Нелінійна навігація: хаб/карта локацій, з якого можна йти в будь-якому порядку. +- Записник/журнал автоматично фіксує знайдені докази та репліки підозрюваних. +- Дошка справи (case board): візуальне зіставлення доказ ↔ підозрюваний ↔ мотив/спосіб/можливість. + +## Кооп на двох з асиметричною інформацією + +Два гравці, дві ролі, кожен бачить свою частину інформації — щоб реально доводилось синхронізуватись і думати разом уголос: + +- **Польовий детектив** — фізичні докази, огляд місця злочину, особисті допити (мова тіла, прямі реакції). +- **Детектив-аналітик** — цифрові сліди: записи камер, фінансові транзакції, повідомлення, логи доступу, бекграунд-перевірки. + +Кожна роль сама по собі НЕ дає повної картини. Приклад механіки синхронізації: польовий детектив чує від підозрюваного алібі ("я був у барі о 23:00"), аналітик бачить транзакцію/геолокацію, яка це підтверджує або спростовує — але тільки якщо гравці проговорять це один одному. + +Технічно: два пристрої заходять під одним кодом сесії (спільний стан через сховище), кожен бачить свій інтерфейс/докази, спільна дошка справи синхронізується для обох. + +## Тривалість + +Розрахунок на 60–90 хв за вечір як базовий кейс, з можливістю розширення до "кампанії" на 2–3 вечори (кейс на кілька актів, де перший вечір відкриває докази, які стають актуальними у другому). + +## Сетинг і тон + +Сучасність або кіберпанк-нуар: місто з чітким розділом на корпоративну верхівку і вулицю. Похмуро-серйозний тон — справжній труп, реальні мотиви (гроші, влада, ревнощі, шантаж), без карикатурного насильства й без "чарівних" пояснень. + +## Правила чесної гри (fair-play mystery) + +1. Один вбивця, 4–6 підозрюваних, у кожного своя пара мотив/спосіб/можливість (заповнена або навмисно неповна — щоб відсіювались логікою). +2. Точний таймлайн подій. У вбивці має бути рівно одна суперечність в алібі, яку можна розкрити ТІЛЬКИ звівши докази з обох ролей. +3. 2–3 red herring (хибні сліди) — кожен обов'язково спростовується конкретним доказом, який десь у грі є. Жодного "просто дивного факту без пояснення". +4. Усе, що потрібно для доведення провини, повинно бути знаходжуваним у грі. Нічого не з'являється тільки у фінальному розкритті. + +## Механіка допитів + +Фіксовані дерева діалогів (не вільний текст) — кожен підозрюваний має набір тем/питань на вибір, відповіді заготовлені заздалегідь, деякі питання розблоковуються тільки після знаходження конкретного доказу (наприклад, "спитати про транзакцію" з'являється тільки якщо аналітик її знайшов і команда це обговорила). + +## Фінал — звинувачення + +Екран "Висунути звинувачення": гравці разом обирають підозрюваного + спосіб/знаряддя + 3–4 докази, що доводять провину. Система перевіряє логічний ланцюжок: +- Якщо помилка — підказка яка саме ланка не сходиться (без спойлера відповіді), можна спробувати ще раз. +- Перемога тільки при повністю правильній комбінації. + +## Візуальна складова + +Вільно — від мінімалістичного текстово-UI підходу (карта, портрети, картки доказів) до повноцінної 2D/3D сцени. Для першої ітерації рекомендую стилізований UI-фокус (як настільна детективна гра), а не 3D-рендер — простіше зробити логіку надійною. + +--- + +## КЕЙС №1 — "Nightshade Protocol" (готовий, можна грати як є) + +**Сетинг:** місто Meridian, 2087, нейроімпланти — норма. Верхній Шпиль (корпорації) / Нижні Доки (робочий клас). + +**Жертва:** Маркус Рейз, 44, брокер даних у Halcyon Dynamics. Знайдений мертвим на балконі свого пентхауса, падіння з 40-го поверху. Офіційна версія — нещасний випадок. Час смерті: 23:14 (підтверджено біометричним піком нейроімпланта — незламний лог). + +**Локації:** пентхаус жертви (балкон + кабінет), лобі будівлі, офіс Halcyon Dynamics, бар "The Hollow Note", підпільна майстерня Рука. + +**Підозрювані:** + +| Хто | Мотив | Спосіб/доступ | Заявлене алібі | Статус | +|---|---|---|---|---| +| **Елена Восс** (партнерка по бізнесу) | Рейз викрив її розтрату через тіньові рахунки, вранці мав доповісти раді директорів | Її пропуск-чіп ще діє з часів стосунків | "Була в офісі допізна" | **ВБИВЦЯ** | +| Кайто Морі (екс-коханець) | Рейз злив його медичні/генетичні дані конкурентам | Хакерські навички | Був у барі "The Hollow Note" всю ніч | Спростовується | +| Д-р Сейбл (біотех-конкурент) | Рейз вкрав і злив його дослідження | Немає доступу/навичок | На конференції | Спростовується залізно | +| Рук (вуличний фіксер, кредитор) | Рейз винен йому великі гроші | Доступ до дронів/мускулів | Не має алібі, "був біля будівлі" | Red herring | +| Айві Чен (асистентка, таємно на зарплаті конкурента) | Побоювалась, що Рейз запідозрив шпигунство | Повний доступ до квартири | Пішла о 22:30 (є на камері) | Спростовується | + +**Таймлайн:** 22:15 — Елена сканує пропуск на вихід з офісу Halcyon (бачить нічний охоронець — фізичний доказ, чується в допиті). 22:47 — Елена повертається в будівлю Рейза (цифровий лог доступу — знаходить аналітик). 22:50 — силует на резервному записі патрульного дрона біля балкона. 23:10 — використано інженерний override для вимкнення силового поля безпеки балкона (лог з ID Елени). 23:14 — біометричний пік смерті. + +**Докази — фізичні (Польовий детектив):** +- Порваний клапоть тканини з піджака на поручні балкона +- Сліди боротьби на балконі +- Частковий відбиток пальця на панелі override +- Допит нічного охоронця офісу: усно каже, що бейдж Елени вийшов о 22:15 +- Фінансовий гросбух у схованці Рейза (доказ розтрати, підтверджує мотив) +- Записи допитів усіх підозрюваних (мова тіла) + +**Докази — цифрові (Аналітик):** +- Лог override панелі балкона — ID Елени, 23:10 +- Резервний запис дрона — силует біля балкона, 22:50 +- Лог повернення Елени в будівлю — 22:47 (суперечить її словам "була в офісі") +- Запрошення в календарі: "Термінова рада — комплаєнс Е.В." на 9:00 ранку +- Лог нейроімпланта/wifi бару Кайто — безперервно в барі до 23:30 (знімає підозру) +- Маніфест доставки Рука — був у будівлі 21:30–21:45 з іншої причини, не пов'язаної (знімає підозру) +- Камера лобі: вихід Айві о 22:30 збігається з бейджем (знімає підозру) +- Лог реєстрації на конференції д-ра Сейбла (знімає підозру) +- Біометричний лог імпланта Рейза — час смерті 23:14, сплеск адреналіну (боротьба/падіння) + +**Розкриття протиріччя (потребує обох ролей одночасно):** Польовий детектив чує від охоронця "вийшла о 22:15". Аналітик бачить "повернулась в будівлю Рейза о 22:47". Кожен окремо — не доказ. Разом — Елена бреше про алібі. + +**Правильне звинувачення:** Елена Восс; спосіб — вимкнула силове поле балкона через override, зіштовхнула Рейза; докази — [лог override з її ID] + [суперечність бейджів 22:15/22:47] + [клапоть тканини на поручні] (+ гросбух як мотив). + +--- + +## Алгоритм процедурної генерації нових кейсів + +Щоб не вигадувати кожен кейс вручну — принцип той самий, що й у Shadows of Doubt: seed → комбінаторна генерація → перевірка розв'язності. + +**Крок 1. Пули даних (заповнюються один раз, дають безкінечні комбінації):** +- Пул жертв (архетип, професія, чому вигідно/небезпечно її вбити) +- Пул локацій смерті (кожна дає свій набір фізичних доказів) +- Пул мотивів (гроші, помста, шантаж, влада, ревнощі, самозахист) +- Пул способів/доступу (кожен спосіб вимагає конкретного "тегу навички/доступу": хакерство, фізична сила, інсайдерський пропуск, хімія тощо) +- Пул типів доказів — окремо фізичні / цифрові, кожен доказ має тег "що доводить" (доступ / мотив / присутність / алібі) + +**Крок 2. Генерація сцени:** +1. Обрати жертву + локацію + точний час смерті (TOD). +2. Обрати 4–6 підозрюваних із пулу ролей, кожному рандомно призначити мотив + тег доступу/навички + тип алібі. + +**Крок 3. Призначення вбивці:** +- Вбивцею стає той підозрюваний, чий тег доступу/навички відповідає вимогам обраного способу вбивства, і чиє алібі навмисно генерується як "правдоподібне, але з рівно однією слабкою ланкою". +- Слабка ланка завжди розбивається на 2 частини — одна дається польовому детективу (почута репліка/фізичний слід), інша аналітику (лог/запис) — і жодна сама по собі не доказ. + +**Крок 4. Призначення решти підозрюваних:** +- 1 підозрюваний отримує "залізне" алібі (конференція, багато свідків, цифрове підтвердження) — навмисно найбільш підозрілий мотивом, але фактично чистий. +- 1–2 отримують red herring: підозріла деталь (не був на камерах, борг, погрози), яка спростовується конкретним доказом десь у грі (обов'язково генерується разом зі спростуванням, ніколи окремо). +- Решта отримують нейтральні, просто перевірювані алібі. + +**Крок 5. Перевірка розв'язності (як міні-solver, обов'язковий крок):** +- Перевірити: рівно один підозрюваний одночасно задовольняє мотив + доступ/спосіб + суперечність в алібі. +- Перевірити: усі інші проваляють хоч один із трьох критеріїв, і це доводиться доказом, який реально є в грі. +- Якщо перевірка не проходить — перегенерувати мотив/алібі/доказ для конфліктного підозрюваного і повторити. + +**Крок 6. Вивід:** зібрати фінальний кейс у той самий формат, що й "Nightshade Protocol" вище (таблиця підозрюваних, таймлайн, списки доказів по ролях, точна комбінація для звинувачення) — це і є те, що завантажується в гру. + +Цей алгоритм можна буквально закодувати (JSON-пули + функція-валідатор) і генерувати новий кейс кожного разу, коли набридне готовий — тоді гра буде реально безкінечною, як міні-версія генерації міста в Shadows of Doubt. + +# Fixes +Зробити "закріплені докази" прикріплені зверху, прибрати кнопку зірки. Тільки галочка, хрестик, приховати і прикріпити. +Додати можливість лишати нотатки на доказах. +Покращити систему комунікації з підозрюваними і іншими, більше питань (де ви були в X (time picker), якщо попали - правда або брехня, в яких стосунках X з Y, etc.(Тут можна пошукати натхнення в інеті)), бо зараз всі кажуть "нічим не можу допомогти". +Прибрати помітки типу "алібі", "дрібниця", "місце". Просто пробрати той pill. +Всі кнопки з "Системи й лабораторія" переробити: зробити інтерактивними як База мешканців і шифри(у з'вязку з видаленням 3301 - видалити цю вкладку). З приводу реалізації цього - задати додаткові уточнюючі питання. +Мапа має кожен раз, залежно від сіду, генеруватись. Різні будинки, різні жителі, різні приміщення. +Також я хочу щоб перед імплементацією, але після опитування, ти запропонував ідеї, які позитивно могли б вплинути на гру.