first commit

This commit is contained in:
UndecimoDia 2026-07-23 17:03:26 +03:00
commit 155bddda0e
29 changed files with 7256 additions and 0 deletions

148
.gitignore vendored Normal file
View file

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

31
chapter1/README.md Normal file
View file

@ -0,0 +1,31 @@
# Nightshade Protocol — детектив на двох
Кооперативна детективна гра для двох гравців в одній локальній мережі. Без залежностей, потрібен лише Node.js.
## Запуск
```
node server.js
```
Сервер надрукує адресу в локальній мережі (наприклад `http://192.168.0.109:3000`).
1. Обидва гравці відкривають цю адресу в браузері (телефон або ноутбук, той самий Wi-Fi).
2. Вводять **однаковий код сесії** (будь-який, наприклад `NOIR`).
3. Один обирає роль **Польового детектива**, інший — **Аналітика**.
## Як грати
- Польовий оглядає локації та допитує людей; аналітик ганяє запити по системах.
- Знайдені докази закріплюйте на **дошці** (📌 у Записнику) — лише закріплене бачить напарник, і лише воно розблоковує нові питання допитів і нові запити.
- Фінал: вкладка **Звинувачення** — хто, яким способом, 34 докази з дошки. Помилка дає підказку, яка ланка не сходиться.
Розрахунок: 6090 хвилин. Стан гри живе в пам'яті сервера — не вимикайте його до кінця партії.
## Перевірка кейса
```
node test.js
```
Перевіряє fair-play: усі докази досяжні, ключове протиріччя вимагає обох ролей, правильна комбінація виграє, а кожна зламана ланка дає підказку.

155
chapter1/case.js Normal file
View file

