Pre-bugs fix
This commit is contained in:
parent
908252d5d9
commit
0af5aabb83
40 changed files with 257 additions and 120 deletions
|
|
@ -1,105 +0,0 @@
|
||||||
# Розділ 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
|
|
||||||
147
detective-game/.gitignore
vendored
Normal file
147
detective-game/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,147 @@
|
||||||
|
# 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/
|
||||||
|
|
||||||
|
saves/
|
||||||
|
bugs.md
|
||||||
|
DESIGN.md
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
// граф доказів (спланований від розв'язку назад), хибні сліди (кожен — спростовний),
|
// граф доказів (спланований від розв'язку назад), хибні сліди (кожен — спростовний),
|
||||||
// шифро-завдання. solve() — симулятор досяжності для fair-play тестів.
|
// шифро-завдання. solve() — симулятор досяжності для fair-play тестів.
|
||||||
|
|
||||||
|
const crypto = require('crypto');
|
||||||
const { makeDistrict, unitBuilding } = require('./district');
|
const { makeDistrict, unitBuilding } = require('./district');
|
||||||
const N = require('./names');
|
const N = require('./names');
|
||||||
const { hashSeed, mulberry32, ri, estRange, pick, chance, shuffle, UKR, caesar, b64, md5, translit, COMMON_PASSWORDS, genPassword } = require('./rng');
|
const { hashSeed, mulberry32, ri, estRange, pick, chance, shuffle, UKR, caesar, b64, md5, translit, COMMON_PASSWORDS, genPassword } = require('./rng');
|
||||||
|
|
@ -32,6 +33,10 @@ const { applyDoppelganger } = require('./mutators');
|
||||||
const { generateAnonymousLetter } = require('./anonymous_letter');
|
const { generateAnonymousLetter } = require('./anonymous_letter');
|
||||||
|
|
||||||
// ---------- Генерація справи ----------
|
// ---------- Генерація справи ----------
|
||||||
|
// Код кімнати (сесія) → сід справи. Хешуємо, щоб близькі коди («ABC», «ABC1»)
|
||||||
|
// давали геть різні справи. Єдине джерело правди: і сервер, і тести сіють цим.
|
||||||
|
function caseSeed(code) { return crypto.createHash('sha512').update(String(code)).digest('hex'); }
|
||||||
|
|
||||||
function generateCase(seed) {
|
function generateCase(seed) {
|
||||||
const r = mulberry32(hashSeed(seed));
|
const r = mulberry32(hashSeed(seed));
|
||||||
const district = makeDistrict(r);
|
const district = makeDistrict(r);
|
||||||
|
|
@ -625,7 +630,7 @@ function generateCase(seed) {
|
||||||
id: 'lab_print_match', side: 'analyst', at: { type: 'lab' }, requires: ['ev_weapon'],
|
id: 'lab_print_match', side: 'analyst', at: { type: 'lab' }, requires: ['ev_weapon'],
|
||||||
title: 'Повний відбиток зі знаряддя', tag: 'вбивця',
|
title: 'Повний відбиток зі знаряддя', tag: 'вбивця',
|
||||||
detail: `Відбиток зі знаряддя порівняно з базою (звузьте кандидатів і зіставте вручну). Збіг за ${ri(r, 12, 16)} точками: ${killer.name}.`,
|
detail: `Відбиток зі знаряддя порівняно з базою (звузьте кандидатів і зіставте вручну). Збіг за ${ri(r, 12, 16)} точками: ${killer.name}.`,
|
||||||
gives: { confirms: true }
|
gives: { confirms: true, pointsTo: killer.id } // чіткий доказ: годиться і в звинувачення, і на ордер
|
||||||
});
|
});
|
||||||
if (!(caseType === 'murder' && mutator === 'gloves')) add({
|
if (!(caseType === 'murder' && mutator === 'gloves')) add({
|
||||||
id: 'lab_dna_match', side: 'analyst', at: { type: 'lab' }, requires: ['sf_dna'], special: 'dna',
|
id: 'lab_dna_match', side: 'analyst', at: { type: 'lab' }, requires: ['sf_dna'], special: 'dna',
|
||||||
|
|
@ -634,7 +639,9 @@ function generateCase(seed) {
|
||||||
? `Електрофорез (${dnaDesc}): повний збіг доріжок — ${accomplice.name}. Дивно: на кого вказують взуття і тканина — це ІНША людина.`
|
? `Електрофорез (${dnaDesc}): повний збіг доріжок — ${accomplice.name}. Дивно: на кого вказують взуття і тканина — це ІНША людина.`
|
||||||
: `Електрофорез (${dnaDesc}): повний збіг доріжок — ${killer.name}.`) +
|
: `Електрофорез (${dnaDesc}): повний збіг доріжок — ${killer.name}.`) +
|
||||||
' Коли і чому ДНК потрапила на місце — аналіз не скаже.',
|
' Коли і чому ДНК потрапила на місце — аналіз не скаже.',
|
||||||
gives: mutator === 'accomplice' ? { pointsTo: accomplice.id } : { confirms: true }
|
// accomplice: ДНК належить спільнику → вказує на нього (твіст лишаємо).
|
||||||
|
// інакше збіг прямо називає вбивцю → чіткий доказ: і в звинувачення, і на ордер.
|
||||||
|
gives: mutator === 'accomplice' ? { pointsTo: accomplice.id } : { confirms: true, pointsTo: killer.id }
|
||||||
});
|
});
|
||||||
if (dna2) add({
|
if (dna2) add({
|
||||||
id: 'lab_dna_match2', side: 'analyst', at: { type: 'lab' }, requires: ['sf_dna2'], special: 'dna2',
|
id: 'lab_dna_match2', side: 'analyst', at: { type: 'lab' }, requires: ['sf_dna2'], special: 'dna2',
|
||||||
|
|
@ -692,6 +699,12 @@ function generateCase(seed) {
|
||||||
`${mutator === 'gloves' ? 'повний відбиток зі знаряддя' : 'ДНК з місця'} і камера, що впіймала зріст. База мешканців зробила решту. Справу закрито.`
|
`${mutator === 'gloves' ? 'повний відбиток зі знаряддя' : 'ДНК з місця'} і камера, що впіймала зріст. База мешканців зробила решту. Справу закрито.`
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Мотив у прямій мові + монетка 50/50: чи проговорить вбивця мотив на зізнанні
|
||||||
|
// (для реалізму). Draw ОСТАННІЙ у генерації — після нього r не чіпаємо, тож
|
||||||
|
// наявні сейви лишаються тією ж справою, лише з новими полями фіналу.
|
||||||
|
finale.recapWhy = recapWhy;
|
||||||
|
finale.revealsMotive = chance(r, 0.5);
|
||||||
|
|
||||||
// admit — пряма мова хибного підозрюваного (див. FM_KINDS), а не третьоособовий
|
// admit — пряма мова хибного підозрюваного (див. FM_KINDS), а не третьоособовий
|
||||||
// зворот: цитуємо, а не вклеюємо в речення.
|
// зворот: цитуємо, а не вклеюємо в речення.
|
||||||
finale.recap += ` ${herring.first} визна${g(herring, 'в', 'ла')}: «${fm.admit}» — та до злочину стосунку не ${g(herring, 'мав', 'мала')}.`;
|
finale.recap += ` ${herring.first} визна${g(herring, 'в', 'ла')}: «${fm.admit}» — та до злочину стосунку не ${g(herring, 'мав', 'мала')}.`;
|
||||||
|
|
@ -712,4 +725,4 @@ function generateCase(seed) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { generateCase, solve, checkAccusation, caesar, md5, translit, COMMON_PASSWORDS };
|
module.exports = { generateCase, caseSeed, solve, checkAccusation, caesar, md5, translit, COMMON_PASSWORDS };
|
||||||
|
|
@ -267,14 +267,24 @@
|
||||||
if (!claims.length) { box.innerHTML = 'Ще нікого не питали, де він був того вечора.'; box.className = 'small dim'; return; }
|
if (!claims.length) { box.innerHTML = 'Ще нікого не питали, де він був того вечора.'; box.className = 'small dim'; return; }
|
||||||
box.className = '';
|
box.className = '';
|
||||||
const bn = id => { const b = App.init.district.buildings.find(x => x.id === id); return b ? b.name.split(',')[0] : id; };
|
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>');
|
const t = el('table', '', '<tr><th>Хто</th><th>Каже, що був</th><th>Камери над тим місцем</th><th></th></tr>');
|
||||||
for (const [cid, claim] of claims) {
|
for (const [cid, claim] of claims) {
|
||||||
const c = App.byId(cid);
|
const c = App.byId(cid);
|
||||||
const cams = App.init.district.cameras.filter(cam => cam.covers.includes(claim));
|
// Той самий ключ, що й у базі мешканців: галочка/викреслення на особі — спільні.
|
||||||
|
const key = 'c:' + cid;
|
||||||
|
if (App.state.board.marks[key] === 'hide') continue; // сховане в базі — сховане й тут
|
||||||
|
// Зізнався, що збрехав → показуємо перехід «стара заява → реальне місце»,
|
||||||
|
// і камери підтягуємо вже над реальним місцем (обидві колонки синхронно).
|
||||||
|
const real = (App.state.alibiReal || {})[cid];
|
||||||
|
const place = real || claim;
|
||||||
|
const said = real ? `${esc(bn(claim))} → ${esc(bn(real))}` : esc(bn(claim));
|
||||||
|
const cams = App.init.district.cameras.filter(cam => cam.covers.includes(place));
|
||||||
const cell = cams.length
|
const cell = cams.length
|
||||||
? cams.map(cam => `${esc(cam.name)}${(App.state.cctvPulled || []).includes(cam.id) ? ' — архів на дошці' : ' — архів не тягнули'}`).join('<br>')
|
? 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>`));
|
const tr = el('tr', cardClasses(key), `<td>${esc(c.name)}</td><td>${said}</td><td class="small">${cell}</td>`);
|
||||||
|
const mk = el('td'); mk.append(markButtons(key)); tr.append(mk);
|
||||||
|
t.append(tr);
|
||||||
}
|
}
|
||||||
box.innerHTML = ''; box.append(t);
|
box.innerHTML = ''; box.append(t);
|
||||||
}
|
}
|
||||||
|
|
@ -178,6 +178,23 @@ function cardClasses(key) {
|
||||||
return m && m !== "hide" ? " " + m : "";
|
return m && m !== "hide" ? " " + m : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Прокрутити дошку до доказу за id і коротко підсвітити. Якщо він у згорнутій
|
||||||
|
// секції «Приховані» — спершу розгортаємо її й перемальовуємо.
|
||||||
|
function jumpToEvidence(id) {
|
||||||
|
const find = () =>
|
||||||
|
document.querySelector(`#sidebody [data-ev="${CSS.escape(id)}"]`);
|
||||||
|
let card = find();
|
||||||
|
if (!card && !showHidden) {
|
||||||
|
showHidden = true;
|
||||||
|
renderSide();
|
||||||
|
card = find();
|
||||||
|
}
|
||||||
|
if (!card) return;
|
||||||
|
card.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||||
|
card.classList.add("flash");
|
||||||
|
setTimeout(() => card.classList.remove("flash"), 1300);
|
||||||
|
}
|
||||||
|
|
||||||
function renderSide() {
|
function renderSide() {
|
||||||
if (!App.state) return;
|
if (!App.state) return;
|
||||||
const body = $("#sidebody");
|
const body = $("#sidebody");
|
||||||
|
|
@ -211,6 +228,7 @@ function renderSide() {
|
||||||
const buildCard = (e) => {
|
const buildCard = (e) => {
|
||||||
const key = "e:" + e.id;
|
const key = "e:" + e.id;
|
||||||
const c = el("div", "card" + cardClasses(key));
|
const c = el("div", "card" + cardClasses(key));
|
||||||
|
c.dataset.ev = e.id; // якір для прокрутки-до-доказу
|
||||||
const t = el("div", "t");
|
const t = el("div", "t");
|
||||||
t.append(el("b", "", esc(e.title)));
|
t.append(el("b", "", esc(e.title)));
|
||||||
const pin = el(
|
const pin = el(
|
||||||
|
|
@ -220,7 +238,17 @@ function renderSide() {
|
||||||
);
|
);
|
||||||
pin.onclick = () => App.send({ t: "pin", evId: e.id });
|
pin.onclick = () => App.send({ t: "pin", evId: e.id });
|
||||||
t.append(pin);
|
t.append(pin);
|
||||||
c.append(t, el("div", "small", esc(e.detail)), markButtons(key));
|
c.append(t, el("div", "small", esc(e.detail)));
|
||||||
|
// Експертиза називає доказ, який розбирала, і веде до нього (прокрутка + підсвітка).
|
||||||
|
if (e.ref) {
|
||||||
|
const src = found.find((x) => x.id === e.ref);
|
||||||
|
if (src) {
|
||||||
|
const link = el("button", "evref small", `↳ аналіз доказу: «${esc(src.title)}»`);
|
||||||
|
link.onclick = () => jumpToEvidence(e.ref);
|
||||||
|
c.append(link);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.append(markButtons(key));
|
||||||
const notes = (App.state.board.enotes || {})[e.id] || [];
|
const notes = (App.state.board.enotes || {})[e.id] || [];
|
||||||
for (const n of notes) {
|
for (const n of notes) {
|
||||||
const row = el(
|
const row = el(
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
"use strict";
|
"use strict";
|
||||||
(() => {
|
(() => {
|
||||||
let cv, ctx, hint;
|
let cv, ctx, hint;
|
||||||
|
let started = false; // слухачі/цикл/розсилку ставимо раз, навіть якщо init прийде знову
|
||||||
let player = { x: 85, y: 290 };
|
let player = { x: 85, y: 290 };
|
||||||
const keys = {};
|
const keys = {};
|
||||||
let solids = [],
|
let solids = [],
|
||||||
|
|
@ -168,11 +169,16 @@
|
||||||
stage.append(cv, hint);
|
stage.append(cv, hint);
|
||||||
ctx = cv.getContext("2d");
|
ctx = cv.getContext("2d");
|
||||||
fitCanvas(cv);
|
fitCanvas(cv);
|
||||||
addEventListener("resize", () => fitCanvas(cv));
|
|
||||||
solids = m.district.buildings
|
solids = m.district.buildings
|
||||||
.filter((b) => !OPEN_ZONES.includes(b.id))
|
.filter((b) => !OPEN_ZONES.includes(b.id))
|
||||||
.map((b) => b.pos);
|
.map((b) => b.pos);
|
||||||
walkIn = m.district.buildings.filter((b) => OPEN_ZONES.includes(b.id));
|
walkIn = m.district.buildings.filter((b) => OPEN_ZONES.includes(b.id));
|
||||||
|
// init прилітає й на реконекті: канвас/стан перебудували вище, а глобальні
|
||||||
|
// слухачі, цикл рендеру й розсилку позицій ставимо ЛИШЕ раз — інакше після
|
||||||
|
// кожного реконекту крутився б зайвий loop і дублювались клавіші.
|
||||||
|
if (started) return;
|
||||||
|
started = true;
|
||||||
|
addEventListener("resize", () => fitCanvas(cv));
|
||||||
// e.code — фізична клавіша, працює на будь-якій розкладці (укр/лат)
|
// e.code — фізична клавіша, працює на будь-якій розкладці (укр/лат)
|
||||||
addEventListener("keydown", (e) => {
|
addEventListener("keydown", (e) => {
|
||||||
if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA")
|
if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA")
|
||||||
|
|
@ -89,6 +89,9 @@
|
||||||
#side .tag { font-size:10.5px; background:transparent; border:1px solid #b3a077; color:var(--inkdim); border-radius:2px; padding:0 5px; }
|
#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 { display:flex; gap:6px; align-items:baseline; }
|
||||||
.card .t b { flex:1; }
|
.card .t b { flex:1; }
|
||||||
|
.evref { display:block; width:100%; text-align:left; margin-top:4px; font-style:italic; }
|
||||||
|
@keyframes evflash { 0%,100% { box-shadow:0 4px 10px rgba(0,0,0,.55); } 25%,60% { box-shadow:0 0 0 3px var(--acc), 0 4px 14px rgba(0,0,0,.6); } }
|
||||||
|
#side .card.flash { animation:evflash 1.3s ease; }
|
||||||
.mk { display:flex; gap:4px; margin-top:6px; }
|
.mk { display:flex; gap:4px; margin-top:6px; }
|
||||||
.mk button { padding:1px 8px; font-size:12px; }
|
.mk button { padding:1px 8px; font-size:12px; }
|
||||||
.mk button.on { border-color:#7a1f1f; color:#7a1f1f; font-weight:bold; }
|
.mk button.on { border-color:#7a1f1f; color:#7a1f1f; font-weight:bold; }
|
||||||
|
|
@ -4,9 +4,8 @@ const http = require('http');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const os = require('os');
|
const os = require('os');
|
||||||
const crypto = require('crypto');
|
|
||||||
const { WebSocketServer } = require('ws');
|
const { WebSocketServer } = require('ws');
|
||||||
const { generateCase, checkAccusation, md5 } = require('./casegen');
|
const { generateCase, caseSeed, checkAccusation, md5 } = require('./casegen');
|
||||||
const { unitBuilding } = require('./casegen/district');
|
const { unitBuilding } = require('./casegen/district');
|
||||||
const RT = require('./casegen/runtime');
|
const RT = require('./casegen/runtime');
|
||||||
|
|
||||||
|
|
@ -40,7 +39,7 @@ function seedRand(seed) {
|
||||||
|
|
||||||
function getSession(code) {
|
function getSession(code) {
|
||||||
if (sessions[code]) return sessions[code];
|
if (sessions[code]) return sessions[code];
|
||||||
const hashedCode = crypto.createHash('sha512').update(code).digest('hex');
|
const hashedCode = caseSeed(code);
|
||||||
const cs = generateCase(hashedCode);
|
const cs = generateCase(hashedCode);
|
||||||
const rng = seedRand([...hashedCode].reduce((a, c) => a * 31 + c.charCodeAt(0), 0));
|
const rng = seedRand([...hashedCode].reduce((a, c) => a * 31 + c.charCodeAt(0), 0));
|
||||||
const stn = cs.district.buildings.find(b => b.id === 'station');
|
const stn = cs.district.buildings.find(b => b.id === 'station');
|
||||||
|
|
@ -131,7 +130,11 @@ function view(s) {
|
||||||
const { cs, st } = s;
|
const { cs, st } = s;
|
||||||
const found = st.found.map(id => {
|
const found = st.found.map(id => {
|
||||||
const ev = cs.evidence[id];
|
const ev = cs.evidence[id];
|
||||||
return { id, title: ev.title, detail: ev.detail, side: ev.side };
|
const card = { id, title: ev.title, detail: ev.detail, side: ev.side };
|
||||||
|
// Експертиза (crim/expert) розбирає конкретний доказ — його id у requires[0].
|
||||||
|
// Віддаємо як ref, щоб на дошці було видно, ЧИЙ це аналіз, і клік вів до нього.
|
||||||
|
if (ev.q && ev.q.kind === 'expert' && ev.requires[0]) card.ref = ev.requires[0];
|
||||||
|
return card;
|
||||||
}).concat(s.extra);
|
}).concat(s.extra);
|
||||||
const killerC = cs.citizens.find(c => c.id === cs.finale.killer);
|
const killerC = cs.citizens.find(c => c.id === cs.finale.killer);
|
||||||
const dnaOwner = cs.citizens.find(c => c.id === (cs.accomplice || cs.finale.killer));
|
const dnaOwner = cs.citizens.find(c => c.id === (cs.accomplice || cs.finale.killer));
|
||||||
|
|
@ -143,6 +146,12 @@ function view(s) {
|
||||||
puzzles: visiblePuzzles(s),
|
puzzles: visiblePuzzles(s),
|
||||||
locs: s.locs, markers: s.markers, log: s.log,
|
locs: s.locs, markers: s.markers, log: s.log,
|
||||||
alibiClaims: s.alibiClaims,
|
alibiClaims: s.alibiClaims,
|
||||||
|
// Хто вже зізнався, що збрехав про вечір → його СПРАВЖНЄ місце (c.evening).
|
||||||
|
// Клієнт правди не має (evening вирізано в pubCitizens), тож віддаємо тут —
|
||||||
|
// тільки для розколотих, тож нічого понад уже сказане в зізнанні не тече.
|
||||||
|
alibiReal: Object.fromEntries(Object.keys(s.alibiClaims)
|
||||||
|
.filter(cid => s.cracked[cid])
|
||||||
|
.map(cid => [cid, byIdC(cs, cid).evening])),
|
||||||
cctvPulled: s.extra.filter(x => x.key && x.key.startsWith('cctv|')).map(x => x.key.slice(5)),
|
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,
|
dnaSample: st.found.includes('sf_dna') ? dnaOwner.dna : null,
|
||||||
dnaLabel: st.found.includes('sf_dna') ? cs.dnaDesc : null,
|
dnaLabel: st.found.includes('sf_dna') ? cs.dnaDesc : null,
|
||||||
|
|
@ -565,8 +574,19 @@ function handle(s, ws, m) {
|
||||||
`${c.name} впізнає слід біля місця злочину: ${g(c, 'проходив', 'проходила')} там того вечора. До справи відношення не має.`);
|
`${c.name} впізнає слід біля місця злочину: ${g(c, 'проходив', 'проходила')} там того вечора. До справи відношення не має.`);
|
||||||
} else if (K && gives.confirms) {
|
} else if (K && gives.confirms) {
|
||||||
a = `Довга пауза. «...Гаразд. Ваша взяла.» — і ${c.name} починає говорити.`;
|
a = `Довга пауза. «...Гаразд. Ваша взяла.» — і ${c.name} починає говорити.`;
|
||||||
|
// Зізнання підтверджує ХТО/ЯК, але мотив цим не доводиться: прокурор вимагає
|
||||||
|
// ЗАКРІПЛЕНИЙ доказ мотиву (accusation.js). Тож картка чесно каже, що ЧОМУ —
|
||||||
|
// ще відкрите, інакше гравець думає, що вже все, і зависає на звинуваченні.
|
||||||
|
const motiveBacked = st.found.some(id => cs.evidence[id] && cs.evidence[id].gives && cs.evidence[id].gives.motive);
|
||||||
|
const whyLine = motiveBacked
|
||||||
|
? `Мотив уже підкріплено доказом. Лишилося оформити справу: ХТО / ЯК / ЧОМУ і закріпити докази для прокурора.`
|
||||||
|
// 50/50 (вирішено при генерації справи): інколи вбивця сам проговорює мотив.
|
||||||
|
// Але слова — ще не доказ: прокурор усе одно вимагає ЗАКРІПЛЕНИЙ доказ мотиву.
|
||||||
|
: cs.finale.revealsMotive && cs.finale.recapWhy
|
||||||
|
? `Цього разу ${g(c, 'він заговорив', 'вона заговорила')} і про мотив: ${cs.finale.recapWhy} Та зізнання на словах — ще не доказ: щоб оформити ЧОМУ, знайдіть і закріпіть доказ мотиву.`
|
||||||
|
: `Але про мотив мовчить: ЧОМУ ще не доведено. Прокурор не прийме справу без ЗАКРІПЛЕНОГО доказу мотиву — знайдіть і закріпіть його, тоді оформлюйте ХТО / ЯК / ЧОМУ.`;
|
||||||
extraCard(s, 'confess', `Зізнання: ${c.name}`,
|
extraCard(s, 'confess', `Зізнання: ${c.name}`,
|
||||||
`Після пред'явлення (${ev.title.toLowerCase()}) ${c.name} ${g(c, 'зізнався', 'зізналася')}. Протокол підписано. Лишилося оформити справу: ХТО / ЯК / ЧОМУ — і закріпити докази для прокурора.`);
|
`Після пред'явлення (${ev.title.toLowerCase()}) ${c.name} ${g(c, 'зізнався', 'зізналася')} у скоєному. Протокол підписано. ${whyLine}`);
|
||||||
} else if (cs.dna2 && c.id === cs.dna2.owner && ['sf_dna2', 'lab_dna_match2'].includes(m.evId)) {
|
} else if (cs.dna2 && c.id === cs.dna2.owner && ['sf_dna2', 'lab_dna_match2'].includes(m.evId)) {
|
||||||
// невинна ДНК: людина пояснює, звідки вона там, — вірити чи ні, вирішують гравці
|
// невинна ДНК: людина пояснює, звідки вона там, — вірити чи ні, вирішують гравці
|
||||||
const vic = byIdC(cs, cs.victim);
|
const vic = byIdC(cs, cs.victim);
|
||||||
|
|
@ -751,8 +771,23 @@ function pubCitizens(cs) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const wss = new WebSocketServer({ server });
|
const wss = new WebSocketServer({ server });
|
||||||
|
// Heartbeat: без нього обірване з'єднання (сон ноута, зміна Wi-Fi, kill) лишає
|
||||||
|
// мертвий сокет у s.clients назавжди — застигла точка на мапі, «Польовий2» за
|
||||||
|
// одного гравця, роздутий лічильник. Пінгуємо всіх; хто не відповів між тіками —
|
||||||
|
// terminate, і штатний 'close' прибирає його з сесії.
|
||||||
|
const HEARTBEAT_MS = +process.env.HEARTBEAT_MS || 30000;
|
||||||
|
const heartbeat = setInterval(() => {
|
||||||
|
for (const ws of wss.clients) {
|
||||||
|
if (ws.isAlive === false) { ws.terminate(); continue; }
|
||||||
|
ws.isAlive = false;
|
||||||
|
try { ws.ping(); } catch { }
|
||||||
|
}
|
||||||
|
}, HEARTBEAT_MS);
|
||||||
|
wss.on('close', () => clearInterval(heartbeat));
|
||||||
wss.on('connection', ws => {
|
wss.on('connection', ws => {
|
||||||
ws.id = ++clientSeq;
|
ws.id = ++clientSeq;
|
||||||
|
ws.isAlive = true;
|
||||||
|
ws.on('pong', () => { ws.isAlive = true; });
|
||||||
ws.on('message', raw => {
|
ws.on('message', raw => {
|
||||||
let m; try { m = JSON.parse(raw); } catch { return; }
|
let m; try { m = JSON.parse(raw); } catch { return; }
|
||||||
if (m.t === 'join') {
|
if (m.t === 'join') {
|
||||||
|
|
@ -5,7 +5,7 @@ const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { spawn } = require('child_process');
|
const { spawn } = require('child_process');
|
||||||
const WebSocket = require('ws');
|
const WebSocket = require('ws');
|
||||||
const { generateCase } = require('./casegen');
|
const { generateCase, caseSeed } = require('./casegen');
|
||||||
|
|
||||||
const PORT = +process.env.PORT || 3102;
|
const PORT = +process.env.PORT || 3102;
|
||||||
const NO_SPAWN = !!process.env.NO_SPAWN; // підключитись до вже запущеного сервера (для дебагу з логами)
|
const NO_SPAWN = !!process.env.NO_SPAWN; // підключитись до вже запущеного сервера (для дебагу з логами)
|
||||||
|
|
@ -43,7 +43,7 @@ function solveTriangulate(cs, towers) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function playToTheEnd(session, accusation) {
|
async function playToTheEnd(session, accusation) {
|
||||||
const cs = generateCase(session);
|
const cs = generateCase(caseSeed(session)); // сервер сіє хешованим кодом — тест мусить так само
|
||||||
const killer = cs.citizens.find(c => c.id === cs.finale.killer);
|
const killer = cs.citizens.find(c => c.id === cs.finale.killer);
|
||||||
const field = client('field'); field.session = session;
|
const field = client('field'); field.session = session;
|
||||||
const analyst = client('analyst'); analyst.session = session;
|
const analyst = client('analyst'); analyst.session = session;
|
||||||
Loading…
Reference in a new issue