first commit
This commit is contained in:
commit
155bddda0e
29 changed files with 7256 additions and 0 deletions
148
.gitignore
vendored
Normal file
148
.gitignore
vendored
Normal 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
31
chapter1/README.md
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
# Nightshade Protocol — детектив на двох
|
||||
|
||||
Кооперативна детективна гра для двох гравців в одній локальній мережі. Без залежностей, потрібен лише Node.js.
|
||||
|
||||
## Запуск
|
||||
|
||||
```
|
||||
node server.js
|
||||
```
|
||||
|
||||
Сервер надрукує адресу в локальній мережі (наприклад `http://192.168.0.109:3000`).
|
||||
|
||||
1. Обидва гравці відкривають цю адресу в браузері (телефон або ноутбук, той самий Wi-Fi).
|
||||
2. Вводять **однаковий код сесії** (будь-який, наприклад `NOIR`).
|
||||
3. Один обирає роль **Польового детектива**, інший — **Аналітика**.
|
||||
|
||||
## Як грати
|
||||
|
||||
- Польовий оглядає локації та допитує людей; аналітик ганяє запити по системах.
|
||||
- Знайдені докази закріплюйте на **дошці** (📌 у Записнику) — лише закріплене бачить напарник, і лише воно розблоковує нові питання допитів і нові запити.
|
||||
- Фінал: вкладка **Звинувачення** — хто, яким способом, 3–4 докази з дошки. Помилка дає підказку, яка ланка не сходиться.
|
||||
|
||||
Розрахунок: 60–90 хвилин. Стан гри живе в пам'яті сервера — не вимикайте його до кінця партії.
|
||||
|
||||
## Перевірка кейса
|
||||
|
||||
```
|
||||
node test.js
|
||||
```
|
||||
|
||||
Перевіряє fair-play: усі докази досяжні, ключове протиріччя вимагає обох ролей, правильна комбінація виграє, а кожна зламана ланка дає підказку.
|
||||
155
chapter1/case.js
Normal file
155
chapter1/case.js
Normal 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:10–23:30. На момент смерті (23:14) він був у барі.' },
|
||||
rook_manifest:{ role: 'analyst', title: 'Маніфест доставки', tag: 'зняття підозри', detail: 'Вантажний під\'їзд будівлі Рейза: Рук, 21:30–21:45, накладна на серводеталі для консьєржа. Виїхав за півтори години до смерті.' },
|
||||
ivy_cam: { role: 'analyst', title: 'Камера лобі', tag: 'зняття підозри', detail: 'Айві Чен виходить з лобі о 22:30, збігається з логом бейджа. До 23:14 не поверталась.' },
|
||||
sable_conf: { role: 'analyst', title: 'Реєстрація конференції', tag: 'зняття підозри', detail: 'Д-р Сейбл: чекін на конференції «Женева-2», виступ 22:40–23:20, сотні свідків і трансляція. Залізне алібі.' },
|
||||
biometric: { role: 'analyst', title: 'Біометрія імпланта Рейза', tag: 'факт', detail: 'Смерть: 23:14. Сплеск адреналіну 23:12–23:14 — боротьба або падіння при свідомості. Це не тихий нещасний випадок.' },
|
||||
print_match: { role: 'analyst', title: 'Збіг відбитка', tag: 'спосіб', requires: ['fingerprint'], detail: 'Частковий відбиток з панелі override: збіг 87% — Елена Восс.' },
|
||||
shadow_tx: { role: 'analyst', title: 'Тіньові транзакції', tag: 'мотив', requires: ['ledger'], detail: 'Рахунки з гросбуха: систематичне виведення коштів Halcyon на офшор. Транзакції підписані корпоративним ключем Е.В. Рейз це знайшов.' }
|
||||
},
|
||||
|
||||
// Польовий детектив
|
||||
locations: [
|
||||
{ id: 'penthouse', name: 'Пентхаус Рейза (40-й поверх)', desc: 'Місце смерті. Балкон над прірвою Нижніх Доків, кабінет із заблокованим терміналом.',
|
||||
spots: [
|
||||
{ id: 's1', label: 'Оглянути поручень балкона', ev: 'fabric' },
|
||||
{ id: 's2', label: 'Оглянути балкон', ev: 'struggle' },
|
||||
{ id: 's3', label: 'Оглянути панель override', ev: 'fingerprint' },
|
||||
{ id: 's4', label: 'Обшукати кабінет', ev: 'ledger' },
|
||||
{ id: 's5', label: 'Робочий термінал Рейза', flavor: 'Термінал заблоковано корпоративним шифруванням. Цифрові сліди — робота аналітика.' }
|
||||
], people: [] },
|
||||
{ id: 'lobby', name: 'Лобі будівлі Рейза', desc: 'Мармур, дрони-консьєржі, камери в кожному куті.',
|
||||
spots: [ { id: 's6', label: 'Стійка охорони', flavor: 'Журнал відвідувачів повністю цифровий. Хай аналітик перевірить логи доступу будівлі.' } ],
|
||||
people: ['ivy'] },
|
||||
{ id: 'halcyon', name: 'Офіс Halcyon Dynamics', desc: 'Верхній Шпиль. Скло, тиша, нічна зміна охорони.',
|
||||
spots: [], people: ['elena', 'guard', 'sable'] },
|
||||
{ id: 'bar', name: 'Бар "The Hollow Note"', desc: 'Нижні Доки. Димно, гучно, ніхто нічого не бачив — офіційно.',
|
||||
spots: [], people: ['kaito', 'barman'] },
|
||||
{ id: 'workshop', name: 'Майстерня Рука', desc: 'Гараж у Доках, завалений дронами й запчастинами.',
|
||||
spots: [ { id: 's7', label: 'Оглянути дрони', flavor: 'Вантажні мули для доставки. Жодних слідів бойових модифікацій.' } ],
|
||||
people: ['rook'] }
|
||||
],
|
||||
|
||||
// Аналітик
|
||||
systems: [
|
||||
{ id: 'access', name: 'Логи доступу', queries: [
|
||||
{ id: 'q1', label: 'Будівля Рейза: входи 20:00–00:00', ev: 'entry_log' },
|
||||
{ id: 'q2', label: 'Панель безпеки пентхауса', ev: 'override_log' },
|
||||
{ id: 'q3', label: 'Вантажний під\'їзд будівлі', ev: 'rook_manifest' },
|
||||
{ id: 'q4', label: 'Офіс Halcyon: нічні логи', flavor: 'Нічний архів Halcyon недоступний — планове обслуговування серверів. Лишається усний журнал охорони на місці.' }
|
||||
]},
|
||||
{ id: 'video', name: 'Відеоархіви', queries: [
|
||||
{ id: 'q5', label: 'Камери лобі будівлі Рейза', ev: 'ivy_cam' },
|
||||
{ id: 'q6', label: 'Патрульні дрони: резервний архів', ev: 'drone_rec' },
|
||||
{ id: 'q7', label: 'Трансляція конференції «Женева-2»', ev: 'sable_conf' }
|
||||
]},
|
||||
{ id: 'net', name: 'Мережа і біометрія', queries: [
|
||||
{ id: 'q8', label: 'Біометричний лог імпланта жертви', ev: 'biometric' },
|
||||
{ id: 'q9', label: 'Мережева активність імпланта Кайто Морі', ev: 'kaito_wifi' },
|
||||
{ id: 'q10', label: 'Звірити відбиток з панелі по базі', ev: 'print_match' }
|
||||
]},
|
||||
{ id: 'finance', name: 'Фінанси і записи', queries: [
|
||||
{ id: 'q11', label: 'Календар жертви', ev: 'calendar' },
|
||||
{ id: 'q12', label: 'Рахунки жертви', flavor: 'Регулярні перекази на рахунок, пов\'язаний з Руком: борг ~40 000 кредитів, останній платіж прострочений.' },
|
||||
{ id: 'q13', label: 'Простежити рахунки з гросбуха', ev: 'shadow_tx' }
|
||||
]}
|
||||
],
|
||||
|
||||
// Допити (веде польовий). requires: id доказів на дошці.
|
||||
people: {
|
||||
elena: { name: 'Елена Восс', suspect: true, topics: [
|
||||
{ id: 't1', q: 'Де ви були цієї ночі?', a: '«Була в офісі допізна. Квартальний звіт. Пішла десь після опівночі — охорона підтвердить.» Дивиться прямо, не кліпає. Занадто рівно.' },
|
||||
{ id: 't2', q: 'Ваші стосунки з Рейзом?', a: '«Партнери. Дванадцять років. Останнім часом він став… параноїком. Всюди бачив змови.» На слові «параноїком» — коротка пауза.' },
|
||||
{ id: 't3', q: 'Що за термінова рада о 9:00?', requires: ['calendar'], a: '«Звичайна процедурна рада. Комплаєнс. Нічого особливого.» Пальці стискають планшет так, що біліють кісточки.' },
|
||||
{ id: 't4', q: 'Охоронець каже: ваш бейдж вийшов о 22:15.', requires: ['guard'], a: '«Виходила подихати. Повернулась і працювала далі. Це не злочин, детективе.» Погляд уперше йде вбік.' },
|
||||
{ id: 't5', q: 'Лог будівлі Рейза: ваш бейдж, вхід о 22:47.', requires: ['guard', 'entry_log'], gives: 'elena_lie', a: 'Довга пауза. «…Гаразд. Я була в нього. Хотіла поговорити до ради. Ми посперечались, і я пішла. Він був живий, чуєте? Живий.» Голос зривається — вперше за розмову.' }
|
||||
]},
|
||||
kaito: { name: 'Кайто Морі', suspect: true, topics: [
|
||||
{ id: 't6', q: 'Де ви були о 23:14?', a: '«Тут. Весь вечір, до пів на дванадцяту. Спитайте бармена — чи мій імплант, якщо вмієте читати логи.»' },
|
||||
{ id: 't7', q: 'Рейз злив ваші генетичні дані.', a: 'Стискає склянку. «Продав мою ДНК як рекламну базу. Я його ненавидів. Але між ненавистю і сорока поверхами — велика різниця.»' },
|
||||
{ id: 't8', q: 'Мережа підтверджує: ви були тут.', requires: ['kaito_wifi'], a: 'Видихає. «Нарешті хоч хтось перевірив. Слухайте… Рейз останній місяць скаржився на Елену. Казав: „вона рахує, що я сліпий". Не знаю, що він знайшов, але воно його зжирало.»' }
|
||||
]},
|
||||
sable: { name: 'Д-р Сейбл (відеозв\'язок)', suspect: true, topics: [
|
||||
{ id: 't9', q: 'Де ви були цієї ночі?', a: '«На „Женеві-2". Виступ о 22:40, чотириста свідків і трансляція. Перевірте — заощадите нам обом час.»' },
|
||||
{ id: 't10', q: 'Рейз украв ваші дослідження.', a: '«Рейз — падальник, і я не шкодуватиму за ним. Але вбивство — інструмент тих, у кого закінчились юристи. У мене вони не закінчились.»' }
|
||||
]},
|
||||
rook: { name: 'Рук', suspect: true, topics: [
|
||||
{ id: 't11', q: 'Вас бачили біля будівлі Рейза.', a: '«Був поруч. Проїздом. Нічого не бачив, нікого не чіпав.» Дивиться повз тебе, витирає руки ганчіркою, яка чистіша за його відповіді.' },
|
||||
{ id: 't12', q: 'Рейз був винен вам гроші.', a: '«Сорок штук. І що? Мертвий він мені не заплатить. Подумай головою, детективе: я кредитор, а не гробар.»' },
|
||||
{ id: 't13', q: 'Маніфест: доставка о 21:30–21:45.', requires: ['rook_manifest'], a: '«Ну так, завозив серводеталі консьєржу. Поїхав о 21:45. Задоволений?» Пауза. «Одне скажу: асистентка його виходила пізніше — нервова, як дріт під струмом.»' }
|
||||
]},
|
||||
ivy: { name: 'Айві Чен', suspect: true, topics: [
|
||||
{ id: 't14', q: 'Коли ви пішли від Рейза?', a: '«О 22:30, як завжди. Камера в лобі підтвердить.» Відповідає миттєво — наче репетирувала.' },
|
||||
{ id: 't15', q: 'Яким він був останні тижні?', a: '«Напруженим. Замкнув частину файлів навіть від мене. Я… я думала, він щось підозрює.» Затинається на слові «підозрює».' },
|
||||
{ id: 't16', q: 'Камера підтверджує. Але чому ви так нервуєте?', requires: ['ivy_cam'], a: 'Довго мовчить. «Я передавала документи Сейблу. За гроші. Але я не вбивала! Рейз готував на ранок якесь викриття — не про мене. Він сказав: „це її поховає". Її.»' }
|
||||
]},
|
||||
guard: { name: 'Нічний охоронець Halcyon', suspect: false, topics: [
|
||||
{ id: 't17', q: 'Хто виходив з офісу ввечері?', gives: 'guard', a: '«Пані Восс вийшла о 22:15 — бейдж пікнув, я записав у журнал. Назад не бачив: після 22:30 я на обході, а нічні логи в них на обслуговуванні.»' }
|
||||
]},
|
||||
barman: { name: 'Бармен "The Hollow Note"', suspect: false, topics: [
|
||||
{ id: 't18', q: 'Кайто Морі був тут уночі?', a: '«Весь вечір, до пів на дванадцяту точно. Сидів он там і пив так, наче хотів забути чиєсь ім\'я.»' }
|
||||
]}
|
||||
},
|
||||
|
||||
finale: {
|
||||
suspect: 'elena', method: 'push', maxEvidence: 4,
|
||||
recap: 'Елена Восс роками виводила гроші Halcyon через тіньові рахунки. Рейз знайшов гросбух і призначив на 9:00 раду, яка б її поховала. О 22:15 вона вийшла з офісу — охоронець це бачив. О 22:47 увійшла в будівлю Рейза старим пропуском. О 23:10 інженерним override вимкнула силове поле балкона. О 23:14 — після короткої боротьби, що лишила клапоть її піджака на поручні, — Маркус Рейз упав з 40-го поверху. Справу закрито.'
|
||||
}
|
||||
};
|
||||
|
||||
// Перевірка звинувачення. Повертає {ok, hint}.
|
||||
function checkAccusation(suspect, method, evs) {
|
||||
const has = id => evs.includes(id);
|
||||
if (suspect !== CASE.finale.suspect)
|
||||
return { ok: false, hint: 'Не сходиться сам підозрюваний: його алібі на момент смерті підтверджується доказом, який у вас уже є або який можна знайти. Перевірте, кого докази виключають.' };
|
||||
if (method !== CASE.finale.method)
|
||||
return { ok: false, hint: 'Підозрюваний правильний, але спосіб не відповідає слідам на місці смерті. Що саме відкрило вбивці шлях до жертви?' };
|
||||
if (!(has('override_log') || has('print_match')))
|
||||
return { ok: false, hint: 'Ланка «спосіб/доступ» не доведена: потрібен доказ, ЧИМ вбивця відкрив шлях до жертви.' };
|
||||
if (!(has('elena_lie') || (has('guard') && has('entry_log'))))
|
||||
return { ok: false, hint: 'Ланка «алібі» не зламана: потрібно довести, що підозрюваний бреше про те, де був. Одного доказу тут мало — потрібна суперечність.' };
|
||||
if (!(has('fabric') || has('struggle')))
|
||||
return { ok: false, hint: 'Ланка «фізичний контакт» не доведена: що пов\'язує вбивцю з боротьбою на балконі?' };
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined') module.exports = { CASE, checkAccusation };
|
||||
360
chapter1/index.html
Normal file
360
chapter1/index.html
Normal 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 => ({'&':'&','<':'<','>':'>','"':'"'}[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
87
chapter1/server.js
Normal 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
54
chapter1/test.js
Normal 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
31
chapter2/README.md
Normal 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
175
chapter2/case2.js
Normal 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: шість полотен «з провенансом» — і поруч шість свіжих копій, ще зі скипидарним запахом. Це лоти 12–18 завтрашніх торгів. Хтось продає фальшаки через дім Гайдука.' },
|
||||
dana_confess:{ role: 'field', title: 'Зізнання Дани', tag: 'зняття підозри', detail: 'Дана визнала: заходила о 20:30 забрати картину Марчука з малої зали — «батько мені її обіцяв». Вийшла о 21:12, до кабінету не піднімалась. Камера це підтверджує.' },
|
||||
stryzh_lie: { role: 'field', title: 'Злам алібі Стрижа', tag: 'алібі', act: 2, detail: 'Під тиском фактів Стриж визнав: повертався в дім службовим входом «за каталогами», виїхав з паркінгу о 22:14. Стверджує, що Гайдук був живий і «пив свій коньяк».' },
|
||||
// ---- АКТ I, цифрові ----
|
||||
watch_log: { role: 'analyst', title: 'Лог смарт-годинника', tag: 'спосіб смерті', detail: 'Годинник жертви: 21:52 — аритмія за патерном глікозидної інтоксикації (передозування серцевим препаратом). 22:03 — зупинка серця. Це не «просто напад» — це отруєння.' },
|
||||
lobby_cam: { role: 'analyst', title: 'Камера головного входу', tag: 'таймлайн', detail: 'Стриж: вихід 21:03. Дана Гайдук: вхід 20:30, вихід 21:12 — на поверх кабінету не піднімалась, була лише в малій залі. Після 21:12 головним входом ніхто не заходив і не виходив до ранку.' },
|
||||
call_log: { role: 'analyst', title: 'Відеодзвінок Ірини', tag: 'зняття підозри', detail: 'Ірина Гайдук: відеодзвінок сестрі в Торонто 21:28–22:36 з домашнього wifi, камера активна, обличчя в кадрі підтверджено. На момент отруєння була вдома.' },
|
||||
stream_log: { role: 'analyst', title: 'Стрім Мазура', tag: 'зняття підозри', detail: 'Лесь Мазур: безперервна пряма трансляція реставрації 20:00–23:05, п\'ятсот глядачів, обличчя й руки в кадрі весь час. Залізне алібі.' },
|
||||
expert_email:{ role: 'analyst', title: 'Лист експертці', tag: 'мотив', detail: 'Гайдук — експертці Катерині Резник, учора 18:44: «Катерино, перевірте лоти 12–18 до торгів. Особисто. Нікому ні слова».' },
|
||||
geo_toll: { role: 'analyst', title: 'Геолокація Плюти', tag: 'зняття підозри', detail: 'Плюта: транзакції на платних дорогах і АЗС — о 21:50 був за 170 км від міста, повертався з Одеси. Фізично не міг бути в кабінеті.' },
|
||||
// ---- АКТ II, цифрові ----
|
||||
parking: { role: 'analyst', title: 'Лог шлагбаума паркінгу', tag: 'алібі', act: 2, detail: 'Паркінг аукціонного дому: авто Стрижа — виїзд о 22:14. Він каже, що «поїхав о 21:00 і о 21:30 був на вечері фонду». Авто каже інше.' },
|
||||
tox_report: { role: 'analyst', title: 'Токсикологія (ордер)', tag: 'спосіб смерті', act: 2, detail: 'Офіційний висновок: дигоксин, семикратна терапевтична доза. У крові жертви — і в залишках коньяку з графина. Отруту розчинили саме там.' },
|
||||
dna_match: { role: 'analyst', title: 'ДНК з недопалка', tag: 'доступ', act: 2, requires: ['cigarillo'], detail: 'ДНК зі слини на недопалку сигарили біля службового входу: збіг — Богдан Стриж. Він стояв під дверима без камер того вечора.' },
|
||||
bank_shell: { role: 'analyst', title: 'Рахунки-прокладки', tag: 'мотив', act: 2, detail: 'Ордер на банки: ТОВ «Артконсалт-Гарант» отримало 2,3 млн за «атрибуцію» лотів 12–18. Кінцевий бенефіціар прокладки — Богдан Стриж. Гайдук збирався скасувати торги і поховати схему.' },
|
||||
pharma_log: { role: 'analyst', title: 'Слід партії дигоксину', tag: 'засіб', act: 2, requires: ['blister'], detail: 'Партія з блістера: онлайн-аптека, замовлення на неіснуючу «О. Кравець», доставка в поштомат за квартал від дому Стрижа, оплата карткою, поповненою готівкою. Ланцюжок веде до нього.' }
|
||||
},
|
||||
|
||||
// Умова переходу в Акт II: обидва докази вбивства на дошці.
|
||||
actGate: ['decanter', 'watch_log'],
|
||||
|
||||
locations: [
|
||||
{ id: 'office', name: 'Кабінет Гайдука', desc: 'Кабінет над аукціонною залою. Крісло, графин, дощ по склу. Тіло вже забрали.',
|
||||
spots: [
|
||||
{ id: 's1', label: 'Оглянути графин', ev: 'decanter' },
|
||||
{ id: 's2', label: 'Оглянути мийку та келих', ev: 'wiped_glass' },
|
||||
{ id: 's3', label: 'Обшукати стіл', ev: 'diary' },
|
||||
{ id: 's4', label: 'Оглянути аптечку', ev: 'pills_missing' },
|
||||
{ id: 's5', label: 'Службові сходи', ev: 'cigarillo' },
|
||||
{ id: 's6', label: 'Оглянути крісло', flavor: 'Жодних слідів боротьби. Він просто сів у крісло з келихом — і не встав. Хтось дуже хотів, щоб це виглядало саме так.' }
|
||||
], people: ['olga'] },
|
||||
{ id: 'hall', name: 'Аукціонна зала', desc: 'Порожня зала, накриті лоти, завтра тут мали бути торги року.',
|
||||
spots: [], people: ['stryzh', 'dana', 'semen'] },
|
||||
{ id: 'house', name: 'Дім Гайдуків', desc: 'Особняк за містом. Тихо, дорого, самотньо.',
|
||||
spots: [], people: ['iryna'] },
|
||||
{ id: 'studio', name: 'Студія Мазура', desc: 'Підвал, лампи, запах лаку. У кутку — кільцева лампа для стрімів.',
|
||||
spots: [], people: ['mazur'] },
|
||||
{ id: 'hotel', name: 'Готель «Централь»', desc: 'Плюта зупинився тут «до кінця судового процесу».',
|
||||
spots: [], people: ['pliuta'] },
|
||||
{ id: 'fund', name: 'Фонд «Меценат»', desc: 'Зала благодійної вечері, де Стриж «був з 21:30». Гардероб працює до останнього гостя.', act: 2,
|
||||
spots: [ { id: 's7', label: 'Розпитати розпорядника', flavor: '«Пан Стриж? Був, звісно. Коли саме прийшов? У нас не клуб, детективе, ми квитки не штампуємо. Спитайте в гардеробі — Зоя все пам\'ятає».' } ],
|
||||
people: ['zoya'] },
|
||||
{ id: 'depot', name: 'Склад «Гайдук і Спадок» №3', desc: 'Ордер відкрив двері, які Стриж тримав замкненими. Його офіс — у кутку складу.', act: 2,
|
||||
spots: [
|
||||
{ id: 's8', label: 'Оглянути ящики з лотами', ev: 'fake_canvases' },
|
||||
{ id: 's9', label: 'Обшукати сміття в офісі Стрижа', ev: 'blister' },
|
||||
{ id: 's10', label: 'Сейф Стрижа', flavor: 'Замкнено. Злам сейфа ордер не покриває — та й те, що лежить у смітті, часом говорить голосніше.' }
|
||||
], people: [] }
|
||||
],
|
||||
|
||||
systems: [
|
||||
{ id: 'video', name: 'Камери й трансляції', queries: [
|
||||
{ id: 'q1', label: 'Камера головного входу аукціонного дому', ev: 'lobby_cam' },
|
||||
{ id: 'q2', label: 'Стрім-платформа: канал Мазура', ev: 'stream_log' },
|
||||
{ id: 'q3', label: 'Камери паркінгу', flavor: 'Камери паркінгу не працюють два тижні — «на технічному обслуговуванні». Пише лише лог шлагбаума, а він видається тільки з ордером.' },
|
||||
{ id: 'q4', label: 'Лог шлагбаума паркінгу (ордер)', ev: 'parking', act: 2 }
|
||||
]},
|
||||
{ id: 'comms', name: 'Зв\'язок', queries: [
|
||||
{ id: 'q5', label: 'Дзвінки та відеозв\'язок Ірини Гайдук', ev: 'call_log' },
|
||||
{ id: 'q6', label: 'Пошта жертви', ev: 'expert_email' }
|
||||
]},
|
||||
{ id: 'bio', name: 'Біометрія і криміналістика', queries: [
|
||||
{ id: 'q7', label: 'Смарт-годинник жертви', ev: 'watch_log' },
|
||||
{ id: 'q8', label: 'Повна токсикологія (ордер)', ev: 'tox_report', act: 2 },
|
||||
{ id: 'q9', label: 'Аналіз ДНК недопалка', ev: 'dna_match', act: 2 }
|
||||
]},
|
||||
{ id: 'money', name: 'Фінанси і реєстри', queries: [
|
||||
{ id: 'q10', label: 'Транзакції Плюти: дороги та АЗС', ev: 'geo_toll' },
|
||||
{ id: 'q11', label: 'Заповіт і страховка', flavor: 'Нотаріус і страхова вимагають ордер. Ордер буде, коли доведете, що це вбивство: потрібні докази отруєння на дошці.' },
|
||||
{ id: 'q12', label: 'Банківські рахунки, пов\'язані з домом (ордер)', ev: 'bank_shell', act: 2 },
|
||||
{ id: 'q13', label: 'Простежити партію препарату', ev: 'pharma_log', act: 2 }
|
||||
]}
|
||||
],
|
||||
|
||||
people: {
|
||||
stryzh: { name: 'Богдан Стриж', suspect: true, topics: [
|
||||
{ id: 't1', q: 'Де ви були вчора ввечері?', a: '«Пішов о 21:00 — камера підтвердить. О 21:30 вже був на вечері фонду „Меценат". Сотня свідків, детективе.» Відповідає гладко, як з каталогу.' },
|
||||
{ id: 't2', q: 'Що тепер буде з торгами?', a: '«Найбільші торги року. Віктор був знервований останній тиждень... Тепер усе на мені.» Скорботи в голосі — рівно нуль.' },
|
||||
{ id: 't3', q: 'У щоденнику: «Скасувати торги».', requires: ['diary'], a: '«Скасувати?! Вперше чую. У нас зобов\'язання перед клієнтами.» Різкий, як удар молотка. Занадто різкий.' },
|
||||
{ id: 't4', q: 'Гайдук просив експертку перевірити лоти 12–18.', requires: ['expert_email'], a: '«Рутинна перевірка провенансу. Ці лоти, до речі, готував я.» Поправляє годинник. Пальці не слухаються.' },
|
||||
{ id: 't5', q: 'Авто виїхало о 22:14. Пальто ви здали о 22:40.', requires: ['coatcheck', 'parking'], gives: 'stryzh_lie', a: 'Довга пауза. «...Гаразд. Я повертався за каталогами. Службовим входом, щоб охорона не бачила — не хотів розмов. Віктор був живий, сидів з коньяком. Це все, що я скажу без адвоката.»' }
|
||||
]},
|
||||
iryna: { name: 'Ірина Гайдук', suspect: true, topics: [
|
||||
{ id: 't6', q: 'Де ви були вчора ввечері?', a: '«Вдома. Дзвонила сестрі в Торонто, ми говорили години зо дві.» Дивиться у вікно, на дощ.' },
|
||||
{ id: 't7', q: 'Ваш шлюб. Страховка. Контракт.', a: '«Місто вже все порахувало, так? Ми десять років разом. Він був складний. Але мій.» Вперше за розмову — живий голос.' },
|
||||
{ id: 't8', q: 'Дзвінок підтверджено. Що ви не договорюєте?', requires: ['call_log'], a: 'Видихає. «Останній тиждень Віктор замикався з паперами. Одного вечора сказав: „Богдан думає, що я сліпий". Я тоді не зрозуміла. Тепер, здається, розумію.»' }
|
||||
]},
|
||||
dana: { name: 'Дана Гайдук', suspect: true, topics: [
|
||||
{ id: 't9', q: 'Що ви робите в залі?', a: '«Забираю речі батька. Маю право.» Тримає порожню теку так, наче там щось є.' },
|
||||
{ id: 't10', q: 'Ви не розмовляли два роки. Чому?', a: '«Він обрав дім. Аукціонний. Я не вписалась у каталог.» Сухо. Але руки тремтять.' },
|
||||
{ id: 't11', q: 'Камера: ви зайшли о 20:30 і вийшли о 21:12.', requires: ['lobby_cam'], gives: 'dana_confess', a: 'Мовчить. Потім: «Я взяла картину. Марчука, з малої зали. Батько обіцяв її мені ще до всього. Нагору я не піднімалась. Не змогла. Тепер уже й не зможу.»' }
|
||||
]},
|
||||
mazur: { name: 'Лесь Мазур', suspect: true, topics: [
|
||||
{ id: 't12', q: 'Де ви були вчора ввечері?', a: '«Стрім. Три години, п\'ятсот глядачів, реставрація наживо. Таке не змонтуєш, детективе — перевірте платформу.»' },
|
||||
{ id: 't13', q: 'Гайдук знищив вашу репутацію.', a: '«Назвав мою атрибуцію шахрайством. Публічно. І знаєте що? Він мав рацію — я тоді помилився. Його прокляттям було бачити підробки скрізь. Схоже, одного разу він подивився надто близько до дому.»' }
|
||||
]},
|
||||
pliuta: { name: 'Артем Плюта', suspect: true, topics: [
|
||||
{ id: 't14', q: 'Де ви були вчора ввечері?', a: '«Повертався з Одеси. Траса, чеки, платні дороги — усе в банківській виписці. Перевіряйте, я нікуди не поспішаю.»' },
|
||||
{ id: 't15', q: 'Гайдук продав вам підробку.', a: '«„Екстер" за триста тисяч, який малювали позавчора. Я подав до суду, детективе, а не в морг. Мертвий Гайдук мені суд не виграє.»' }
|
||||
]},
|
||||
semen: { name: 'Охоронець Семен', suspect: false, topics: [
|
||||
{ id: 't16', q: 'Хто заходив і виходив увечері?', gives: 'guard2', a: '«Пан Стриж вийшов о 21:03 головним, я записав. Дочка господаря ще була... Службовий вхід? Він на защіпці постійно, камери там нема — економлять. На паркінгу камери два тижні мертві, тільки шлагбаум лог пише.»' }
|
||||
]},
|
||||
olga: { name: 'Прибиральниця пані Ольга', suspect: false, topics: [
|
||||
{ id: 't17', q: 'Розкажіть про вчорашній вечір.', gives: 'cleaner_st', a: '«Графин я мила о сьомій, як завжди. Пан Віктор наливав перший келих о пів на десяту — ритуал, свята справа, хоч годинник звіряй. Келих біля мийки? Я його не мила і догори дном не ставила. Не мій почерк.»' }
|
||||
]},
|
||||
zoya: { name: 'Гардеробниця пані Зоя', suspect: false, topics: [
|
||||
{ id: 't18', q: 'Коли Стриж здав пальто?', gives: 'coatcheck', a: '«Десь після пів на одинадцяту. Точно після початку дощу — мокрий був наскрізь, аж з рукавів текло. А дощ учора почався рівно о десятій, я ще парасолю з дому не взяла.»' }
|
||||
]}
|
||||
},
|
||||
|
||||
finale: {
|
||||
suspect: 'stryzh', method: 'digoxin', maxEvidence: 4,
|
||||
recap: 'Богдан Стриж роками продавав підробки через дім «Гайдук і Спадок», отримуючи мільйони через прокладку «Артконсалт-Гарант». Гайдук помітив, замовив таємну експертизу лотів 12–18 і зібрався скасувати торги. О 21:03 Стриж демонстративно вийшов головним входом — для камери. Повернувся службовими сходами, де немає камер, викуривши сигарилу під дверима. Розчинив дигоксин у графині, знаючи ритуал партнера: перший келих о пів на десяту. Вимив свій келих, забрав пляшечку ліків — хай виглядає як хворе серце. О 22:14 виїхав з паркінгу, о 22:40, мокрий від дощу, здав пальто на вечері, де «був з 21:30». Годинник на зап\'ясті жертви виявився чеснішим за некролог. Справу закрито.'
|
||||
}
|
||||
};
|
||||
|
||||
// Перевірка звинувачення. Повертає {ok, hint}.
|
||||
function checkAccusation(suspect, method, evs) {
|
||||
const has = id => evs.includes(id);
|
||||
if (suspect !== CASE.finale.suspect)
|
||||
return { ok: false, hint: 'Не сходиться сам підозрюваний: його місцеперебування на вечір смерті підтверджене доказами, які у вас є або які можна знайти. Кого докази виключають — той не вбивця.' };
|
||||
if (method !== CASE.finale.method)
|
||||
return { ok: false, hint: 'Підозрюваний правильний, але спосіб суперечить біометрії та токсикології. ЩО саме вбило жертву і ДЕ це було розчинено?' };
|
||||
if (!(has('blister') || has('pharma_log')))
|
||||
return { ok: false, hint: 'Ланка «засіб» не доведена: потрібен доказ, що пов\'язує вбивцю з отрутою, а не лише отруту з жертвою.' };
|
||||
if (!(has('stryzh_lie') || (has('coatcheck') && has('parking'))))
|
||||
return { ok: false, hint: 'Ланка «алібі» не зламана: потрібна суперечність із двох незалежних джерел — або зізнання. Один доказ тут завжди можна відбрехати.' };
|
||||
if (!has('dna_match'))
|
||||
return { ok: false, hint: 'Ланка «доступ» не доведена: як вбивця потрапив у кабінет повз камери? Потрібен доказ його присутності на шляху без камер.' };
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined') module.exports = { CASE, checkAccusation };
|
||||
532
chapter2/index.html
Normal file
532
chapter2/index.html
Normal 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 => ({'&':'&','<':'<','>':'>','"':'"'}[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
36
chapter2/minigames.js
Normal 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
91
chapter2/server.js
Normal 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
107
chapter2/test.js
Normal 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
105
chapter3/DESIGN.md
Normal 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):
|
||||
2–4 of e.g. a phone number on a scrap of paper, a footprint size, a fabric fiber, a tattoo,
|
||||
a keyring, a partial fingerprint. From there:
|
||||
|
||||
- **Act I — Who died?** Identify the victim (DB queries, biometrics, missing persons, phone trace).
|
||||
- **Act II — Who did it, how, and why?** The killer is NOT on any suspect gallery. You build a
|
||||
profile from traces and cross-reference the citizen database until one person fits.
|
||||
|
||||
## Roles (asymmetric, both required)
|
||||
|
||||
### Польовий детектив — top-down 2D district
|
||||
- Canvas top-down map of a **fixed hand-crafted compact district**: ~10 buildings
|
||||
(apartment blocks, bar, diner, offices, shop, clinic, police HQ), walkable with WASD/arrows.
|
||||
- Enters buildings, searches rooms via hotspots, collects **samples**: DNA swabs, lifted prints,
|
||||
photos, documents, objects. Samples go to the shared board; analyst processes them.
|
||||
- **Apartment access is legal-only**: public places open; private doors need a **key**
|
||||
(found on the body, in a work safe, at a relative's — never just lying around) or a **warrant**
|
||||
(requires pinned evidence pointing at that person/place).
|
||||
- **Interrogation of any NPC** — but evidence-gated: topics unlock only when you hold the
|
||||
relevant pinned evidence (ch2 `requires:` pattern, generated).
|
||||
|
||||
### Аналітик (поліцейський хакер) — the terminal
|
||||
- **Citizen DB**: ~50 generated residents. Query by **typed name (keyboard — wrong spelling =
|
||||
wrong/no dossier)** or filter by attributes (blood type, shoe size, employer, address, print type).
|
||||
- **City map with addresses** — who lives/works where; mirrors the district and shows the
|
||||
partner's live position.
|
||||
- **Labs & systems** (each a hands-on minigame, not a button):
|
||||
- **DNA lab**: gel electrophoresis — pipette sample into lanes, visually match band patterns
|
||||
against DB candidates. Wrong match = confidently wrong lead.
|
||||
- **Fingerprints**: overlay lifted print on candidate prints, rotate/align, mark ridge points.
|
||||
DB narrows only by print *type*; the final match is your eyes.
|
||||
- **CCTV archive**: scrub recorded footage of the crime night (timestamps, not a live clock),
|
||||
spot a figure matching the profile (height, coat, gait).
|
||||
- **Phone records + triangulation**: call logs for a number, triangulate a phone across
|
||||
cell towers onto the city map.
|
||||
- **Crypto/logic tasks** (analyst has a tech background — difficulty is allowed):
|
||||
base64, Caesar/Vigenère and other classical ciphers, binary/hex conversion, **md5 password
|
||||
cracking** for a suspect's email/laptop (weak password derivable from dossier facts: pet name +
|
||||
birth year — detective work feeds the wordlist), and **one optional multi-stage Cicada3301-style
|
||||
puzzle** guarding a bonus master-evidence cache. Puzzles are generated with the case.
|
||||
|
||||
## No clock
|
||||
|
||||
No time mechanic. Citizens have static current locations (at work or at home); all CCTV and
|
||||
phone data is archival (the crime night). Actions cost nothing but thought.
|
||||
|
||||
## Case generator — standalone library
|
||||
|
||||
`chapter3/casegen/` — pure JS, no DOM/no server deps, reusable anywhere
|
||||
(`generateCase(seed, opts)`):
|
||||
|
||||
- **Deterministic from seed**: same seed → same case. Needed for saves and both-players sync;
|
||||
also lets you share a good case with friends.
|
||||
- Generates: citizens (names, homes, jobs, relationships, biometrics, phones, current positions),
|
||||
victim, killer, **motive** from template pool — jealousy/love triangle, debt/desperation,
|
||||
blackmail turned fatal, silenced witness / mistaken identity, kidnapping-gone-wrong, and more
|
||||
drawn from classic Holmes-style structures — method, and the full **evidence chain planted
|
||||
backwards** from the solution to the starting facts.
|
||||
- **Red herrings by design**: every case plants 1–2 plausible false leads (a citizen with motive
|
||||
and no alibi, a second print at the scene). **Fair-play rule: every false lead is disprovable**
|
||||
by reachable evidence — the game may mislead, never cheat.
|
||||
- `opts` reserved for growth: `citizens`, `districts`, `difficulty` (only `seed` used at first).
|
||||
- **Fairness validator in test.js**: for many random seeds, prove the chain from starting facts to
|
||||
(victim identity, killer, motive, means) is reachable, and every red herring is refutable.
|
||||
|
||||
## Shared board & sync
|
||||
|
||||
- Server: Node `http` for static files + **`ws`** for real-time sync (state actions AND live
|
||||
field-player position). Session stores the **seed** + full state, **persisted to a JSON file
|
||||
per session code** — quit anytime, resume with the same code (the game is sized for 2–3 evenings).
|
||||
- Evidence list and citizen/suspect list are both **interactive for both players**: tick,
|
||||
cross out, highlight, hide, keyboard notes — all synced.
|
||||
|
||||
## Finale — one shot
|
||||
|
||||
Form with three pickers fed by the interactive lists: **ХТО** (from citizens),
|
||||
**ЯК / ЧИМ** (method/weapon), **ЧОМУ** (motive), each link requiring attached evidence.
|
||||
|
||||
**Single accusation.** Submitting asks for explicit confirmation from *both* players.
|
||||
Wrong → **you're fired**: the real case unfolds on screen, game over. Right → full recap, rank
|
||||
based on evidence completeness and how few false leads you chased.
|
||||
|
||||
## Dependencies policy
|
||||
|
||||
Deps allowed, but only popular well-maintained packages, pinned versions, and only where stdlib
|
||||
genuinely falls short. Current plan: **`ws` only.** Explicitly not used: game engines (canvas 2D
|
||||
suffices), `express` (http.createServer suffices), `seedrandom` (mulberry32 is 5 lines),
|
||||
md5 libs (`node:crypto`).
|
||||
|
||||
## Milestones
|
||||
|
||||
1. `casegen` lib + fairness tests (the heart — everything else renders its output)
|
||||
2. Server: `ws` sync, sessions + seed + disk saves
|
||||
3. Analyst terminal: DB, typed queries, city map
|
||||
4. Field: top-down 2D district, movement, search, samples, keys/warrants
|
||||
5. Minigames: DNA gel, fingerprints, CCTV, triangulation
|
||||
6. Crypto tasks + Cicada puzzle
|
||||
7. Interrogations, finale form, firing ending, polish, README
|
||||
107
chapter3/README.md
Normal file
107
chapter3/README.md
Normal 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(...)`.
|
||||
Можна брати в будь-який інший проєкт.
|
||||
132
chapter3/casegen/district.js
Normal file
132
chapter3/casegen/district.js
Normal 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
1437
chapter3/casegen/index.js
Normal file
File diff suppressed because it is too large
Load diff
99
chapter3/casegen/names.js
Normal file
99
chapter3/casegen/names.js
Normal 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 };
|
||||
44
chapter3/casegen/runtime.js
Normal file
44
chapter3/casegen/runtime.js
Normal 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
278
chapter3/casegen/test.js
Normal 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, `фільтри лишають 2–6 кандидатів, зараз ${cand.length}`);
|
||||
|
||||
// -- шифро-завдання чесні й розв'язні (усі типи)
|
||||
for (const p of cs.puzzles) {
|
||||
if (p.kind === 'caesar') assert.strictEqual(caesar(p.payload, -p.shift), p.answer, 'Цезар розшифровується');
|
||||
if (p.kind === 'b64') assert.strictEqual(Buffer.from(p.payload, 'base64').toString('utf8'), p.answer, 'base64 розкодовується');
|
||||
if (p.kind === 'pin' || p.kind === 'md5') assert.strictEqual(md5(p.answer), p.hash, 'хеш відповідає відповіді');
|
||||
if (p.kind === 'pin' && p.id === 'pz_pin') assert.strictEqual(p.answer, String(victim.birthYear), 'PIN виводиться з досьє');
|
||||
if (p.kind === 'pin' && p.id.startsWith('pz_dev_'))
|
||||
assert.ok(cs.citizens.some(c => String(c.birthYear) === p.answer), 'PIN пристрою — рік народження власника');
|
||||
if (p.kind === 'md5' && p.id === 'pz_md5') {
|
||||
// пароль завжди латиниця (щоб реально крекнути), стиль — особистий або випадковий
|
||||
assert.ok(/^[a-z0-9]+$/.test(p.answer), 'пароль ноутбука — латиниця: ' + p.answer);
|
||||
if (p.style === 'random')
|
||||
assert.ok(COMMON_PASSWORDS.includes(p.answer), 'випадковий пароль — реальний запис зі словника rockyou');
|
||||
else {
|
||||
// особистий: виводиться з досьє (кличка/ім'я/прізвище латиницею ± рік)
|
||||
const yr = String(victim.birthYear), parts = [translit(victim.first), translit(victim.last), victim.dog && translit(victim.dog)].filter(Boolean);
|
||||
assert.ok(parts.some(pt => p.answer.includes(pt)), 'особистий пароль містить транслітероване особисте дане');
|
||||
assert.ok(cs.evidence.id_dossier.detail.includes(victim.name.split(' ')[0]), 'ім\'я жертви є в досьє');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -- звинувачення: правильна повна відповідь приймається, кожна зламана ланка — ні
|
||||
const pinnedAll = [...sol.reached];
|
||||
const rightHow = cs.caseType === 'kidnap' ? cs.finale.where : cs.finale.method;
|
||||
const wrongHow = cs.caseType === 'kidnap'
|
||||
? cs.finale.whereOptions.find(w => w !== cs.finale.where)
|
||||
: (cs.finale.method === 'Отруєння' ? 'Ніж' : 'Отруєння');
|
||||
const right = { who: killer.name, how: rightHow, why: cs.finale.motive };
|
||||
assert.ok(checkAccusation(cs, right, pinnedAll).ok, 'правильне звинувачення з доказами приймається');
|
||||
assert.ok(!checkAccusation(cs, { ...right, who: herring.name }, pinnedAll).ok, 'хибний підозрюваний відхиляється');
|
||||
assert.ok(!checkAccusation(cs, { ...right, how: wrongHow }, pinnedAll).ok, 'хибний спосіб/місце відхиляється');
|
||||
assert.ok(!checkAccusation(cs, { ...right, why: 'Пограбування' }, pinnedAll).ok, 'хибний мотив відхиляється');
|
||||
assert.ok(!checkAccusation(cs, right, []).ok, 'без закріплених доказів прокурор не підписує');
|
||||
assert.ok(checkAccusation(cs, { who: killer.name.toUpperCase(), how: right.how, why: right.why }, pinnedAll).ok, 'регістр імені не важливий');
|
||||
|
||||
// -- варіанти фіналу містять правильні відповіді
|
||||
if (cs.caseType === 'murder') assert.ok(cs.finale.methodOptions.includes(cs.finale.method), 'спосіб є серед варіантів');
|
||||
assert.ok(cs.finale.motiveOptions.includes(cs.finale.motive), 'мотив є серед варіантів');
|
||||
const md5pz = cs.puzzles.find(p => p.id === 'pz_md5');
|
||||
if (md5pz) pwStyles[md5pz.style]++;
|
||||
muts[cs.mutator || 'none'] = (muts[cs.mutator || 'none'] || 0) + 1;
|
||||
if (cs.dna2) muts['dna2:' + cs.dna2.why] = (muts['dna2:' + cs.dna2.why] || 0) + 1;
|
||||
return cs.caseType + ':' + cs.templateId;
|
||||
}
|
||||
|
||||
const seen = {}, pwStyles = { personal: 0, random: 0 }, muts = {}, devLayers = {};
|
||||
for (let i = 0; i < SEEDS; i++) {
|
||||
const seed = 'seed-' + i;
|
||||
try { const t = checkCase(seed); seen[t] = (seen[t] || 0) + 1; }
|
||||
catch (e) { fails++; console.error(`✗ ${seed}: ${e.message}`); if (fails > 5) break; }
|
||||
}
|
||||
|
||||
assert.ok(Object.keys(seen).length >= 11, 'генератор реально використовує всі шаблони мотивів: ' + JSON.stringify(seen));
|
||||
assert.ok(Object.keys(seen).some(k => k.startsWith('kidnap:')) && Object.keys(seen).some(k => k.startsWith('murder:')),
|
||||
'обидва типи справ трапляються: ' + JSON.stringify(seen));
|
||||
assert.ok(pwStyles.personal && pwStyles.random,
|
||||
'алгоритм пароля дає і особисті, і випадкові варіанти: ' + JSON.stringify(pwStyles));
|
||||
if (fails) { console.error(`\n${fails} збоїв — генератор нечесний.`); process.exit(1); }
|
||||
console.log(`✓ ${SEEDS} сідів: кожна справа розв'язна, хибні сліди спростовні, шифри чесні.`);
|
||||
console.log(' Розподіл шаблонів:', JSON.stringify(seen));
|
||||
console.log(' Стилі паролів (ноутбук):', JSON.stringify(pwStyles));
|
||||
console.log(' Мутатори:', JSON.stringify(muts));
|
||||
assert.ok(muts.accomplice && muts.gloves && muts.frame && muts.none, 'усі мутатори трапляються');
|
||||
assert.ok(muts['dna2:brawl'] && muts['dna2:finder'] && muts['dna2:passer'], 'усі види невинної ДНК трапляються');
|
||||
assert.ok(devLayers.clean && devLayers.rich, 'пристрої бувають і чистими, і з вмістом: ' + JSON.stringify(devLayers));
|
||||
37
chapter3/package-lock.json
generated
Normal file
37
chapter3/package-lock.json
generated
Normal 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
14
chapter3/package.json
Normal 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
453
chapter3/public/analyst.js
Normal 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
524
chapter3/public/client.js
Normal 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) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[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
707
chapter3/public/field.js
Normal 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
225
chapter3/public/index.html
Normal 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>> АНАЛІТИК_</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
108
chapter3/public/sound.js
Normal 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
823
chapter3/server.js
Normal 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
215
chapter3/test-server.js
Normal 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); });
|
||||
141
detective-coop-game-prompt.md
Normal file
141
detective-coop-game-prompt.md
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
## Що будуємо
|
||||
|
||||
Інтерактивну веб-гру (single HTML/React artifact), детектив на двох гравців, кооп, нелінійна структура — досліджуємо локації, допитуємо підозрюваних, збираємо докази, зіставляємо їх на дошці, в кінці називаємо вбивцю і доводимо чому. Формат натхненний тим, як влаштовані справи в Shadows of Doubt: відкрита структура розслідування, а не лінійний ланцюжок квестів.
|
||||
|
||||
**Жорсткий принцип: без живого AI всередині гри.** Все — діалоги, докази, логіка розв'язання — прописано наперед і детерміноване. Гра має логічно "закриватись": є один винний, і його провину можна довести конкретним набором доказів без вгадування.
|
||||
|
||||
## Формат і платформа
|
||||
|
||||
- Один HTML-файл (React/vanilla JS), відкривається в браузері з телефону чи ноутбука.
|
||||
- Нелінійна навігація: хаб/карта локацій, з якого можна йти в будь-якому порядку.
|
||||
- Записник/журнал автоматично фіксує знайдені докази та репліки підозрюваних.
|
||||
- Дошка справи (case board): візуальне зіставлення доказ ↔ підозрюваний ↔ мотив/спосіб/можливість.
|
||||
|
||||
## Кооп на двох з асиметричною інформацією
|
||||
|
||||
Два гравці, дві ролі, кожен бачить свою частину інформації — щоб реально доводилось синхронізуватись і думати разом уголос:
|
||||
|
||||
- **Польовий детектив** — фізичні докази, огляд місця злочину, особисті допити (мова тіла, прямі реакції).
|
||||
- **Детектив-аналітик** — цифрові сліди: записи камер, фінансові транзакції, повідомлення, логи доступу, бекграунд-перевірки.
|
||||
|
||||
Кожна роль сама по собі НЕ дає повної картини. Приклад механіки синхронізації: польовий детектив чує від підозрюваного алібі ("я був у барі о 23:00"), аналітик бачить транзакцію/геолокацію, яка це підтверджує або спростовує — але тільки якщо гравці проговорять це один одному.
|
||||
|
||||
Технічно: два пристрої заходять під одним кодом сесії (спільний стан через сховище), кожен бачить свій інтерфейс/докази, спільна дошка справи синхронізується для обох.
|
||||
|
||||
## Тривалість
|
||||
|
||||
Розрахунок на 60–90 хв за вечір як базовий кейс, з можливістю розширення до "кампанії" на 2–3 вечори (кейс на кілька актів, де перший вечір відкриває докази, які стають актуальними у другому).
|
||||
|
||||
## Сетинг і тон
|
||||
|
||||
Сучасність або кіберпанк-нуар: місто з чітким розділом на корпоративну верхівку і вулицю. Похмуро-серйозний тон — справжній труп, реальні мотиви (гроші, влада, ревнощі, шантаж), без карикатурного насильства й без "чарівних" пояснень.
|
||||
|
||||
## Правила чесної гри (fair-play mystery)
|
||||
|
||||
1. Один вбивця, 4–6 підозрюваних, у кожного своя пара мотив/спосіб/можливість (заповнена або навмисно неповна — щоб відсіювались логікою).
|
||||
2. Точний таймлайн подій. У вбивці має бути рівно одна суперечність в алібі, яку можна розкрити ТІЛЬКИ звівши докази з обох ролей.
|
||||
3. 2–3 red herring (хибні сліди) — кожен обов'язково спростовується конкретним доказом, який десь у грі є. Жодного "просто дивного факту без пояснення".
|
||||
4. Усе, що потрібно для доведення провини, повинно бути знаходжуваним у грі. Нічого не з'являється тільки у фінальному розкритті.
|
||||
|
||||
## Механіка допитів
|
||||
|
||||
Фіксовані дерева діалогів (не вільний текст) — кожен підозрюваний має набір тем/питань на вибір, відповіді заготовлені заздалегідь, деякі питання розблоковуються тільки після знаходження конкретного доказу (наприклад, "спитати про транзакцію" з'являється тільки якщо аналітик її знайшов і команда це обговорила).
|
||||
|
||||
## Фінал — звинувачення
|
||||
|
||||
Екран "Висунути звинувачення": гравці разом обирають підозрюваного + спосіб/знаряддя + 3–4 докази, що доводять провину. Система перевіряє логічний ланцюжок:
|
||||
- Якщо помилка — підказка яка саме ланка не сходиться (без спойлера відповіді), можна спробувати ще раз.
|
||||
- Перемога тільки при повністю правильній комбінації.
|
||||
|
||||
## Візуальна складова
|
||||
|
||||
Вільно — від мінімалістичного текстово-UI підходу (карта, портрети, картки доказів) до повноцінної 2D/3D сцени. Для першої ітерації рекомендую стилізований UI-фокус (як настільна детективна гра), а не 3D-рендер — простіше зробити логіку надійною.
|
||||
|
||||
---
|
||||
|
||||
## КЕЙС №1 — "Nightshade Protocol" (готовий, можна грати як є)
|
||||
|
||||
**Сетинг:** місто Meridian, 2087, нейроімпланти — норма. Верхній Шпиль (корпорації) / Нижні Доки (робочий клас).
|
||||
|
||||
**Жертва:** Маркус Рейз, 44, брокер даних у Halcyon Dynamics. Знайдений мертвим на балконі свого пентхауса, падіння з 40-го поверху. Офіційна версія — нещасний випадок. Час смерті: 23:14 (підтверджено біометричним піком нейроімпланта — незламний лог).
|
||||
|
||||
**Локації:** пентхаус жертви (балкон + кабінет), лобі будівлі, офіс Halcyon Dynamics, бар "The Hollow Note", підпільна майстерня Рука.
|
||||
|
||||
**Підозрювані:**
|
||||
|
||||
| Хто | Мотив | Спосіб/доступ | Заявлене алібі | Статус |
|
||||
|---|---|---|---|---|
|
||||
| **Елена Восс** (партнерка по бізнесу) | Рейз викрив її розтрату через тіньові рахунки, вранці мав доповісти раді директорів | Її пропуск-чіп ще діє з часів стосунків | "Була в офісі допізна" | **ВБИВЦЯ** |
|
||||
| Кайто Морі (екс-коханець) | Рейз злив його медичні/генетичні дані конкурентам | Хакерські навички | Був у барі "The Hollow Note" всю ніч | Спростовується |
|
||||
| Д-р Сейбл (біотех-конкурент) | Рейз вкрав і злив його дослідження | Немає доступу/навичок | На конференції | Спростовується залізно |
|
||||
| Рук (вуличний фіксер, кредитор) | Рейз винен йому великі гроші | Доступ до дронів/мускулів | Не має алібі, "був біля будівлі" | Red herring |
|
||||
| Айві Чен (асистентка, таємно на зарплаті конкурента) | Побоювалась, що Рейз запідозрив шпигунство | Повний доступ до квартири | Пішла о 22:30 (є на камері) | Спростовується |
|
||||
|
||||
**Таймлайн:** 22:15 — Елена сканує пропуск на вихід з офісу Halcyon (бачить нічний охоронець — фізичний доказ, чується в допиті). 22:47 — Елена повертається в будівлю Рейза (цифровий лог доступу — знаходить аналітик). 22:50 — силует на резервному записі патрульного дрона біля балкона. 23:10 — використано інженерний override для вимкнення силового поля безпеки балкона (лог з ID Елени). 23:14 — біометричний пік смерті.
|
||||
|
||||
**Докази — фізичні (Польовий детектив):**
|
||||
- Порваний клапоть тканини з піджака на поручні балкона
|
||||
- Сліди боротьби на балконі
|
||||
- Частковий відбиток пальця на панелі override
|
||||
- Допит нічного охоронця офісу: усно каже, що бейдж Елени вийшов о 22:15
|
||||
- Фінансовий гросбух у схованці Рейза (доказ розтрати, підтверджує мотив)
|
||||
- Записи допитів усіх підозрюваних (мова тіла)
|
||||
|
||||
**Докази — цифрові (Аналітик):**
|
||||
- Лог override панелі балкона — ID Елени, 23:10
|
||||
- Резервний запис дрона — силует біля балкона, 22:50
|
||||
- Лог повернення Елени в будівлю — 22:47 (суперечить її словам "була в офісі")
|
||||
- Запрошення в календарі: "Термінова рада — комплаєнс Е.В." на 9:00 ранку
|
||||
- Лог нейроімпланта/wifi бару Кайто — безперервно в барі до 23:30 (знімає підозру)
|
||||
- Маніфест доставки Рука — був у будівлі 21:30–21:45 з іншої причини, не пов'язаної (знімає підозру)
|
||||
- Камера лобі: вихід Айві о 22:30 збігається з бейджем (знімає підозру)
|
||||
- Лог реєстрації на конференції д-ра Сейбла (знімає підозру)
|
||||
- Біометричний лог імпланта Рейза — час смерті 23:14, сплеск адреналіну (боротьба/падіння)
|
||||
|
||||
**Розкриття протиріччя (потребує обох ролей одночасно):** Польовий детектив чує від охоронця "вийшла о 22:15". Аналітик бачить "повернулась в будівлю Рейза о 22:47". Кожен окремо — не доказ. Разом — Елена бреше про алібі.
|
||||
|
||||
**Правильне звинувачення:** Елена Восс; спосіб — вимкнула силове поле балкона через override, зіштовхнула Рейза; докази — [лог override з її ID] + [суперечність бейджів 22:15/22:47] + [клапоть тканини на поручні] (+ гросбух як мотив).
|
||||
|
||||
---
|
||||
|
||||
## Алгоритм процедурної генерації нових кейсів
|
||||
|
||||
Щоб не вигадувати кожен кейс вручну — принцип той самий, що й у Shadows of Doubt: seed → комбінаторна генерація → перевірка розв'язності.
|
||||
|
||||
**Крок 1. Пули даних (заповнюються один раз, дають безкінечні комбінації):**
|
||||
- Пул жертв (архетип, професія, чому вигідно/небезпечно її вбити)
|
||||
- Пул локацій смерті (кожна дає свій набір фізичних доказів)
|
||||
- Пул мотивів (гроші, помста, шантаж, влада, ревнощі, самозахист)
|
||||
- Пул способів/доступу (кожен спосіб вимагає конкретного "тегу навички/доступу": хакерство, фізична сила, інсайдерський пропуск, хімія тощо)
|
||||
- Пул типів доказів — окремо фізичні / цифрові, кожен доказ має тег "що доводить" (доступ / мотив / присутність / алібі)
|
||||
|
||||
**Крок 2. Генерація сцени:**
|
||||
1. Обрати жертву + локацію + точний час смерті (TOD).
|
||||
2. Обрати 4–6 підозрюваних із пулу ролей, кожному рандомно призначити мотив + тег доступу/навички + тип алібі.
|
||||
|
||||
**Крок 3. Призначення вбивці:**
|
||||
- Вбивцею стає той підозрюваний, чий тег доступу/навички відповідає вимогам обраного способу вбивства, і чиє алібі навмисно генерується як "правдоподібне, але з рівно однією слабкою ланкою".
|
||||
- Слабка ланка завжди розбивається на 2 частини — одна дається польовому детективу (почута репліка/фізичний слід), інша аналітику (лог/запис) — і жодна сама по собі не доказ.
|
||||
|
||||
**Крок 4. Призначення решти підозрюваних:**
|
||||
- 1 підозрюваний отримує "залізне" алібі (конференція, багато свідків, цифрове підтвердження) — навмисно найбільш підозрілий мотивом, але фактично чистий.
|
||||
- 1–2 отримують red herring: підозріла деталь (не був на камерах, борг, погрози), яка спростовується конкретним доказом десь у грі (обов'язково генерується разом зі спростуванням, ніколи окремо).
|
||||
- Решта отримують нейтральні, просто перевірювані алібі.
|
||||
|
||||
**Крок 5. Перевірка розв'язності (як міні-solver, обов'язковий крок):**
|
||||
- Перевірити: рівно один підозрюваний одночасно задовольняє мотив + доступ/спосіб + суперечність в алібі.
|
||||
- Перевірити: усі інші проваляють хоч один із трьох критеріїв, і це доводиться доказом, який реально є в грі.
|
||||
- Якщо перевірка не проходить — перегенерувати мотив/алібі/доказ для конфліктного підозрюваного і повторити.
|
||||
|
||||
**Крок 6. Вивід:** зібрати фінальний кейс у той самий формат, що й "Nightshade Protocol" вище (таблиця підозрюваних, таймлайн, списки доказів по ролях, точна комбінація для звинувачення) — це і є те, що завантажується в гру.
|
||||
|
||||
Цей алгоритм можна буквально закодувати (JSON-пули + функція-валідатор) і генерувати новий кейс кожного разу, коли набридне готовий — тоді гра буде реально безкінечною, як міні-версія генерації міста в Shadows of Doubt.
|
||||
|
||||
# Fixes
|
||||
Зробити "закріплені докази" прикріплені зверху, прибрати кнопку зірки. Тільки галочка, хрестик, приховати і прикріпити.
|
||||
Додати можливість лишати нотатки на доказах.
|
||||
Покращити систему комунікації з підозрюваними і іншими, більше питань (де ви були в X (time picker), якщо попали - правда або брехня, в яких стосунках X з Y, etc.(Тут можна пошукати натхнення в інеті)), бо зараз всі кажуть "нічим не можу допомогти".
|
||||
Прибрати помітки типу "алібі", "дрібниця", "місце". Просто пробрати той pill.
|
||||
Всі кнопки з "Системи й лабораторія" переробити: зробити інтерактивними як База мешканців і шифри(у з'вязку з видаленням 3301 - видалити цю вкладку). З приводу реалізації цього - задати додаткові уточнюючі питання.
|
||||
Мапа має кожен раз, залежно від сіду, генеруватись. Різні будинки, різні жителі, різні приміщення.
|
||||
Також я хочу щоб перед імплементацією, але після опитування, ти запропонував ідеї, які позитивно могли б вплинути на гру.
|
||||
Loading…
Reference in a new issue