@ -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:1023:30. На момент смерті (23:14) він був у барі.' },
rook_manifest:{ role: 'analyst', title: 'Маніфест доставки', tag: 'зняття підозри', detail: 'Вантажний під\'їзд будівлі Рейза: Рук, 21:3021:45, накладна на серводеталі для консьєржа. Виїхав за півтори години до смерті.' },
ivy_cam: { role: 'analyst', title: 'Камера лобі', tag: 'зняття підозри', detail: 'Айві Чен виходить з лобі о 22:30, збігається з логом бейджа. До 23:14 не поверталась.' },
sable_conf: { role: 'analyst', title: 'Реєстрація конференції', tag: 'зняття підозри', detail: 'Д-р Сейбл: чекін на конференції «Женева-2», виступ 22:4023:20, сотні свідків і трансляція. Залізне алібі.' },
biometric: { role: 'analyst', title: 'Біометрія імпланта Рейза', tag: 'факт', detail: 'Смерть: 23:14. Сплеск адреналіну 23:1223: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:0000: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:3021: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 };

360
chapter1/index.html Normal file
View file

@ -0,0 +1,360 @@
<!DOCTYPE html>
<html lang="uk">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Nightshade Protocol</title>
<style>
:root { --bg:#0b0e14; --panel:#131824; --panel2:#1a2130; --line:#2a3347; --txt:#c9d2e3; --dim:#7a8699; --amber:#e3b341; --cyan:#4dd0e1; --red:#e06c75; --green:#7ec87e; }
* { box-sizing:border-box; margin:0; }
body { background:var(--bg); color:var(--txt); font:16px/1.5 system-ui,sans-serif; min-height:100vh; }
#app { max-width:760px; margin:0 auto; padding:12px; padding-bottom:80px; }
h1,h2,h3 { font-weight:600; }
h1 { color:var(--amber); letter-spacing:2px; font-size:1.4em; }
h2 { font-size:1.15em; margin:14px 0 8px; color:var(--amber); }
h3 { font-size:1em; margin:10px 0 6px; }
button { background:var(--panel2); color:var(--txt); border:1px solid var(--line); border-radius:8px; padding:10px 14px; font-size:15px; cursor:pointer; text-align:left; }
button:hover { border-color:var(--amber); }
button.primary { background:var(--amber); color:#0b0e14; font-weight:600; text-align:center; }
button.locked { color:var(--dim); border-style:dashed; cursor:default; }
button.locked:hover { border-color:var(--line); }
button.done { border-color:var(--green); }
select { background:var(--panel2); color:var(--txt); border:1px solid var(--line); border-radius:8px; padding:8px; font-size:14px; }
.card { background:var(--panel); border:1px solid var(--line); border-radius:10px; padding:12px; margin:8px 0; }
.card.ev { border-left:3px solid var(--cyan); }
.card.ev.field { border-left-color:var(--amber); }
.stack { display:flex; flex-direction:column; gap:8px; }
.row { display:flex; gap:8px; flex-wrap:wrap; align-items:center; }
.dim { color:var(--dim); font-size:0.88em; }
.tag { display:inline-block; font-size:0.75em; padding:1px 8px; border-radius:10px; border:1px solid var(--line); color:var(--dim); }
.back { color:var(--cyan); background:none; border:none; padding:4px 0; font-size:14px; }
#tabs { position:fixed; bottom:0; left:0; right:0; display:flex; background:var(--panel); border-top:1px solid var(--line); z-index:5; }
#tabs button { flex:1; border:none; border-radius:0; text-align:center; padding:12px 4px; font-size:13px; background:none; }
#tabs button.active { color:var(--amber); border-top:2px solid var(--amber); }
.banner { border-radius:10px; padding:12px; margin:10px 0; }
.banner.bad { background:#2a1518; border:1px solid var(--red); }
.banner.good { background:#152a18; border:1px solid var(--green); }
.quote { border-left:2px solid var(--line); padding-left:10px; margin:8px 0; color:var(--txt); }
.overlay { position:fixed; inset:0; background:rgba(5,7,12,.93); z-index:10; overflow-y:auto; padding:20px; }
.overlay .inner { max-width:600px; margin:40px auto; }
input[type=text] { background:var(--panel2); color:var(--txt); border:1px solid var(--line); border-radius:8px; padding:12px; font-size:18px; width:100%; text-transform:uppercase; letter-spacing:3px; text-align:center; }
label.chk { display:flex; gap:10px; align-items:flex-start; padding:8px; border:1px solid var(--line); border-radius:8px; cursor:pointer; }
label.chk input { margin-top:4px; }
.pill { font-size:0.8em; color:var(--cyan); }
.pill.field { color:var(--amber); }
</style>
</head>
<body>
<div id="app"></div>
<div id="tabs" style="display:none"></div>
<script src="/case.js"></script>
<script>
const $ = s => document.getElementById(s);
const esc = t => t.replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
let session = localStorage.getItem('np_session') || '';
let role = localStorage.getItem('np_role') || '';
let state = null;
let ui = { tab: 'invest', loc: null, person: null, briefed: false, accEv: [], detail: '' };
function send(a) {
fetch('/act', { method: 'POST', body: JSON.stringify({ session, ...a }) });
}
function connect() {
const es = new EventSource('/events?s=' + encodeURIComponent(session) + '&role=' + role);
es.onmessage = e => { state = JSON.parse(e.data); render(); };
}
const pinnedIds = () => state.pinned.map(p => p.id);
const isFound = id => !!state.found[id];
const unlocked = item => !item.requires || item.requires.every(r => pinnedIds().includes(r));
const suspectName = id => id === 'general' ? 'Загальне' : CASE.suspects.find(s => s.id === id).name;
// ---------- екрани ----------
function render() {
if (!session || !role) return renderJoin();
if (!state) return;
if (!ui.briefed) return renderBriefing();
$('tabs').style.display = 'flex';
if (state.solved) return renderVictory();
renderTabs();
const t = { invest: role === 'field' ? renderField : renderAnalyst, notes: renderNotes, board: renderBoard, accuse: renderAccuse }[ui.tab];
t();
}
function renderJoin() {
$('tabs').style.display = 'none';
$('app').innerHTML = `
<div style="margin-top:12vh;text-align:center">
<h1>NIGHTSHADE PROTOCOL</h1>
<p class="dim" style="margin:8px 0 24px">Детектив на двох · Меридіан, 2087</p>
<div class="stack" style="max-width:340px;margin:0 auto">
<input type="text" id="code" maxlength="12" placeholder="КОД СЕСІЇ" value="${esc(session)}">
<p class="dim">Придумайте будь-який код і введіть однаковий на обох пристроях.</p>
<button class="primary" onclick="pickRole('field')">Грати як ${CASE.roles.field.name}</button>
<button class="primary" style="background:var(--cyan)" onclick="pickRole('analyst')">Грати як ${CASE.roles.analyst.name}</button>
</div>
</div>`;
}
function pickRole(r) {
const code = $('code').value.trim().toUpperCase();
if (!code) return alert('Введіть код сесії');
session = code; role = r;
localStorage.setItem('np_session', session);
localStorage.setItem('np_role', role);
send({ t: 'join', role });
connect();
}
function renderBriefing() {
$('app').innerHTML = `
<div class="overlay"><div class="inner">
<h1>СПРАВА: NIGHTSHADE PROTOCOL</h1>
<p style="margin:14px 0">${CASE.intro}</p>
<h2>Твоя роль: ${CASE.roles[role].name}</h2>
<p>${CASE.roles[role].desc}</p>
<h2>Як грати</h2>
<p>${CASE.howto}</p>
<p style="margin-top:10px" class="dim">Мета: разом висунути звинувачення — хто, яким способом, і які докази це доводять.</p>
<button class="primary" style="margin-top:20px;width:100%" onclick="ui.briefed=true;render()">Почати розслідування</button>
</div></div>`;
}
function renderTabs() {
const online = state.online || {};
const other = role === 'field' ? 'analyst' : 'field';
const tabs = [
['invest', role === 'field' ? 'Локації' : 'Системи'],
['notes', 'Записник'],
['board', 'Дошка (' + state.pinned.length + ')'],
['accuse', 'Звинувачення']
];
$('tabs').innerHTML = tabs.map(([id, name]) =>
`<button class="${ui.tab === id ? 'active' : ''}" onclick="ui.tab='${id}';ui.loc=null;ui.person=null;ui.detail='';render()">${name}</button>`).join('');
window._header = `
<div class="row" style="justify-content:space-between">
<h1 style="font-size:1.05em">NIGHTSHADE</h1>
<span class="dim">${session} · ${CASE.roles[role].name} · напарник: ${online[other] ? '<span style="color:var(--green)">онлайн</span>' : 'офлайн'}</span>
</div>`;
}
// ---------- польовий ----------
function renderField() {
if (ui.person) return renderPerson();
if (ui.loc) return renderLocation();
$('app').innerHTML = window._header + `<h2>Локації</h2><div class="stack">` +
CASE.locations.map(l => `<button onclick="ui.loc='${l.id}';ui.detail='';render()"><b>${l.name}</b><br><span class="dim">${l.desc}</span></button>`).join('') + `</div>`;
}
function renderLocation() {
const l = CASE.locations.find(x => x.id === ui.loc);
let html = window._header + `<button class="back" onclick="ui.loc=null;ui.detail='';render()">← Локації</button><h2>${l.name}</h2><p class="dim">${l.desc}</p>`;
if (l.spots.length) {
html += `<h3>Огляд</h3><div class="stack">` + l.spots.map(sp => {
if (sp.ev && isFound(sp.ev)) return `<button class="done" onclick="showEv('${sp.ev}')">✓ ${sp.label}</button>`;
return `<button onclick="inspect('${l.id}','${sp.id}')">${sp.label}</button>`;
}).join('') + `</div>`;
}
if (l.people.length) {
html += `<h3>Люди</h3><div class="stack">` + l.people.map(pid => {
const p = CASE.people[pid];
return `<button onclick="ui.person='${pid}';render()"><b>${p.name}</b>${p.suspect ? ' <span class="tag">підозрюваний</span>' : ''}</button>`;
}).join('') + `</div>`;
}
html += `<div id="detail">${ui.detail}</div>`;
$('app').innerHTML = html;
}
function inspect(locId, spotId) {
const sp = CASE.locations.find(x => x.id === locId).spots.find(x => x.id === spotId);
if (sp.flavor) { ui.detail = `<div class="card"><p>${sp.flavor}</p></div>`; return render(); }
ui.detail = evCard(sp.ev, CASE.evidence[sp.ev], true);
send({ t: 'find', id: sp.ev, by: role });
render();
}
function showEv(id) {
ui.detail = evCard(id, CASE.evidence[id], false);
render();
}
function evCard(id, ev, isNew) {
return `<div class="card ev ${ev.role}">
${isNew ? '<p class="dim">НОВИЙ ДОКАЗ</p>' : ''}
<h3>${ev.title} <span class="tag">${ev.tag}</span></h3>
<p>${ev.detail}</p>
<p class="dim" style="margin-top:6px">Закріпи на дошці через Записник, щоб напарник побачив.</p>
</div>`;
}
function renderPerson() {
const p = CASE.people[ui.person];
const asked = state.asked[ui.person] || [];
let html = window._header + `<button class="back" onclick="ui.person=null;render()">← Назад</button><h2>${p.name}</h2><div class="stack">`;
for (const t of p.topics) {
if (asked.includes(t.id)) {
html += `<div class="card"><b>— ${t.q}</b><div class="quote">${t.a}</div></div>`;
} else if (unlocked(t)) {
html += `<button onclick="ask('${ui.person}','${t.id}')">— ${t.q}</button>`;
} else {
html += `<button class="locked">🔒 Нова тема з'явиться, коли потрібні докази будуть на дошці</button>`;
}
}
$('app').innerHTML = html + `</div>`;
}
function ask(pid, tid) {
const t = CASE.people[pid].topics.find(x => x.id === tid);
send({ t: 'ask', s: pid, q: tid });
if (t.gives) send({ t: 'find', id: t.gives, by: role });
}
// ---------- аналітик ----------
function renderAnalyst() {
let html = window._header + `<h2>Системи Меридіана</h2>`;
for (const sys of CASE.systems) {
html += `<h3>${sys.name}</h3><div class="stack">`;
for (const q of sys.queries) {
if (q.ev && isFound(q.ev)) html += `<button class="done" onclick="showQ('${q.id}')">✓ ${q.label}</button>`;
else if (!unlocked(q)) html += `<button class="locked">🔒 Запит стане доступним, коли потрібні матеріали будуть на дошці</button>`;
else html += `<button onclick="runQ('${q.id}')">▸ ${q.label}</button>`;
}
html += `</div>`;
}
$('app').innerHTML = html + `<div id="detail">${ui.detail}</div>`;
}
function findQ(qid) { for (const s of CASE.systems) for (const q of s.queries) if (q.id === qid) return q; }
function runQ(qid) {
const q = findQ(qid);
if (q.flavor) { ui.detail = `<div class="card"><b>${q.label}</b><p>${q.flavor}</p></div>`; render(); return window.scrollTo(0, 1e6); }
ui.detail = evCard(q.ev, CASE.evidence[q.ev], true);
send({ t: 'find', id: q.ev, by: role });
render();
window.scrollTo(0, 1e6);
}
function showQ(qid) {
const q = findQ(qid);
ui.detail = evCard(q.ev, CASE.evidence[q.ev], false);
render();
window.scrollTo(0, 1e6);
}
// ---------- записник ----------
function renderNotes() {
const mine = Object.keys(state.found).filter(id => CASE.evidence[id].role === role);
let html = window._header + `<h2>Мої докази (${mine.length})</h2>`;
if (!mine.length) html += `<p class="dim">Поки нічого. ${role === 'field' ? 'Оглянь локації і поговори з людьми.' : 'Прожени запити по системах.'}</p>`;
for (const id of mine) {
const ev = CASE.evidence[id];
const pin = state.pinned.find(p => p.id === id);
html += `<div class="card ev ${ev.role}">
<h3>${ev.title} <span class="tag">${ev.tag}</span></h3>
<p>${ev.detail}</p>
<div class="row" style="margin-top:8px">` +
(pin ? `<span class="dim">📌 На дошці: ${suspectName(pin.s)}</span>`
: `<select id="pin_${id}"><option value="general">Загальне</option>${CASE.suspects.map(s => `<option value="${s.id}">${s.name}</option>`).join('')}</select>
<button onclick="send({t:'pin',id:'${id}',s:document.getElementById('pin_${id}').value,by:role})">📌 На дошку</button>`) +
`</div></div>`;
}
// журнал допитів — спільний
const askedAny = Object.entries(state.asked).filter(([, v]) => v.length);
if (askedAny.length) {
html += `<h2>Журнал допитів</h2>`;
for (const [pid, tids] of askedAny) {
html += `<div class="card"><h3>${CASE.people[pid].name}</h3>` +
tids.map(tid => { const t = CASE.people[pid].topics.find(x => x.id === tid); return `<p style="margin-top:6px"><b>— ${t.q}</b></p><div class="quote">${t.a}</div>`; }).join('') + `</div>`;
}
}
html += `<h2>Фабула</h2><div class="card"><p>${CASE.intro}</p></div>
<h2>Підозрювані</h2>` + CASE.suspects.map(s => `<div class="card"><b>${s.name}</b> <span class="tag">${s.role}</span><p class="dim">${s.desc}</p></div>`).join('');
$('app').innerHTML = html;
}
// ---------- дошка ----------
function renderBoard() {
let html = window._header + `<h2>Дошка справи</h2><p class="dim">Спільна для обох. Тут видно все, що закріпили ви й напарник.</p>`;
const groups = ['general', ...CASE.suspects.map(s => s.id)];
for (const g of groups) {
const pins = state.pinned.filter(p => p.s === g);
if (!pins.length && g === 'general') continue;
if (g !== 'general') {
const s = CASE.suspects.find(x => x.id === g);
html += `<h3>${s.name} <span class="tag">${s.role}</span></h3>`;
if (!pins.length) { html += `<p class="dim">— нічого не закріплено</p>`; continue; }
} else html += `<h3>Загальне</h3>`;
for (const p of pins) {
const ev = CASE.evidence[p.id];
html += `<div class="card ev ${ev.role}">
<h3>${ev.title} <span class="tag">${ev.tag}</span> <span class="pill ${ev.role}">${ev.role === 'field' ? 'польовий' : 'аналітик'}</span></h3>
<p>${ev.detail}</p>
<div class="row" style="margin-top:8px">
<select onchange="send({t:'repin',id:'${p.id}',s:this.value})">${groups.map(x => `<option value="${x}" ${x === p.s ? 'selected' : ''}>${suspectName(x)}</option>`).join('')}</select>
<button onclick="send({t:'unpin',id:'${p.id}'})">Зняти</button>
</div></div>`;
}
}
if (!state.pinned.length) html += `<p class="dim" style="margin-top:20px">Дошка порожня. Закріплюйте докази із Записника — це відкриває нові питання й запити.</p>`;
$('app').innerHTML = html;
}
// ---------- звинувачення ----------
function renderAccuse() {
const last = state.lastAccusation;
let html = window._header + `<h2>Висунути звинувачення</h2>
<p class="dim">Оберіть разом: хто, яким способом, і 3${CASE.finale.maxEvidence} докази З ДОШКИ, що це доводять. Помилка — не кінець: отримаєте підказку, яка ланка не сходиться.</p>`;
if (last && !last.result.ok)
html += `<div class="banner bad"><b>Спроба №${last.n} — відхилено.</b><p>${last.result.hint}</p></div>`;
html += `<h3>Вбивця</h3><select id="acc_s">${CASE.suspects.map(s => `<option value="${s.id}">${s.name} — ${s.role}</option>`).join('')}</select>
<h3>Спосіб</h3><select id="acc_m" style="max-width:100%">${CASE.methods.map(m => `<option value="${m.id}">${m.label}</option>`).join('')}</select>
<h3>Докази (з дошки, до ${CASE.finale.maxEvidence})</h3><div class="stack">`;
if (!state.pinned.length) html += `<p class="dim">На дошці порожньо — спершу закріпіть докази.</p>`;
for (const p of state.pinned) {
const ev = CASE.evidence[p.id];
html += `<label class="chk"><input type="checkbox" value="${p.id}" ${ui.accEv.includes(p.id) ? 'checked' : ''} onchange="toggleAccEv(this)"><span><b>${ev.title}</b> <span class="tag">${ev.tag}</span></span></label>`;
}
html += `</div><button class="primary" style="width:100%;margin-top:16px" onclick="submitAccusation()">⚖ Висунути звинувачення</button>`;
$('app').innerHTML = html;
}
function toggleAccEv(cb) {
if (cb.checked) {
if (ui.accEv.length >= CASE.finale.maxEvidence) { cb.checked = false; return alert('Не більше ' + CASE.finale.maxEvidence + ' доказів. Оберіть найсильніші.'); }
ui.accEv.push(cb.value);
} else ui.accEv = ui.accEv.filter(x => x !== cb.value);
}
function submitAccusation() {
if (ui.accEv.length < 3) return alert('Потрібно щонайменше 3 докази.');
const suspect = $('acc_s').value, method = $('acc_m').value;
const result = checkAccusation(suspect, method, ui.accEv);
send({ t: 'accuse', suspect, method, evidence: ui.accEv, result });
}
function renderVictory() {
$('tabs').style.display = 'none';
const a = state.lastAccusation;
$('app').innerHTML = `<div class="overlay"><div class="inner">
<h1 style="color:var(--green)">СПРАВУ РОЗКРИТО</h1>
<div class="banner good"><b>${CASE.suspects.find(s => s.id === a.suspect).name}</b> — винна. Спроб: ${a.n}. Доказів знайдено: ${Object.keys(state.found).length} з ${Object.keys(CASE.evidence).length}.</div>
<h2>Як усе було</h2>
<p>${CASE.finale.recap}</p>
<button class="primary" style="margin-top:20px;width:100%" onclick="localStorage.removeItem('np_session');location.reload()">Нова гра</button>
</div></div>`;
}
// ---------- старт ----------
if (session && role) connect(); else render();
</script>
</body>
</html>

87
chapter1/server.js Normal file
View file

@ -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Обидва гравці відкривають адресу мережі, вводять однаковий код сесії і обирають різні ролі.`);
});

54
chapter1/test.js Normal file
View file

@ -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 дотримано.');

31
chapter2/README.md Normal file
View file

@ -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), логіка обох міні-ігор, перевірка звинувачення.

175
chapter2/case2.js Normal file
View file

@ -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: шість полотен «з провенансом» — і поруч шість свіжих копій, ще зі скипидарним запахом. Це лоти 1218 завтрашніх торгів. Хтось продає фальшаки через дім Гайдука.' },
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:2822:36 з домашнього wifi, камера активна, обличчя в кадрі підтверджено. На момент отруєння була вдома.' },
stream_log: { role: 'analyst', title: 'Стрім Мазура', tag: 'зняття підозри', detail: 'Лесь Мазур: безперервна пряма трансляція реставрації 20:0023:05, п\'ятсот глядачів, обличчя й руки в кадрі весь час. Залізне алібі.' },
expert_email:{ role: 'analyst', title: 'Лист експертці', tag: 'мотив', detail: 'Гайдук — експертці Катерині Резник, учора 18:44: «Катерино, перевірте лоти 1218 до торгів. Особисто. Нікому ні слова».' },
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 млн за «атрибуцію» лотів 1218. Кінцевий бенефіціар прокладки — Богдан Стриж. Гайдук збирався скасувати торги і поховати схему.' },
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: 'Гайдук просив експертку перевірити лоти 1218.', 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: 'Богдан Стриж роками продавав підробки через дім «Гайдук і Спадок», отримуючи мільйони через прокладку «Артконсалт-Гарант». Гайдук помітив, замовив таємну експертизу лотів 1218 і зібрався скасувати торги. О 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 };

532
chapter2/index.html Normal file
View file

@ -0,0 +1,532 @@
<!DOCTYPE html>
<html lang="uk">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Тихий аукціон</title>
<style>
:root { --bg:#0c0d10; --panel:#15171c; --panel2:#1c1f26; --line:#2c313b; --txt:#cfd4dc; --dim:#7d8590; --amber:#d4a017; --cyan:#56b6c2; --red:#e06c75; --green:#7ec87e; }
* { box-sizing:border-box; margin:0; }
body { background:var(--bg); color:var(--txt); font:16px/1.5 Georgia,'Times New Roman',serif; min-height:100vh; }
#app { max-width:760px; margin:0 auto; padding:12px; padding-bottom:80px; }
h1,h2,h3 { font-weight:600; }
h1 { color:var(--amber); letter-spacing:2px; font-size:1.4em; }
h2 { font-size:1.15em; margin:14px 0 8px; color:var(--amber); }
h3 { font-size:1em; margin:10px 0 6px; }
button { background:var(--panel2); color:var(--txt); border:1px solid var(--line); border-radius:8px; padding:10px 14px; font-size:15px; cursor:pointer; text-align:left; font-family:inherit; }
button:hover { border-color:var(--amber); }
button.primary { background:var(--amber); color:#0c0d10; font-weight:600; text-align:center; }
button.locked { color:var(--dim); border-style:dashed; cursor:default; }
button.locked:hover { border-color:var(--line); }
button.done { border-color:var(--green); }
button.mini { padding:4px 10px; font-size:13px; }
select, textarea { background:var(--panel2); color:var(--txt); border:1px solid var(--line); border-radius:8px; padding:8px; font-size:14px; font-family:inherit; }
textarea { width:100%; min-height:60px; resize:vertical; }
.card { background:var(--panel); border:1px solid var(--line); border-radius:10px; padding:12px; margin:8px 0; }
.card.ev { border-left:3px solid var(--cyan); }
.card.ev.field { border-left-color:var(--amber); }
.card.starred { border-color:var(--amber); box-shadow:0 0 0 1px var(--amber); }
.stack { display:flex; flex-direction:column; gap:8px; }
.row { display:flex; gap:8px; flex-wrap:wrap; align-items:center; }
.spread { display:flex; justify-content:space-between; align-items:flex-start; gap:8px; }
.dim { color:var(--dim); font-size:0.88em; }
.tag { display:inline-block; font-size:0.75em; padding:1px 8px; border-radius:10px; border:1px solid var(--line); color:var(--dim); font-family:system-ui,sans-serif; }
.back { color:var(--cyan); background:none; border:none; padding:4px 0; font-size:14px; }
.crossed { text-decoration:line-through; opacity:.45; }
.act { color:var(--red); font-family:system-ui,sans-serif; font-size:.8em; letter-spacing:1px; }
#tabs { position:fixed; bottom:0; left:0; right:0; display:flex; background:var(--panel); border-top:1px solid var(--line); z-index:5; }
#tabs button { flex:1; border:none; border-radius:0; text-align:center; padding:12px 4px; font-size:13px; background:none; }
#tabs button.active { color:var(--amber); border-top:2px solid var(--amber); }
.banner { border-radius:10px; padding:12px; margin:10px 0; }
.banner.bad { background:#2a1518; border:1px solid var(--red); }
.banner.good { background:#152a18; border:1px solid var(--green); }
.quote { border-left:2px solid var(--line); padding-left:10px; margin:8px 0; }
.overlay { position:fixed; inset:0; background:rgba(6,7,9,.94); z-index:10; overflow-y:auto; padding:20px; }
.overlay .inner { max-width:600px; margin:40px auto; }
input[type=text] { background:var(--panel2); color:var(--txt); border:1px solid var(--line); border-radius:8px; padding:12px; font-size:18px; width:100%; text-transform:uppercase; letter-spacing:3px; text-align:center; }
label.chk { display:flex; gap:10px; align-items:flex-start; padding:8px; border:1px solid var(--line); border-radius:8px; cursor:pointer; }
label.chk input { margin-top:4px; }
.pill { font-size:0.8em; color:var(--cyan); font-family:system-ui,sans-serif; }
.pill.field { color:var(--amber); }
.note { background:var(--panel2); border-radius:8px; padding:8px 10px; margin:6px 0; }
/* міні-ігри */
.mgbar { position:relative; height:46px; background:var(--panel2); border:1px solid var(--line); border-radius:8px; margin:16px 0; overflow:hidden; }
.zone { position:absolute; top:0; bottom:0; background:rgba(126,200,126,.30); border-left:1px solid var(--green); border-right:1px solid var(--green); }
.marker { position:absolute; top:0; bottom:0; width:4px; background:var(--amber); }
.symrow { display:flex; gap:8px; justify-content:center; margin-top:14px; flex-wrap:wrap; }
.symrow button { font-size:24px; padding:10px 16px; text-align:center; }
.symshow { font-size:52px; text-align:center; height:80px; line-height:80px; letter-spacing:8px; color:var(--cyan); }
</style>
</head>
<body>
<div id="app"></div>
<div id="mg"></div>
<div id="tabs" style="display:none"></div>
<script src="/case2.js"></script>
<script src="/minigames.js"></script>
<script>
const $ = s => document.getElementById(s);
const esc = t => String(t).replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
let session = localStorage.getItem('np2_session') || '';
let role = localStorage.getItem('np2_role') || '';
let state = null;
let ui = { tab: 'invest', loc: null, person: null, briefed: false, accEv: [], detail: '', advSent: false };
let mg = null; // активна міні-гра: {kind:'search'|'code', ...} — живе в #mg, render() її не чіпає
function send(a) {
fetch('/act', { method: 'POST', body: JSON.stringify({ session, ...a }) });
}
function connect() {
const es = new EventSource('/events?s=' + encodeURIComponent(session) + '&role=' + role);
es.onmessage = e => {
state = JSON.parse(e.data);
// авто-перехід в Акт II, коли докази вбивства на дошці
const pins = pinnedIds();
if (state.act === 1 && !ui.advSent && CASE.actGate.every(id => pins.includes(id))) {
ui.advSent = true;
send({ t: 'advance', act: 2 });
}
render();
renderInterstitial();
};
}
const pinnedIds = () => state.pinned.map(p => p.id);
const isFound = id => !!state.found[id];
const unlocked = item => !item.requires || item.requires.every(r => pinnedIds().includes(r));
const actOk = item => (item.act || 1) <= state.act;
const suspectName = id => id === 'general' ? 'Загальне' : CASE.suspects.find(s => s.id === id).name;
const crossedCls = id => state.crossed[id] ? 'crossed' : '';
const roleLabel = r => r === 'field' ? 'польовий' : 'аналітик';
// ---------- екрани ----------
function render() {
if (!session || !role) return renderJoin();
if (!state) return;
if (!ui.briefed) return renderBriefing();
$('tabs').style.display = 'flex';
if (state.solved) return renderVictory();
renderTabs();
({ invest: role === 'field' ? renderField : renderAnalyst, notes: renderNotes, board: renderBoard, accuse: renderAccuse })[ui.tab]();
}
function renderJoin() {
$('tabs').style.display = 'none';
$('app').innerHTML = `
<div style="margin-top:12vh;text-align:center">
<h1>ТИХИЙ АУКЦІОН</h1>
<p class="dim" style="margin:8px 0 24px">Детектив на двох · Розділ 2 · Нуар</p>
<div class="stack" style="max-width:340px;margin:0 auto">
<input type="text" id="code" maxlength="12" placeholder="КОД СЕСІЇ" value="${esc(session)}">
<p class="dim">Придумайте будь-який код і введіть однаковий на обох пристроях.</p>
<button class="primary" onclick="pickRole('field')">Грати як ${CASE.roles.field.name}</button>
<button class="primary" style="background:var(--cyan)" onclick="pickRole('analyst')">Грати як ${CASE.roles.analyst.name}</button>
</div>
</div>`;
}
function pickRole(r) {
const code = $('code').value.trim().toUpperCase();
if (!code) return alert('Введіть код сесії');
session = code; role = r;
localStorage.setItem('np2_session', session);
localStorage.setItem('np2_role', role);
connect();
}
function renderBriefing() {
$('app').innerHTML = `
<div class="overlay"><div class="inner">
<h1>СПРАВА: ТИХИЙ АУКЦІОН</h1>
<p style="margin:14px 0">${CASE.intro}</p>
<p class="act">${CASE.actNote}</p>
<h2>Твоя роль: ${CASE.roles[role].name}</h2>
<p>${CASE.roles[role].desc}</p>
<h2>Як грати</h2>
<p>${CASE.howto}</p>
<button class="primary" style="margin-top:20px;width:100%" onclick="ui.briefed=true;render()">Почати розслідування</button>
</div></div>`;
}
function renderInterstitial() {
if (mg || !state || !ui.briefed) return;
const key = 'np2_act2_' + session;
if (state.act >= 2 && !localStorage.getItem(key)) {
$('mg').innerHTML = `<div class="overlay"><div class="inner" style="text-align:center">
<h1 style="color:var(--red)">${CASE.act2title}</h1>
<p style="margin:16px 0;text-align:left">${CASE.act2text}</p>
<p class="dim" style="text-align:left">Відкрились нові локації, запити з ордером — і нові питання до старих знайомих.</p>
<button class="primary" style="margin-top:20px;width:100%" onclick="localStorage.setItem('${key}','1');document.getElementById('mg').innerHTML='';render()">До справи</button>
</div></div>`;
}
}
function renderTabs() {
const online = state.online || {};
const other = role === 'field' ? 'analyst' : 'field';
const tabs = [
['invest', role === 'field' ? 'Локації' : 'Системи'],
['notes', 'Записник'],
['board', 'Дошка (' + state.pinned.length + ')'],
['accuse', 'Звинувачення']
];
$('tabs').innerHTML = tabs.map(([id, name]) =>
`<button class="${ui.tab === id ? 'active' : ''}" onclick="ui.tab='${id}';ui.loc=null;ui.person=null;ui.detail='';render()">${name}</button>`).join('');
window._header = `
<div class="row" style="justify-content:space-between">
<h1 style="font-size:1.05em">ТИХИЙ АУКЦІОН <span class="act">АКТ ${state.act === 1 ? 'I' : 'II'}</span></h1>
<span class="dim">${session} · ${CASE.roles[role].name} · напарник: ${online[other] ? '<span style="color:var(--green)">онлайн</span>' : 'офлайн'}</span>
</div>`;
}
// ---------- польовий ----------
function renderField() {
if (ui.person) return renderPerson();
if (ui.loc) return renderLocation();
$('app').innerHTML = window._header + `<h2>Локації</h2><div class="stack">` +
CASE.locations.map(l => actOk(l)
? `<button onclick="ui.loc='${l.id}';ui.detail='';render()"><b>${l.name}</b><br><span class="dim">${l.desc}</span></button>`
: `<button class="locked">🔒 <b>${l.name}</b><br><span class="dim">Доступ відкриється в Акті II — спершу доведіть, що це вбивство.</span></button>`
).join('') + `</div>`;
}
function renderLocation() {
const l = CASE.locations.find(x => x.id === ui.loc);
let html = window._header + `<button class="back" onclick="ui.loc=null;ui.detail='';render()">← Локації</button><h2>${l.name}</h2><p class="dim">${l.desc}</p>`;
if (l.spots.length) {
html += `<h3>Огляд</h3><div class="stack">` + l.spots.map(sp => {
if (sp.ev && isFound(sp.ev)) return `<button class="done" onclick="showEv('${sp.ev}')">✓ ${sp.label}</button>`;
return `<button onclick="inspect('${l.id}','${sp.id}')">${sp.label}</button>`;
}).join('') + `</div>`;
}
if (l.people.length) {
html += `<h3>Люди</h3><div class="stack">` + l.people.map(pid => {
const p = CASE.people[pid];
return `<button onclick="ui.person='${pid}';render()"><b class="${crossedCls(pid)}">${p.name}</b>${p.suspect ? ' <span class="tag">підозрюваний</span>' : ''}</button>`;
}).join('') + `</div>`;
}
html += `<div id="detail">${ui.detail}</div>`;
$('app').innerHTML = html;
}
function inspect(locId, spotId) {
const sp = CASE.locations.find(x => x.id === locId).spots.find(x => x.id === spotId);
if (sp.flavor) { ui.detail = `<div class="card"><p>${sp.flavor}</p></div>`; return render(); }
startSearchGame(sp);
}
function showEv(id) {
ui.detail = evCard(id, CASE.evidence[id], false);
render();
}
function evCard(id, ev, isNew) {
return `<div class="card ev ${ev.role}">
${isNew ? '<p class="dim">НОВИЙ ДОКАЗ</p>' : ''}
<h3>${ev.title} <span class="tag">${ev.tag}</span></h3>
<p>${ev.detail}</p>
<p class="dim" style="margin-top:6px">Закріпи на дошці через Записник, щоб напарник побачив.</p>
</div>`;
}
function renderPerson() {
const p = CASE.people[ui.person];
const asked = state.asked[ui.person] || [];
let html = window._header + `<button class="back" onclick="ui.person=null;render()">← Назад</button>
<div class="spread"><h2 class="${crossedCls(ui.person)}">${p.name}</h2>${p.suspect ? crossBtn(ui.person) : ''}</div><div class="stack">`;
for (const t of p.topics) {
if (asked.includes(t.id)) html += `<div class="card"><b>— ${t.q}</b><div class="quote">${t.a}</div></div>`;
else if (unlocked(t)) html += `<button onclick="ask('${ui.person}','${t.id}')">— ${t.q}</button>`;
else html += `<button class="locked">🔒 Нова тема з'явиться, коли потрібні докази будуть на дошці</button>`;
}
$('app').innerHTML = html + `</div>`;
}
function ask(pid, tid) {
const t = CASE.people[pid].topics.find(x => x.id === tid);
send({ t: 'ask', s: pid, q: tid });
if (t.gives) send({ t: 'find', id: t.gives, by: role });
}
// ---------- аналітик ----------
function renderAnalyst() {
let html = window._header + `<h2>Системи</h2>`;
for (const sys of CASE.systems) {
html += `<h3>${sys.name}</h3><div class="stack">`;
for (const q of sys.queries) {
if (q.ev && isFound(q.ev)) html += `<button class="done" onclick="showQ('${q.id}')">✓ ${q.label}</button>`;
else if (!actOk(q)) html += `<button class="locked">🔒 ${q.label} — потрібен ордер (Акт II)</button>`;
else if (!unlocked(q.ev ? CASE.evidence[q.ev] : q)) html += `<button class="locked">🔒 Запит стане доступним, коли потрібні матеріали будуть на дошці</button>`;
else html += `<button onclick="runQ('${q.id}')">▸ ${q.label}</button>`;
}
html += `</div>`;
}
$('app').innerHTML = html + `<div id="detail">${ui.detail}</div>`;
}
function findQ(qid) { for (const s of CASE.systems) for (const q of s.queries) if (q.id === qid) return q; }
function runQ(qid) {
const q = findQ(qid);
if (q.flavor) { ui.detail = `<div class="card"><b>${q.label}</b><p>${q.flavor}</p></div>`; render(); return window.scrollTo(0, 1e6); }
startCodeGame(q);
}
function showQ(qid) {
const q = findQ(qid);
ui.detail = evCard(q.ev, CASE.evidence[q.ev], false);
render();
window.scrollTo(0, 1e6);
}
// ---------- міні-гра польового: сканер (зафіксуй маркер у зоні) ----------
function startSearchGame(sp) {
const hard = state.act >= 2;
mg = { kind: 'search', sp, period: hard ? 1300 : 1600, zoneW: hard ? 12 : 16, fails: 0 };
newZone();
$('mg').innerHTML = `<div class="overlay"><div class="inner">
<h2>Обшук: ${sp.label}</h2>
<p class="dim">Сканер ловить сигнал. Зафіксуй маркер у зеленій зоні.</p>
<div class="mgbar"><div class="zone" id="mgZone"></div><div class="marker" id="mgMarker"></div></div>
<div class="row">
<button class="primary" style="flex:1" onclick="searchFix()">Зафіксувати</button>
<button onclick="closeMg()">Відійти</button>
</div>
<p id="mgMsg" class="dim" style="margin-top:10px"></p>
</div></div>`;
drawZone();
mg.t0 = performance.now();
(function loop(now) {
if (!mg || mg.kind !== 'search') return;
$('mgMarker').style.left = triPos(now - mg.t0, mg.period) + '%';
mg.raf = requestAnimationFrame(loop);
})(performance.now());
}
function newZone() { mg.zoneStart = 8 + Math.random() * (84 - mg.zoneW); }
function drawZone() { const z = $('mgZone'); z.style.left = mg.zoneStart + '%'; z.style.width = mg.zoneW + '%'; }
function searchFix() {
const pos = triPos(performance.now() - mg.t0, mg.period);
if (inZone(pos, mg.zoneStart, mg.zoneW)) {
const sp = mg.sp;
closeMg();
ui.detail = evCard(sp.ev, CASE.evidence[sp.ev], true);
send({ t: 'find', id: sp.ev, by: role });
render();
} else {
mg.fails++;
$('mgMsg').textContent = 'Сигнал втрачено. Спокійно, ще раз.' + (mg.fails >= 3 ? ' (зона стала ширшою)' : '');
if (mg.fails === 3) mg.zoneW += 8; // ponytail: полегшення після 3 промахів, щоб гра не стала стіною
newZone(); drawZone();
}
}
// ---------- міні-гра аналітика: перехоплення коду (повтори послідовність) ----------
function startCodeGame(q) {
const len = state.act >= 2 ? 5 : 4;
mg = { kind: 'code', q, seq: seqGen(Math.floor(Math.random() * 1e9), len), input: [], showing: true, fails: 0 };
$('mg').innerHTML = `<div class="overlay"><div class="inner">
<h2>Код доступу: ${q.label}</h2>
<p class="dim">Система показує код. Запам'ятай і повтори послідовність.</p>
<div class="symshow" id="mgShow"></div>
<div class="symrow" id="mgKeys">${MG_SYMBOLS.map((s, i) => `<button onclick="codePress(${i})">${s}</button>`).join('')}</div>
<div class="row" style="margin-top:14px">
<button onclick="codeReplay()">Показати ще раз</button>
<button onclick="closeMg()">Скасувати</button>
</div>
<p id="mgMsg" class="dim" style="margin-top:10px"></p>
</div></div>`;
codeShow();
}
function codeShow() {
mg.showing = true; mg.input = [];
$('mgMsg').textContent = '';
let i = 0;
(function step() {
if (!mg || mg.kind !== 'code') return;
if (i < mg.seq.length) {
$('mgShow').textContent = MG_SYMBOLS[mg.seq[i]];
i++;
mg.timer = setTimeout(() => { $('mgShow').textContent = '·'; mg.timer = setTimeout(step, 160); }, 520);
} else {
$('mgShow').textContent = '?';
mg.showing = false;
}
})();
}
function codeReplay() { if (mg && mg.kind === 'code' && !mg.showing) codeShow(); }
function codePress(i) {
if (!mg || mg.kind !== 'code' || mg.showing) return;
mg.input.push(i);
if (!seqPrefixOk(mg.seq, mg.input)) {
mg.fails++;
$('mgMsg').textContent = 'Невірний символ. Система повторює код.' + (mg.fails >= 3 ? ' (показ уповільнено)' : '');
codeShow();
return;
}
$('mgShow').textContent = mg.input.map(x => MG_SYMBOLS[x]).join('');
if (mg.input.length === mg.seq.length) {
const q = mg.q;
closeMg();
ui.detail = evCard(q.ev, CASE.evidence[q.ev], true);
send({ t: 'find', id: q.ev, by: role });
render();
window.scrollTo(0, 1e6);
}
}
function closeMg() {
if (mg) { if (mg.raf) cancelAnimationFrame(mg.raf); if (mg.timer) clearTimeout(mg.timer); }
mg = null;
$('mg').innerHTML = '';
renderInterstitial();
}
// ---------- записник ----------
function crossBtn(sid) {
return `<button class="mini" onclick="send({t:'cross',s:'${sid}'})">${state.crossed[sid] ? '↩ Повернути' : '✗ Відкинути'}</button>`;
}
function starBtn(id) {
return `<button class="mini" onclick="send({t:'star',id:'${id}'})">${state.stars[id] ? '★' : '☆'}</button>`;
}
function renderNotes() {
let html = window._header + `<h2>Нотатки <span class="dim">(спільні)</span></h2>`;
for (const n of state.notes) {
html += `<div class="note spread"><span>${esc(n.txt)} <span class="pill ${n.by}">${roleLabel(n.by)}</span></span>
<button class="mini" onclick="send({t:'note_del',id:${n.id}})"></button></div>`;
}
html += `<textarea id="noteTxt" placeholder="Версії, таймлайн, питання напарнику..."></textarea>
<button class="primary" style="margin-top:6px" onclick="addNote()">Додати нотатку</button>`;
const mine = Object.keys(state.found).filter(id => CASE.evidence[id].role === role);
html += `<h2>Мої докази (${mine.length})</h2>`;
if (!mine.length) html += `<p class="dim">Поки нічого. ${role === 'field' ? 'Оглянь локації і поговори з людьми.' : 'Прожени запити по системах.'}</p>`;
for (const id of mine) {
const ev = CASE.evidence[id];
const pin = state.pinned.find(p => p.id === id);
html += `<div class="card ev ${ev.role} ${state.stars[id] ? 'starred' : ''}">
<div class="spread"><h3>${ev.title} <span class="tag">${ev.tag}</span></h3>${starBtn(id)}</div>
<p>${ev.detail}</p>
<div class="row" style="margin-top:8px">` +
(pin ? `<span class="dim">📌 На дошці: ${suspectName(pin.s)}</span>`
: `<select id="pin_${id}"><option value="general">Загальне</option>${CASE.suspects.map(s => `<option value="${s.id}">${s.name}</option>`).join('')}</select>
<button onclick="send({t:'pin',id:'${id}',s:document.getElementById('pin_${id}').value,by:role})">📌 На дошку</button>`) +
`</div></div>`;
}
const askedAny = Object.entries(state.asked).filter(([, v]) => v.length);
if (askedAny.length) {
html += `<h2>Журнал допитів</h2>`;
for (const [pid, tids] of askedAny) {
html += `<div class="card"><h3 class="${crossedCls(pid)}">${CASE.people[pid].name}</h3>` +
tids.map(tid => { const t = CASE.people[pid].topics.find(x => x.id === tid); return `<p style="margin-top:6px"><b>— ${t.q}</b></p><div class="quote">${t.a}</div>`; }).join('') + `</div>`;
}
}
html += `<h2>Фабула</h2><div class="card"><p>${CASE.intro}</p><p class="act" style="margin-top:8px">${CASE.actNote}</p></div>
<h2>Підозрювані</h2>` + CASE.suspects.map(s =>
`<div class="card"><div class="spread"><span><b class="${crossedCls(s.id)}">${s.name}</b> <span class="tag">${s.role}</span></span>${crossBtn(s.id)}</div><p class="dim ${crossedCls(s.id)}">${s.desc}</p></div>`).join('');
$('app').innerHTML = html;
}
function addNote() {
const txt = $('noteTxt').value.trim();
if (!txt) return;
send({ t: 'note', txt, by: role });
$('noteTxt').value = '';
}
// ---------- дошка ----------
function renderBoard() {
let html = window._header + `<h2>Дошка справи</h2><p class="dim">Спільна для обох. Викреслені підозрювані лишаються на дошці — раптом ви помилились.</p>`;
const groups = ['general', ...CASE.suspects.map(s => s.id)];
for (const g of groups) {
const pins = state.pinned.filter(p => p.s === g);
if (g === 'general') { if (!pins.length) continue; html += `<h3>Загальне</h3>`; }
else {
const s = CASE.suspects.find(x => x.id === g);
html += `<div class="spread"><h3 class="${crossedCls(g)}">${s.name} <span class="tag">${s.role}</span></h3>${crossBtn(g)}</div>`;
if (!pins.length) { html += `<p class="dim">— нічого не закріплено</p>`; continue; }
}
for (const p of pins) {
const ev = CASE.evidence[p.id];
html += `<div class="card ev ${ev.role} ${state.stars[p.id] ? 'starred' : ''}">
<div class="spread"><h3>${ev.title} <span class="tag">${ev.tag}</span> <span class="pill ${ev.role}">${roleLabel(ev.role)}</span></h3>${starBtn(p.id)}</div>
<p>${ev.detail}</p>
<div class="row" style="margin-top:8px">
<select onchange="send({t:'repin',id:'${p.id}',s:this.value})">${groups.map(x => `<option value="${x}" ${x === p.s ? 'selected' : ''}>${suspectName(x)}</option>`).join('')}</select>
<button onclick="send({t:'unpin',id:'${p.id}'})">Зняти</button>
</div></div>`;
}
}
if (!state.pinned.length) html += `<p class="dim" style="margin-top:20px">Дошка порожня. Закріплюйте докази із Записника — це відкриває нові питання й запити.</p>`;
$('app').innerHTML = html;
}
// ---------- звинувачення ----------
function renderAccuse() {
const last = state.lastAccusation;
let html = window._header + `<h2>Висунути звинувачення</h2>
<p class="dim">Оберіть разом: хто, яким способом, і 3${CASE.finale.maxEvidence} докази З ДОШКИ. Помилка — не кінець: отримаєте підказку, яка ланка не сходиться.</p>`;
if (state.act === 1) html += `<div class="banner bad"><b>Зарано.</b><p>Прокурор не прийме звинувачення, поки не доведено сам факт вбивства. Завершіть Акт I.</p></div>`;
if (last && !last.result.ok)
html += `<div class="banner bad"><b>Спроба №${last.n} — відхилено.</b><p>${last.result.hint}</p></div>`;
html += `<h3>Вбивця</h3><select id="acc_s">${CASE.suspects.map(s => `<option value="${s.id}">${s.name} — ${s.role}${state.crossed[s.id] ? ' (викреслений)' : ''}</option>`).join('')}</select>
<h3>Спосіб</h3><select id="acc_m" style="max-width:100%">${CASE.methods.map(m => `<option value="${m.id}">${m.label}</option>`).join('')}</select>
<h3>Докази (з дошки, до ${CASE.finale.maxEvidence})</h3><div class="stack">`;
if (!state.pinned.length) html += `<p class="dim">На дошці порожньо — спершу закріпіть докази.</p>`;
for (const p of state.pinned) {
const ev = CASE.evidence[p.id];
html += `<label class="chk"><input type="checkbox" value="${p.id}" ${ui.accEv.includes(p.id) ? 'checked' : ''} onchange="toggleAccEv(this)"><span><b>${ev.title}</b> <span class="tag">${ev.tag}</span>${state.stars[p.id] ? ' ★' : ''}</span></label>`;
}
html += `</div><button class="primary" style="width:100%;margin-top:16px" ${state.act === 1 ? 'disabled style="width:100%;margin-top:16px;opacity:.4"' : ''} onclick="submitAccusation()">⚖ Висунути звинувачення</button>`;
$('app').innerHTML = html;
}
function toggleAccEv(cb) {
if (cb.checked) {
if (ui.accEv.length >= CASE.finale.maxEvidence) { cb.checked = false; return alert('Не більше ' + CASE.finale.maxEvidence + ' доказів. Оберіть найсильніші.'); }
ui.accEv.push(cb.value);
} else ui.accEv = ui.accEv.filter(x => x !== cb.value);
}
function submitAccusation() {
if (state.act === 1) return;
if (ui.accEv.length < 3) return alert('Потрібно щонайменше 3 докази.');
const suspect = $('acc_s').value, method = $('acc_m').value;
const result = checkAccusation(suspect, method, ui.accEv);
send({ t: 'accuse', suspect, method, evidence: ui.accEv, result });
}
function renderVictory() {
$('tabs').style.display = 'none';
const a = state.lastAccusation;
$('app').innerHTML = `<div class="overlay" style="position:static;background:none"><div class="inner">
<h1 style="color:var(--green)">СПРАВУ РОЗКРИТО</h1>
<div class="banner good"><b>${CASE.suspects.find(s => s.id === a.suspect).name}</b> — винен. Спроб: ${a.n}. Доказів знайдено: ${Object.keys(state.found).length} з ${Object.keys(CASE.evidence).length}.</div>
<h2>Як усе було</h2>
<p>${CASE.finale.recap}</p>
<button class="primary" style="margin-top:20px;width:100%" onclick="localStorage.removeItem('np2_session');localStorage.removeItem('np2_act2_'+session);location.reload()">Нова гра</button>
</div></div>`;
}
// ---------- старт ----------
if (session && role) connect(); else render();
</script>
</body>
</html>

36
chapter2/minigames.js Normal file
View file

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

91
chapter2/server.js Normal file
View file

@ -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Обидва гравці відкривають адресу мережі, вводять однаковий код сесії і обирають різні ролі.`);
});

107
chapter2/test.js Normal file
View file

@ -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 — кейс розв\'язний, акти працюють, міні-ігри перевірено.');

105
chapter3/DESIGN.md Normal file
View file

@ -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):
24 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 12 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 23 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

107
chapter3/README.md Normal file
View file

@ -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 <hash> rockyou.txt` знайде `cookie`, `ginger`, `letmein`
Пароль флешки 3301 теж латиницею: акростих оголошення транслітеруєте й додаєте рік.
## CICADA 3301
У **кожній** справі захована чотиришарова головоломка в дусі Cicada 3301 (прості числа,
шари шифрів, «оголошення в місті» замість плакатів):
скринька з кодовим замком (код — на гравіюванні десь у квартирі) → картка з рядком
Base64-поверх-Цезаря → інструкція веде до оголошення в місті → перші літери рядків +
чотири цифри = пароль до флешки. Нагорода — особистий архів жертви: найкоротший шлях
до правди. Ланцюжок необов'язковий: справа розв'язується і без нього.
## Перевірка
```
npm test
```
Fair-play валідатор ганяє 300 випадкових справ: кожна розв'язна від стартових фактів,
кожен хибний слід спростовний, усі шифри чесні. Інтеграційний тест проходить справжню
справу двома ws-клієнтами до розкриття (і до звільнення).
## Генератор як бібліотека
`casegen/` не залежить від сервера й DOM: `generateCase(seed)` → мешканці, жертва, вбивця,
мотив, граф доказів, шифри; `solve(cs)` — симулятор досяжності; `checkAccusation(...)`.
Можна брати в будь-який інший проєкт.

View file

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

1437
chapter3/casegen/index.js Normal file

File diff suppressed because it is too large Load diff

99
chapter3/casegen/names.js Normal file
View file

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

View file

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

278
chapter3/casegen/test.js Normal file
View file

@ -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, `фільтри лишають 26 кандидатів, зараз ${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));

37
chapter3/package-lock.json generated Normal file
View file

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

14
chapter3/package.json Normal file
View file

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

453
chapter3/public/analyst.js Normal file
View file

@ -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 = `
<h3>База мешканців району</h3>
<p class="dim small">Пошук за точним ім'ям помилка в написанні дасть порожній результат. Значення для фільтрів беріть зі знайдених слідів і вводьте самі. Реєстр місцями застарілий: одяг і розмір взуття надійніше звіряти очним оглядом (ордер у польового).</p>
<div class="filters">
<input id="q-name" placeholder="Ім'я Прізвище" style="width:220px">
<button id="q-go" class="primary">Знайти</button>
</div>
<div class="filters">
<select id="f-sex"><option value="">стать: будь-яка</option><option value="ч">чол.</option><option value="ж">жін.</option></select>
<input id="f-smin" type="number" placeholder="взуття від" min="35" max="46">
<input id="f-smax" type="number" placeholder="взуття до" min="35" max="46">
<input id="f-hmin" type="number" placeholder="зріст від">
<input id="f-hmax" type="number" placeholder="зріст до">
<select id="f-print"><option value="">відбитки: всі</option><option>дуга</option><option>петля</option><option>завиток</option></select>
<select id="f-coat"><option value="">одяг: будь-який</option>${[...new Set(App.init.citizens.map(c => c.coat))].map(x => `<option>${esc(x)}</option>`).join('')}</select>
<label class="small dim"><input type="checkbox" id="f-smoke"> курить</label>
<button id="f-go">Фільтрувати</button>
</div>
<div id="db-out"></div>`;
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 = '<p class="dim">— Записів не знайдено. Перевірте написання.</p>'; 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 = `<p class="dim small">Збігів: ${lastRows.length}</p>`;
const t = el('table', '', '<tr><th>Ім\'я</th><th>Вік</th><th>Адреса</th><th>Робота</th><th>Зріст</th><th>Взуття</th><th>Відбитки</th><th></th></tr>');
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), `<td>${esc(c.name)}${App.state.victim === c.id ? (App.init.caseType === 'kidnap' ? ' ⚠' : ' †') : ''}</td><td>${c.age}</td><td>${c.home}</td><td>${esc(c.job)}</td><td>${c.height}</td><td>${c.shoe}</td><td>${c.printType}</td>`);
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', '', '<tr><th>Ім\'я</th><th>Вік</th><th>Адреса</th><th>Робота</th><th></th></tr>');
for (const c of hidden) {
const tr = el('tr', 'rowlink dim', `<td>${esc(c.name)}</td><td>${c.age}</td><td>${c.home}</td><td>${esc(c.job)}</td>`);
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 = '<h3>Мапа району</h3><p class="dim small">Клік по будівлі — адреси й люди. Клік по порожньому місцю — мітка «перевір тут» для напарника (повторний клік — зняти). Золота точка — напарник.</p>';
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 = `<h3>${esc(b.name)}</h3>`;
if (res.length) html += '<div class="syshead">Мешканці</div>' + res.map(c => `<div class="small">${c.home}: ${esc(c.name)}</div>`).join('');
if (work.length) html += '<div class="syshead">Працюють</div>' + work.map(c => `<div class="small">${esc(c.name)}${esc(c.job)}</div>`).join('');
html += `<div class="syshead">Зараз тут</div><div class="small dim">${here.length ? here.map(c => esc(c.name)).join(', ') : 'нікого не зафіксовано'}</div>`;
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 = `
<h3>Системи й лабораторія</h3>
<p class="dim small">Дані для запитів номери, імена, прикмети, роки вичитуйте з доказів і вводьте самі. Кожен результат лягає на дошку: що з нього стосується справи вирішуєте ви.</p>
<div class="syshead">Оператор зв'язку</div>
<div class="filters">
<input id="ph-n" placeholder="+380…" style="width:180px">
<button data-q="owner">Власник номера</button>
<button data-q="calls">Журнал дзвінків</button>
<button data-q="locate">Тріангуляція (де зараз)</button>
</div>
<div class="syshead">Банк</div>
<div class="filters">
<input id="bk-n" placeholder="Ім'я Прізвище або заклад" style="width:220px">
<button id="bk-go">Запросити виписку</button>
</div>
<div class="syshead">Реєстри</div>
<div class="filters">
<input id="db-n" placeholder="ім'я / тату / кличка собаки…" style="width:220px">
<button id="db-go">Пошук у реєстрах</button>
</div>
<div class="syshead">Криміналістика</div>
<div class="filters">
<button id="cr-exp" class="primary">Експертиза місця/тіла</button>
<input id="cr-n" placeholder="архів: рік, слово, прізвище…" style="width:200px">
<button id="cr-go">Пошук в архіві справ</button>
</div>
<div class="syshead">Камери архів ночі злочину</div>
<div class="filters">
<select id="cc-cam">${App.init.district.cameras.map(c => `<option value="${c.id}">${esc(c.name)}</option>`).join('')}</select>
<input type="range" id="cc-t" min="1200" max="1439" value="1290" style="width:180px">
<span id="cc-v" style="font-family:monospace"></span>
<button id="cc-go">Переглянути ±10 хв</button>
</div>
<div class="syshead">Зведення алібі</div>
<div id="al-table" class="small dim">Ще нікого не питали, де він був того вечора.</div>
<div class="syshead">Лабораторія</div>
<div class="filters" id="lab-btns"></div>
<div id="pz-list"></div>
<details style="margin-top:10px"><summary class="dim small">Інструменти дешифрування (Base64, Цезар)</summary>
<div class="card">
<div class="syshead">Base64</div>
<textarea id="tb64" rows="2" style="width:100%" placeholder="вставте рядок…"></textarea>
<button id="tb64go">Розкодувати</button>
<div id="tb64out" class="small" style="word-break:break-all"></div>
<div class="syshead">Цезар перебір усіх зсувів</div>
<textarea id="tcz" rows="2" style="width:100%" placeholder="вставте текст…"></textarea>
<button id="tczgo">Показати всі 32</button>
<pre id="tczout" class="small" style="white-space:pre-wrap;max-height:220px;overflow:auto"></pre>
</div>
</details>`;
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', '', '<tr><th>Хто</th><th>Каже, що був</th><th>Камери над тим місцем</th></tr>');
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('<br>')
: 'жодна камера туди не дивиться';
t.append(el('tr', '', `<td>${esc(c.name)}</td><td>${esc(bn(claim))}</td><td class="small">${cell}</td>`));
}
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', `<b>${z.solved ? '✓ ' : ''}${esc(z.prompt)}</b>`));
if (z.solved) { pzList.append(c); continue; }
if (z.kind === 'caesar') {
c.append(el('div', 'small', `<code>${esc(z.payload)}</code>`));
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}: <code>${esc(caesarDec(z.payload, +sl.value))}</code>`;
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', `<code>${esc(z.payload)}</code>`));
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) =>
`<div style="display:inline-block;text-align:center;margin:0 7px;vertical-align:top">
<div style="position:relative;width:26px;height:180px;background:#0d1116;border:1px solid var(--line);margin:0 auto">
${dna.map(v => `<div style="position:absolute;left:2px;right:2px;top:${8 + v * 10}px;height:4px;background:var(--blue)"></div>`).join('')}
</div><div class="small dim" style="max-width:86px">${name}</div></div>`;
// Ручний шортліст: аналітик сам вирішує, кого нести в лабораторію.
// ponytail: сервер не гейтить ДНК — перебір усієї бази можливий, але по 6 і руками
function pickCompare(title, go) {
const mm = openModal(`<h3>${esc(title)}</h3>
<p class="dim small">Оберіть до 6 профілів для порівняння зі зразком.</p>
<input id="pk-q" placeholder="фільтр за іменем…" style="width:100%">
<div id="pk-list" style="max-height:280px;overflow-y:auto;margin:8px 0"></div>
<button class="primary" id="pk-go">Порівняти</button> <span class="dim small" id="pk-n"></span>`);
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(`<h3>Гель-електрофорез</h3>
<p class="dim small">Зразок із місця злочину ліворуч. Клацніть по профілю, який вважаєте збігом. Памʼятайте: збіг скаже лише, ЧИЯ це ДНК, не коли і не чому вона там.</p>
<div style="overflow-x:auto;white-space:nowrap">${laneHtml(sample, '<b>ЗРАЗОК</b>')}<span style="display:inline-block;width:16px;border-left:1px solid var(--line);height:180px;vertical-align:top"></span>${ids.map(id => { const c = App.byId(id); return `<span data-cid="${id}" style="cursor:pointer">${laneHtml(c.dna, esc(c.name))}</span>`; }).join('')}</div>`);
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(`<h3>Дактилоскопія: відбиток зі знаряддя</h3>
<p class="dim small">Зразок ліворуч. Клацніть по власнику відбитка.</p>
<canvas id="psample" width="150" height="150" style="border:1px solid var(--acc);border-radius:6px"></canvas>
<span class="dim small"> зразок</span>
<div id="pcands" style="display:flex;gap:12px;flex-wrap:wrap;margin-top:10px"></div>`);
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(`<h3>Тріангуляція номера ${esc(data.number)}</h3>
<p class="dim small">Три вежі, три відстані до апарата станом на зараз. Клацніть будинок, що лежить на перетині всіх трьох дуг.</p>
<canvas id="trcv"></canvas>
<div id="trreads" class="small" style="margin-top:6px"></div>`);
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);
});
})();

