diff --git a/chapter3/DESIGN.md b/chapter3/DESIGN.md deleted file mode 100644 index b351cc8..0000000 --- a/chapter3/DESIGN.md +++ /dev/null @@ -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 diff --git a/detective-game/.gitignore b/detective-game/.gitignore new file mode 100644 index 0000000..19ed07e --- /dev/null +++ b/detective-game/.gitignore @@ -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 diff --git a/chapter3/README.md b/detective-game/README.md similarity index 100% rename from chapter3/README.md rename to detective-game/README.md diff --git a/chapter3/casegen/accusation.js b/detective-game/casegen/accusation.js similarity index 100% rename from chapter3/casegen/accusation.js rename to detective-game/casegen/accusation.js diff --git a/chapter3/casegen/anonymous_letter.js b/detective-game/casegen/anonymous_letter.js similarity index 100% rename from chapter3/casegen/anonymous_letter.js rename to detective-game/casegen/anonymous_letter.js diff --git a/chapter3/casegen/citizens.js b/detective-game/casegen/citizens.js similarity index 100% rename from chapter3/casegen/citizens.js rename to detective-game/casegen/citizens.js diff --git a/chapter3/casegen/constants.js b/detective-game/casegen/constants.js similarity index 100% rename from chapter3/casegen/constants.js rename to detective-game/casegen/constants.js diff --git a/chapter3/casegen/devices.js b/detective-game/casegen/devices.js similarity index 100% rename from chapter3/casegen/devices.js rename to detective-game/casegen/devices.js diff --git a/chapter3/casegen/district.js b/detective-game/casegen/district.js similarity index 100% rename from chapter3/casegen/district.js rename to detective-game/casegen/district.js diff --git a/chapter3/casegen/evening.js b/detective-game/casegen/evening.js similarity index 100% rename from chapter3/casegen/evening.js rename to detective-game/casegen/evening.js diff --git a/chapter3/casegen/index.js b/detective-game/casegen/index.js similarity index 97% rename from chapter3/casegen/index.js rename to detective-game/casegen/index.js index 55c536e..dc603ee 100644 --- a/chapter3/casegen/index.js +++ b/detective-game/casegen/index.js @@ -3,6 +3,7 @@ // граф доказів (спланований від розв'язку назад), хибні сліди (кожен — спростовний), // шифро-завдання. solve() — симулятор досяжності для fair-play тестів. +const crypto = require('crypto'); const { makeDistrict, unitBuilding } = require('./district'); const N = require('./names'); 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'); // ---------- Генерація справи ---------- +// Код кімнати (сесія) → сід справи. Хешуємо, щоб близькі коди («ABC», «ABC1») +// давали геть різні справи. Єдине джерело правди: і сервер, і тести сіють цим. +function caseSeed(code) { return crypto.createHash('sha512').update(String(code)).digest('hex'); } + function generateCase(seed) { const r = mulberry32(hashSeed(seed)); const district = makeDistrict(r); @@ -625,7 +630,7 @@ function generateCase(seed) { id: 'lab_print_match', side: 'analyst', at: { type: 'lab' }, requires: ['ev_weapon'], title: 'Повний відбиток зі знаряддя', tag: 'вбивця', detail: `Відбиток зі знаряддя порівняно з базою (звузьте кандидатів і зіставте вручну). Збіг за ${ri(r, 12, 16)} точками: ${killer.name}.`, - gives: { confirms: true } + gives: { confirms: true, pointsTo: killer.id } // чіткий доказ: годиться і в звинувачення, і на ордер }); if (!(caseType === 'murder' && mutator === 'gloves')) add({ 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}): повний збіг доріжок — ${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({ id: 'lab_dna_match2', side: 'analyst', at: { type: 'lab' }, requires: ['sf_dna2'], special: 'dna2', @@ -692,6 +699,12 @@ function generateCase(seed) { `${mutator === 'gloves' ? 'повний відбиток зі знаряддя' : 'ДНК з місця'} і камера, що впіймала зріст. База мешканців зробила решту. Справу закрито.` }; + // Мотив у прямій мові + монетка 50/50: чи проговорить вбивця мотив на зізнанні + // (для реалізму). Draw ОСТАННІЙ у генерації — після нього r не чіпаємо, тож + // наявні сейви лишаються тією ж справою, лише з новими полями фіналу. + finale.recapWhy = recapWhy; + finale.revealsMotive = chance(r, 0.5); + // admit — пряма мова хибного підозрюваного (див. FM_KINDS), а не третьоособовий // зворот: цитуємо, а не вклеюємо в речення. 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 }; diff --git a/chapter3/casegen/junk.js b/detective-game/casegen/junk.js similarity index 100% rename from chapter3/casegen/junk.js rename to detective-game/casegen/junk.js diff --git a/chapter3/casegen/motives/blackmail.js b/detective-game/casegen/motives/blackmail.js similarity index 100% rename from chapter3/casegen/motives/blackmail.js rename to detective-game/casegen/motives/blackmail.js diff --git a/chapter3/casegen/motives/business.js b/detective-game/casegen/motives/business.js similarity index 100% rename from chapter3/casegen/motives/business.js rename to detective-game/casegen/motives/business.js diff --git a/chapter3/casegen/motives/debt.js b/detective-game/casegen/motives/debt.js similarity index 100% rename from chapter3/casegen/motives/debt.js rename to detective-game/casegen/motives/debt.js diff --git a/chapter3/casegen/motives/inherit.js b/detective-game/casegen/motives/inherit.js similarity index 100% rename from chapter3/casegen/motives/inherit.js rename to detective-game/casegen/motives/inherit.js diff --git a/chapter3/casegen/motives/jealousy.js b/detective-game/casegen/motives/jealousy.js similarity index 100% rename from chapter3/casegen/motives/jealousy.js rename to detective-game/casegen/motives/jealousy.js diff --git a/chapter3/casegen/motives/kidnap_debt.js b/detective-game/casegen/motives/kidnap_debt.js similarity index 100% rename from chapter3/casegen/motives/kidnap_debt.js rename to detective-game/casegen/motives/kidnap_debt.js diff --git a/chapter3/casegen/motives/kidnap_obsession.js b/detective-game/casegen/motives/kidnap_obsession.js similarity index 100% rename from chapter3/casegen/motives/kidnap_obsession.js rename to detective-game/casegen/motives/kidnap_obsession.js diff --git a/chapter3/casegen/motives/kidnap_ransom.js b/detective-game/casegen/motives/kidnap_ransom.js similarity index 100% rename from chapter3/casegen/motives/kidnap_ransom.js rename to detective-game/casegen/motives/kidnap_ransom.js diff --git a/chapter3/casegen/motives/mistaken.js b/detective-game/casegen/motives/mistaken.js similarity index 100% rename from chapter3/casegen/motives/mistaken.js rename to detective-game/casegen/motives/mistaken.js diff --git a/chapter3/casegen/motives/oldcrime.js b/detective-game/casegen/motives/oldcrime.js similarity index 100% rename from chapter3/casegen/motives/oldcrime.js rename to detective-game/casegen/motives/oldcrime.js diff --git a/chapter3/casegen/motives/ransom.js b/detective-game/casegen/motives/ransom.js similarity index 100% rename from chapter3/casegen/motives/ransom.js rename to detective-game/casegen/motives/ransom.js diff --git a/chapter3/casegen/motives/serial.js b/detective-game/casegen/motives/serial.js similarity index 100% rename from chapter3/casegen/motives/serial.js rename to detective-game/casegen/motives/serial.js diff --git a/chapter3/casegen/motives/stalker.js b/detective-game/casegen/motives/stalker.js similarity index 100% rename from chapter3/casegen/motives/stalker.js rename to detective-game/casegen/motives/stalker.js diff --git a/chapter3/casegen/motives/witness.js b/detective-game/casegen/motives/witness.js similarity index 100% rename from chapter3/casegen/motives/witness.js rename to detective-game/casegen/motives/witness.js diff --git a/chapter3/casegen/mutators.js b/detective-game/casegen/mutators.js similarity index 100% rename from chapter3/casegen/mutators.js rename to detective-game/casegen/mutators.js diff --git a/chapter3/casegen/names.js b/detective-game/casegen/names.js similarity index 100% rename from chapter3/casegen/names.js rename to detective-game/casegen/names.js diff --git a/chapter3/casegen/rng.js b/detective-game/casegen/rng.js similarity index 100% rename from chapter3/casegen/rng.js rename to detective-game/casegen/rng.js diff --git a/chapter3/casegen/runtime.js b/detective-game/casegen/runtime.js similarity index 100% rename from chapter3/casegen/runtime.js rename to detective-game/casegen/runtime.js diff --git a/chapter3/casegen/test.js b/detective-game/casegen/test.js similarity index 100% rename from chapter3/casegen/test.js rename to detective-game/casegen/test.js diff --git a/chapter3/package-lock.json b/detective-game/package-lock.json similarity index 100% rename from chapter3/package-lock.json rename to detective-game/package-lock.json diff --git a/chapter3/package.json b/detective-game/package.json similarity index 100% rename from chapter3/package.json rename to detective-game/package.json diff --git a/chapter3/public/analyst.js b/detective-game/public/analyst.js similarity index 96% rename from chapter3/public/analyst.js rename to detective-game/public/analyst.js index a69f7bf..427cf66 100644 --- a/chapter3/public/analyst.js +++ b/detective-game/public/analyst.js @@ -267,14 +267,24 @@ 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', '', 'ХтоКаже, що бувКамери над тим місцем'); + const t = el('table', '', 'ХтоКаже, що бувКамери над тим місцем'); for (const [cid, claim] of claims) { const c = App.byId(cid); - const cams = App.init.district.cameras.filter(cam => cam.covers.includes(claim)); + // Той самий ключ, що й у базі мешканців: галочка/викреслення на особі — спільні. + const 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 ? cams.map(cam => `${esc(cam.name)}${(App.state.cctvPulled || []).includes(cam.id) ? ' — архів на дошці' : ' — архів не тягнули'}`).join('
') : 'жодна камера туди не дивиться'; - t.append(el('tr', '', `${esc(c.name)}${esc(bn(claim))}${cell}`)); + const tr = el('tr', cardClasses(key), `${esc(c.name)}${said}${cell}`); + const mk = el('td'); mk.append(markButtons(key)); tr.append(mk); + t.append(tr); } box.innerHTML = ''; box.append(t); } diff --git a/chapter3/public/client.js b/detective-game/public/client.js similarity index 94% rename from chapter3/public/client.js rename to detective-game/public/client.js index 420534a..ae31fba 100644 --- a/chapter3/public/client.js +++ b/detective-game/public/client.js @@ -178,6 +178,23 @@ function cardClasses(key) { 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() { if (!App.state) return; const body = $("#sidebody"); @@ -211,6 +228,7 @@ function renderSide() { const buildCard = (e) => { const key = "e:" + e.id; const c = el("div", "card" + cardClasses(key)); + c.dataset.ev = e.id; // якір для прокрутки-до-доказу const t = el("div", "t"); t.append(el("b", "", esc(e.title))); const pin = el( @@ -220,7 +238,17 @@ function renderSide() { ); pin.onclick = () => App.send({ t: "pin", evId: e.id }); 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] || []; for (const n of notes) { const row = el( diff --git a/chapter3/public/field.js b/detective-game/public/field.js similarity index 98% rename from chapter3/public/field.js rename to detective-game/public/field.js index 3be7ac6..f2012d6 100644 --- a/chapter3/public/field.js +++ b/detective-game/public/field.js @@ -2,6 +2,7 @@ "use strict"; (() => { let cv, ctx, hint; + let started = false; // слухачі/цикл/розсилку ставимо раз, навіть якщо init прийде знову let player = { x: 85, y: 290 }; const keys = {}; let solids = [], @@ -168,11 +169,16 @@ 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)); + // init прилітає й на реконекті: канвас/стан перебудували вище, а глобальні + // слухачі, цикл рендеру й розсилку позицій ставимо ЛИШЕ раз — інакше після + // кожного реконекту крутився б зайвий loop і дублювались клавіші. + if (started) return; + started = true; + addEventListener("resize", () => fitCanvas(cv)); // e.code — фізична клавіша, працює на будь-якій розкладці (укр/лат) addEventListener("keydown", (e) => { if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA") diff --git a/chapter3/public/index.html b/detective-game/public/index.html similarity index 98% rename from chapter3/public/index.html rename to detective-game/public/index.html index 114e149..ba54b03 100644 --- a/chapter3/public/index.html +++ b/detective-game/public/index.html @@ -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; } .card .t { display:flex; gap:6px; align-items:baseline; } .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 button { padding:1px 8px; font-size:12px; } .mk button.on { border-color:#7a1f1f; color:#7a1f1f; font-weight:bold; } diff --git a/chapter3/public/sound.js b/detective-game/public/sound.js similarity index 100% rename from chapter3/public/sound.js rename to detective-game/public/sound.js diff --git a/chapter3/server.js b/detective-game/server.js similarity index 92% rename from chapter3/server.js rename to detective-game/server.js index 2791ccd..eab50fc 100644 --- a/chapter3/server.js +++ b/detective-game/server.js @@ -4,9 +4,8 @@ const http = require('http'); const fs = require('fs'); const path = require('path'); const os = require('os'); -const crypto = require('crypto'); const { WebSocketServer } = require('ws'); -const { generateCase, checkAccusation, md5 } = require('./casegen'); +const { generateCase, caseSeed, checkAccusation, md5 } = require('./casegen'); const { unitBuilding } = require('./casegen/district'); const RT = require('./casegen/runtime'); @@ -40,7 +39,7 @@ function seedRand(seed) { function getSession(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 rng = seedRand([...hashedCode].reduce((a, c) => a * 31 + c.charCodeAt(0), 0)); const stn = cs.district.buildings.find(b => b.id === 'station'); @@ -131,7 +130,11 @@ 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 }; + 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); const killerC = cs.citizens.find(c => c.id === 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), locs: s.locs, markers: s.markers, log: s.log, 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)), dnaSample: st.found.includes('sf_dna') ? dnaOwner.dna : null, dnaLabel: st.found.includes('sf_dna') ? cs.dnaDesc : null, @@ -565,8 +574,19 @@ function handle(s, ws, m) { `${c.name} впізнає слід біля місця злочину: ${g(c, 'проходив', 'проходила')} там того вечора. До справи відношення не має.`); } else if (K && gives.confirms) { 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}`, - `Після пред'явлення (${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)) { // невинна ДНК: людина пояснює, звідки вона там, — вірити чи ні, вирішують гравці const vic = byIdC(cs, cs.victim); @@ -751,8 +771,23 @@ function pubCitizens(cs) { } 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 => { ws.id = ++clientSeq; + ws.isAlive = true; + ws.on('pong', () => { ws.isAlive = true; }); ws.on('message', raw => { let m; try { m = JSON.parse(raw); } catch { return; } if (m.t === 'join') { diff --git a/chapter3/test-server.js b/detective-game/test-server.js similarity index 98% rename from chapter3/test-server.js rename to detective-game/test-server.js index 8e59f6a..bdec899 100644 --- a/chapter3/test-server.js +++ b/detective-game/test-server.js @@ -5,7 +5,7 @@ const fs = require('fs'); const path = require('path'); const { spawn } = require('child_process'); const WebSocket = require('ws'); -const { generateCase } = require('./casegen'); +const { generateCase, caseSeed } = require('./casegen'); const PORT = +process.env.PORT || 3102; const NO_SPAWN = !!process.env.NO_SPAWN; // підключитись до вже запущеного сервера (для дебагу з логами) @@ -43,7 +43,7 @@ function solveTriangulate(cs, towers) { } 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 field = client('field'); field.session = session; const analyst = client('analyst'); analyst.session = session;