524
chapter3/public/client.js Normal file
View file

@ -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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[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)} <span class="tag">${n.by === "field" ? "польовий" : "аналітик"}</span>`,
);
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) =>
`<span style="display:inline-block;width:10px;height:${6 + v * 2}px;background:var(--blue);margin-right:2px;vertical-align:bottom"></span>`,
)
.join("");
const m = openModal(`
<h3>${esc(c.name)}</h3>
<table>
<tr><th>Вік / стать</th><td>${c.age} / ${c.sex === "ч" ? "чол." : "жін."} (${c.birthYear} р.н.)</td></tr>
<tr><th>Адреса</th><td>${esc(c.home)}</td></tr>
<tr><th>Робота</th><td>${esc(c.job)}${c.workplace ? " " + esc(bName(c.workplace)) : ""}</td></tr>
<tr><th>Телефон</th><td>${esc(c.phone)}</td></tr>
<tr><th>Зріст / взуття</th><td>${c.height} см / ${c.shoe} розмір</td></tr>
<tr><th>Кров / відбитки</th><td>${esc(c.blood)} / ${esc(c.printType)}</td></tr>
<tr><th>Верхній одяг</th><td>${esc(c.coat)}</td></tr>
<tr><th>Звички</th><td>${c.smoker ? "курить; " : ""}${c.dog ? "собака " + esc(c.dog) + "; " : ""}${c.tattoo ? "тату: " + esc(c.tattoo) : ""}</td></tr>
<tr><th>ДНК-профіль</th><td>${dnaCells}</td></tr>
</table>`);
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", "", `<span class="dim">${esc(e.q)}</span><br>${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 = `
<div class="syshead">Польовий детектив</div>
<ul class="small">
<li>WASD/стрілки рух, E увійти. Почніть із <b>місця злочину</b> (позначене ): обшукуйте його точку, поки знахідки не скінчаться. Не кожен слід вбивці: місце публічне.</li>
<li><b>Розмови:</b> у кожної людини теми у справі, «Де ви були о ?» (повзунок часу), «Про людину», «Показати доказ». Люди брешуть і помиляються. Збрехав? Перевірте його місце камерами і <b>спитайте вдруге</b> розколеться і скаже, чому брехав. Вірити чи ні вирішуєте ви.</li>
<li><b>Ордер</b> особисто у відділку: оберіть людину і доказ із закріплених 📌, який прямо на неї вказує.</li>
<li><b>Пристрої:</b> в житлі під ордером трапляються телефони й ноутбуки. Вилучайте самі ви їх не відкриєте, вони йдуть аналітику в лабораторію на злам.</li>
<li>З ордером можна <b>оглянути людину особисто</b> реєстр місцями застарілий, очі надійніші. Результат ніде не записується: занотовуйте самі.</li>
<li>Червоні на мапі мітки напарника «перевір тут».</li>
</ul>`;
const analystHow = `
<div class="syshead">Аналітик</div>
<ul class="small">
<li><b>База мешканців:</b> фільтри вводите руками — значення беріть із доказів. Викреслюйте людей по одному (✓//). Реєстр місцями застарілий: одяг/взуття надійніше звіряти очним оглядом польового.</li>
<li><b>Системи:</b> оператор (власник, дзвінки, тріангуляція «де зараз»), банк, реєстри, криміналістика (експертиза + архів), камери. Дані для запитів шукайте в доказах. <b>Кожен результат лягає на дошку</b> навіть дрібниця; що з того справа, вирішуєте ви.</li>
<li><b>Журнал дзвінків</b> це покази, які не брешуть: хто з ким говорив і о котрій у вечір злочину. Звіряйте з тим, що люди кажуть на допиті. Порожній вечір теж промовистий: апарат могли вимкнути.</li>
<li><b>Вилучені пристрої</b> потрапляють у лабораторію заблокованими: PIN це зазвичай рік народження власника (є в реєстрі), пароль ноутбука або щось особисте латиницею, або словниковий перебір. Усередині буває історія браузера й чати а буває порожньо.</li>
<li><b>Камери:</b> час злочину ±12 хв — головні кадри; будь-який вечірній час — хто взагалі потрапляв у кадри. Звіряйте з тим, що люди <i>кажуть</i> («Зведення алібі»). Брехня вбивці, спіймана камерою, повноцінний доказ для прокурора.</li>
<li><b>Лабораторія:</b> ДНК і відбитки порівнюєте вручну до 6 профілів за раз. Шифри теж тут. Увага: ДНК каже лише, ЧИЯ вона, не коли й чому лишена. На місці буває ДНК людей, що не мають стосунку до злочину: збіг привід для допиту, а не вирок.</li>
<li>Клік по порожньому місцю мапи мітка для напарника.</li>
</ul>`;
openModal(`<h3>${esc(i.title)}: ${esc(i.scene)}, ~${esc(i.time)}</h3><p>${esc(i.text)}</p>
<p class="dim small">Роль: ${App.role === "field" ? "польовий детектив — район, обшуки, розмови" : "аналітик — бази, камери, лабораторія"}.</p>
${App.role === "field" ? fieldHow : analystHow}
<div class="syshead">Фінал</div>
<p class="small">Одне звинувачення на двох: ХТО (ім'я з клавіатури) / ЯК чи ДЕ / ЧОМУ, підписи обох. Прокурор дивиться лише на закріплені 📌 докази: потрібне підтвердження особи (ДНК, відбиток або зламане алібі), мотив і спосіб. Нотатки спільні: загальні у вкладці, точкові прямо на картці доказу.</p>`);
}
$("#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(`
<h3>${isK ? "Рапорт на штурм — один шанс" : "Звинувачення — один шанс"}</h3>
<p class="dim small">Помилка в будь-якому полі і ${isK ? "штурм піде не туди" : "вас звільнять"}. Прокурор дивиться лише на закріплені 📌 докази.</p>
<p>ХТО (повне ім'я, з клавіатури):<br><input id="ac-who" style="width:100%" placeholder="Ім'я Прізвище"></p>
<p>${isK ? "ДЕ утримують:" : "ЯК / ЧИМ:"}<br><select id="ac-how" style="width:100%">${o.methods.map((x) => `<option>${esc(x)}</option>`).join("")}</select></p>
<p>ЧОМУ:<br><select id="ac-why" style="width:100%">${o.motives.map((x) => `<option>${esc(x)}</option>`).join("")}</select></p>
<p>Закріплено доказів: <b>${st.board.pins.length}</b></p>
<button class="danger" id="ac-go">Подати на підпис напарнику</button>`);
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(`
<h3>${App.init.caseType === "kidnap" ? "Рапорт на штурм — на підписі" : "Звинувачення на підписі"}</h3>
<table>
<tr><th>ХТО</th><td>${esc(d.who)}</td></tr>
<tr><th>${App.init.caseType === "kidnap" ? "ДЕ" : "ЯК"}</th><td>${esc(d.how)}</td></tr>
<tr><th>ЧОМУ</th><td>${esc(d.why)}</td></tr>
</table>
<p class="small">Підписано: ${d.signed} з ${d.need}</p>
<p class="dim small">Потрібен підпис КОЖНОГО гравця і вироку вже не відкликати.</p>
<button class="danger" id="ac-sign">Підписати</button>
<button id="ac-cancel">Відкликати</button>`);
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(
`
<div id="endscreen">
<h3>${e.solved ? (App.init.caseType === "kidnap" ? "ЛЮДИНУ ВРЯТОВАНО" : "СПРАВУ РОЗКРИТО") : "ВАС ЗВІЛЬНЕНО"}</h3>
${e.hint ? `<p class="dim">Де ви схибили: ${esc(e.hint)}</p>` : ""}
<p>${esc(e.recap)}</p>
<p class="dim small">${e.solved ? "Новий код сесії — цілком нова справа." : "Справжню картину відновив ваш наступник. Новий код сесії — нова спроба в новій справі."}</p>
</div>`,
{ locked: false },
);
}

707
chapter3/public/field.js Normal file
View file

@ -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(`<h3>Відділок поліції</h3>
<p class="dim small">Ваша база: дошка справи і термінал аналітика синхронізовані. Тут-таки сидить прокурор ордер на обшук житла подають особисто, з доказом, який прямо вказує на людину.</p>
<button class="primary" id="warr">📜 Подати на ордер</button>
<button id="db">🗂 База мешканців</button>`);
m.inner.querySelector("#db").onclick = () => { m.wrap.remove(); residentsModal(); };
m.inner.querySelector("#warr").onclick = () => {
m.wrap.remove();
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(
`<h3>Підстава для ордера на: ${esc(c.name)}</h3>
<p class="dim">Тека порожня. Спершу закріпіть 📌 на дошці доказ, який прямо вказує на цю особу, з усім підряд до прокурора не ходять.</p>`,
);
const mm = openModal(
`<h3>Підстава для ордера на: ${esc(c.name)}</h3>
<p class="dim small">Прокурор підпише, лише якщо доказ прямо вказує на цю особу. У теці закріплене 📌.</p>
<div class="grid" style="grid-template-columns:1fr">` +
evs
.map((e) => `<button data-ev="${e.id}">${esc(e.title)}</button>`)
.join("") +
"</div>",
);
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(`<h3>База мешканців району</h3>
<p class="dim small">Термінал аналітика, тільки перегляд. Мітки (//) ставить аналітик тут вони лише видно. Реєстр місцями застарілий: одяг і взуття надійніше звіряти очним оглядом за ордером.</p>
<div class="filters">
<input id="rq" placeholder="Ім'я Прізвище" style="width:200px"><button id="rqgo">Знайти</button>
</div>
<div class="filters">
<select id="rf-sex"><option value="">стать: будь-яка</option><option value="ч">чол.</option><option value="ж">жін.</option></select>
<input id="rf-smin" type="number" placeholder="взуття від" min="35" max="46">
<input id="rf-smax" type="number" placeholder="взуття до" min="35" max="46">
<input id="rf-hmin" type="number" placeholder="зріст від">
<input id="rf-hmax" type="number" placeholder="зріст до">
<select id="rf-print"><option value="">відбитки: всі</option><option>дуга</option><option>петля</option><option>завиток</option></select>
<select id="rf-coat"><option value="">одяг: будь-який</option>${coats.map((x) => `<option>${esc(x)}</option>`).join("")}</select>
<label class="small dim"><input type="checkbox" id="rf-smoke"> курить</label>
<button id="rfgo">Фільтрувати</button>
</div>
<div id="rout"><p class="dim small">Задайте пошук або фільтр.</p></div>`);
const out = m.inner.querySelector("#rout");
const MK = { tick: "✓", cross: "✗", hide: "…" };
const draw = (rows) => {
out.innerHTML = `<p class="dim small">Збігів: ${rows.length}</p>`;
const t = el(
"table",
"",
"<tr><th>Ім'я</th><th>Вік</th><th>Адреса</th><th>Робота</th><th>Зріст</th><th>Взуття</th><th>Відбитки</th><th></th></tr>",
);
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),
`<td>${esc(c.name)}${vic}</td><td>${c.age}</td><td>${esc(c.home)}</td><td>${esc(c.job)}</td><td>${c.height}</td><td>${c.shoe}</td><td>${esc(c.printType)}</td><td class="dim">${mark ? MK[mark] : ""}</td>`,
);
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
? ""
: '<p class="dim small">— Записів не знайдено. Перевірте написання.</p>';
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(`<h3>${esc(title)}</h3>
<input id="pc-q" placeholder="фільтр за іменем…" style="width:100%">
<div id="pc-list" class="grid" style="grid-template-columns:1fr 1fr;max-height:300px;overflow-y:auto;margin-top:8px"></div>`);
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 = `<h3>${esc(b.name)}</h3>`;
if (b.spots && b.spots.length) {
html +=
'<div class="syshead">Оглянути</div><div class="grid">' +
b.spots
.map((s, i) => `<button data-spot="${i}">${esc(s)}</button>`)
.join("") +
"</div>";
}
if (b.units) {
html +=
'<div class="syshead">Квартири</div><div class="grid">' +
b.units
.map(
(u) =>
`<button data-unit="${u}">${u}${App.state.keys.includes(u) ? " 🔑" : ""}</button>`,
)
.join("") +
"</div>";
}
if (ppl.length && !b.units) {
html +=
'<div class="syshead">Тут зараз</div><div class="grid">' +
ppl
.map((c) => `<button data-cid="${c.id}">${esc(c.name)}</button>`)
.join("") +
"</div>";
}
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(`<h3>${esc(b.name)} — кв. ${unit}</h3>
<p class="dim small">${home.length ? "Мешканці: " + home.map((c) => esc(c.name)).join(", ") : "За реєстром тут ніхто не живе."}</p>
<button id="knock">Постукати</button>
<button id="enter">Увійти${App.state.keys.includes(unit) ? " 🔑" : ""}</button>`);
m.inner.querySelector("#knock").onclick = () => {
if (!present.length) return toast("Тиша. Ніхто не відчиняє.");
m.wrap.remove();
pickPerson(present);
};
m.inner.querySelector("#enter").onclick = () => {
m.wrap.remove();
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(`<h3>Кв. ${m.unit} — обшук</h3>
${inside.length ? '<p class="dim small">Вдома: ' + inside.map((c) => `<button data-cid="${c.id}" class="small">${esc(c.name)}</button>`).join(" ") + "</p>" : ""}
<div class="grid">${m.slots.map((s, i) => `<button data-slot="${i}">${esc(s)}</button>`).join("")}</div>`);
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(
'<h3>Хто відчинив?</h3><div class="grid">' +
list
.map((c) => `<button data-cid="${c.id}">${esc(c.name)}</button>`)
.join("") +
"</div>",
);
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
? '<div class="syshead">Балачки про район</div>' +
c.gossip
.map((l) => `<p class="small dim">💬 «${esc(l)}»</p>`)
.join("")
: "";
const topicsHtml = m.topics.length
? '<div class="syshead">Теми у справі</div><div class="grid" style="grid-template-columns:1fr">' +
m.topics
.map(
(t) =>
`<button data-topic="${t.id}" ${t.found ? "disabled" : ""}>${esc(t.title)}${t.found ? " ✓" : ""}</button>`,
)
.join("") +
'</div><p class="dim small">Сірі теми вже в доказах на дошці.</p>'
: "";
const mm = openModal(
`<h3>${esc(c.name)}</h3>` +
topicsHtml +
`<div class="syshead">Допит</div>
<div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap">
<input type="range" id="al-t" min="1200" max="1439" value="1330" style="flex:1;min-width:120px">
<span id="al-v" style="font-family:monospace"></span>
<button id="al-go">Де ви були?</button>
</div>
<div style="display:flex;gap:6px;margin-top:6px;flex-wrap:wrap">
<button id="ab-go">Про людину</button>
<button id="sh-go">Показати доказ</button>
${App.state.warrants.includes(m.cid) ? '<button id="insp-go">Оглянути особисто 📜</button>' : ''}
</div>
<p class="dim small">Відповіді можуть бути правдою, брехнею або «не знаю» перевіряйте камерами і чужими словами.</p>` +
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(
'<h3>Показати доказ</h3><div class="grid" style="grid-template-columns:1fr">' +
App.state.found
.map((e) => `<button data-ev="${e.id}">${esc(e.title)}</button>`)
.join("") +
"</div>",
);
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(
`<h3>Очний огляд: ${esc(c.name)}</h3><p>${esc(m.body)}</p>`,
);
});
// відповідь допитуваного
App.on("answer", (m) => {
const c = App.byId(m.cid);
openModal(
`<h3>${esc(c.name)}</h3><p class="dim small">${esc(m.q)}</p><p>${esc(m.a)}</p>
<p class="dim small">Записано в журнал допитів (досьє в аналітика і картка розмови).</p>`,
);
});
// ---------- міні-гра «вилучення»: тремтячі руки псують доказ ----------
let mgMisses = 0;
App.on("mg", (m) => {
if (m.kind !== "collect" || App.role !== "field") return;
const mm = openModal(
`<h3>Вилучення: ${esc(m.title)}</h3>
<p class="dim small">Зафіксуйте пінцет у зеленій зоні (кнопка або пробіл). Промахи розширюють зону руки звикають.</p>
<div id="mgtrack" style="position:relative;height:26px;background:#0d1116;border:1px solid var(--line);border-radius:6px;margin:10px 0">
<div id="mgzone" style="position:absolute;top:0;bottom:0;background:rgba(90,168,112,.35);border:1px solid var(--green)"></div>
<div id="mgmark" style="position:absolute;top:2px;bottom:2px;width:4px;background:var(--acc)"></div>
</div>
<button class="primary" id="mgfix">Зафіксувати</button>
<button id="mgoff">Відкласти</button>
<span class="dim small" id="mgmsg"></span>`,
{ 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;
});
})();

225
chapter3/public/index.html Normal file
View file

@ -0,0 +1,225 @@
<!DOCTYPE html>
<html lang="uk">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Невпізнаний — детектив на двох, розділ 3</title>
<style>
:root {
--night:#0b0d12; --panel:#12151c; --panel2:#1a1f29; --line:#262c38;
--txt:#cfd6df; --dim:#7d8794; --acc:#e8b04b; --red:#c94f4f; --green:#5aa870; --blue:#5b8dbf;
--paper:#e9ddc0; --paper2:#dcCfae; --ink:#282218; --inkdim:#6f6450; --paperline:#c3b28b;
--amber:#ffb454; --amberdim:#8a6a34; --crt:#0e0903;
--noise:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='140' height='140'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='2'/><feColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .05 0'/></filter><rect width='140' height='140' filter='url(%23n)'/></svg>");
}
* { box-sizing:border-box; margin:0; }
html { accent-color:var(--acc); }
body { background:var(--night); color:var(--txt); font:15px/1.45 Georgia, 'Times New Roman', serif; height:100vh; overflow:hidden; }
button { font:inherit; color:var(--txt); background:var(--panel2); border:1px solid var(--line); border-radius:4px; padding:6px 12px; cursor:pointer; }
button:hover { border-color:var(--acc); }
button.primary { background:var(--acc); color:#14100a; font-weight:bold; }
button.danger { background:var(--red); color:#fff; }
button:disabled { opacity:.5; cursor:default; }
input, select, textarea { font:inherit; color:var(--txt); background:#0d1116; border:1px solid var(--line); border-radius:4px; padding:6px 10px; }
input:focus, textarea:focus { outline:none; border-color:var(--acc); }
h3 { font-size:15px; color:var(--acc); margin-bottom:6px; letter-spacing:.4px; }
.dim { color:var(--dim); } .small { font-size:12.5px; }
ul { padding-left:18px; } li { margin:4px 0; }
/* ---------- Лобі: дощова ніч ---------- */
#lobby { position:fixed; inset:0; display:flex; align-items:center; justify-content:center; z-index:50; overflow:hidden;
background:
radial-gradient(ellipse at 20% 110%, rgba(232,176,75,.10), transparent 45%),
radial-gradient(ellipse at 85% 105%, rgba(91,141,191,.09), transparent 40%),
linear-gradient(180deg, #05070c 0%, #0a0e16 55%, #11141c 100%); }
#lobby::before { content:''; position:absolute; inset:-60px; pointer-events:none; opacity:.5;
background:repeating-linear-gradient(103deg, transparent 0 16px, rgba(175,195,220,.05) 16px 17px);
animation:rainfall 0.9s linear infinite; }
@keyframes rainfall { to { transform:translate(-34px, 120px); } }
#lobby::after { content:''; position:absolute; left:0; right:0; bottom:0; height:26%; pointer-events:none; opacity:.9;
background:
linear-gradient(0deg, #04060a 30%, transparent),
repeating-linear-gradient(90deg, transparent 0 46px, rgba(20,26,36,.9) 46px 118px, transparent 118px 150px); }
#lobby .card { position:relative; z-index:2; background:rgba(16,19,27,.94); border:1px solid var(--line); border-radius:10px;
padding:36px 40px; width:460px; box-shadow:0 30px 80px rgba(0,0,0,.7), inset 0 0 60px rgba(0,0,0,.35); }
#lobby h1 { font-size:34px; color:var(--txt); letter-spacing:8px; margin-bottom:2px;
text-shadow:0 0 22px rgba(201,79,79,.5), 0 2px 0 #000; }
#lobby h1::after { content:'СПРАВУ ВІДКРИТО'; display:block; font-size:10px; letter-spacing:4px; color:var(--red);
border:1px solid var(--red); width:max-content; padding:2px 8px; margin-top:8px; transform:rotate(-2deg); opacity:.85; }
#lobby .row { margin:16px 0; display:flex; gap:8px; }
#lobby input { flex:1; text-transform:uppercase; letter-spacing:2px; }
.rolebtn { flex:1; padding:12px; text-align:left; transition:transform .12s; }
.rolebtn:hover { transform:translateY(-2px); }
.rolebtn b { display:block; color:var(--acc); }
.rolebtn[data-role="analyst"] b { color:var(--amber); font-family:'Courier New', monospace; letter-spacing:1px; }
/* ---------- Каркас ---------- */
#app { display:none; height:100vh; flex-direction:column; }
#app.on { display:flex; }
header { display:flex; align-items:center; gap:14px; padding:8px 14px; background:linear-gradient(180deg, #171b24, #121620);
border-bottom:1px solid var(--line); box-shadow:0 4px 16px rgba(0,0,0,.4); }
header .title { color:var(--txt); font-weight:bold; font-size:16px; letter-spacing:4px; text-shadow:0 0 14px rgba(201,79,79,.4); }
.badge { font-size:12px; border:1px solid var(--line); border-radius:2px; padding:2px 9px; color:var(--dim); letter-spacing:.5px; }
.badge.on { color:var(--green); border-color:var(--green); }
#main { flex:1; display:flex; min-height:0; }
#stage { flex:1; min-width:0; display:flex; flex-direction:column; padding:12px; gap:8px; overflow:auto; }
#side { width:400px; border-left:1px solid #1c130c; display:flex; flex-direction:column; min-height:0;
background:linear-gradient(160deg, #33261a, #2a1f14 60%, #241a10); background-blend-mode:multiply; }
#side::before { content:''; position:absolute; } /* резерв */
/* ---------- Дошка: корок і папір ---------- */
.tabs { display:flex; border-bottom:2px solid rgba(0,0,0,.35); background:rgba(0,0,0,.18); }
.tabs button { flex:1; border:none; border-radius:0; background:none; padding:9px 4px; color:#b7a284; border-bottom:2px solid transparent; letter-spacing:1px; }
.tabs button.on { color:var(--paper); border-bottom-color:var(--acc); text-shadow:0 1px 0 #000; }
.tabbody { flex:1; overflow-y:auto; padding:12px 12px 20px; background-image:var(--noise); }
.tabbody > .dim { color:#c9b596; text-shadow:0 1px 0 rgba(0,0,0,.5); }
#side .card { position:relative; background:var(--paper); background-image:var(--noise); color:var(--ink);
border:1px solid var(--paperline); border-radius:2px; padding:14px 11px 9px; margin:0 0 12px;
box-shadow:0 4px 10px rgba(0,0,0,.55); }
#side .card:nth-child(odd) { transform:rotate(.5deg); }
#side .card:nth-child(3n) { transform:rotate(-.6deg); }
#side .card::before { content:''; position:absolute; top:-5px; left:50%; width:11px; height:11px; margin-left:-6px;
border-radius:50%; background:radial-gradient(circle at 35% 30%, #ff9a8a, #a72a1e 65%); box-shadow:0 2px 3px rgba(0,0,0,.6); }
#side .card .dim, #side .card .small.dim { color:var(--inkdim); }
#side .card b { color:#1d1812; }
#side .card input { background:transparent; border:none; border-bottom:1px dashed #a5946e; border-radius:0; color:var(--ink); padding:3px 2px; }
#side .card input::placeholder { color:#a5946e; }
#side button { background:#d9cba6; color:var(--ink); border:1px solid #b3a077; }
#side button:hover { border-color:#7a1f1f; }
#side .tag { font-size:10.5px; background:transparent; border:1px solid #b3a077; color:var(--inkdim); border-radius:2px; padding:0 5px; }
.card .t { display:flex; gap:6px; align-items:baseline; }
.card .t b { flex:1; }
.mk { display:flex; gap:4px; margin-top:6px; }
.mk button { padding:1px 8px; font-size:12px; }
.mk button.on { border-color:#7a1f1f; color:#7a1f1f; font-weight:bold; }
#side .card.cross { opacity:.55; }
#side .card.cross::after { content:'✗'; position:absolute; inset:0; display:flex; align-items:center; justify-content:center;
font-size:64px; color:rgba(167,42,30,.35); transform:rotate(-14deg); pointer-events:none; }
#side .card.cross b { text-decoration:line-through; }
#side .card.tick { border-color:#4d7a44; box-shadow:0 4px 10px rgba(0,0,0,.55), inset 0 0 0 2px rgba(77,122,68,.35); }
.pinbtn { background:none; border:none; opacity:.45; font-size:15px; padding:0 4px; }
.pinbtn.on { opacity:1; filter:drop-shadow(0 0 4px rgba(232,176,75,.8)); }
/* ---------- Сцена: польовий ---------- */
#cv { width:100%; border:1px solid #1c222c; border-radius:10px; background:#0a0e12; display:block;
box-shadow:0 0 0 1px #04060a, 0 14px 44px rgba(0,0,0,.6); }
#fieldhint { color:#8fa6bd; font-size:13px; min-height:18px; font-family:'Courier New', monospace; letter-spacing:.4px;
text-shadow:0 0 8px rgba(120,160,210,.35); }
#fieldhint::before { content:'▸ '; color:var(--acc); }
/* ---------- Сцена: аналітик = CRT-термінал ---------- */
.role-analyst #stage { position:relative; margin:10px; border-radius:10px; background:var(--crt);
border:1px solid #33230d; box-shadow:inset 0 0 140px rgba(255,170,60,.07), 0 12px 40px rgba(0,0,0,.6);
color:var(--amber); font-family:'Courier New', monospace; animation:crtflicker 7s infinite; }
.role-analyst #stage::after { content:''; position:fixed; pointer-events:none; z-index:5; inset:0;
background:repeating-linear-gradient(0deg, rgba(0,0,0,.14) 0 1px, transparent 1px 3px); mix-blend-mode:multiply; }
@keyframes crtflicker { 0%,94%,100% { opacity:1; } 95% { opacity:.96; } 96% { opacity:1; } 98% { opacity:.985; } }
.role-analyst #stage h3, .role-analyst #stage .syshead { color:var(--amber); text-shadow:0 0 8px rgba(255,180,84,.5); }
.role-analyst #stage .dim { color:var(--amberdim); }
.role-analyst #stage button { background:transparent; color:var(--amber); border:1px solid var(--amberdim); border-radius:2px; font-family:inherit; }
.role-analyst #stage button:hover:not(:disabled) { border-color:var(--amber); box-shadow:0 0 10px rgba(255,180,84,.25); }
.role-analyst #stage button.primary { background:rgba(255,180,84,.16); color:var(--amber); font-weight:bold; }
.role-analyst #stage input, .role-analyst #stage select, .role-analyst #stage textarea {
background:#080502; color:var(--amber); border:1px solid #3a2a10; font-family:inherit; border-radius:2px; }
.role-analyst #stage .card { background:#120b03; border:1px dashed #45330f; border-radius:2px; padding:9px 11px; margin-bottom:8px; }
.role-analyst #stage .card.tick { border-color:var(--green); border-style:solid; }
.role-analyst #stage table th { color:var(--amberdim); }
.role-analyst #stage th, .role-analyst #stage td { border-bottom:1px solid #33240c; }
.role-analyst #stage tr.rowlink:hover td { background:rgba(255,180,84,.07); }
.role-analyst #cv { filter:sepia(.35) hue-rotate(-22deg) saturate(1.5) contrast(1.05); border-color:#33230d; }
.role-analyst #stage details summary { cursor:pointer; color:var(--amberdim); }
.atabs { display:flex; gap:6px; flex-wrap:wrap; }
.atabs button.on { border-color:var(--amber) !important; color:var(--amber); box-shadow:0 0 10px rgba(255,180,84,.3); }
.apanel { display:none; } .apanel.on { display:block; }
table { width:100%; border-collapse:collapse; font-size:13.5px; }
th, td { text-align:left; padding:4px 8px; border-bottom:1px solid var(--line); }
th { color:var(--dim); font-weight:normal; }
tr.rowlink { cursor:pointer; } tr.rowlink:hover td { background:var(--panel2); }
tr.cross td { opacity:.45; text-decoration:line-through; }
tr.tick td { color:var(--green); }
tr.hl td { background:rgba(232,176,75,.1); }
.filters { display:flex; gap:8px; flex-wrap:wrap; margin:8px 0; align-items:center; }
.filters select, .filters input { width:130px; }
.syshead { color:var(--dim); text-transform:uppercase; font-size:12px; letter-spacing:2px; margin:14px 0 6px; }
.syshead::before { content:'— '; opacity:.5; }
/* ---------- Модалки: паперовий протокол ---------- */
.modal { position:fixed; inset:0; background:rgba(2,4,8,.72); display:flex; align-items:center; justify-content:center; z-index:40;
backdrop-filter:blur(2px); }
.modal > div { background:var(--paper); background-image:var(--noise); color:var(--ink);
border:1px solid #b6a67e; border-radius:3px; padding:22px 24px; width:min(600px, 94vw); max-height:88vh; overflow-y:auto;
box-shadow:0 30px 90px rgba(0,0,0,.8), inset 0 0 0 6px rgba(0,0,0,.03); font-family:Georgia, serif; }
.modal h3 { color:#7a1f1f; border-bottom:2px solid rgba(122,31,31,.5); padding-bottom:5px; margin-bottom:10px;
text-transform:uppercase; letter-spacing:1.5px; font-size:14px; }
.modal .dim, .modal .small.dim { color:var(--inkdim); }
.modal .syshead { color:#7a1f1f; opacity:.8; }
.modal button { background:#d9cba6; color:var(--ink); border:1px solid #b3a077; border-radius:2px; }
.modal button:hover { border-color:#7a1f1f; }
.modal button.primary { background:#7a1f1f; color:var(--paper); }
.modal button.danger { background:#7a1f1f; color:var(--paper); letter-spacing:1px; }
.modal input, .modal select, .modal textarea { background:rgba(255,255,255,.5); color:var(--ink); border:1px solid #b3a077; border-radius:2px; }
.modal th, .modal td { border-bottom:1px solid #cbbd9b; }
.modal th { color:var(--inkdim); }
.modal .tag { border:1px solid #b3a077; color:var(--inkdim); background:transparent; }
.modal .card { background:rgba(255,255,255,.35); border:1px solid #c3b28b; border-radius:2px; padding:9px 11px; margin-bottom:8px; }
.modal label { color:var(--ink); }
.grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(110px,1fr)); gap:8px; margin-top:8px; }
/* ---------- Тости: телеграма ---------- */
#toasts { position:fixed; bottom:18px; left:50%; transform:translateX(-50%); display:flex; flex-direction:column; gap:6px; z-index:60; }
.toast { background:var(--paper); background-image:var(--noise); color:var(--ink); border-top:3px double #7a1f1f; border-bottom:3px double #7a1f1f;
padding:8px 18px; animation:fade 4s forwards; box-shadow:0 8px 24px rgba(0,0,0,.6); font-size:14px; }
@keyframes fade { 0%,80% { opacity:1 } 100% { opacity:0 } }
/* ---------- Фінал ---------- */
#endscreen p { margin:10px 0; }
#endscreen h3 { display:inline-block; transform:rotate(-2deg); border:3px solid #7a1f1f; padding:6px 14px;
font-size:20px; letter-spacing:3px; box-shadow:none; }
</style>
</head>
<body>
<div id="lobby">
<div class="card">
<h1>НЕВПІЗНАНИЙ</h1>
<div class="dim" style="margin-top:10px">Детектив на двох • розділ 3 • кожна справа — нова</div>
<div class="row"><input id="code" maxlength="24" placeholder="КОД СЕСІЇ (він же — справа)"></div>
<div class="row">
<button class="rolebtn" data-role="field"><b>Польовий детектив</b><span class="small dim">район, обшуки, допити</span></button>
<button class="rolebtn" data-role="analyst"><b>&gt; АНАЛІТИК_</b><span class="small dim">бази, камери, лабораторія</span></button>
</div>
<div class="dim small">Той самий код — та сама справа і її збереження. Новий код — цілком нова справа.</div>
</div>
</div>
<div id="app">
<header>
<span class="title">НЕВПІЗНАНИЙ</span>
<span class="badge" id="b-session"></span>
<span class="badge" id="b-field">поле</span>
<span class="badge" id="b-analyst">аналітик</span>
<span style="flex:1"></span>
<button id="sndbtn" class="small" title="звук">🔊</button>
<button id="introbtn" class="small">Справа</button>
<button id="accusebtn" class="danger">ЗВИНУВАЧЕННЯ</button>
</header>
<div id="main">
<div id="stage"></div>
<div id="side">
<div class="tabs" id="sidetabs">
<button data-tab="ev" class="on">Докази</button>
<button data-tab="notes">Нотатки</button>
</div>
<div class="tabbody" id="sidebody"></div>
</div>
</div>
</div>
<div id="toasts"></div>
<script src="sound.js"></script>
<script src="client.js"></script>
<script src="field.js"></script>
<script src="analyst.js"></script>
</body>
</html>

108
chapter3/public/sound.js Normal file
View file

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

823
chapter3/server.js Normal file
View file

@ -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 справи) і різні ролі.');
});

215
chapter3/test-server.js Normal file
View file

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

View file

@ -0,0 +1,141 @@
## Що будуємо
Інтерактивну веб-гру (single HTML/React artifact), детектив на двох гравців, кооп, нелінійна структура — досліджуємо локації, допитуємо підозрюваних, збираємо докази, зіставляємо їх на дошці, в кінці називаємо вбивцю і доводимо чому. Формат натхненний тим, як влаштовані справи в Shadows of Doubt: відкрита структура розслідування, а не лінійний ланцюжок квестів.
**Жорсткий принцип: без живого AI всередині гри.** Все — діалоги, докази, логіка розв'язання — прописано наперед і детерміноване. Гра має логічно "закриватись": є один винний, і його провину можна довести конкретним набором доказів без вгадування.
## Формат і платформа
- Один HTML-файл (React/vanilla JS), відкривається в браузері з телефону чи ноутбука.
- Нелінійна навігація: хаб/карта локацій, з якого можна йти в будь-якому порядку.
- Записник/журнал автоматично фіксує знайдені докази та репліки підозрюваних.
- Дошка справи (case board): візуальне зіставлення доказ ↔ підозрюваний ↔ мотив/спосіб/можливість.
## Кооп на двох з асиметричною інформацією
Два гравці, дві ролі, кожен бачить свою частину інформації — щоб реально доводилось синхронізуватись і думати разом уголос:
- **Польовий детектив** — фізичні докази, огляд місця злочину, особисті допити (мова тіла, прямі реакції).
- **Детектив-аналітик** — цифрові сліди: записи камер, фінансові транзакції, повідомлення, логи доступу, бекграунд-перевірки.
Кожна роль сама по собі НЕ дає повної картини. Приклад механіки синхронізації: польовий детектив чує від підозрюваного алібі ("я був у барі о 23:00"), аналітик бачить транзакцію/геолокацію, яка це підтверджує або спростовує — але тільки якщо гравці проговорять це один одному.
Технічно: два пристрої заходять під одним кодом сесії (спільний стан через сховище), кожен бачить свій інтерфейс/докази, спільна дошка справи синхронізується для обох.
## Тривалість
Розрахунок на 6090 хв за вечір як базовий кейс, з можливістю розширення до "кампанії" на 23 вечори (кейс на кілька актів, де перший вечір відкриває докази, які стають актуальними у другому).
## Сетинг і тон
Сучасність або кіберпанк-нуар: місто з чітким розділом на корпоративну верхівку і вулицю. Похмуро-серйозний тон — справжній труп, реальні мотиви (гроші, влада, ревнощі, шантаж), без карикатурного насильства й без "чарівних" пояснень.
## Правила чесної гри (fair-play mystery)
1. Один вбивця, 46 підозрюваних, у кожного своя пара мотив/спосіб/можливість (заповнена або навмисно неповна — щоб відсіювались логікою).
2. Точний таймлайн подій. У вбивці має бути рівно одна суперечність в алібі, яку можна розкрити ТІЛЬКИ звівши докази з обох ролей.
3. 23 red herring (хибні сліди) — кожен обов'язково спростовується конкретним доказом, який десь у грі є. Жодного "просто дивного факту без пояснення".
4. Усе, що потрібно для доведення провини, повинно бути знаходжуваним у грі. Нічого не з'являється тільки у фінальному розкритті.
## Механіка допитів
Фіксовані дерева діалогів (не вільний текст) — кожен підозрюваний має набір тем/питань на вибір, відповіді заготовлені заздалегідь, деякі питання розблоковуються тільки після знаходження конкретного доказу (наприклад, "спитати про транзакцію" з'являється тільки якщо аналітик її знайшов і команда це обговорила).
## Фінал — звинувачення
Екран "Висунути звинувачення": гравці разом обирають підозрюваного + спосіб/знаряддя + 34 докази, що доводять провину. Система перевіряє логічний ланцюжок:
- Якщо помилка — підказка яка саме ланка не сходиться (без спойлера відповіді), можна спробувати ще раз.
- Перемога тільки при повністю правильній комбінації.
## Візуальна складова
Вільно — від мінімалістичного текстово-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:3021: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. Обрати 46 підозрюваних із пулу ролей, кожному рандомно призначити мотив + тег доступу/навички + тип алібі.
**Крок 3. Призначення вбивці:**
- Вбивцею стає той підозрюваний, чий тег доступу/навички відповідає вимогам обраного способу вбивства, і чиє алібі навмисно генерується як "правдоподібне, але з рівно однією слабкою ланкою".
- Слабка ланка завжди розбивається на 2 частини — одна дається польовому детективу (почута репліка/фізичний слід), інша аналітику (лог/запис) — і жодна сама по собі не доказ.
**Крок 4. Призначення решти підозрюваних:**
- 1 підозрюваний отримує "залізне" алібі (конференція, багато свідків, цифрове підтвердження) — навмисно найбільш підозрілий мотивом, але фактично чистий.
- 12 отримують red herring: підозріла деталь (не був на камерах, борг, погрози), яка спростовується конкретним доказом десь у грі (обов'язково генерується разом зі спростуванням, ніколи окремо).
- Решта отримують нейтральні, просто перевірювані алібі.
**Крок 5. Перевірка розв'язності (як міні-solver, обов'язковий крок):**
- Перевірити: рівно один підозрюваний одночасно задовольняє мотив + доступ/спосіб + суперечність в алібі.
- Перевірити: усі інші проваляють хоч один із трьох критеріїв, і це доводиться доказом, який реально є в грі.
- Якщо перевірка не проходить — перегенерувати мотив/алібі/доказ для конфліктного підозрюваного і повторити.
**Крок 6. Вивід:** зібрати фінальний кейс у той самий формат, що й "Nightshade Protocol" вище (таблиця підозрюваних, таймлайн, списки доказів по ролях, точна комбінація для звинувачення) — це і є те, що завантажується в гру.
Цей алгоритм можна буквально закодувати (JSON-пули + функція-валідатор) і генерувати новий кейс кожного разу, коли набридне готовий — тоді гра буде реально безкінечною, як міні-версія генерації міста в Shadows of Doubt.
# Fixes
Зробити "закріплені докази" прикріплені зверху, прибрати кнопку зірки. Тільки галочка, хрестик, приховати і прикріпити.
Додати можливість лишати нотатки на доказах.
Покращити систему комунікації з підозрюваними і іншими, більше питань (де ви були в X (time picker), якщо попали - правда або брехня, в яких стосунках X з Y, etc.(Тут можна пошукати натхнення в інеті)), бо зараз всі кажуть "нічим не можу допомогти".
Прибрати помітки типу "алібі", "дрібниця", "місце". Просто пробрати той pill.
Всі кнопки з "Системи й лабораторія" переробити: зробити інтерактивними як База мешканців і шифри(у з'вязку з видаленням 3301 - видалити цю вкладку). З приводу реалізації цього - задати додаткові уточнюючі питання.
Мапа має кожен раз, залежно від сіду, генеруватись. Різні будинки, різні жителі, різні приміщення.
Також я хочу щоб перед імплементацією, але після опитування, ти запропонував ідеї, які позитивно могли б вплинути на гру.