From 645566b295c142b40941ee1c3088089c6964b174 Mon Sep 17 00:00:00 2001 From: UndecimoDia Date: Thu, 23 Jul 2026 18:10:36 +0300 Subject: [PATCH] refactoring casegen + new anonymous letter and copy --- chapter3/casegen/accusation.js | 70 ++ chapter3/casegen/anonymous_letter.js | 55 + chapter3/casegen/citizens.js | 68 ++ chapter3/casegen/constants.js | 29 + chapter3/casegen/devices.js | 86 ++ chapter3/casegen/evening.js | 33 + chapter3/casegen/index.js | 1016 +++--------------- chapter3/casegen/junk.js | 100 ++ chapter3/casegen/motives/blackmail.js | 36 + chapter3/casegen/motives/business.js | 40 + chapter3/casegen/motives/debt.js | 36 + chapter3/casegen/motives/inherit.js | 33 + chapter3/casegen/motives/jealousy.js | 40 + chapter3/casegen/motives/kidnap_debt.js | 47 + chapter3/casegen/motives/kidnap_obsession.js | 40 + chapter3/casegen/motives/kidnap_ransom.js | 39 + chapter3/casegen/motives/mistaken.js | 33 + chapter3/casegen/motives/oldcrime.js | 32 + chapter3/casegen/motives/ransom.js | 38 + chapter3/casegen/motives/serial.js | 45 + chapter3/casegen/motives/stalker.js | 35 + chapter3/casegen/motives/witness.js | 39 + chapter3/casegen/mutators.js | 39 + chapter3/casegen/rng.js | 76 ++ chapter3/casegen/test.js | 4 +- chapter3/test-server.js | 2 +- 26 files changed, 1239 insertions(+), 872 deletions(-) create mode 100644 chapter3/casegen/accusation.js create mode 100644 chapter3/casegen/anonymous_letter.js create mode 100644 chapter3/casegen/citizens.js create mode 100644 chapter3/casegen/constants.js create mode 100644 chapter3/casegen/devices.js create mode 100644 chapter3/casegen/evening.js create mode 100644 chapter3/casegen/junk.js create mode 100644 chapter3/casegen/motives/blackmail.js create mode 100644 chapter3/casegen/motives/business.js create mode 100644 chapter3/casegen/motives/debt.js create mode 100644 chapter3/casegen/motives/inherit.js create mode 100644 chapter3/casegen/motives/jealousy.js create mode 100644 chapter3/casegen/motives/kidnap_debt.js create mode 100644 chapter3/casegen/motives/kidnap_obsession.js create mode 100644 chapter3/casegen/motives/kidnap_ransom.js create mode 100644 chapter3/casegen/motives/mistaken.js create mode 100644 chapter3/casegen/motives/oldcrime.js create mode 100644 chapter3/casegen/motives/ransom.js create mode 100644 chapter3/casegen/motives/serial.js create mode 100644 chapter3/casegen/motives/stalker.js create mode 100644 chapter3/casegen/motives/witness.js create mode 100644 chapter3/casegen/mutators.js create mode 100644 chapter3/casegen/rng.js diff --git a/chapter3/casegen/accusation.js b/chapter3/casegen/accusation.js new file mode 100644 index 0000000..6705c8c --- /dev/null +++ b/chapter3/casegen/accusation.js @@ -0,0 +1,70 @@ +// casegen/accusation.js — симулятор досяжності та перевірка звинувачення +const RT = require('./runtime'); + +function solve(cs) { + const E = cs.evidence; + const st = RT.newState(); + let changed = true; + while (changed) { + changed = false; + for (const cid of st.pointed) if (!st.warrants.includes(cid)) { st.warrants.push(cid); changed = true; } + for (const ev of Object.values(E)) { + if (st.found.includes(ev.id) || !RT.available(cs, st, ev, { ignorePuzzles: true })) continue; + RT.applyFound(cs, st, ev); changed = true; + } + } + const has = pred => Object.values(E).some(ev => st.found.includes(ev.id) && pred(ev)); + return { + reached: new Set(st.found), identity: st.identity, + killerConfirmed: has(ev => ev.gives.confirms), + methodKnown: has(ev => ev.gives.method), + motiveProven: has(ev => ev.gives.motive), + herringDisproved: has(ev => ev.gives.disproves === cs.herring), + herringLeadFound: st.found.includes('her_lead'), + lairProven: has(ev => ev.gives.lair), + finalCandidates: RT.candidates(cs, st) + }; +} + +function checkAccusation(cs, ans, pinned) { + const E = cs.evidence, killer = cs.citizens.find(c => c.id === cs.finale.killer); + const has = pred => pinned.some(id => E[id] && pred(E[id])); + const hasConfirmFor = (name) => pinned.some(id => { + const ev = E[id]; + if (!ev || !ev.gives || !ev.gives.confirms) return false; + if (ev.gives.pointsTo) { + const target = cs.citizens.find(c => c.id === ev.gives.pointsTo); + return target && target.name.toLowerCase() === name; + } + return true; + }); + const accused = (ans.who || '').trim().toLowerCase(); + if (accused !== killer.name.toLowerCase()) + return { ok: false, hint: 'Ім\'я не сходиться з доказами: сліди з місця злочину (взуття, тканина, зріст, ДНК) звужують коло — і хтось із перевірених має підтверджене алібі.' }; + if (cs.caseType === 'kidnap') { + if (ans.how !== cs.finale.where) + return { ok: false, hint: 'Місце не сходиться. ДЕ саме вказують знахідки: бирка ключа, план, чек за замок?' }; + if (ans.why !== cs.finale.motive) + return { ok: false, hint: 'Мотив не той. ЩО пов\'язувало викрадача зі зниклою особою?' }; + if (!hasConfirmFor(accused)) + return { ok: false, hint: 'Особу викрадача не підтверджено: потрібен збіг ДНК або спростоване алібі на дошці.' }; + if (!has(ev => ev.gives.motive)) + return { ok: false, hint: 'Мотив не підкріплено жодним закріпленим доказом.' }; + if (!has(ev => ev.gives.lair)) + return { ok: false, hint: 'Місце утримання не підкріплено закріпленим доказом — штурм наосліп ніхто не санкціонує.' }; + return { ok: true }; + } + if (ans.how !== cs.finale.method) + return { ok: false, hint: 'Спосіб суперечить висновку розтину. Перечитайте, що саме сказав патологоанатом.' }; + if (ans.why !== cs.finale.motive) + return { ok: false, hint: 'Мотив не той. ЩО пов\'язувало цих двох людей за тиждень до смерті?' }; + if (!hasConfirmFor(accused)) + return { ok: false, hint: 'Немає прямого підтвердження особи: потрібен збіг ДНК, повний відбиток зі знаряддя або спростоване алібі на дошці.' }; + if (!has(ev => ev.gives.motive)) + return { ok: false, hint: 'Мотив не підкріплено жодним закріпленим доказом.' }; + if (!has(ev => ev.gives.method)) + return { ok: false, hint: 'Спосіб смерті не підкріплено: закріпіть висновок розтину.' }; + return { ok: true }; +} + +module.exports = { solve, checkAccusation }; diff --git a/chapter3/casegen/anonymous_letter.js b/chapter3/casegen/anonymous_letter.js new file mode 100644 index 0000000..d51bb0e --- /dev/null +++ b/chapter3/casegen/anonymous_letter.js @@ -0,0 +1,55 @@ +// casegen/anonymous_letter.js — генерація анонімного листа +const { pick, chance } = require('./rng'); + +function generateAnonymousLetter(ctx) { + const { r, killer, victim, citizens, add } = ctx; + + const letterType = pick(r, [1, 2, 3]); + let detail = ''; + + if (letterType === 1) { + const hints = [ + `Шукайте серед тих, хто носить ${killer.coat}.`, + `Вбивця — ${killer.sex === 'ч' ? 'чоловік' : 'жінка'} на зріст приблизно ${killer.height} см.`, + `Той, хто це зробив, має татуювання — "${killer.tattoo}".`, + `Вбивця працює ${killer.job === 'тимчасово не працює' ? 'без роботи' : killer.job + 'ом'}.`, + `Слід шукати серед мешканців будинку ${killer.home}.` + ].filter(h => !h.includes('undefined') && !h.includes('null')); + if (hints.length === 0) return null; + detail = pick(r, hints) + ' Це все, що я можу сказати.'; + } else if (letterType === 2) { + const innocent = pick(r, citizens.filter(c => c.id !== killer.id && c.id !== victim.id)); + const falseHints = [ + `Шукайте серед тих, хто носить ${innocent.coat}.`, + `Вбивця — ${innocent.sex === 'ч' ? 'чоловік' : 'жінка'} на зріст приблизно ${innocent.height} см.`, + `Той, хто це зробив, має татуювання — "${innocent.tattoo}".`, + `Вбивця працює ${innocent.job === 'тимчасово не працює' ? 'без роботи' : innocent.job + 'ом'}.`, + `Слід шукати серед мешканців будинку ${innocent.home}.` + ].filter(h => !h.includes('undefined') && !h.includes('null')); + if (falseHints.length === 0) return null; + detail = pick(r, falseHints) + ' Не дайте йому втекти.'; + } else { + const neutralHints = [ + 'Злочин стався близько півночі, але не раніше.', + 'Жертва не була випадковою — її знали.', + 'Місце злочину обране не випадково.', + 'Слідів більше, ніж здається на перший погляд.', + 'Дехто в районі знає більше, ніж говорить.' + ]; + detail = pick(r, neutralHints); + } + + if (!detail) return null; + + return add({ + id: 'anonymous_letter', + side: 'analyst', + at: { type: 'start' }, + title: 'Анонімний лист', + tag: 'підказка', + detail: `Лист без підпису, надісланий до відділку: «${detail}». Походження невідоме.`, + gives: {} + }); +} + +module.exports = { generateAnonymousLetter }; diff --git a/chapter3/casegen/citizens.js b/chapter3/casegen/citizens.js new file mode 100644 index 0000000..e87058f --- /dev/null +++ b/chapter3/casegen/citizens.js @@ -0,0 +1,68 @@ +// casegen/citizens.js — генерація мешканців району +const N = require('./names'); +const { allUnits, allJobs } = require('./district'); +const { pick, chance, shuffle, ri } = require('./rng'); + +const BLOOD = ['O(I) Rh+', 'O(I) Rh+', 'A(II) Rh+', 'A(II) Rh+', 'B(III) Rh+', 'AB(IV) Rh+', 'O(I) Rh−', 'A(II) Rh−']; +const PRINTS = ['дуга', 'петля', 'петля', 'завиток', 'завиток']; + +function makeCitizens(r, district) { + const citizens = []; + const usedNames = new Set(), usedPhones = new Set(), usedDna = new Set(); + let nextId = 1; + + function person(sex, last, ageRange) { + let first, full; + do { first = pick(r, sex === 'ч' ? N.MALE : N.FEMALE); full = `${first} ${last}`; } while (usedNames.has(full)); + usedNames.add(full); + const age = ri(r, ageRange[0], ageRange[1]); + const height = sex === 'ч' ? ri(r, 165, 195) : ri(r, 152, 183); + let phone; + do { phone = `+380${pick(r, ['50', '63', '66', '67', '68', '73', '95', '96', '97', '98', '99'])}${String(ri(r, 0, 9999999)).padStart(7, '0')}`; } while (usedPhones.has(phone)); + usedPhones.add(phone); + let dna; + do { dna = Array.from({ length: 8 }, () => ri(r, 0, 15)); } while (usedDna.has(dna.join(','))); + usedDna.add(dna.join(',')); + return { + id: 'c' + nextId++, name: full, first, last, sex, age, birthYear: 2026 - age, + blood: pick(r, BLOOD), height, + shoe: Math.max(35, Math.min(46, Math.round(height * 0.24) + ri(r, -1, 1))), + printType: pick(r, PRINTS), printSeed: ri(r, 1, 2 ** 30), coat: pick(r, N.COATS), + smoker: chance(r, 0.3), dog: chance(r, 0.2) ? pick(r, N.DOG_NAMES) : null, + tattoo: chance(r, 0.25) ? pick(r, N.TATTOOS) : null, + phone, dna, home: null, job: null, workplace: null, spouse: null + }; + } + + const unitList = allUnits(district); + const sizes = {}; + shuffle(r, unitList.filter(u => u[0] !== 'C')).forEach((u, i) => sizes[u] = i < 3 ? 1 : pick(r, [1, 2, 2, 3, 3, 4])); + for (const u of unitList) if (u[0] === 'C') sizes[u] = pick(r, [1, 1, 2]); + for (const unit of unitList) { + const dorm = unit[0] === 'C'; + const size = sizes[unit]; + const last = pick(r, N.SURNAMES); + if (size === 1) { + const p = person(pick(r, ['ч', 'ж']), last, dorm ? [19, 45] : [25, 74]); + p.home = unit; citizens.push(p); + } else { + const a = person('ч', last, [26, 68]); + const b = person('ж', chance(r, 0.7) ? last : pick(r, N.SURNAMES), [Math.max(22, a.age - 8), Math.min(70, a.age + 8)]); + if (!dorm) { a.spouse = b.id; b.spouse = a.id; } + a.home = b.home = unit; citizens.push(a, b); + for (let k = 2; k < size; k++) { + const kid = person(pick(r, ['ч', 'ж']), last, [18, Math.max(19, a.age - 22)]); + kid.home = unit; citizens.push(kid); + } + } + } + + const jobs = shuffle(r, allJobs(district)); + const workers = shuffle(r, citizens.filter(c => c.age <= 65)); + workers.slice(0, jobs.length).forEach((c, i) => { c.job = jobs[i].title; c.workplace = jobs[i].building; }); + for (const c of citizens) if (!c.job) + c.job = c.age > 62 ? 'пенсіонер' : c.age < 23 ? 'студент' : 'тимчасово не працює'; + return citizens; +} + +module.exports = { makeCitizens }; diff --git a/chapter3/casegen/constants.js b/chapter3/casegen/constants.js new file mode 100644 index 0000000..d356bea --- /dev/null +++ b/chapter3/casegen/constants.js @@ -0,0 +1,29 @@ +// casegen/constants.js — довідники фіналу +const METHODS = [ + { id: 'stab', label: 'Ніж', autopsy: 'дві колото-різані рани; знаряддя — вузький клинок ~2 см', weapon: 'кухонний ніж із вузьким лезом' }, + { id: 'blunt', label: 'Тупий предмет', autopsy: 'тупа травма голови; слід шестигранної грані', weapon: 'розвідний ключ' }, + { id: 'strangle', label: 'Удушення', autopsy: 'странгуляційна борозна; у рані — синтетичні волокна шнура', weapon: 'капроновий шнур' }, + { id: 'poison', label: 'Отруєння', autopsy: 'слідів боротьби немає; токсикологія — летальна доза барбітуратів у алкоголі', weapon: 'пляшка коньяку з домішкою' } +]; +const METHOD_OPTIONS = ['Ніж', 'Тупий предмет', 'Удушення', 'Отруєння', 'Падіння з висоти']; +const MOTIVE_OPTIONS = ['Ревнощі', 'Борг', 'Шантаж', 'Свідок злочину', 'Помилкова ціль', 'Пограбування', + 'Спадок і страховка', 'Старі гріхи', 'Одержимість', 'Кримінальний бізнес', 'Серійний патерн', 'Викуп']; +const SIGNATURES = [ + { text: 'чорна стрічка, акуратно зав\'язана на зап\'ясті', key: 'стрічка' }, + { text: 'крейдяне коло, дбайливо обведене довкола тіла', key: 'коло' }, + { text: 'дитяча іграшкова машинка, вкладена в кишеню', key: 'машинка' }, + { text: 'гральна карта — піковий валет — під долонею', key: 'валет' }, + { text: 'засушена фіалка, вкладена між пальцями', key: 'фіалка' }, + { text: 'зупинений годинник, переведений на 3:33', key: 'годинник' }, + { text: 'ґудзик, відрізаний і покладений на повіку', key: 'ґудзик' }, + { text: 'аркуш нотного паперу з єдиною нотою', key: 'нота' }, + { text: 'ключ без бородки, вкладений у долоню', key: 'ключ' }]; +const LAIRS = [ + { building: 'pier', spot: 'елінг', label: 'Елінг на причалі' }, + { building: 'shop', spot: 'склад', label: 'Склад мінімаркету' }, + { building: 'bar', spot: 'підсобка', label: 'Підсобка бару' }, + { building: 'garage', spot: 'яма', label: 'Оглядова яма автомайстерні' }, + { building: 'diner', spot: 'роздягальня персоналу', label: 'Підсобні приміщення їдальні' } +]; + +module.exports = { METHODS, METHOD_OPTIONS, MOTIVE_OPTIONS, SIGNATURES, LAIRS }; diff --git a/chapter3/casegen/devices.js b/chapter3/casegen/devices.js new file mode 100644 index 0000000..e155893 --- /dev/null +++ b/chapter3/casegen/devices.js @@ -0,0 +1,86 @@ +// casegen/devices.js — телефони та ноутбуки фігурантів, PIN/паролі, вміст +const { chance, pick, shuffle, ri, md5 } = require('./rng'); +const { genPassword } = require('./rng'); + +function setupDevices(ctx) { + const { r, citizens, killer, herring, victim, accomplice, caseType, templateId, district, add, addPuzzle, byId, E, method, fm } = ctx; + + const HIST_COMMON = ['погода на тиждень', 'розклад маршруток', 'рецепт як у їдальні', 'дешеві авіаквитки', + 'чому болить спина зранку', 'серіали за вечір список', 'як позбутися мурах', 'гороскоп терези']; + const CHAT_COMMON = ['«Купи хліба» — «Вже йду»', '«Ти де?» — «Затримуюсь, не чекайте»', + '«Завтра зміна з восьмої» — «Прийду»', '«Мама дзвонила?» — «Подзвоню ввечері»']; + + const histFor = who => { + if (who === killer) return caseType === 'kidnap' + ? pick(r, [['навісні замки які надійніші', 'скільки людина може без їжі'], ['чи пишуть камери вночі', 'зняти готівку без банкомата']]) + : method.id === 'poison' + ? [['барбітурати дозування', 'через скільки діє снодійне'], ['алкоголь і снодійне сумісність', 'чи виявляє розтин отруту']][ri(r, 0, 1)] + : pick(r, [['чи бачать камери вночі', 'як поводитись на допиті'], ['квитки в один бік дешево', 'строк давності тяжких злочинів']]); + if (who === herring) return { + 'Борг': ['мікропозика без відмови', 'як швидко заробити'], + 'Ревнощі': ['як повернути кохану людину', 'чому мене не помічають'], + 'Сусідська війна': ['зразок скарги на сусіда', 'куди скаржитись на шум'], + 'Розбите авто': ['вартість ремонту крила', 'як стягнути збитки з винуватця'] + }[fm.label]; + if (accomplice && who === accomplice) return ['чи можна відкликати свідчення', 'адвокат недорого консультація']; + return { + jealousy: ['готелі подобово поруч'], debt: ['шаблон розписки про борг'], blackmail: ['анонімна пошта створити'], + witness: ['куди повідомити про крадіжку'], stalker: ['як зафіксувати переслідування'], inherit: ['зразок заповіту'], + oldcrime: ['строк давності злочинів'], mistaken: ['як змінити номер телефону'], business: ['митні правила дрібні партії'], + serial: ['самооборона курси'], ransom: ['страхування життя умови'], k_ransom: ['страхування життя умови'], + k_obsession: ['як зафіксувати переслідування'], k_debt: ['шаблон розписки про борг'] + }[templateId] || []; + }; + + const usedDevSlots = new Set(); + const devFigures = [[killer, 'k'], [herring, 'h']]; + if (accomplice) devFigures.push([accomplice, 'a']); + if (caseType === 'murder' && !['jealousy', 'witness', 'blackmail'].includes(templateId)) devFigures.push([victim, 'v']); + + for (const [who, suf] of devFigures) { + const isPhone = chance(r, 0.5); + const taken = sl => usedDevSlots.has(who.home + '|' + sl) || + Object.values(E).some(e => e.at.type === 'unit' && e.at.unit === who.home && e.at.slot === sl); + const slot = shuffle(r, district.homeSlots).find(sl => !taken(sl)) || 'комора'; + usedDevSlots.add(who.home + '|' + slot); + let pz; + if (isPhone) { + const pin = String(who.birthYear); + pz = addPuzzle({ + id: 'pz_dev_' + suf, kind: 'pin', hash: md5(pin), answer: pin, + prompt: `Вилучений телефон (${who.name}): заблоковано 4-значним PIN.`, + hint: 'Люди ставлять те, що не забудуть. Рік народження власника є в реєстрі мешканців.' + }); + } else { + const pw = genPassword(r, who); + pz = addPuzzle({ + id: 'pz_dev_' + suf, kind: 'md5', hash: md5(pw.answer), answer: pw.answer, style: pw.style, + prompt: `Вилучений ноутбук (${who.name}): вхід за паролем. Хеш (md5): ${md5(pw.answer)}.`, + hint: (pw.personal ? 'Особистий пароль — щось про власника, латиницею (див. реєстр): ' : 'Пароль випадковий — лише словниковий перебір: ') + pw.hint + }); + } + add({ + id: 'dev_' + suf, side: 'field', at: { type: 'unit', unit: who.home, slot }, + title: `${isPhone ? 'Телефон' : 'Ноутбук'}: ${who.name}`, tag: 'слід', + detail: `${isPhone ? 'Телефон' : 'Ноутбук'} у житлі ${who.name}. Вилучено для лабораторії: ${isPhone ? 'заблокований PIN-кодом' : 'вхід за паролем'} — далі справа аналітика.` + }); + const parts = []; + if (chance(r, 0.3)) { + const lines = shuffle(r, [...histFor(who), ...shuffle(r, HIST_COMMON).slice(0, 3)]).slice(0, 4); + parts.push('Історія браузера: ' + lines.map(l => `«${l}»`).join(', ') + '.'); + } + if (chance(r, 0.3)) { + const mateC = who.spouse ? byId(who.spouse) : pick(r, citizens.filter(o => o !== who && o.id !== victim.id)); + parts.push(`Останні чати: побут із ${mateC.name}. ` + pick(r, CHAT_COMMON) + + (who === killer ? ` І одне непрочитане того вечора: «Подзвони, як будеш удома» — без відповіді.` : '')); + } + add({ + id: 'dev_' + suf + '_data', side: 'analyst', at: { type: 'lab' }, requires: ['dev_' + suf], puzzle: pz, + title: `Вміст пристрою: ${who.name}`, tag: 'слід', + detail: parts.length ? parts.join(' ') + : 'Чисто: банк, погода, пасьянс. Або власник обережний, або йому справді нічого ховати.' + }); + } +} + +module.exports = { setupDevices }; diff --git a/chapter3/casegen/evening.js b/chapter3/casegen/evening.js new file mode 100644 index 0000000..2d2155f --- /dev/null +++ b/chapter3/casegen/evening.js @@ -0,0 +1,33 @@ +// casegen/evening.js — налаштування вечора злочину: +// де хто був насправді (evening) і що каже (claim), брехуни, спільники +const { chance, pick } = require('./rng'); +const { unitBuilding } = require('./district'); +const N = require('./names'); + +function setupEvening(ctx) { + const { r, citizens, victim, killer, herring, district, scene, coveredB } = ctx; + + for (const c of citizens) { + c.evening = c.workplace && chance(r, 0.2) ? c.workplace + : chance(r, 0.2) ? pick(r, ['bar', 'diner']) : unitBuilding(c.home); + c.liar = chance(r, 0.15); + } + victim.evening = scene.building; victim.liar = false; + killer.evening = scene.building; killer.liar = true; + + herring.evening = pick(r, coveredB.filter(b => b !== scene.building && b !== 'station')) || unitBuilding(herring.home); + herring.liar = false; + + const otherB = b => pick(r, district.buildings.filter(x => x.id !== b && x.id !== 'station')).id; + for (const c of citizens) c.claim = c.liar ? otherB(c.evening) : c.evening; + killer.claim = pick(r, coveredB.filter(b => b !== scene.building && b !== 'station')) || otherB(scene.building); + + for (const c of citizens) { + if (!c.liar || c === killer) continue; + c.excuse = pick(r, N.EXCUSES); + const m2 = citizens.find(o => o !== c && o.id !== victim.id && o.id !== killer.id && o.evening === c.evening); + c.mate = m2 ? m2.id : null; + } +} + +module.exports = { setupEvening }; diff --git a/chapter3/casegen/index.js b/chapter3/casegen/index.js index fcf6905..55c536e 100644 --- a/chapter3/casegen/index.js +++ b/chapter3/casegen/index.js @@ -3,184 +3,33 @@ // граф доказів (спланований від розв'язку назад), хибні сліди (кожен — спростовний), // шифро-завдання. solve() — симулятор досяжності для fair-play тестів. -const crypto = require('crypto'); -const { makeDistrict, unitBuilding, allUnits, allJobs } = require('./district'); +const { makeDistrict, unitBuilding } = require('./district'); const N = require('./names'); - -// ---------- RNG (детермінований від seed-рядка) ---------- -function hashSeed(str) { - let h = 2166136261 >>> 0; - for (const c of String(str)) { h ^= c.codePointAt(0); h = Math.imul(h, 16777619); } - return h >>> 0; -} -function mulberry32(a) { - return function () { - a |= 0; a = (a + 0x6D2B79F5) | 0; - let t = Math.imul(a ^ (a >>> 15), 1 | a); - t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; - return ((t ^ (t >>> 14)) >>> 0) / 4294967296; - }; -} -const ri = (r, a, b) => a + Math.floor(r() * (b - a + 1)); -// оцінка «на око»: асиметричний діапазон навколо справжнього значення. -// нижня межа завжди нижча за справжнє, верхня — вища, тож ціль всередині. -const estRange = (r, actual, lo = 3, hi = 5) => `${actual - ri(r, lo, hi)}–${actual + ri(r, lo, hi)}`; -const pick = (r, arr) => arr[Math.floor(r() * arr.length)]; -const chance = (r, p) => r() < p; -function shuffle(r, arr) { - const a = arr.slice(); - for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(r() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } - return a; -} - -// ---------- Шифри ---------- -const UKR = 'абвгґдеєжзиіїйклмнопрстуфхцчшщьюя'; -function caesar(text, k) { - return [...text].map(ch => { - const lower = ch.toLowerCase(), i = UKR.indexOf(lower); - if (i < 0) return ch; - const out = UKR[(i + k + UKR.length) % UKR.length]; - return ch === lower ? out : out.toUpperCase(); - }).join(''); -} -const b64 = t => Buffer.from(t, 'utf8').toString('base64'); -const md5 = t => crypto.createHash('md5').update(t, 'utf8').digest('hex'); - -// ---------- Паролі: транслітерація + алгоритм генерації ---------- -// Люди пишуть паролі латиницею. Кличка собаки й ім'я в досьє — кирилицею -// (рідна мова), тож «особистий» пароль треба ще й транслітерувати — це і є думання. -const TRANSLIT = { а:'a',б:'b',в:'v',г:'h',ґ:'g',д:'d',е:'e',є:'ie',ж:'zh',з:'z',и:'y',і:'i',ї:'i',й:'i',к:'k',л:'l',м:'m',н:'n',о:'o',п:'p',р:'r',с:'s',т:'t',у:'u',ф:'f',х:'kh',ц:'ts',ч:'ch',ш:'sh',щ:'shch',ь:'',ю:'iu',я:'ia' }; -const translit = s => [...String(s).toLowerCase()].map(c => TRANSLIT[c] ?? c).join('').replace(/[^a-z0-9]/g, ''); - -// Реальні паролі з rockyou.txt (усі перевірені grep-ом). «Випадковий» пароль -// не має жодного зв'язку з жертвою — його бере лише перебір за словником. -const COMMON_PASSWORDS = ['sunshine', 'dragon', 'monkey', 'princess', 'football', 'iloveyou', - 'superman', 'batman', 'master', 'hunter', 'freedom', 'trustno1', 'whatever', 'ginger', - 'flower', 'summer', 'winter', 'orange', 'purple', 'silver', 'letmein', 'qwerty', 'pepper', - 'cookie', 'tigger', 'jasmine', 'soccer', 'maggie', 'hannah', 'ashley', 'chelsea', 'butterfly', - 'password', 'shadow', 'michael', 'jessica', 'charlie', 'daniel', 'jordan', 'matrix', 'falcon', - 'phoenix', 'rainbow', 'diamond', 'banana', 'cheese', 'coffee', 'chocolate', 'midnight', - 'october', 'november', 'chester', 'oliver', 'ranger', 'harley', 'buster', 'george', 'sammy', - 'pumpkin', 'muffin', 'peanut', 'smokey', 'bandit', 'lucky', 'angel', 'lovely', 'sunflower', - 'babygirl', 'bubbles', 'snickers', 'chicken', 'turtle', 'rabbit', 'dolphin', 'junior', - 'dexter', 'guitar', 'hammer', 'winner', 'copper', 'marina', 'sierra', 'tucker', 'willow', - 'zombie', 'viking', 'thunder', 'topgun', 'maverick', 'eclipse', 'scorpion', 'panther']; - -// Алгоритм пароля: половина справ — особисте (читай досьє + транслітеруй), -// половина — випадкове (лише вордліст). Повертає {answer, style, hint, personal}. -function genPassword(r, victim) { - const yr = String(victim.birthYear), yr2 = yr.slice(2); - const personal = []; - if (victim.dog) personal.push( - { answer: translit(victim.dog), hint: 'кличка собаки латиницею (див. досьє)' }, - { answer: translit(victim.dog) + yr, hint: 'кличка собаки латиницею + повний рік народження' }); - personal.push( - { answer: translit(victim.first) + yr, hint: `ім'я латиницею + повний рік народження` }, - { answer: translit(victim.last) + yr2, hint: 'прізвище латиницею + дві цифри року народження' }, - { answer: translit(victim.first) + translit(victim.last), hint: `ім'я та прізвище латиницею разом` }); - // 50/50 особисте vs випадкове - if (chance(r, 0.5)) { - const p = pick(r, personal); - return { ...p, style: 'personal', personal: true }; - } - return { answer: pick(r, COMMON_PASSWORDS), style: 'random', personal: false, - hint: 'жодного зв\'язку з жертвою — тільки перебір за словником (rockyou.txt)' }; -} - -// ---------- Мешканці ---------- -const BLOOD = ['O(I) Rh+', 'O(I) Rh+', 'A(II) Rh+', 'A(II) Rh+', 'B(III) Rh+', 'AB(IV) Rh+', 'O(I) Rh−', 'A(II) Rh−']; -const PRINTS = ['дуга', 'петля', 'петля', 'завиток', 'завиток']; - -function makeCitizens(r, district) { - const citizens = []; - const usedNames = new Set(), usedPhones = new Set(), usedDna = new Set(); - let nextId = 1; - - function person(sex, last, ageRange) { - let first, full; - do { first = pick(r, sex === 'ч' ? N.MALE : N.FEMALE); full = `${first} ${last}`; } while (usedNames.has(full)); - usedNames.add(full); - const age = ri(r, ageRange[0], ageRange[1]); - const height = sex === 'ч' ? ri(r, 165, 195) : ri(r, 152, 183); - let phone; - do { phone = `+380${pick(r, ['50', '63', '66', '67', '68', '73', '95', '96', '97', '98', '99'])}${String(ri(r, 0, 9999999)).padStart(7, '0')}`; } while (usedPhones.has(phone)); - usedPhones.add(phone); - let dna; - do { dna = Array.from({ length: 8 }, () => ri(r, 0, 15)); } while (usedDna.has(dna.join(','))); - usedDna.add(dna.join(',')); - return { - id: 'c' + nextId++, name: full, first, last, sex, age, birthYear: 2026 - age, - blood: pick(r, BLOOD), height, - shoe: Math.max(35, Math.min(46, Math.round(height * 0.24) + ri(r, -1, 1))), - printType: pick(r, PRINTS), printSeed: ri(r, 1, 2 ** 30), coat: pick(r, N.COATS), - smoker: chance(r, 0.3), dog: chance(r, 0.2) ? pick(r, N.DOG_NAMES) : null, - tattoo: chance(r, 0.25) ? pick(r, N.TATTOOS) : null, - phone, dna, home: null, job: null, workplace: null, spouse: null - }; - } - - // розміри родин — від кількості квартир у згенерованому районі; - // щонайменше 3 квартири-одинаки (серед них генератор обере жертву) - const unitList = allUnits(district); - const sizes = {}; - shuffle(r, unitList.filter(u => u[0] !== 'C')).forEach((u, i) => sizes[u] = i < 3 ? 1 : pick(r, [1, 2, 2, 3, 3, 4])); - for (const u of unitList) if (u[0] === 'C') sizes[u] = pick(r, [1, 1, 2]); - for (const unit of unitList) { - const dorm = unit[0] === 'C'; - const size = sizes[unit]; - const last = pick(r, N.SURNAMES); - if (size === 1) { - const p = person(pick(r, ['ч', 'ж']), last, dorm ? [19, 45] : [25, 74]); - p.home = unit; citizens.push(p); - } else { - const a = person('ч', last, [26, 68]); - const b = person('ж', chance(r, 0.7) ? last : pick(r, N.SURNAMES), [Math.max(22, a.age - 8), Math.min(70, a.age + 8)]); - if (!dorm) { a.spouse = b.id; b.spouse = a.id; } - a.home = b.home = unit; citizens.push(a, b); - for (let k = 2; k < size; k++) { - const kid = person(pick(r, ['ч', 'ж']), last, [18, Math.max(19, a.age - 22)]); - kid.home = unit; citizens.push(kid); - } - } - } - - const jobs = shuffle(r, allJobs(district)); - const workers = shuffle(r, citizens.filter(c => c.age <= 65)); - workers.slice(0, jobs.length).forEach((c, i) => { c.job = jobs[i].title; c.workplace = jobs[i].building; }); - for (const c of citizens) if (!c.job) - c.job = c.age > 62 ? 'пенсіонер' : c.age < 23 ? 'студент' : 'тимчасово не працює'; - return citizens; -} - -// ---------- Довідники фіналу ---------- -const METHODS = [ - { id: 'stab', label: 'Ніж', autopsy: 'дві колото-різані рани; знаряддя — вузький клинок ~2 см', weapon: 'кухонний ніж із вузьким лезом' }, - { id: 'blunt', label: 'Тупий предмет', autopsy: 'тупа травма голови; слід шестигранної грані', weapon: 'розвідний ключ' }, - { id: 'strangle', label: 'Удушення', autopsy: 'странгуляційна борозна; у рані — синтетичні волокна шнура', weapon: 'капроновий шнур' }, - { id: 'poison', label: 'Отруєння', autopsy: 'слідів боротьби немає; токсикологія — летальна доза барбітуратів у алкоголі', weapon: 'пляшка коньяку з домішкою' } -]; -const METHOD_OPTIONS = ['Ніж', 'Тупий предмет', 'Удушення', 'Отруєння', 'Падіння з висоти']; -const MOTIVE_OPTIONS = ['Ревнощі', 'Борг', 'Шантаж', 'Свідок злочину', 'Помилкова ціль', 'Пограбування', - 'Спадок і страховка', 'Старі гріхи', 'Одержимість', 'Кримінальний бізнес', 'Серійний патерн', 'Викуп']; -// key — слово, за яким «підпис» шукається в архіві справ -const SIGNATURES = [ - { text: 'чорна стрічка, акуратно зав\'язана на зап\'ясті', key: 'стрічка' }, - { text: 'крейдяне коло, дбайливо обведене довкола тіла', key: 'коло' }, - { text: 'дитяча іграшкова машинка, вкладена в кишеню', key: 'машинка' }, - { text: 'гральна карта — піковий валет — під долонею', key: 'валет' }, - { text: 'засушена фіалка, вкладена між пальцями', key: 'фіалка' }, - { text: 'зупинений годинник, переведений на 3:33', key: 'годинник' }, - { text: 'ґудзик, відрізаний і покладений на повіку', key: 'ґудзик' }, - { text: 'аркуш нотного паперу з єдиною нотою', key: 'нота' }, - { text: 'ключ без бородки, вкладений у долоню', key: 'ключ' }]; -// Можливі місця утримання для справ-викрадень (реальні точки на мапі) -const LAIRS = [ - { building: 'pier', spot: 'елінг', label: 'Елінг на причалі' }, - { building: 'shop', spot: 'склад', label: 'Склад мінімаркету' }, - { building: 'bar', spot: 'підсобка', label: 'Підсобка бару' }, - { building: 'garage', spot: 'яма', label: 'Оглядова яма автомайстерні' }, - { building: 'diner', spot: 'роздягальня персоналу', label: 'Підсобні приміщення їдальні' } -]; +const { hashSeed, mulberry32, ri, estRange, pick, chance, shuffle, UKR, caesar, b64, md5, translit, COMMON_PASSWORDS, genPassword } = require('./rng'); +const { makeCitizens } = require('./citizens'); +const { METHODS, METHOD_OPTIONS, MOTIVE_OPTIONS, SIGNATURES, LAIRS } = require('./constants'); +const { solve, checkAccusation } = require('./accusation'); +const motives = { + debt: require('./motives/debt'), + jealousy: require('./motives/jealousy'), + blackmail: require('./motives/blackmail'), + witness: require('./motives/witness'), + mistaken: require('./motives/mistaken'), + inherit: require('./motives/inherit'), + oldcrime: require('./motives/oldcrime'), + stalker: require('./motives/stalker'), + business: require('./motives/business'), + serial: require('./motives/serial'), + ransom: require('./motives/ransom'), + k_ransom: require('./motives/kidnap_ransom'), + k_obsession: require('./motives/kidnap_obsession'), + k_debt: require('./motives/kidnap_debt'), +}; +const { setupEvening } = require('./evening'); +const { setupDevices } = require('./devices'); +const { setupJunk } = require('./junk'); +const { applyDoppelganger } = require('./mutators'); +const { generateAnonymousLetter } = require('./anonymous_letter'); // ---------- Генерація справи ---------- function generateCase(seed) { @@ -236,7 +85,6 @@ function generateCase(seed) { let accomplice = null; // призначається мутатором нижче; свідком бути не може const isFigure = c => [victim.id, killer.id, tSpouse && tSpouse.id, tTarget && tTarget.id, accomplice && accomplice.id].includes(c.id); - const witnessFrom = arr => pick(r, (arr.filter(c => !isFigure(c) && c.id !== herring.id).length ? arr.filter(c => !isFigure(c) && c.id !== herring.id) : citizens.filter(c => !isFigure(c)))); // -- сліди вбивці на місці: розмиті. Взуття читається як діапазон ±1, // тож після самого лише огляду місця коло мусить лишатися широким (≥8). @@ -251,6 +99,17 @@ function generateCase(seed) { herring.shoe = killer.shoe; herring.coat = killer.coat; herring.height = Math.min(195, Math.max(150, killer.height + ri(r, -4, 4))); + // -- мутатор "Двійник": ще одна людина, схожа на вбивцю, і теж бреше про алібі + const doppelganger = chance(r, 0.3) ? applyDoppelganger({ + r, citizens, killer, victim, herring, district, notHousehold + }) : null; + + // Оновлений witnessFrom: тепер виключає herring і doppelganger зі свідків + const witnessFrom = arr => { + const candidates = arr.filter(c => !isFigure(c) && c.id !== herring.id && c.id !== (doppelganger ? doppelganger.id : null)); + return pick(r, candidates.length ? candidates : citizens.filter(c => !isFigure(c))); + }; + // повний набір слідів (взуття ±1, тканина, зріст ±10) лишає 2–6 кандидатів у фіналі const matchesTraces = c => nearShoe(c) && c.coat === killer.coat && Math.abs(c.height - killer.height) <= 10; for (const c of citizens) { @@ -259,31 +118,13 @@ function generateCase(seed) { c.coat = pick(r, N.COATS); } - // -- вечір злочину: де хто був насправді (evening) і що каже (claim). - // Вбивця завжди бреше; ~15% людей брешуть чи «не знають» і без причини. - // Ці поля — таємниця сервера: клієнту в init вони не віддаються. const coveredB = [...new Set(district.cameras.flatMap(c => c.covers))]; - for (const c of citizens) { - c.evening = c.workplace && chance(r, 0.2) ? c.workplace - : chance(r, 0.2) ? pick(r, ['bar', 'diner']) : unitBuilding(c.home); - c.liar = chance(r, 0.15); - } - victim.evening = scene.building; victim.liar = false; - killer.evening = scene.building; killer.liar = true; - // хибний слід того вечора був у полі зору камер — цим його алібі й доводиться - herring.evening = pick(r, coveredB.filter(b => b !== scene.building && b !== 'station')) || unitBuilding(herring.home); - herring.liar = false; const otherB = b => pick(r, district.buildings.filter(x => x.id !== b && x.id !== 'station')).id; - for (const c of citizens) c.claim = c.liar ? otherB(c.evening) : c.evening; - // вбивця називає місце, яке БАЧАТЬ камери: саме так його брехню можна довести - killer.claim = pick(r, coveredB.filter(b => b !== scene.building && b !== 'station')) || otherB(scene.building); - // брехуни: чому збрехали (виправдання на повторному допиті) і хто може підтвердити правду - for (const c of citizens) { - if (!c.liar || c === killer) continue; - c.excuse = pick(r, N.EXCUSES); - const m2 = citizens.find(o => o !== c && o.id !== victim.id && o.id !== killer.id && o.evening === c.evening); - c.mate = m2 ? m2.id : null; - } + + // -- вечір злочину: налаштування evening/claim/liars через модуль + setupEvening({ r, citizens, victim, killer, herring, district, scene, coveredB, + pick, chance, unitBuilding, otherB + }); // -- мутатор справи: зрідка правила змінюються, щоб маршрут не заучувався let mutator = null; @@ -407,6 +248,12 @@ function generateCase(seed) { detail: 'Ані волосини, ані недопалка, ані слини. На пряжці — змазані сліди латексних рукавичок. Вбивця знав, що таке ДНК, і не залишив її. Доведеться йти іншими шляхами: відбиток зі знаряддя — або зламане алібі.' }); + // Анонімний лист (40% шанс) + if (chance(r, 0.4)) { + const letterId = generateAnonymousLetter({ r, killer, victim, citizens, add }); + if (letterId) startingFacts.push(letterId); + } + // -- фейкові сліди на місці: воно публічне, і не кожен слід стосується справи const passer = pick(r, citizens.filter(c => !isFigure(c) && Math.abs(c.shoe - killer.shoe) > 1)) || pick(r, citizens.filter(c => !isFigure(c))); @@ -525,472 +372,132 @@ function generateCase(seed) { let motiveId, recapWhy, herringLead; if (templateId === 'jealousy') { - motiveId = 'Ревнощі'; - const pin = String(victim.birthYear); - const pinPuzzle = addPuzzle({ - id: 'pz_pin', kind: 'pin', prompt: 'Телефон жертви заблоковано 4-значним PIN-кодом.', - hash: md5(pin), answer: pin, hint: 'Люди ставлять те, що не забудуть: спробуйте рік народження з досьє.' - }); - add({ - id: 'sf_phone_locked', side: 'field', at: atScene, - title: 'Телефон жертви (заблокований)', tag: 'мотив', - detail: 'Екран розбитий, але телефон живий. Заблокований PIN-кодом. Аналітик може спробувати підібрати.' - }); - motiveEvs.push(add({ - id: 'mot_messages', side: 'analyst', at: { type: 'lab' }, requires: ['sf_phone_locked'], needsIdentity: true, puzzle: pinPuzzle, - title: 'Листування в телефоні', tag: 'мотив', - detail: `Місяці переписки з номером ${tSpouse.phone}: зустрічі, готелі, «коли ${tSpouse.sex === 'ч' ? 'він' : 'вона'} у відрядженні». Номер зареєстровано: ${tSpouse.name}.`, - gives: { pointsTo: tSpouse.id } - })); - motiveEvs.push(add({ - id: 'mot_confess', side: 'field', at: { type: 'person', cid: tSpouse.id }, requires: ['mot_messages'], - title: `Розмова: ${tSpouse.name}`, tag: 'мотив', - detail: `Довге мовчання. «Так, ми зустрічалися. Тиждень тому ${killer.first} ${g(killer, 'знайшов', 'знайшла')} листування. ${g(killer, 'Сказав', 'Сказала')}: „Я це закінчу". Я ${g(tSpouse, 'думав', 'думала')} — про шлюб…»`, - gives: { pointsTo: killer.id, motive: true } - })); - motiveEvs.push(add({ - id: 'mot_witness', side: 'field', at: { type: 'person', cid: witnessFrom(citizens.filter(c => c.workplace === 'bar')).id }, needsIdentity: true, - title: 'Свідчення бармена', tag: 'мотив', - detail: `«${victim.first}? Бували тут. У четвер ${g(victim, 'сидів', 'сиділа')} за стійкою, коли ${g(killer, 'влетів', 'влетіла')} ${killer.name} — ${g(killer, 'кричав', 'кричала')} щось про «не лізь у мою сім'ю». Ледь розборонили.»`, - gives: { pointsTo: killer.id, motive: true } - })); - recapWhy = `${killer.name} ${g(killer, 'дізнався', 'дізналася')} про роман. Другою стороною роману були ${tSpouse.first} і жертва. Пробачення не сталося.`; - herringLead = `«Та в ${g(victim, 'нього', 'неї')} був конфлікт! ${herring.name} з ${herring.home} місяць тому ${g(herring, 'влаштував', 'влаштувала')} скандал через залите авто — усі чули погрози.»`; + const ctx = { r, add, addPuzzle, motiveEvs, ri, estRange, pick, chance, shuffle, md5, b64, caesar, + citizens, victim, killer, herring, district, witnessFrom, unitBuilding, g, byId, + tSpouse, atScene }; + const res = motives.jealousy.generate(ctx); + motiveId = res.motiveId; + recapWhy = res.recapWhy; + herringLead = res.herringLead; } if (templateId === 'debt') { - motiveId = 'Борг'; - const shift = ri(r, 3, 9); - const plain = `${killer.last} ${killer.first[0]}. — 84 000, прострочено. ${herring.last} ${herring.first[0]}. — 9 000, повернуто.`; - const czPuzzle = addPuzzle({ - id: 'pz_caesar', kind: 'caesar', prompt: 'Зошит, у якому кожен рядок — беззмістовний набір літер.', - payload: caesar(plain, shift), answer: plain, shift, hint: 'Схоже на шифр зсуву (Цезар): переберіть усі зсуви абетки.' - }); - motiveEvs.push(add({ - id: 'mot_notebook', side: 'field', at: { type: 'unit', unit: victim.home, slot: 'під ліжком' }, needsIdentity: true, puzzle: czPuzzle, - title: 'Зошит із шифром', tag: 'мотив', - detail: `Під ліжком — бляшана коробка: готівка і зошит, списаний шифром. ${victim.name} давав гроші під відсоток. Розшифруйте — і побачите боржників.`, - gives: { pointsTo: killer.id, motive: true } - })); - motiveEvs.push(add({ - id: 'mot_bank', side: 'analyst', at: { type: 'sys', sys: 'bank' }, q: { sys: 'bank', match: victim.name }, needsIdentity: true, - title: 'Виписка з рахунку жертви', tag: 'мотив', - detail: `Щомісячні перекази по 7 000 (відправник: ${killer.name}) — і тиша останні три місяці. Останнє повідомлення до переказу: «це все, більше нема».`, - gives: { pointsTo: killer.id, motive: true } - })); - motiveEvs.push(add({ - id: 'mot_neighbor', side: 'field', at: { type: 'person', cid: witnessFrom(citizens.filter(c => unitBuilding(c.home) === unitBuilding(victim.home) && c.home !== victim.home)).id }, needsIdentity: true, - title: 'Свідчення сусіда', tag: 'мотив', - detail: `«За стіною кричали про гроші. ${killer.sex === 'ч' ? 'Чоловічий' : 'Жіночий'} голос повторював: „Я не маю чим, зрозумій, не маю чим"».`, - gives: { motive: true } - })); - recapWhy = `Кредитором був не банк: гроші під відсоток давала жертва. Боржник на 84 тисячі — ${killer.name}, віддавати не було чим. Борг закрили інакше.`; - herringLead = `У зошиті жертви є й ${herring.name} — сусіди кажуть, «теж у боргах, теж сварилися». Щоправда, поруч із тією сумою в зошиті стоїть слово, яке варто прочитати уважно.`; + const ctx = { r, add, addPuzzle, motiveEvs, ri, estRange, pick, chance, shuffle, md5, b64, caesar, + citizens, victim, killer, herring, district, witnessFrom, unitBuilding, g, byId }; + const res = motives.debt.generate(ctx); + motiveId = res.motiveId; + recapWhy = res.recapWhy; + herringLead = res.herringLead; } if (templateId === 'blackmail') { - motiveId = 'Шантаж'; - const pw = genPassword(r, victim); - const pwPuzzle = addPuzzle({ - id: 'pz_md5', kind: 'md5', hash: md5(pw.answer), answer: pw.answer, style: pw.style, - prompt: `Ноутбук жертви. Логін відкритий, пароль — ні. Хеш пароля (md5): ${md5(pw.answer)}.`, - hint: (pw.personal - ? 'Схоже на «особистий» пароль — щось про власника, латиницею. Досьє в поміч: ' - : 'Пароль не пов\'язаний із жертвою — лише перебір за словником (rockyou.txt). ') + pw.hint - }); - const secret = pick(r, [ - `у березні ${killer.name} ${g(killer, 'збив', 'збила')} пішохода на трасі й ${g(killer, 'поїхав', 'поїхала')}; бампер ремонтували «без записів»`, - `диплом, за яким ${killer.name} ${g(killer, 'отримав', 'отримала')} роботу (${killer.job}), — куплений`, - `${killer.name} роками ${g(killer, 'зливав', 'зливала')} касу на роботі повз облік` - ]); - motiveEvs.push(add({ - id: 'mot_laptop', side: 'field', at: { type: 'unit', unit: victim.home, slot: 'письмовий стіл' }, needsIdentity: true, puzzle: pwPuzzle, - title: 'Ноутбук жертви', tag: 'мотив', - detail: `Чернетки листів без підпису: «Я знаю, що ${secret}. 10 000 щомісяця — і ніхто не дізнається». Остання чернетка різкіша: «Ціна подвоюється».`, - gives: { pointsTo: killer.id, motive: true } - })); - motiveEvs.push(add({ - id: 'mot_cash', side: 'analyst', at: { type: 'sys', sys: 'bank' }, q: { sys: 'bank', match: killer.name }, requires: ['mot_laptop'], - title: 'Готівкові зняття', tag: 'мотив', - detail: `Рахунок ${killer.name}: десяте число кожного місяця — зняття 10 000 готівкою, банкомат біля ${district.buildings.find(b => b.id === 'shop').name}. Півроку поспіль. Останній місяць — 20 000.`, - gives: { pointsTo: killer.id, motive: true } - })); - recapWhy = `Жертва знала чужу таємницю і брала за мовчання гроші. Таємниця така: ${secret}. Подвоєна «ціна мовчання» стала вироком самому шантажисту.`; - herringLead = `Серед чернеток є і другий адресат: «Я знаю про ваші оборудки», — а хто в районі має «оборудки», всі скажуть одразу: ${herring.name}.`; + const ctx = { r, add, addPuzzle, motiveEvs, ri, estRange, pick, chance, shuffle, md5, b64, caesar, + citizens, victim, killer, herring, district, witnessFrom, unitBuilding, g, byId, + genPassword }; + const res = motives.blackmail.generate(ctx); + motiveId = res.motiveId; + recapWhy = res.recapWhy; + herringLead = res.herringLead; } if (templateId === 'witness') { - motiveId = 'Свідок злочину'; - const pin = String(victim.birthYear); - const pinPuzzle = addPuzzle({ - id: 'pz_pin', kind: 'pin', prompt: 'Телефон жертви заблоковано 4-значним PIN-кодом.', - hash: md5(pin), answer: pin, hint: 'Люди ставлять те, що не забудуть: спробуйте рік народження з досьє.' - }); - add({ - id: 'sf_phone_locked', side: 'field', at: atScene, - title: 'Телефон жертви (заблокований)', tag: 'мотив', - detail: 'Екран у павутинні тріщин, але апарат вмикається. PIN. Аналітик може спробувати підібрати.' - }); - motiveEvs.push(add({ - id: 'mot_photos', side: 'analyst', at: { type: 'lab' }, requires: ['sf_phone_locked'], needsIdentity: true, puzzle: pinPuzzle, - title: 'Фото в телефоні жертви', tag: 'мотив', - detail: `Нічні знімки: постать у ${killer.coat} заносить велосипеди в елінг на причалі. Обличчя не видно. Дата — за чотири дні до вбивства.`, - gives: { filter: { attr: 'coat', val: killer.coat } } - })); - motiveEvs.push(add({ - id: 'mot_boathouse', side: 'field', at: { type: 'spot', building: 'pier', spot: 'елінг' }, requires: ['mot_photos'], - title: 'Обшук елінгу', tag: 'мотив', - detail: 'Шість велосипедів зі спиляними номерами рам, добрі замки зрізані болторізом. На стіні — оголошення «продам недорого» з номером.', - gives: { motive: true } - })); - motiveEvs.push(add({ - id: 'mot_ads', side: 'analyst', at: { type: 'sys', sys: 'phone' }, q: { sys: 'phone', kind: 'owner', match: killer.phone }, requires: ['mot_boathouse'], - title: 'Номер з оголошення', tag: 'мотив', - detail: `Номер з оголошення в елінгу зареєстровано: ${killer.name}, прож. ${killer.home}. Жертва сфотографувала крадія — і крадій це помітив.`, - gives: { pointsTo: killer.id, motive: true } - })); - recapWhy = `Жертва випадково сфотографувала нічні «рейси» з краденими велосипедами. Крадій — ${killer.name}. Свідка прибрали.`; - herringLead = `«Кажуть, ${herring.name} вечорами вічно біля елінгу — разів п'ять там ${g(herring, 'крутився', 'крутилася')}. Чого — хтозна.»`; + const ctx = { r, add, addPuzzle, motiveEvs, ri, estRange, pick, chance, shuffle, md5, b64, caesar, + citizens, victim, killer, herring, district, witnessFrom, unitBuilding, g, byId, atScene }; + const res = motives.witness.generate(ctx); + motiveId = res.motiveId; + recapWhy = res.recapWhy; + herringLead = res.herringLead; } if (templateId === 'mistaken') { - motiveId = 'Помилкова ціль'; - const accName = 'night_owl_' + ri(r, 100, 999); - const b64Puzzle = addPuzzle({ - id: 'pz_b64', kind: 'b64', prompt: 'Експорт бази месенджера: поле власника акаунта — беззмістовний рядок символів.', - payload: b64(`owner=${killer.name}; phone=${killer.phone}`), answer: `owner=${killer.name}; phone=${killer.phone}`, hint: 'Це не шифр, а кодування Base64 — розкодуйте.' - }); - motiveEvs.push(add({ - id: 'mot_lookalike', side: 'field', at: { type: 'person', cid: witnessFrom(citizens.filter(c => unitBuilding(c.home) === unitBuilding(victim.home) && c.home !== victim.home)).id }, needsIdentity: true, - title: 'Свідчення сусідів', tag: 'мотив', - detail: `«${victim.first}? Царство небесне… Знаєте, ${victim.first} і ${tTarget.first} — їх вічно плутали: той самий одяг, той самий зріст, той самий під'їзд. Навіть листоноша плутав.»` - })); - motiveEvs.push(add({ - id: 'mot_target', side: 'field', at: { type: 'person', cid: tTarget.id }, requires: ['mot_lookalike'], - title: `Розмова: ${tTarget.name}`, tag: 'мотив', - detail: `Блідне. «Це мені писали. Місяць погроз з анонімного акаунта „${accName}": „поверни, або пошкодуєш". Я нікому не ${g(tTarget, 'винен', 'винна')}! Ось, дивіться самі» — і простягає телефон.`, - gives: { motive: true } - })); - motiveEvs.push(add({ - id: 'mot_account', side: 'analyst', at: { type: 'sys', sys: 'phone' }, requires: ['mot_target'], puzzle: b64Puzzle, - title: `Деанон акаунта «${accName}»`, tag: 'мотив', - detail: `Запит до платформи повернув експорт. Розкодоване поле власника: ${killer.name}, номер ${killer.phone}. Погрози на адресу ${tTarget.name} писа${g(killer, 'в', 'ла')} саме ${g(killer, 'він', 'вона')} — через давній борг за розбите в ДТП авто.`, - gives: { pointsTo: killer.id, motive: true } - })); - recapWhy = `Справжня ціль — ${tTarget.name}: місяці погроз через давній борг, і автор погроз — ${killer.name}. У темряві ${g(killer, 'вбивця сплутав', 'вбивця сплутала')} жертву з ціллю: той самий одяг, той самий зріст, той самий під'їзд.`; - herringLead = `А ще сусіди шепочуться: «${herring.name} з ${victim.first} давно на ножах — через паркування, через сміття, через усе».`; + const ctx = { r, add, addPuzzle, motiveEvs, ri, estRange, pick, chance, shuffle, md5, b64, caesar, + citizens, victim, killer, herring, district, witnessFrom, unitBuilding, g, byId, tTarget }; + const res = motives.mistaken.generate(ctx); + motiveId = res.motiveId; + recapWhy = res.recapWhy; + herringLead = res.herringLead; } if (templateId === 'inherit') { - motiveId = 'Спадок і страховка'; - const kin = g(killer, 'племінник', 'племінниця'); - motiveEvs.push(add({ - id: 'mot_will', side: 'field', at: { type: 'unit', unit: victim.home, slot: 'шафа' }, needsIdentity: true, - title: 'Чернетка нового заповіту', tag: 'мотив', - detail: 'У шафі, під білизною — чернетка заповіту, датована минулим тижнем: усе майно — благодійному фонду. Підпис не завірений нотаріусом. Хтось дуже не хотів, щоб це було завірено.', - gives: { motive: true } - })); - motiveEvs.push(add({ - id: 'mot_notary', side: 'analyst', at: { type: 'sys', sys: 'db' }, q: { sys: 'db', match: victim.name }, needsIdentity: true, - title: 'Реєстр нотаріуса', tag: 'мотив', - detail: `Чинний заповіт від 2022 року: єдиний спадкоємець — ${kin} жертви, ${killer.name}. Запис про нову зустріч у нотаріуса стояв на наступний тиждень. Зустріч не відбулася.`, - gives: { pointsTo: killer.id, motive: true } - })); - motiveEvs.push(add({ - id: 'mot_insur', side: 'analyst', at: { type: 'sys', sys: 'bank' }, q: { sys: 'bank', match: victim.name }, needsIdentity: true, - title: 'Страховий поліс', tag: 'мотив', - detail: `Страхування життя на 900 000, бенефіціар — ${killer.name}. Найцікавіше: останній рік внески платив не власник поліса, а сам бенефіціар. Інвестиція.`, - gives: { pointsTo: killer.id, motive: true } - })); - motiveEvs.push(add({ - id: 'mot_kin_row', side: 'field', at: { type: 'person', cid: witnessFrom(citizens.filter(c => unitBuilding(c.home) === unitBuilding(victim.home) && c.home !== victim.home)).id }, needsIdentity: true, - title: 'Свідчення сусіда', tag: 'мотив', - detail: `«Тижнів зо два тому на сходах кричали. Другий голос: „Викреслиш мене — пошкодуєш!" Гримнули двері. Я не з тих, хто підслуховує, але сходи в нас лункі.»` - })); - recapWhy = `Чинний заповіт 2022 року називав єдиного спадкоємця: ${killer.name}. Новий заповіт, який усе віддавав фонду, лишалося тільки завірити. Спадщина і страховка на 900 000 переважили.`; - herringLead = `«А ${herring.name} усе розпитує, скільки коштують квартири в будинку жертви. І нащо воно ${g(herring, 'йому', 'їй')}?»`; + const ctx = { r, add, addPuzzle, motiveEvs, ri, estRange, pick, chance, shuffle, md5, b64, caesar, + citizens, victim, killer, herring, district, witnessFrom, unitBuilding, g, byId }; + const res = motives.inherit.generate(ctx); + motiveId = res.motiveId; + recapWhy = res.recapWhy; + herringLead = res.herringLead; } if (templateId === 'oldcrime') { - motiveId = 'Старі гріхи'; - motiveEvs.push(add({ - id: 'mot_confession', side: 'field', at: { type: 'unit', unit: victim.home, slot: 'письмовий стіл' }, needsIdentity: true, - title: 'Незакінчений лист', tag: 'мотив', - detail: 'Лист без адресата, перекреслений і початий заново: «Я більше не можу мовчати про те, що ми зробили у 2019-му. Сторож нас не виказав навіть у лікарні. Якщо не зізнаєшся ти — зізнаюсь я.»', - gives: { motive: true } - })); - motiveEvs.push(add({ - id: 'mot_arch2019', side: 'analyst', at: { type: 'sys', sys: 'crim' }, q: { sys: 'crim', kind: 'archive', match: ['2019', 'хвиля'] }, requires: ['mot_confession'], - title: 'Архів: справа 2019 року', tag: 'мотив', - detail: `Крадіжка каси артілі «Хвиля»: сторож дістав травму голови, за рік помер. Підозрювали двох вантажників — недоведено, справу закрито. Прізвища в матеріалах: ${victim.last} і ${killer.last}. Адреса другого збігається з нинішньою: ${killer.home}.`, - gives: { pointsTo: killer.id, motive: true } - })); - motiveEvs.push(add({ - id: 'mot_bank_old', side: 'analyst', at: { type: 'sys', sys: 'bank' }, q: { sys: 'bank', match: killer.name }, requires: ['mot_arch2019'], - title: 'Виписки за 2019 рік', tag: 'мотив', - detail: `Архів банку: восени 2019-го на рахунок ${killer.name} тричі заходила готівка — разом близько чверті вкраденої каси. Задекларованих доходів під ці суми немає.`, - gives: { motive: true } - })); - motiveEvs.push(add({ - id: 'mot_oldtimer', side: 'field', at: { type: 'person', cid: witnessFrom(citizens.filter(c => c.job === 'пенсіонер')).id }, needsIdentity: true, - title: 'Пам\'ять старожила', tag: 'мотив', - detail: `«Ці двоє? Нерозлийвода були, разом вантажниками в артілі. А після тієї історії зі сторожем — ані слова одне одному, роками. Таке буває тільки після грошей або гріха. Або обох.»` - })); - recapWhy = `У 2019-му двоє вантажників винесли касу артілі; сторож, що їх упізнав, помер за рік. Жертва вирішила зізнатися — і подзвонила спільнику. Спільник — ${killer.name} — ${g(killer, 'вирішив', 'вирішила')} інакше.`; - herringLead = `«Питаєте про старе? ${herring.name} тоді теж біля артілі ${g(herring, 'крутився', 'крутилася')}, усі знали. Темна історія.»`; + const ctx = { r, add, addPuzzle, motiveEvs, ri, estRange, pick, chance, shuffle, md5, b64, caesar, + citizens, victim, killer, herring, district, witnessFrom, unitBuilding, g, byId }; + const res = motives.oldcrime.generate(ctx); + motiveId = res.motiveId; + recapWhy = res.recapWhy; + herringLead = res.herringLead; } if (templateId === 'stalker') { - motiveId = 'Одержимість'; - motiveEvs.push(add({ - id: 'mot_letters', side: 'field', at: { type: 'unit', unit: victim.home, slot: 'шафа' }, needsIdentity: true, - title: 'Пачка листів без підпису', tag: 'мотив', - detail: `Перев'язана шпагатом пачка — десятки листів, однаковий почерк, без підпису: «я бачу тебе щодня», «ти всміхаєшся не мені», «я вмію чекати». Найстаріший — дворічної давності. Останній закінчується інакше: «чекати я більше не буду».`, - gives: { motive: true } - })); - motiveEvs.push(add({ - id: 'mot_complaint', side: 'analyst', at: { type: 'sys', sys: 'crim' }, q: { sys: 'crim', kind: 'archive', match: [victim.last, victim.name] }, needsIdentity: true, - title: 'Торішня заява жертви', tag: 'мотив', - detail: `Заява про переслідування, торік, руху не дали: «невідома особа стежить, лишає квіти під дверима». Прикмети з заяви: ${killer.coat}, зріст приблизно ${estRange(r, killer.height)} см.`, - gives: { filter: { attr: 'coat', val: killer.coat }, motive: true } - })); - motiveEvs.push(add({ - id: 'mot_flowers', side: 'field', at: { type: 'person', cid: witnessFrom(citizens.filter(c => c.workplace === 'shop')).id }, requires: ['mot_complaint'], - title: 'Свідчення з мінімаркету', tag: 'мотив', - detail: `«Три гвоздики щоп'ятниці, завжди готівкою, завжди мовчки. Хто? Та ${killer.name} же. От, думаю собі, людина постійна.»`, - gives: { pointsTo: killer.id } - })); - motiveEvs.push(add({ - id: 'mot_shrine', side: 'field', at: { type: 'unit', unit: killer.home, slot: 'шафа' }, - title: 'Стіна фотографій', tag: 'мотив', - detail: 'На внутрішньому боці дверцят шафи — десятки фото жертви, знятих здалеку: біля дому, на набережній, крізь вітрину. Дати на звороті — два роки спостережень.', - gives: { motive: true } - })); - recapWhy = `Два роки листів без відповіді, квіти під дверима, фото здалеку. Автор листів — ${killer.name}. Одержимість скінчилась там, де закінчилось «я вмію чекати».`; - herringLead = `«Хочете про дивних? ${herring.name} вічно сидить біля вікна з біноклем. Каже — «за птахами». Ага, за птахами.»`; + const ctx = { r, add, addPuzzle, motiveEvs, ri, estRange, pick, chance, shuffle, md5, b64, caesar, + citizens, victim, killer, herring, district, witnessFrom, unitBuilding, g, byId }; + const res = motives.stalker.generate(ctx); + motiveId = res.motiveId; + recapWhy = res.recapWhy; + herringLead = res.herringLead; } if (templateId === 'business') { - motiveId = 'Кримінальний бізнес'; - const shift2 = ri(r, 3, 9); - const plain2 = `Частка ${killer.last} ${killer.first[0]}. — сорок відсотків. Не сплачено двічі. Точка — склад маркету.`; - const czPuzzle2 = addPuzzle({ - id: 'pz_caesar', kind: 'caesar', prompt: 'Зошит обліку, писаний нечитабельними літерами.', - payload: caesar(plain2, shift2), answer: plain2, shift: shift2, hint: 'Схоже на шифр зсуву (Цезар): переберіть усі зсуви абетки.' - }); - motiveEvs.push(add({ - id: 'mot_ledger', side: 'field', at: { type: 'unit', unit: victim.home, slot: 'кухня' }, needsIdentity: true, puzzle: czPuzzle2, - title: 'Зошит подвійної бухгалтерії', tag: 'мотив', - detail: 'У бляшанці з-під кави — зошит: колонки сум без підписів і шифровані нотатки. Легальних доходів на такі колонки жертва не мала. Розшифруйте — і стане видно, з ким ділили.', - gives: { pointsTo: killer.id, motive: true } - })); - motiveEvs.push(add({ - id: 'mot_stash', side: 'field', at: { type: 'spot', building: 'shop', spot: 'склад' }, requires: ['mot_ledger'], - title: 'Тайник на складі', tag: 'мотив', - detail: 'За стелажем із мінералкою — коробки без маркування, лише код маркером. Той самий код — у зошиті жертви. Контрабандний товар ішов через район тихо і давно.', - gives: { motive: true } - })); - motiveEvs.push(add({ - id: 'mot_bank_biz', side: 'analyst', at: { type: 'sys', sys: 'bank' }, q: { sys: 'bank', match: [victim.name, killer.name] }, needsIdentity: true, - title: 'Синхронні внески', tag: 'мотив', - detail: `Два рахунки, однакові готівкові внески в ті самі дати, місяць за місяцем: рахунок жертви — і рахунок ${killer.name}. Партнерство, якого офіційно не існувало. Останні два місяці вносила тільки жертва.`, - gives: { pointsTo: killer.id, motive: true } - })); - motiveEvs.push(add({ - id: 'mot_bar_corner', side: 'field', at: { type: 'person', cid: witnessFrom(citizens.filter(c => c.workplace === 'bar')).id }, needsIdentity: true, - title: 'Свідчення з бару', tag: 'мотив', - detail: `«Ці двоє завжди сідали в кутку, рахували щось у зошиті. Останнім часом — на підвищених тонах. Слово «частка» я чув частіше, ніж «будьмо».»` - })); - recapWhy = `Через район тихо йшов контрабандний товар; частки ділили за зошитом. Коли жертва двічі не заплатила партнерську частку, партнер — ${killer.name} — ${g(killer, 'закрив', 'закрила')} баланс назавжди.`; - herringLead = `«Шукаєте, хто мутить? ${herring.name} на склад маркету заходить частіше, ніж туди завозять хліб. Просто кажу.»`; + const ctx = { r, add, addPuzzle, motiveEvs, ri, estRange, pick, chance, shuffle, md5, b64, caesar, + citizens, victim, killer, herring, district, witnessFrom, unitBuilding, g, byId }; + const res = motives.business.generate(ctx); + motiveId = res.motiveId; + recapWhy = res.recapWhy; + herringLead = res.herringLead; } if (templateId === 'serial') { - motiveId = 'Серійний патерн'; - if (!killer.tattoo) killer.tattoo = pick(r, N.TATTOOS); - for (const c of citizens) if (c !== killer && c.tattoo === killer.tattoo) c.tattoo = null; - const sign = pick(r, SIGNATURES); - add({ - id: 'sf_signature', side: 'field', at: atScene, - title: 'Підпис на тілі', tag: 'патерн', - detail: `Деталь, яка не дає спокою: ${sign.text}. Це не випадковість і не пограбування. Це підпис. Такі речі повторюються.` - }); - motiveEvs.push(add({ - id: 'mot_arch_serial', side: 'analyst', at: { type: 'sys', sys: 'crim' }, q: { sys: 'crim', kind: 'archive', match: [sign.key, 'підпис'] }, requires: ['sf_signature'], - title: 'Архів: справа 2021 року', tag: 'мотив', - detail: `Пошук за підписом дав збіг: 2021 рік, сусідній район, нерозкрите вбивство — і той самий підпис: ${sign.text}. Тоді вирішили, що це дрібниця, і не внесли в орієнтування. Це не перше його полювання.`, - gives: { motive: true } - })); - motiveEvs.push(add({ - id: 'mot_sketch', side: 'analyst', at: { type: 'sys', sys: 'crim' }, q: { sys: 'crim', kind: 'archive', match: [sign.key, 'підпис', '2021'] }, requires: ['mot_arch_serial'], - title: 'Свідок 2021 року: прикмети', tag: 'мотив', - detail: `У справі 2021-го є свідчення: біля місця бачили постать у ${killer.coat}, а на передпліччі — татуювання: ${killer.tattoo}. Свідка тоді не сприйняли серйозно.`, - gives: { filter: { attr: 'coat', val: killer.coat } } - })); - motiveEvs.push(add({ - id: 'mot_tattoo_db', side: 'analyst', at: { type: 'sys', sys: 'db' }, q: { sys: 'db', match: killer.tattoo }, requires: ['mot_sketch'], - title: 'Пошук за татуюванням', tag: 'вбивця', - detail: `Запит по базі мешканців: «${killer.tattoo}» — єдиний збіг у районі: ${killer.name}, прож. ${killer.home}.`, - gives: { pointsTo: killer.id } - })); - motiveEvs.push(add({ - id: 'mot_trophies', side: 'field', at: { type: 'unit', unit: killer.home, slot: 'шафа' }, - title: 'Коробка з трофеями', tag: 'мотив', - detail: 'Взуттєва коробка на антресолі: жіночий годинник із чужим гравіюванням, стара запальничка, окуляри — і чохол від телефона жертви. Кожна річ — чиясь. Кожна річ — трофей.', - gives: { motive: true } - })); - recapWhy = `Мотиву в людському сенсі не було. Був патерн: підпис (${sign.text}) уже з'являвся у 2021-му, і тоді його не помітили. ${killer.name} ${g(killer, 'обирав', 'обирала')} тих, хто живе сам і повертається затемна.`; - herringLead = `«Моторошне питаєте? ${herring.name} ночами гуляє районом і фотографує чужі вікна. Сусіди двічі таке бачили.»`; + const ctx = { r, add, addPuzzle, motiveEvs, ri, estRange, pick, chance, shuffle, md5, b64, caesar, + citizens, victim, killer, herring, district, witnessFrom, unitBuilding, g, byId, atScene }; + const res = motives.serial.generate(ctx); + motiveId = res.motiveId; + recapWhy = res.recapWhy; + herringLead = res.herringLead; } if (templateId === 'ransom') { - motiveId = 'Викуп'; - const relative = witnessFrom(citizens.filter(c => c.home !== victim.home)); - motiveEvs.push(add({ - id: 'mot_lair', side: 'field', at: { type: 'spot', building: 'pier', spot: 'елінг' }, - title: 'Підготовлене лігво', tag: 'мотив', - detail: 'В елінгу за брезентом — матрац, ланцюг із замком, каністра води, відро. Усе нове, з цінниками. Хтось готувався тримати тут людину. Живою.', - gives: { motive: true } - })); - motiveEvs.push(add({ - id: 'mot_supplies', side: 'field', at: { type: 'person', cid: witnessFrom(citizens.filter(c => c.workplace === 'shop')).id }, needsIdentity: true, - title: 'Свідчення з мінімаркету', tag: 'мотив', - detail: `«Дивна закупівля була днями: стяжки, скотч, брезент, ланцюг — усе разом. Хто? Не пригадаю обличчя, але платили карткою, це точно в терміналі лишилось.»` - })); - motiveEvs.push(add({ - id: 'mot_card', side: 'analyst', at: { type: 'sys', sys: 'bank' }, q: { sys: 'bank', match: ['термінал', district.buildings.find(b => b.id === 'shop').name] }, requires: ['mot_supplies'], - title: 'Термінал мінімаркету', tag: 'мотив', - detail: `Транзакція по картці за ту закупівлю: ${killer.name}. Стяжки, скотч, брезент і ланцюг — за три дні до смерті жертви.`, - gives: { pointsTo: killer.id, motive: true } - })); - motiveEvs.push(add({ - id: 'mot_ransom_note', side: 'field', at: { type: 'unit', unit: killer.home, slot: 'письмовий стіл' }, - title: 'Чернетка вимоги викупу', tag: 'мотив', - detail: `Аркуш із друкованими літерами, тричі переписаний: «200 000 за життя. Поліція = труна.» Адресат — рідня жертви. Лист так і не знадобився: викрадення пішло не за планом.`, - gives: { motive: true } - })); - add({ - id: 'mot_relative', side: 'field', at: { type: 'person', cid: relative.id }, needsIdentity: true, - title: `Розмова: ${relative.name}`, tag: 'мотив', - detail: `${g(relative, 'Далекий родич', 'Далека родичка')} жертви. «За день до того був дзвінок із прихованого номера. Мовчали. Дихали. Я ще пожартувала: „Може, викрасти хочуть?" Господи.»` - }); - recapWhy = `План був інший: викрасти живою людину, тримати в елінгу і взяти з рідні 200 000. Але жертва пручалася — і план скінчився в перші ж хвилини. Автор плану — ${killer.name}.`; - herringLead = `«${herring.name} нещодавно в барі розпитува${g(herring, 'в', 'ла')}, хто в районі має гроші і хто живе сам. Тоді здалося балачкою.»`; + const ctx = { r, add, addPuzzle, motiveEvs, ri, estRange, pick, chance, shuffle, md5, b64, caesar, + citizens, victim, killer, herring, district, witnessFrom, unitBuilding, g, byId }; + const res = motives.ransom.generate(ctx); + motiveId = res.motiveId; + recapWhy = res.recapWhy; + herringLead = res.herringLead; } if (templateId === 'k_ransom') { - motiveId = 'Викуп'; - const rep = witnessFrom(citizens.filter(c => c.home !== victim.home)); - startingFacts.push(add({ - id: 'sf_report', side: 'field', at: { type: 'start' }, - title: 'Заява про викрадення', tag: 'жертва', - detail: `${rep.name} (рідня зниклої особи) ${g(rep, 'приніс', 'принесла')} записку зі своєї поштової скриньки: «200 000 — і побачите ${g(victim, 'його', 'її')} ${g(victim, 'живим', 'живою')}. Поліція = кінець». Зникла особа: ${victim.name}, ${victim.age} р.`, - gives: { identity: true, motive: true } - })); - motiveEvs.push(add({ - id: 'mot_note_exp', side: 'analyst', at: { type: 'sys', sys: 'crim' }, q: { sys: 'crim', kind: 'expert' }, requires: ['sf_report'], - title: 'Експертиза записки', tag: 'мотив', - detail: 'Друковані літери через трафарет, папір — зі звичайного офісного стосу. Жодних відбитків: писали в рукавичках. Готувалися заздалегідь і не поспішали.', - gives: { motive: true } - })); - motiveEvs.push(add({ - id: 'mot_supplies', side: 'field', at: { type: 'person', cid: witnessFrom(citizens.filter(c => c.workplace === 'shop')).id }, - title: 'Свідчення з мінімаркету', tag: 'мотив', - detail: `«Дивна закупівля була: стяжки, скотч, брезент, навісний замок — усе разом. Обличчя не пригадаю, але платили карткою — термінал пам'ятає все.»` - })); - motiveEvs.push(add({ - id: 'mot_card', side: 'analyst', at: { type: 'sys', sys: 'bank' }, q: { sys: 'bank', match: ['термінал', district.buildings.find(b => b.id === 'shop').name] }, requires: ['mot_supplies'], - title: 'Термінал мінімаркету', tag: 'мотив', - detail: `Транзакція за ту закупівлю: ${killer.name}. Стяжки, скотч і новий замок — за два дні до викрадення.`, - gives: { pointsTo: killer.id, motive: true } - })); - add({ - id: 'mot_lairclue', side: 'field', at: { type: 'unit', unit: killer.home, slot: 'письмовий стіл' }, - title: 'Чек і нотатка', tag: 'місце', - detail: `У кошику для паперів — зіжмаканий чек за замок і аркуш із розрахунком: «200 000 − борги = вистачить». На звороті олівцем — «${lair.label}» і час обходів охорони.`, - gives: { lair: true, motive: true } - }); - recapWhy = `План був простий і холодний: викрасти, сховати, взяти з рідні 200 000. Автор плану — ${killer.name}.`; - herringLead = `«${herring.name} нещодавно в барі розпитува${g(herring, 'в', 'ла')}, хто в районі має гроші і хто живе сам. Тоді здалося балачкою.»`; + const ctx = { r, add, addPuzzle, motiveEvs, ri, estRange, pick, chance, shuffle, md5, b64, caesar, + citizens, victim, killer, herring, district, witnessFrom, unitBuilding, g, byId, + startingFacts, lair }; + const res = motives.k_ransom.generate(ctx); + motiveId = res.motiveId; + recapWhy = res.recapWhy; + herringLead = res.herringLead; } - if (templateId === 'k_obsession') { - motiveId = 'Одержимість'; - const rep = witnessFrom(victim.workplace ? citizens.filter(c => c.workplace === victim.workplace) : citizens); - startingFacts.push(add({ - id: 'sf_report', side: 'field', at: { type: 'start' }, - title: 'Заява про зникнення', tag: 'жертва', - detail: `${rep.name} ${g(rep, 'заявив', 'заявила')}: ${victim.name} другий день не виходить на зв'язок, телефон вимкнений, двері зачинені. «Це не в ${g(victim, 'його', 'її')} стилі. І ще: останнім часом ${g(victim, 'він скаржився', 'вона скаржилася')}, що за ${g(victim, 'ним', 'нею')} хтось ходить.»`, - gives: { identity: true } - })); - motiveEvs.push(add({ - id: 'mot_letters', side: 'field', at: { type: 'unit', unit: victim.home, slot: 'шафа' }, - title: 'Пачка листів без підпису', tag: 'мотив', - detail: 'Десятки листів, однаковий почерк: «я бачу тебе щодня», «ти всміхаєшся не мені», «я вмію чекати». Останній інакший: «скоро ти зрозумієш, що нам ніхто не потрібен».', - gives: { motive: true } - })); - motiveEvs.push(add({ - id: 'mot_complaint', side: 'analyst', at: { type: 'sys', sys: 'crim' }, q: { sys: 'crim', kind: 'archive', match: [victim.last, victim.name] }, - title: 'Торішня заява зниклої особи', tag: 'мотив', - detail: `Заява про переслідування, руху не дали: «невідома особа стежить, лишає квіти під дверима». Прикмети: ${killer.coat}, зріст приблизно ${estRange(r, killer.height)} см.`, - gives: { filter: { attr: 'coat', val: killer.coat }, motive: true } - })); - motiveEvs.push(add({ - id: 'mot_flowers', side: 'field', at: { type: 'person', cid: witnessFrom(citizens.filter(c => c.workplace === 'shop')).id }, requires: ['mot_complaint'], - title: 'Свідчення з мінімаркету', tag: 'мотив', - detail: `«Три гвоздики щоп'ятниці, завжди готівкою, завжди мовчки. Хто? Та ${killer.name} же. От, думаю собі, людина постійна.»`, - gives: { pointsTo: killer.id } - })); - add({ - id: 'mot_lairclue', side: 'field', at: { type: 'unit', unit: killer.home, slot: 'шафа' }, - title: 'План «нашого дому»', tag: 'місце', - detail: `На внутрішньому боці дверцят — фото ${victim.name}, зняті здалеку. І аркуш, накреслений від руки: матрац, лампа, засув. Підпис під планом: «${lair.label}». Усе обведено сердечком.`, - gives: { lair: true, motive: true } - }); - recapWhy = `Два роки листів, квітів і фото здалеку. Коли «я вмію чекати» скінчилось, ${killer.name} ${g(killer, 'вирішив', 'вирішила')}: якщо не разом — то під замком.`; - herringLead = `«Хочете про дивних? ${herring.name} вічно сидить біля вікна з біноклем. Каже — «за птахами». Ага, за птахами.»`; + const ctx = { r, add, addPuzzle, motiveEvs, ri, estRange, pick, chance, shuffle, md5, b64, caesar, + citizens, victim, killer, herring, district, witnessFrom, unitBuilding, g, byId, + startingFacts, lair }; + const res = motives.k_obsession.generate(ctx); + motiveId = res.motiveId; + recapWhy = res.recapWhy; + herringLead = res.herringLead; } - if (templateId === 'k_debt') { - motiveId = 'Борг'; - const rep = witnessFrom(citizens.filter(c => c.home !== victim.home)); - const shiftK = ri(r, 3, 9); - const plainK = `Винен ${killer.last} ${killer.first[0]}. — дев'яносто тисяч. Прострочено двічі. Каже: відпрацюєш.`; - const czK = addPuzzle({ - id: 'pz_caesar', kind: 'caesar', prompt: 'Нотатки зниклої особи — суцільний беззмістовний набір літер.', - payload: caesar(plainK, shiftK), answer: plainK, shift: shiftK, hint: 'Схоже на шифр зсуву (Цезар): переберіть усі зсуви абетки.' - }); - startingFacts.push(add({ - id: 'sf_report', side: 'field', at: { type: 'start' }, - title: 'Заява про зникнення', tag: 'жертва', - detail: `${rep.name} ${g(rep, 'заявив', 'заявила')}: ${victim.name} ${g(victim, 'зник', 'зникла')} по дорозі додому. «За день до того ${g(victim, 'він просив', 'вона просила')} у мене грошей — багато і терміново. Сказали тільки: „інакше буде зле".»`, - gives: { identity: true } - })); - motiveEvs.push(add({ - id: 'mot_iou', side: 'field', at: { type: 'unit', unit: victim.home, slot: 'письмовий стіл' }, puzzle: czK, - title: 'Шифровані нотатки боржника', tag: 'мотив', - detail: 'У шухляді — розписка на 90 000 без імені кредитора і нотатник, писаний шифром. Людина боялась навіть власних записів. Розшифруйте — і кредитор матиме ім\'я.', - gives: { pointsTo: killer.id, motive: true } - })); - motiveEvs.push(add({ - id: 'mot_bank_k', side: 'analyst', at: { type: 'sys', sys: 'bank' }, q: { sys: 'bank', match: victim.name }, - title: 'Виписка зниклої особи', tag: 'мотив', - detail: `Щомісячні перекази з рахунку жертви (отримувач: ${killer.name}) припинились три місяці тому. Останній коментар до переказу: «решту віддам, чесно».`, - gives: { pointsTo: killer.id, motive: true } - })); - motiveEvs.push(add({ - id: 'mot_threat', side: 'field', at: { type: 'person', cid: witnessFrom(citizens.filter(c => unitBuilding(c.home) === unitBuilding(victim.home) && c.home !== victim.home)).id }, - title: 'Свідчення сусіда', tag: 'мотив', - detail: `«Тиждень тому під дверима стояв хтось і цідив крізь зуби: „Тиждень. Або поїдеш зі мною і відпрацюєш". Я в чужі справи не лізу, але голос був недобрий.»` - })); - add({ - id: 'mot_lairclue', side: 'field', at: { type: 'unit', unit: killer.home, slot: 'під ліжком' }, - title: 'Ключ із биркою', tag: 'місце', - detail: `Під ліжком — ящик з інструментами, а в ньому ключ від навісного замка зі свіжою паперовою биркою: «${lair.label}». Замок із комплекту — той самий, що зник із полиці маркету.`, - gives: { lair: true } - }); - recapWhy = `Борг у 90 000, прострочений двічі. «Відпрацюєш» виявилось буквальним: ${killer.name} ${g(killer, 'вирішив', 'вирішила')} утримувати боржника, поки рідня не збере гроші.`; - herringLead = `«У зошитах жертви і ${herring.name} десь фігурує — теж, кажуть, позичали одне одному. Хто кому винен — спитайте самі.»`; + const ctx = { r, add, addPuzzle, motiveEvs, ri, estRange, pick, chance, shuffle, md5, b64, caesar, + citizens, victim, killer, herring, district, witnessFrom, unitBuilding, g, byId, + startingFacts, lair }; + const res = motives.k_debt.generate(ctx); + motiveId = res.motiveId; + recapWhy = res.recapWhy; + herringLead = res.herringLead; } // місце утримання: знайти можна лише за зачіпкою, навмання обшукати всі кутки не вийде @@ -1089,82 +596,12 @@ function generateCase(seed) { const fm = shuffle(r, FM_KINDS).find(f => f.label !== motiveId) || FM_KINDS[0]; fm.lead(); fm.talk(); - // -- пристрої фігурантів: польовий вилучає (ордер), аналітик ламає PIN/пароль, - // всередині — шари з шансом ~35% кожен: історія браузера, чати. Може бути й чисто. - const HIST_COMMON = ['погода на тиждень', 'розклад маршруток', 'рецепт як у їдальні', 'дешеві авіаквитки', - 'чому болить спина зранку', 'серіали за вечір список', 'як позбутися мурах', 'гороскоп терези']; - const CHAT_COMMON = ['«Купи хліба» — «Вже йду»', '«Ти де?» — «Затримуюсь, не чекайте»', - '«Завтра зміна з восьмої» — «Прийду»', '«Мама дзвонила?» — «Подзвоню ввечері»']; - const histFor = who => { - if (who === killer) return caseType === 'kidnap' - ? pick(r, [['навісні замки які надійніші', 'скільки людина може без їжі'], ['чи пишуть камери вночі', 'зняти готівку без банкомата']]) - : method.id === 'poison' - ? [['барбітурати дозування', 'через скільки діє снодійне'], ['алкоголь і снодійне сумісність', 'чи виявляє розтин отруту']][ri(r, 0, 1)] - : pick(r, [['чи бачать камери вночі', 'як поводитись на допиті'], ['квитки в один бік дешево', 'строк давності тяжких злочинів']]); - if (who === herring) return { - 'Борг': ['мікропозика без відмови', 'як швидко заробити'], - 'Ревнощі': ['як повернути кохану людину', 'чому мене не помічають'], - 'Сусідська війна': ['зразок скарги на сусіда', 'куди скаржитись на шум'], - 'Розбите авто': ['вартість ремонту крила', 'як стягнути збитки з винуватця'] - }[fm.label]; - if (accomplice && who === accomplice) return ['чи можна відкликати свідчення', 'адвокат недорого консультація']; - return { - jealousy: ['готелі подобово поруч'], debt: ['шаблон розписки про борг'], blackmail: ['анонімна пошта створити'], - witness: ['куди повідомити про крадіжку'], stalker: ['як зафіксувати переслідування'], inherit: ['зразок заповіту'], - oldcrime: ['строк давності злочинів'], mistaken: ['як змінити номер телефону'], business: ['митні правила дрібні партії'], - serial: ['самооборона курси'], ransom: ['страхування життя умови'], k_ransom: ['страхування життя умови'], - k_obsession: ['як зафіксувати переслідування'], k_debt: ['шаблон розписки про борг'] - }[templateId] || []; - }; - const usedDevSlots = new Set(); - const devFigures = [[killer, 'k'], [herring, 'h']]; - if (accomplice) devFigures.push([accomplice, 'a']); - if (caseType === 'murder' && !['jealousy', 'witness', 'blackmail'].includes(templateId)) devFigures.push([victim, 'v']); - for (const [who, suf] of devFigures) { - const isPhone = chance(r, 0.5); - const taken = sl => usedDevSlots.has(who.home + '|' + sl) || - Object.values(E).some(e => e.at.type === 'unit' && e.at.unit === who.home && e.at.slot === sl); - const slot = shuffle(r, district.homeSlots).find(sl => !taken(sl)) || 'комора'; - usedDevSlots.add(who.home + '|' + slot); - let pz; - if (isPhone) { - const pin = String(who.birthYear); - pz = addPuzzle({ - id: 'pz_dev_' + suf, kind: 'pin', hash: md5(pin), answer: pin, - prompt: `Вилучений телефон (${who.name}): заблоковано 4-значним PIN.`, - hint: 'Люди ставлять те, що не забудуть. Рік народження власника є в реєстрі мешканців.' - }); - } else { - const pw = genPassword(r, who); - pz = addPuzzle({ - id: 'pz_dev_' + suf, kind: 'md5', hash: md5(pw.answer), answer: pw.answer, style: pw.style, - prompt: `Вилучений ноутбук (${who.name}): вхід за паролем. Хеш (md5): ${md5(pw.answer)}.`, - hint: (pw.personal ? 'Особистий пароль — щось про власника, латиницею (див. реєстр): ' : 'Пароль випадковий — лише словниковий перебір: ') + pw.hint - }); - } - add({ - id: 'dev_' + suf, side: 'field', at: { type: 'unit', unit: who.home, slot }, - title: `${isPhone ? 'Телефон' : 'Ноутбук'}: ${who.name}`, tag: 'слід', - detail: `${isPhone ? 'Телефон' : 'Ноутбук'} у житлі ${who.name}. Вилучено для лабораторії: ${isPhone ? 'заблокований PIN-кодом' : 'вхід за паролем'} — далі справа аналітика.` - }); - // кожен шар — рідкісний і незалежний: один пристрій майже ніколи не дає всього - const parts = []; - if (chance(r, 0.3)) { - const lines = shuffle(r, [...histFor(who), ...shuffle(r, HIST_COMMON).slice(0, 3)]).slice(0, 4); - parts.push('Історія браузера: ' + lines.map(l => `«${l}»`).join(', ') + '.'); - } - if (chance(r, 0.3)) { - const mateC = who.spouse ? byId(who.spouse) : pick(r, citizens.filter(o => o !== who && o.id !== victim.id)); - parts.push(`Останні чати: побут із ${mateC.name}. ` + pick(r, CHAT_COMMON) + - (who === killer ? ` І одне непрочитане того вечора: «Подзвони, як будеш удома» — без відповіді.` : '')); - } - add({ - id: 'dev_' + suf + '_data', side: 'analyst', at: { type: 'lab' }, requires: ['dev_' + suf], puzzle: pz, - title: `Вміст пристрою: ${who.name}`, tag: 'слід', - detail: parts.length ? parts.join(' ') - : 'Чисто: банк, погода, пасьянс. Або власник обережний, або йому справді нічого ховати.' - }); - } + setupDevices({ + r, citizens, killer, herring, victim, accomplice, + caseType, templateId, district, add, addPuzzle, byId, E, + method, fm, + md5, genPassword, chance, pick, shuffle, ri + }); add({ id: 'her_alibi', side: 'analyst', at: { type: 'sys', sys: 'cctv' }, q: { sys: 'cctv', building: herring.evening }, requires: ['her_lead'], @@ -1233,95 +670,12 @@ function generateCase(seed) { // -- дрібниці: у кожному кутку щось лежить; справжнє і мотлох — упереміш, фільтруйте самі const nb = () => witnessFrom(citizens); // випадковий не-фігурант для згадки в тексті - const dogOwner = citizens.find(c => c.dog && c.id !== victim.id); - const junkTexts = shuffle(r, [ - { t: 'Чек із бару', d: 'Два еспресо і коньяк, 21:47. На звороті олівцем — номер телефону без імені. Половина цифр затерта.' }, - { t: 'Стара запальничка', d: `Бензинова, з гравіюванням «${pick(r, ['С.', 'М.', 'В.', 'Т.'])}». Давно не заправлялась.` }, - { t: 'Флаєр', d: '«Ремонт взуття за годину». Усі відривні номери відірвані — популярний швець.' }, - { t: 'Порожня пляшка', d: 'З-під дешевого вина, ще липка. Поруч — два пластикові стаканчики.' }, - { t: 'Список покупок', d: 'Молоко, батарейки, свічки, скотч. Почерк квапливий.' }, - { t: 'Одна рукавичка', d: 'Шкіряна, права, добротна. Ліва, вочевидь, досі гріє чиюсь кишеню.' }, - { t: 'Прострочена квитанція', d: `Комуналка на ім'я «${nb().name}», третє нагадування. Хтось не любить платити вчасно.` }, - { t: 'Записник із адресами', d: `Половина сторінок вирвана. Серед уцілілих записів — «${nb().name}» і закреслений номер.` }, - { t: 'Білети в кіно', d: 'Два білети двомісячної давності, сеанс 21:30, місця поруч. І обгортка від шоколаду.' }, - { t: 'Іржавий ключ', d: 'Не підходить до жодних дверей поблизу. Хтось носив його «про всяк випадок» роками.' }, - { t: 'Оголошення про пса', d: dogOwner ? `«Загубився пес на кличку ${dogOwner.dog}. Винагорода.» Судячи з дати — пес давно вдома.` : '«Загубився пес. Винагорода.» Без прикмет — оптимістично.' }, - { t: 'Стос старих газет', d: 'В одній обведено оголошення про оренду квартири. Олівець аж продавив папір.' }, - { t: 'Візитка таксі', d: 'Від руки написано час: «23:15». Ні дати, ні імені.' }, - { t: 'Розбитий парасоль', d: 'І чек на новий — того ж дня, з маркету. Практична людина.' }, - { t: 'Гребінець із монограмою', d: 'Кістяний, старий, доглянутий. Літера затерта до нечитабельності.' }, - { t: 'Театральна програмка', d: 'Гастролі обласного театру, третій ряд. На полях — сердечко олівцем.' }, - { t: 'Ключниця з трьома ключами', d: 'Жоден не підписаний. Хтось досі відчиняє двері навпомацки.' }, - { t: 'Обгортка від валідолу', d: 'Свіжа. Комусь тут нещодавно було недобре — або дуже нервово.' }, - { t: 'Дитячий малюнок', d: 'Будинок, сонце і три постаті. Складений учетверо і затертий на згинах — його носили з собою.' }, - { t: 'Лотерейний білет', d: 'Не виграшний. Закреслений хрест-навхрест із таким натиском, що порвався папір.' }, - { t: 'Окуляри з тріснутим склом', d: 'Сильні діоптрії. Власник без них як без рук — але по них не повернувся.' }, - { t: 'Розклад електричок', d: 'Обведено останній нічний рейс. Двічі.' }, - { t: 'Порожній футляр від скрипки', d: 'Оксамит витертий. Або інструмент продали, або носять у ньому щось інше.' }, - { t: 'Пакетик насіння', d: 'Чорнобривці. Строк придатності вийшов три роки тому. Хтось усе не наважиться посадити.' }, - { t: 'Квитанція з ломбарду', d: `Здано «годинник чол. позолоч.». Викуп — до кінця місяця. Сума немала.` }, - { t: 'Зламана рація', d: 'Дитяча, з наліпкою блискавки. Друга з пари десь чекає на звʼязок.' }, - { t: 'Листівка без марки', d: '«Вітаю з ювілеєм!» — написано, підписано, не надіслано. Вже роки як.' }, - { t: 'Шматок пакувального шпагату', d: 'Свіжий зріз, гострим ножем. Таким перевʼязують коробки. Або щось інше.' }, - { t: 'Кишеньковий календарик', d: 'Позаминулий рік. Пʼятниці обведені. Усі до однієї.' }, - { t: 'Металева фляжка', d: 'Порожня, з увігнутим боком. Пахне не чаєм.' } - ]); - const junkSpots = []; - for (const b of district.buildings) for (const sp of (b.spots || [])) junkSpots.push({ type: 'spot', building: b.id, spot: sp }); - const chosen = shuffle(r, junkSpots).slice(0, 8); - const usedSlots = new Set(Object.values(E).filter(e => e.at.type === 'unit').map(e => e.at.unit + '|' + e.at.slot)); - for (const [unit, n] of [[victim.home, 2], [killer.home, 1], [herring.home, 1]]) { - let placed = 0; - for (const slot of shuffle(r, district.homeSlots)) { - if (placed >= n) break; - if (usedSlots.has(unit + '|' + slot)) continue; - usedSlots.add(unit + '|' + slot); chosen.push({ type: 'unit', unit, slot }); placed++; - } - } - chosen.forEach((at, i) => { - const j = junkTexts[i % junkTexts.length]; - add({ id: 'junk_' + i, side: 'field', at, title: j.t, tag: 'дрібниця', detail: j.d }); + setupJunk({ + r, citizens, victim, killer, herring, caseType, district, + add, E, g, witnessFrom, dna2, nb, + pick, shuffle, chance }); - // -- плітки: у кожного є що сказати про район; трохи правди тоне в шумі - const GOSSIP = [ - () => `Кажуть, ${nb().first} ${nb().last} і ${nb().first} не вітаються після сварки за паркування.`, - () => `${nb().name} нібито у великих боргах за комуналку. Але то таке, люди всяке кажуть.`, - () => `Подейкують, ${nb().name} шукає нову роботу — на старій щось не склалося.`, - () => `${nb().name} щовечора в барі. Щовечора!`, - () => `Бачили, як ${nb().name} кричить у телефон просто посеред вулиці. Аж луна йшла.`, - () => `У «Каштані» хтось заливає сусідів знизу, і всі чомусь думають на ${nb().name}.`, - () => 'На складі маркету вночі інколи світиться. Сторож каже — миші. Ага, миші з ліхтариком.', - () => `${nb().name} нібито бачили на набережній о третій ночі. Безсоння, кажуть.`, - () => 'У гуртожитку знову міняли замки — хтось утретє загубив ключі.', - () => `Подейкують, ${nb().name} збирає гроші на авто. Або на переїзд. Або на весілля. Версії різняться.`, - () => `${nb().name} виносить сміття о п'ятій ранку. Щодня. Хто взагалі стільки смітить?`, - () => { const p = nb(); return `Кажуть, ${p.name} писа${g(p, 'в', 'ла')} скарги на пів району. Пише красиво, з посиланнями на закони.`; }, - () => `У ${nb().first} з ${nb().first} щось було. Або є. Або буде. Двір слідкує.`, - () => `${nb().name} тримає на балконі голубів. Сусіди знизу мають окрему думку про це.`, - () => 'На набережній вечорами хтось грає на трубі. Гарно грає. Сумно.', - () => { const p = nb(); return `${p.name} нібито ви${g(p, 'грав', 'грала')} у лотерею і мовчить. Мовчить підозріло добре.`; }, - () => { const p = nb(); return `Бачили, як ${p.name} годує всіх котів району. Клички ${g(p, 'дав', 'дала')} кожному. Веде список.`; }, - () => 'У клініці кажуть, що нічний охоронець спить. Охоронець каже, що медитує.', - () => { const p = nb(); return `${p.name} посвари${g(p, 'вся', 'лася')} з усім будинком через антену на даху. Війна триває.`; }, - () => `Подейкують, ${nb().name} ходить до бару, але не п'є — сидить і слухає чужі розмови.`, - () => 'В автомайстерні беруть «за терміновість» удвічі. Терміново тепер усе.', - () => `${nb().name} щонеділі кудись їздить першим автобусом. Питати незручно, а цікаво.`, - () => 'Хтось ночами переставляє лавку в парку. Комунальники здалися.' - ]; - for (const c of citizens) { - if (c.id === victim.id) { c.gossip = []; continue; } - c.gossip = [pick(r, GOSSIP)()]; - if (chance(r, 0.4)) c.gossip.push(pick(r, GOSSIP)()); - } - const wit = nb(); // ключова плітка — це чиїсь особисті слова, а не голос двору - wit.gossip.push(caseType === 'kidnap' - ? `Пізно того вечора хтось у ${killer.coat} тягнув щось важке в бік проїзду. ${g(wit, 'Подумав', 'Подумала')} — килим. А тепер думаю — не килим.` - : `Пізно того вечора хтось у ${killer.coat} швидко йшов від місця, де потім знайшли тіло. Може, здалося.`); - nb().gossip.push(`У квартирі ${killer.home} останнім часом світло горить до третьої ночі. Комусь не спиться.`); - if (dna2 && dna2.why === 'brawl') - nb().gossip.push(`Удень перед тим бачили, як ${dna2.owner.name} ${g(dna2.owner, 'зчепився', 'зчепилася')} з ${victim.first} ${victim.last} у барі — ${dna2.reason}. Розборонили швидко, обоє розійшлися своїми ногами.`); - // -- фінал const finale = caseType === 'kidnap' ? { @@ -1358,80 +712,4 @@ function generateCase(seed) { }; } -// ---------- Симулятор досяжності (fair-play) ---------- -const RT = require('./runtime'); - -function solve(cs) { - const E = cs.evidence; - const st = RT.newState(); - // симулятор вважає: ордер беруть, щойно прокурор готовий (pointed ⇒ warrant) - let changed = true; - while (changed) { - changed = false; - for (const cid of st.pointed) if (!st.warrants.includes(cid)) { st.warrants.push(cid); changed = true; } - for (const ev of Object.values(E)) { - if (st.found.includes(ev.id) || !RT.available(cs, st, ev, { ignorePuzzles: true })) continue; - RT.applyFound(cs, st, ev); changed = true; - } - } - - const has = pred => Object.values(E).some(ev => st.found.includes(ev.id) && pred(ev)); - return { - reached: new Set(st.found), identity: st.identity, - killerConfirmed: has(ev => ev.gives.confirms), - methodKnown: has(ev => ev.gives.method), - motiveProven: has(ev => ev.gives.motive), - herringDisproved: has(ev => ev.gives.disproves === cs.herring), - herringLeadFound: st.found.includes('her_lead'), - lairProven: has(ev => ev.gives.lair), - finalCandidates: RT.candidates(cs, st) - }; -} - -// ---------- Перевірка звинувачення ---------- -function checkAccusation(cs, ans, pinned) { - const E = cs.evidence, killer = cs.citizens.find(c => c.id === cs.finale.killer); - const has = pred => pinned.some(id => E[id] && pred(E[id])); - const hasConfirmFor = (name) => pinned.some(id => { - const ev = E[id]; - if (!ev || !ev.gives || !ev.gives.confirms) return false; - // Якщо доказ має pointsTo, перевіряємо, чи це обвинувачений - if (ev.gives.pointsTo) { - const target = cs.citizens.find(c => c.id === ev.gives.pointsTo); - return target && target.name.toLowerCase() === name; - } - // Якщо pointsTo немає (старі докази), вважаємо, що confirms — універсальне - return true; - }); - const accused = (ans.who || '').trim().toLowerCase(); - if (accused !== killer.name.toLowerCase()) - return { ok: false, hint: 'Ім\'я не сходиться з доказами: сліди з місця злочину (взуття, тканина, зріст, ДНК) звужують коло — і хтось із перевірених має підтверджене алібі.' }; - if (cs.caseType === 'kidnap') { - if (ans.how !== cs.finale.where) - return { ok: false, hint: 'Місце не сходиться. ДЕ саме вказують знахідки: бирка ключа, план, чек за замок?' }; - if (ans.why !== cs.finale.motive) - return { ok: false, hint: 'Мотив не той. ЩО пов\'язувало викрадача зі зниклою особою?' }; - //if (!has(ev => ev.gives.confirms)) - if (!hasConfirmFor(accused)) - return { ok: false, hint: 'Особу викрадача не підтверджено: потрібен збіг ДНК або спростоване алібі на дошці.' }; - if (!has(ev => ev.gives.motive)) - return { ok: false, hint: 'Мотив не підкріплено жодним закріпленим доказом.' }; - if (!has(ev => ev.gives.lair)) - return { ok: false, hint: 'Місце утримання не підкріплено закріпленим доказом — штурм наосліп ніхто не санкціонує.' }; - return { ok: true }; - } - if (ans.how !== cs.finale.method) - return { ok: false, hint: 'Спосіб суперечить висновку розтину. Перечитайте, що саме сказав патологоанатом.' }; - if (ans.why !== cs.finale.motive) - return { ok: false, hint: 'Мотив не той. ЩО пов\'язувало цих двох людей за тиждень до смерті?' }; - // if (!has(ev => ev.gives.confirms)) - if (!hasConfirmFor(accused)) - return { ok: false, hint: 'Немає прямого підтвердження особи: потрібен збіг ДНК, повний відбиток зі знаряддя або спростоване алібі на дошці.' }; - if (!has(ev => ev.gives.motive)) - return { ok: false, hint: 'Мотив не підкріплено жодним закріпленим доказом.' }; - if (!has(ev => ev.gives.method)) - return { ok: false, hint: 'Спосіб смерті не підкріплено: закріпіть висновок розтину.' }; - return { ok: true }; -} - module.exports = { generateCase, solve, checkAccusation, caesar, md5, translit, COMMON_PASSWORDS }; diff --git a/chapter3/casegen/junk.js b/chapter3/casegen/junk.js new file mode 100644 index 0000000..4ae6b2d --- /dev/null +++ b/chapter3/casegen/junk.js @@ -0,0 +1,100 @@ +// casegen/junk.js — фейкові сліди, дрібниці, плітки +const { pick, shuffle, chance } = require('./rng'); +const N = require('./names'); + +function setupJunk(ctx) { + const { r, citizens, victim, killer, herring, caseType, district, add, E, g, witnessFrom, dna2 } = ctx; + + const nb = () => witnessFrom(citizens); + const dogOwner = citizens.find(c => c.dog && c.id !== victim.id); + const junkTexts = shuffle(r, [ + { t: 'Чек із бару', d: 'Два еспресо і коньяк, 21:47. На звороті олівцем — номер телефону без імені. Половина цифр затерта.' }, + { t: 'Стара запальничка', d: `Бензинова, з гравіюванням «${pick(r, ['С.', 'М.', 'В.', 'Т.'])}». Давно не заправлялась.` }, + { t: 'Флаєр', d: '«Ремонт взуття за годину». Усі відривні номери відірвані — популярний швець.' }, + { t: 'Порожня пляшка', d: 'З-під дешевого вина, ще липка. Поруч — два пластикові стаканчики.' }, + { t: 'Список покупок', d: 'Молоко, батарейки, свічки, скотч. Почерк квапливий.' }, + { t: 'Одна рукавичка', d: 'Шкіряна, права, добротна. Ліва, вочевидь, досі гріє чиюсь кишеню.' }, + { t: 'Прострочена квитанція', d: `Комуналка на ім'я «${nb().name}», третє нагадування. Хтось не любить платити вчасно.` }, + { t: 'Записник із адресами', d: `Половина сторінок вирвана. Серед уцілілих записів — «${nb().name}» і закреслений номер.` }, + { t: 'Білети в кіно', d: 'Два білети двомісячної давності, сеанс 21:30, місця поруч. І обгортка від шоколаду.' }, + { t: 'Іржавий ключ', d: 'Не підходить до жодних дверей поблизу. Хтось носив його «про всяк випадок» роками.' }, + { t: 'Оголошення про пса', d: dogOwner ? `«Загубився пес на кличку ${dogOwner.dog}. Винагорода.» Судячи з дати — пес давно вдома.` : '«Загубився пес. Винагорода.» Без прикмет — оптимістично.' }, + { t: 'Стос старих газет', d: 'В одній обведено оголошення про оренду квартири. Олівець аж продавив папір.' }, + { t: 'Візитка таксі', d: 'Від руки написано час: «23:15». Ні дати, ні імені.' }, + { t: 'Розбитий парасоль', d: 'І чек на новий — того ж дня, з маркету. Практична людина.' }, + { t: 'Гребінець із монограмою', d: 'Кістяний, старий, доглянутий. Літера затерта до нечитабельності.' }, + { t: 'Театральна програмка', d: 'Гастролі обласного театру, третій ряд. На полях — сердечко олівцем.' }, + { t: 'Ключниця з трьома ключами', d: 'Жоден не підписаний. Хтось досі відчиняє двері навпомацки.' }, + { t: 'Обгортка від валідолу', d: 'Свіжа. Комусь тут нещодавно було недобре — або дуже нервово.' }, + { t: 'Дитячий малюнок', d: 'Будинок, сонце і три постаті. Складений учетверо і затертий на згинах — його носили з собою.' }, + { t: 'Лотерейний білет', d: 'Не виграшний. Закреслений хрест-навхрест із таким натиском, що порвався папір.' }, + { t: 'Окуляри з тріснутим склом', d: 'Сильні діоптрії. Власник без них як без рук — але по них не повернувся.' }, + { t: 'Розклад електричок', d: 'Обведено останній нічний рейс. Двічі.' }, + { t: 'Порожній футляр від скрипки', d: 'Оксамит витертий. Або інструмент продали, або носять у ньому щось інше.' }, + { t: 'Пакетик насіння', d: 'Чорнобривці. Строк придатності вийшов три роки тому. Хтось усе не наважиться посадити.' }, + { t: 'Квитанція з ломбарду', d: `Здано «годинник чол. позолоч.». Викуп — до кінця місяця. Сума немала.` }, + { t: 'Зламана рація', d: 'Дитяча, з наліпкою блискавки. Друга з пари десь чекає на звʼязок.' }, + { t: 'Листівка без марки', d: '«Вітаю з ювілеєм!» — написано, підписано, не надіслано. Вже роки як.' }, + { t: 'Шматок пакувального шпагату', d: 'Свіжий зріз, гострим ножем. Таким перевʼязують коробки. Або щось інше.' }, + { t: 'Кишеньковий календарик', d: 'Позаминулий рік. Пʼятниці обведені. Усі до однієї.' }, + { t: 'Металева фляжка', d: 'Порожня, з увігнутим боком. Пахне не чаєм.' } + ]); + + const junkSpots = []; + for (const b of district.buildings) for (const sp of (b.spots || [])) junkSpots.push({ type: 'spot', building: b.id, spot: sp }); + const chosen = shuffle(r, junkSpots).slice(0, 8); + const usedSlots = new Set(Object.values(E).filter(e => e.at.type === 'unit').map(e => e.at.unit + '|' + e.at.slot)); + for (const [unit, n] of [[victim.home, 2], [killer.home, 1], [herring.home, 1]]) { + let placed = 0; + for (const slot of shuffle(r, district.homeSlots)) { + if (placed >= n) break; + if (usedSlots.has(unit + '|' + slot)) continue; + usedSlots.add(unit + '|' + slot); chosen.push({ type: 'unit', unit, slot }); placed++; + } + } + chosen.forEach((at, i) => { + const j = junkTexts[i % junkTexts.length]; + add({ id: 'junk_' + i, side: 'field', at, title: j.t, tag: 'дрібниця', detail: j.d }); + }); + + // Плітки + const GOSSIP = [ + () => `Кажуть, ${nb().first} ${nb().last} і ${nb().first} не вітаються після сварки за паркування.`, + () => `${nb().name} нібито у великих боргах за комуналку. Але то таке, люди всяке кажуть.`, + () => `Подейкують, ${nb().name} шукає нову роботу — на старій щось не склалося.`, + () => `${nb().name} щовечора в барі. Щовечора!`, + () => `Бачили, як ${nb().name} кричить у телефон просто посеред вулиці. Аж луна йшла.`, + () => `У «Каштані» хтось заливає сусідів знизу, і всі чомусь думають на ${nb().name}.`, + () => 'На складі маркету вночі інколи світиться. Сторож каже — миші. Ага, миші з ліхтариком.', + () => `${nb().name} нібито бачили на набережній о третій ночі. Безсоння, кажуть.`, + () => 'У гуртожитку знову міняли замки — хтось утретє загубив ключі.', + () => `Подейкують, ${nb().name} збирає гроші на авто. Або на переїзд. Або на весілля. Версії різняться.`, + () => `${nb().name} виносить сміття о п'ятій ранку. Щодня. Хто взагалі стільки смітить?`, + () => { const p = nb(); return `Кажуть, ${p.name} писа${g(p, 'в', 'ла')} скарги на пів району. Пише красиво, з посиланнями на закони.`; }, + () => `У ${nb().first} з ${nb().first} щось було. Або є. Або буде. Двір слідкує.`, + () => `${nb().name} тримає на балконі голубів. Сусіди знизу мають окрему думку про це.`, + () => 'На набережній вечорами хтось грає на трубі. Гарно грає. Сумно.', + () => { const p = nb(); return `${p.name} нібито ви${g(p, 'грав', 'грала')} у лотерею і мовчить. Мовчить підозріло добре.`; }, + () => { const p = nb(); return `Бачили, як ${p.name} годує всіх котів району. Клички ${g(p, 'дав', 'дала')} кожному. Веде список.`; }, + () => 'У клініці кажуть, що нічний охоронець спить. Охоронець каже, що медитує.', + () => { const p = nb(); return `${p.name} посвари${g(p, 'вся', 'лася')} з усім будинком через антену на даху. Війна триває.`; }, + () => `Подейкують, ${nb().name} ходить до бару, але не п'є — сидить і слухає чужі розмови.`, + () => 'В автомайстерні беруть «за терміновість» удвічі. Терміново тепер усе.', + () => `${nb().name} щонеділі кудись їздить першим автобусом. Питати незручно, а цікаво.`, + () => 'Хтось ночами переставляє лавку в парку. Комунальники здалися.' + ]; + for (const c of citizens) { + if (c.id === victim.id) { c.gossip = []; continue; } + c.gossip = [pick(r, GOSSIP)()]; + if (chance(r, 0.4)) c.gossip.push(pick(r, GOSSIP)()); + } + const wit = nb(); + wit.gossip.push(caseType === 'kidnap' + ? `Пізно того вечора хтось у ${killer.coat} тягнув щось важке в бік проїзду. ${g(wit, 'Подумав', 'Подумала')} — килим. А тепер думаю — не килим.` + : `Пізно того вечора хтось у ${killer.coat} швидко йшов від місця, де потім знайшли тіло. Може, здалося.`); + nb().gossip.push(`У квартирі ${killer.home} останнім часом світло горить до третьої ночі. Комусь не спиться.`); + if (dna2 && dna2.why === 'brawl') + nb().gossip.push(`Удень перед тим бачили, як ${dna2.owner.name} ${g(dna2.owner, 'зчепився', 'зчепилася')} з ${victim.first} ${victim.last} у барі — ${dna2.reason}. Розборонили швидко, обоє розійшлися своїми ногами.`); +} + +module.exports = { setupJunk }; diff --git a/chapter3/casegen/motives/blackmail.js b/chapter3/casegen/motives/blackmail.js new file mode 100644 index 0000000..3c6f89f --- /dev/null +++ b/chapter3/casegen/motives/blackmail.js @@ -0,0 +1,36 @@ +const { genPassword, pick } = require('../rng'); + +function generate(ctx) { + const { r, add, addPuzzle, motiveEvs, victim, killer, herring, district } = ctx; + const motiveId = 'Шантаж'; + const pw = genPassword(r, victim); + const pwPuzzle = addPuzzle({ + id: 'pz_md5', kind: 'md5', hash: require('../rng').md5(pw.answer), answer: pw.answer, style: pw.style, + prompt: `Ноутбук жертви. Логін відкритий, пароль — ні. Хеш пароля (md5): ${require('../rng').md5(pw.answer)}.`, + hint: (pw.personal + ? 'Схоже на «особистий» пароль — щось про власника, латиницею. Досьє в поміч: ' + : 'Пароль не пов\'язаний із жертвою — лише перебір за словником (rockyou.txt). ') + pw.hint + }); + const secret = pick(r, [ + `у березні ${killer.name} ${ctx.g(killer, 'збив', 'збила')} пішохода на трасі й ${ctx.g(killer, 'поїхав', 'поїхала')}; бампер ремонтували «без записів»`, + `диплом, за яким ${killer.name} ${ctx.g(killer, 'отримав', 'отримала')} роботу (${killer.job}), — куплений`, + `${killer.name} роками ${ctx.g(killer, 'зливав', 'зливала')} касу на роботі повз облік` + ]); + motiveEvs.push(add({ + id: 'mot_laptop', side: 'field', at: { type: 'unit', unit: victim.home, slot: 'письмовий стіл' }, needsIdentity: true, puzzle: pwPuzzle, + title: 'Ноутбук жертви', tag: 'мотив', + detail: `Чернетки листів без підпису: «Я знаю, що ${secret}. 10 000 щомісяця — і ніхто не дізнається». Остання чернетка різкіша: «Ціна подвоюється».`, + gives: { pointsTo: killer.id, motive: true } + })); + motiveEvs.push(add({ + id: 'mot_cash', side: 'analyst', at: { type: 'sys', sys: 'bank' }, q: { sys: 'bank', match: killer.name }, requires: ['mot_laptop'], + title: 'Готівкові зняття', tag: 'мотив', + detail: `Рахунок ${killer.name}: десяте число кожного місяця — зняття 10 000 готівкою, банкомат біля ${district.buildings.find(b => b.id === 'shop').name}. Півроку поспіль. Останній місяць — 20 000.`, + gives: { pointsTo: killer.id, motive: true } + })); + const recapWhy = `Жертва знала чужу таємницю і брала за мовчання гроші. Таємниця така: ${secret}. Подвоєна «ціна мовчання» стала вироком самому шантажисту.`; + const herringLead = `Серед чернеток є і другий адресат: «Я знаю про ваші оборудки», — а хто в районі має «оборудки», всі скажуть одразу: ${herring.name}.`; + return { motiveId, recapWhy, herringLead }; +} + +module.exports = { generate }; diff --git a/chapter3/casegen/motives/business.js b/chapter3/casegen/motives/business.js new file mode 100644 index 0000000..4a5beb7 --- /dev/null +++ b/chapter3/casegen/motives/business.js @@ -0,0 +1,40 @@ +const { caesar, ri } = require('../rng'); + +function generate(ctx) { + const { r, add, addPuzzle, motiveEvs, victim, killer, herring, district } = ctx; + const motiveId = 'Кримінальний бізнес'; + const shift2 = ri(r, 3, 9); + const plain2 = `Частка ${killer.last} ${killer.first[0]}. — сорок відсотків. Не сплачено двічі. Точка — склад маркету.`; + const czPuzzle2 = addPuzzle({ + id: 'pz_caesar', kind: 'caesar', prompt: 'Зошит обліку, писаний нечитабельними літерами.', + payload: caesar(plain2, shift2), answer: plain2, shift: shift2, hint: 'Схоже на шифр зсуву (Цезар): переберіть усі зсуви абетки.' + }); + motiveEvs.push(add({ + id: 'mot_ledger', side: 'field', at: { type: 'unit', unit: victim.home, slot: 'кухня' }, needsIdentity: true, puzzle: czPuzzle2, + title: 'Зошит подвійної бухгалтерії', tag: 'мотив', + detail: 'У бляшанці з-під кави — зошит: колонки сум без підписів і шифровані нотатки. Легальних доходів на такі колонки жертва не мала. Розшифруйте — і стане видно, з ким ділили.', + gives: { pointsTo: killer.id, motive: true } + })); + motiveEvs.push(add({ + id: 'mot_stash', side: 'field', at: { type: 'spot', building: 'shop', spot: 'склад' }, requires: ['mot_ledger'], + title: 'Тайник на складі', tag: 'мотив', + detail: 'За стелажем із мінералкою — коробки без маркування, лише код маркером. Той самий код — у зошиті жертви. Контрабандний товар ішов через район тихо і давно.', + gives: { motive: true } + })); + motiveEvs.push(add({ + id: 'mot_bank_biz', side: 'analyst', at: { type: 'sys', sys: 'bank' }, q: { sys: 'bank', match: [victim.name, killer.name] }, needsIdentity: true, + title: 'Синхронні внески', tag: 'мотив', + detail: `Два рахунки, однакові готівкові внески в ті самі дати, місяць за місяцем: рахунок жертви — і рахунок ${killer.name}. Партнерство, якого офіційно не існувало. Останні два місяці вносила тільки жертва.`, + gives: { pointsTo: killer.id, motive: true } + })); + motiveEvs.push(add({ + id: 'mot_bar_corner', side: 'field', at: { type: 'person', cid: ctx.witnessFrom(ctx.citizens.filter(c => c.workplace === 'bar')).id }, needsIdentity: true, + title: 'Свідчення з бару', tag: 'мотив', + detail: `«Ці двоє завжди сідали в кутку, рахували щось у зошиті. Останнім часом — на підвищених тонах. Слово «частка» я чув частіше, ніж «будьмо».»` + })); + const recapWhy = `Через район тихо йшов контрабандний товар; частки ділили за зошитом. Коли жертва двічі не заплатила партнерську частку, партнер — ${killer.name} — ${ctx.g(killer, 'закрив', 'закрила')} баланс назавжди.`; + const herringLead = `«Шукаєте, хто мутить? ${herring.name} на склад маркету заходить частіше, ніж туди завозять хліб. Просто кажу.»`; + return { motiveId, recapWhy, herringLead }; +} + +module.exports = { generate }; diff --git a/chapter3/casegen/motives/debt.js b/chapter3/casegen/motives/debt.js new file mode 100644 index 0000000..89f4ed9 --- /dev/null +++ b/chapter3/casegen/motives/debt.js @@ -0,0 +1,36 @@ +// casegen/motives/debt.js — мотив "Борг" +const { caesar } = require('../rng'); + +function generate(ctx) { + const { r, add, addPuzzle, motiveEvs, victim, killer, herring, citizens, witnessFrom } = ctx; + const motiveId = 'Борг'; + const shift = ctx.ri(r, 3, 9); + const plain = `${killer.last} ${killer.first[0]}. — 84 000, прострочено. ${herring.last} ${herring.first[0]}. — 9 000, повернуто.`; + const czPuzzle = addPuzzle({ + id: 'pz_caesar', kind: 'caesar', prompt: 'Зошит, у якому кожен рядок — беззмістовний набір літер.', + payload: caesar(plain, shift), answer: plain, shift, hint: 'Схоже на шифр зсуву (Цезар): переберіть усі зсуви абетки.' + }); + motiveEvs.push(add({ + id: 'mot_notebook', side: 'field', at: { type: 'unit', unit: victim.home, slot: 'під ліжком' }, needsIdentity: true, puzzle: czPuzzle, + title: 'Зошит із шифром', tag: 'мотив', + detail: `Під ліжком — бляшана коробка: готівка і зошит, списаний шифром. ${victim.name} давав гроші під відсоток. Розшифруйте — і побачите боржників.`, + gives: { pointsTo: killer.id, motive: true } + })); + motiveEvs.push(add({ + id: 'mot_bank', side: 'analyst', at: { type: 'sys', sys: 'bank' }, q: { sys: 'bank', match: victim.name }, needsIdentity: true, + title: 'Виписка з рахунку жертви', tag: 'мотив', + detail: `Щомісячні перекази по 7 000 (відправник: ${killer.name}) — і тиша останні три місяці. Останнє повідомлення до переказу: «це все, більше нема».`, + gives: { pointsTo: killer.id, motive: true } + })); + motiveEvs.push(add({ + id: 'mot_neighbor', side: 'field', at: { type: 'person', cid: witnessFrom(citizens.filter(c => ctx.unitBuilding(c.home) === ctx.unitBuilding(victim.home) && c.home !== victim.home)).id }, needsIdentity: true, + title: 'Свідчення сусіда', tag: 'мотив', + detail: `«За стіною кричали про гроші. ${killer.sex === 'ч' ? 'Чоловічий' : 'Жіночий'} голос повторював: „Я не маю чим, зрозумій, не маю чим"».`, + gives: { motive: true } + })); + const recapWhy = `Кредитором був не банк: гроші під відсоток давала жертва. Боржник на 84 тисячі — ${killer.name}, віддавати не було чим. Борг закрили інакше.`; + const herringLead = `У зошиті жертви є й ${herring.name} — сусіди кажуть, «теж у боргах, теж сварилися». Щоправда, поруч із тією сумою в зошиті стоїть слово, яке варто прочитати уважно.`; + return { motiveId, recapWhy, herringLead }; +} + +module.exports = { generate }; diff --git a/chapter3/casegen/motives/inherit.js b/chapter3/casegen/motives/inherit.js new file mode 100644 index 0000000..1dd2594 --- /dev/null +++ b/chapter3/casegen/motives/inherit.js @@ -0,0 +1,33 @@ +function generate(ctx) { + const { r, add, addPuzzle, motiveEvs, victim, killer, herring } = ctx; + const motiveId = 'Спадок і страховка'; + const kin = ctx.g(killer, 'племінник', 'племінниця'); + motiveEvs.push(add({ + id: 'mot_will', side: 'field', at: { type: 'unit', unit: victim.home, slot: 'шафа' }, needsIdentity: true, + title: 'Чернетка нового заповіту', tag: 'мотив', + detail: 'У шафі, під білизною — чернетка заповіту, датована минулим тижнем: усе майно — благодійному фонду. Підпис не завірений нотаріусом. Хтось дуже не хотів, щоб це було завірено.', + gives: { motive: true } + })); + motiveEvs.push(add({ + id: 'mot_notary', side: 'analyst', at: { type: 'sys', sys: 'db' }, q: { sys: 'db', match: victim.name }, needsIdentity: true, + title: 'Реєстр нотаріуса', tag: 'мотив', + detail: `Чинний заповіт від 2022 року: єдиний спадкоємець — ${kin} жертви, ${killer.name}. Запис про нову зустріч у нотаріуса стояв на наступний тиждень. Зустріч не відбулася.`, + gives: { pointsTo: killer.id, motive: true } + })); + motiveEvs.push(add({ + id: 'mot_insur', side: 'analyst', at: { type: 'sys', sys: 'bank' }, q: { sys: 'bank', match: victim.name }, needsIdentity: true, + title: 'Страховий поліс', tag: 'мотив', + detail: `Страхування життя на 900 000, бенефіціар — ${killer.name}. Найцікавіше: останній рік внески платив не власник поліса, а сам бенефіціар. Інвестиція.`, + gives: { pointsTo: killer.id, motive: true } + })); + motiveEvs.push(add({ + id: 'mot_kin_row', side: 'field', at: { type: 'person', cid: ctx.witnessFrom(ctx.citizens.filter(c => ctx.unitBuilding(c.home) === ctx.unitBuilding(victim.home) && c.home !== victim.home)).id }, needsIdentity: true, + title: 'Свідчення сусіда', tag: 'мотив', + detail: `«Тижнів зо два тому на сходах кричали. Другий голос: „Викреслиш мене — пошкодуєш!" Гримнули двері. Я не з тих, хто підслуховує, але сходи в нас лункі.»` + })); + const recapWhy = `Чинний заповіт 2022 року називав єдиного спадкоємця: ${killer.name}. Новий заповіт, який усе віддавав фонду, лишалося тільки завірити. Спадщина і страховка на 900 000 переважили.`; + const herringLead = `«А ${herring.name} усе розпитує, скільки коштують квартири в будинку жертви. І нащо воно ${ctx.g(herring, 'йому', 'їй')}?»`; + return { motiveId, recapWhy, herringLead }; +} + +module.exports = { generate }; diff --git a/chapter3/casegen/motives/jealousy.js b/chapter3/casegen/motives/jealousy.js new file mode 100644 index 0000000..3044a2b --- /dev/null +++ b/chapter3/casegen/motives/jealousy.js @@ -0,0 +1,40 @@ +// casegen/motives/jealousy.js — мотив "Ревнощі" +const { md5, caesar } = require('../rng'); + +function generate(ctx) { + const { r, add, addPuzzle, motiveEvs, victim, killer, herring, citizens, tSpouse } = ctx; + const motiveId = 'Ревнощі'; + const pin = String(victim.birthYear); + const pinPuzzle = addPuzzle({ + id: 'pz_pin', kind: 'pin', prompt: 'Телефон жертви заблоковано 4-значним PIN-кодом.', + hash: md5(pin), answer: pin, hint: 'Люди ставлять те, що не забудуть: спробуйте рік народження з досьє.' + }); + add({ + id: 'sf_phone_locked', side: 'field', at: { type: 'spot', building: ctx.atScene.building, spot: ctx.atScene.spot }, + title: 'Телефон жертви (заблокований)', tag: 'мотив', + detail: 'Екран розбитий, але телефон живий. Заблокований PIN-кодом. Аналітик може спробувати підібрати.' + }); + motiveEvs.push(add({ + id: 'mot_messages', side: 'analyst', at: { type: 'lab' }, requires: ['sf_phone_locked'], needsIdentity: true, puzzle: pinPuzzle, + title: 'Листування в телефоні', tag: 'мотив', + detail: `Місяці переписки з номером ${tSpouse.phone}: зустрічі, готелі, «коли ${tSpouse.sex === 'ч' ? 'він' : 'вона'} у відрядженні». Номер зареєстровано: ${tSpouse.name}.`, + gives: { pointsTo: tSpouse.id } + })); + motiveEvs.push(add({ + id: 'mot_confess', side: 'field', at: { type: 'person', cid: tSpouse.id }, requires: ['mot_messages'], + title: `Розмова: ${tSpouse.name}`, tag: 'мотив', + detail: `Довге мовчання. «Так, ми зустрічалися. Тиждень тому ${killer.first} ${ctx.g(killer, 'знайшов', 'знайшла')} листування. ${ctx.g(killer, 'Сказав', 'Сказала')}: „Я це закінчу". Я ${ctx.g(tSpouse, 'думав', 'думала')} — про шлюб…»`, + gives: { pointsTo: killer.id, motive: true } + })); + motiveEvs.push(add({ + id: 'mot_witness', side: 'field', at: { type: 'person', cid: ctx.witnessFrom(citizens.filter(c => c.workplace === 'bar')).id }, needsIdentity: true, + title: 'Свідчення бармена', tag: 'мотив', + detail: `«${victim.first}? Бували тут. У четвер ${ctx.g(victim, 'сидів', 'сиділа')} за стійкою, коли ${ctx.g(killer, 'влетів', 'влетіла')} ${killer.name} — ${ctx.g(killer, 'кричав', 'кричала')} щось про «не лізь у мою сім'ю». Ледь розборонили.»`, + gives: { pointsTo: killer.id, motive: true } + })); + const recapWhy = `${killer.name} ${ctx.g(killer, 'дізнався', 'дізналася')} про роман. Другою стороною роману були ${tSpouse.first} і жертва. Пробачення не сталося.`; + const herringLead = `«Та в ${ctx.g(victim, 'нього', 'неї')} був конфлікт! ${herring.name} з ${herring.home} місяць тому ${ctx.g(herring, 'влаштував', 'влаштувала')} скандал через залите авто — усі чули погрози.»`; + return { motiveId, recapWhy, herringLead }; +} + +module.exports = { generate }; diff --git a/chapter3/casegen/motives/kidnap_debt.js b/chapter3/casegen/motives/kidnap_debt.js new file mode 100644 index 0000000..e1bc4a8 --- /dev/null +++ b/chapter3/casegen/motives/kidnap_debt.js @@ -0,0 +1,47 @@ +const { caesar, ri } = require('../rng'); + +function generate(ctx) { + const { r, add, addPuzzle, motiveEvs, victim, killer, herring, startingFacts, lair } = ctx; + const motiveId = 'Борг'; + const rep = ctx.witnessFrom(ctx.citizens.filter(c => c.home !== victim.home)); + const shiftK = ri(r, 3, 9); + const plainK = `Винен ${killer.last} ${killer.first[0]}. — дев'яносто тисяч. Прострочено двічі. Каже: відпрацюєш.`; + const czK = addPuzzle({ + id: 'pz_caesar', kind: 'caesar', prompt: 'Нотатки зниклої особи — суцільний беззмістовний набір літер.', + payload: caesar(plainK, shiftK), answer: plainK, shift: shiftK, hint: 'Схоже на шифр зсуву (Цезар): переберіть усі зсуви абетки.' + }); + startingFacts.push(add({ + id: 'sf_report', side: 'field', at: { type: 'start' }, + title: 'Заява про зникнення', tag: 'жертва', + detail: `${rep.name} ${ctx.g(rep, 'заявив', 'заявила')}: ${victim.name} ${ctx.g(victim, 'зник', 'зникла')} по дорозі додому. «За день до того ${ctx.g(victim, 'він просив', 'вона просила')} у мене грошей — багато і терміново. Сказали тільки: „інакше буде зле".»`, + gives: { identity: true } + })); + motiveEvs.push(add({ + id: 'mot_iou', side: 'field', at: { type: 'unit', unit: victim.home, slot: 'письмовий стіл' }, puzzle: czK, + title: 'Шифровані нотатки боржника', tag: 'мотив', + detail: 'У шухляді — розписка на 90 000 без імені кредитора і нотатник, писаний шифром. Людина боялась навіть власних записів. Розшифруйте — і кредитор матиме ім\'я.', + gives: { pointsTo: killer.id, motive: true } + })); + motiveEvs.push(add({ + id: 'mot_bank_k', side: 'analyst', at: { type: 'sys', sys: 'bank' }, q: { sys: 'bank', match: victim.name }, + title: 'Виписка зниклої особи', tag: 'мотив', + detail: `Щомісячні перекази з рахунку жертви (отримувач: ${killer.name}) припинились три місяці тому. Останній коментар до переказу: «решту віддам, чесно».`, + gives: { pointsTo: killer.id, motive: true } + })); + motiveEvs.push(add({ + id: 'mot_threat', side: 'field', at: { type: 'person', cid: ctx.witnessFrom(ctx.citizens.filter(c => ctx.unitBuilding(c.home) === ctx.unitBuilding(victim.home) && c.home !== victim.home)).id }, + title: 'Свідчення сусіда', tag: 'мотив', + detail: `«Тиждень тому під дверима стояв хтось і цідив крізь зуби: „Тиждень. Або поїдеш зі мною і відпрацюєш". Я в чужі справи не лізу, але голос був недобрий.»` + })); + add({ + id: 'mot_lairclue', side: 'field', at: { type: 'unit', unit: killer.home, slot: 'під ліжком' }, + title: 'Ключ із биркою', tag: 'місце', + detail: `Під ліжком — ящик з інструментами, а в ньому ключ від навісного замка зі свіжою паперовою биркою: «${lair.label}». Замок із комплекту — той самий, що зник із полиці маркету.`, + gives: { lair: true } + }); + const recapWhy = `Борг у 90 000, прострочений двічі. «Відпрацюєш» виявилось буквальним: ${killer.name} ${ctx.g(killer, 'вирішив', 'вирішила')} утримувати боржника, поки рідня не збере гроші.`; + const herringLead = `«У зошитах жертви і ${herring.name} десь фігурує — теж, кажуть, позичали одне одному. Хто кому винен — спитайте самі.»`; + return { motiveId, recapWhy, herringLead }; +} + +module.exports = { generate }; diff --git a/chapter3/casegen/motives/kidnap_obsession.js b/chapter3/casegen/motives/kidnap_obsession.js new file mode 100644 index 0000000..a92a4b9 --- /dev/null +++ b/chapter3/casegen/motives/kidnap_obsession.js @@ -0,0 +1,40 @@ +function generate(ctx) { + const { r, add, addPuzzle, motiveEvs, victim, killer, herring, startingFacts, lair } = ctx; + const motiveId = 'Одержимість'; + const rep = ctx.witnessFrom(victim.workplace ? ctx.citizens.filter(c => c.workplace === victim.workplace) : ctx.citizens); + startingFacts.push(add({ + id: 'sf_report', side: 'field', at: { type: 'start' }, + title: 'Заява про зникнення', tag: 'жертва', + detail: `${rep.name} ${ctx.g(rep, 'заявив', 'заявила')}: ${victim.name} другий день не виходить на зв'язок, телефон вимкнений, двері зачинені. «Це не в ${ctx.g(victim, 'його', 'її')} стилі. І ще: останнім часом ${ctx.g(victim, 'він скаржився', 'вона скаржилася')}, що за ${ctx.g(victim, 'ним', 'нею')} хтось ходить.»`, + gives: { identity: true } + })); + motiveEvs.push(add({ + id: 'mot_letters', side: 'field', at: { type: 'unit', unit: victim.home, slot: 'шафа' }, + title: 'Пачка листів без підпису', tag: 'мотив', + detail: 'Десятки листів, однаковий почерк: «я бачу тебе щодня», «ти всміхаєшся не мені», «я вмію чекати». Останній інакший: «скоро ти зрозумієш, що нам ніхто не потрібен».', + gives: { motive: true } + })); + motiveEvs.push(add({ + id: 'mot_complaint', side: 'analyst', at: { type: 'sys', sys: 'crim' }, q: { sys: 'crim', kind: 'archive', match: [victim.last, victim.name] }, + title: 'Торішня заява зниклої особи', tag: 'мотив', + detail: `Заява про переслідування, руху не дали: «невідома особа стежить, лишає квіти під дверима». Прикмети: ${killer.coat}, зріст приблизно ${ctx.estRange(r, killer.height)} см.`, + gives: { filter: { attr: 'coat', val: killer.coat }, motive: true } + })); + motiveEvs.push(add({ + id: 'mot_flowers', side: 'field', at: { type: 'person', cid: ctx.witnessFrom(ctx.citizens.filter(c => c.workplace === 'shop')).id }, requires: ['mot_complaint'], + title: 'Свідчення з мінімаркету', tag: 'мотив', + detail: `«Три гвоздики щоп'ятниці, завжди готівкою, завжди мовчки. Хто? Та ${killer.name} же. От, думаю собі, людина постійна.»`, + gives: { pointsTo: killer.id } + })); + add({ + id: 'mot_lairclue', side: 'field', at: { type: 'unit', unit: killer.home, slot: 'шафа' }, + title: 'План «нашого дому»', tag: 'місце', + detail: `На внутрішньому боці дверцят — фото ${victim.name}, зняті здалеку. І аркуш, накреслений від руки: матрац, лампа, засув. Підпис під планом: «${lair.label}». Усе обведено сердечком.`, + gives: { lair: true, motive: true } + }); + const recapWhy = `Два роки листів, квітів і фото здалеку. Коли «я вмію чекати» скінчилось, ${killer.name} ${ctx.g(killer, 'вирішив', 'вирішила')}: якщо не разом — то під замком.`; + const herringLead = `«Хочете про дивних? ${herring.name} вічно сидить біля вікна з біноклем. Каже — «за птахами». Ага, за птахами.»`; + return { motiveId, recapWhy, herringLead }; +} + +module.exports = { generate }; diff --git a/chapter3/casegen/motives/kidnap_ransom.js b/chapter3/casegen/motives/kidnap_ransom.js new file mode 100644 index 0000000..ab6cff0 --- /dev/null +++ b/chapter3/casegen/motives/kidnap_ransom.js @@ -0,0 +1,39 @@ +function generate(ctx) { + const { r, add, addPuzzle, motiveEvs, victim, killer, herring, startingFacts, lair, district } = ctx; + const motiveId = 'Викуп'; + const rep = ctx.witnessFrom(ctx.citizens.filter(c => c.home !== victim.home)); + startingFacts.push(add({ + id: 'sf_report', side: 'field', at: { type: 'start' }, + title: 'Заява про викрадення', tag: 'жертва', + detail: `${rep.name} (рідня зниклої особи) ${ctx.g(rep, 'приніс', 'принесла')} записку зі своєї поштової скриньки: «200 000 — і побачите ${ctx.g(victim, 'його', 'її')} ${ctx.g(victim, 'живим', 'живою')}. Поліція = кінець». Зникла особа: ${victim.name}, ${victim.age} р.`, + gives: { identity: true, motive: true } + })); + motiveEvs.push(add({ + id: 'mot_note_exp', side: 'analyst', at: { type: 'sys', sys: 'crim' }, q: { sys: 'crim', kind: 'expert' }, requires: ['sf_report'], + title: 'Експертиза записки', tag: 'мотив', + detail: 'Друковані літери через трафарет, папір — зі звичайного офісного стосу. Жодних відбитків: писали в рукавичках. Готувалися заздалегідь і не поспішали.', + gives: { motive: true } + })); + motiveEvs.push(add({ + id: 'mot_supplies', side: 'field', at: { type: 'person', cid: ctx.witnessFrom(ctx.citizens.filter(c => c.workplace === 'shop')).id }, + title: 'Свідчення з мінімаркету', tag: 'мотив', + detail: `«Дивна закупівля була: стяжки, скотч, брезент, навісний замок — усе разом. Обличчя не пригадаю, але платили карткою — термінал пам'ятає все.»` + })); + motiveEvs.push(add({ + id: 'mot_card', side: 'analyst', at: { type: 'sys', sys: 'bank' }, q: { sys: 'bank', match: ['термінал', district.buildings.find(b => b.id === 'shop').name] }, requires: ['mot_supplies'], + title: 'Термінал мінімаркету', tag: 'мотив', + detail: `Транзакція за ту закупівлю: ${killer.name}. Стяжки, скотч і новий замок — за два дні до викрадення.`, + gives: { pointsTo: killer.id, motive: true } + })); + add({ + id: 'mot_lairclue', side: 'field', at: { type: 'unit', unit: killer.home, slot: 'письмовий стіл' }, + title: 'Чек і нотатка', tag: 'місце', + detail: `У кошику для паперів — зіжмаканий чек за замок і аркуш із розрахунком: «200 000 − борги = вистачить». На звороті олівцем — «${lair.label}» і час обходів охорони.`, + gives: { lair: true, motive: true } + }); + const recapWhy = `План був простий і холодний: викрасти, сховати, взяти з рідні 200 000. Автор плану — ${killer.name}.`; + const herringLead = `«${herring.name} нещодавно в барі розпитува${ctx.g(herring, 'в', 'ла')}, хто в районі має гроші і хто живе сам. Тоді здалося балачкою.»`; + return { motiveId, recapWhy, herringLead }; +} + +module.exports = { generate }; diff --git a/chapter3/casegen/motives/mistaken.js b/chapter3/casegen/motives/mistaken.js new file mode 100644 index 0000000..abc84d7 --- /dev/null +++ b/chapter3/casegen/motives/mistaken.js @@ -0,0 +1,33 @@ +const { ri, b64 } = require('../rng'); + +function generate(ctx) { + const { r, add, addPuzzle, motiveEvs, victim, killer, herring, tTarget, district } = ctx; + const motiveId = 'Помилкова ціль'; + const accName = 'night_owl_' + ri(r, 100, 999); + const b64Puzzle = addPuzzle({ + id: 'pz_b64', kind: 'b64', prompt: 'Експорт бази месенджера: поле власника акаунта — беззмістовний рядок символів.', + payload: b64(`owner=${killer.name}; phone=${killer.phone}`), answer: `owner=${killer.name}; phone=${killer.phone}`, hint: 'Це не шифр, а кодування Base64 — розкодуйте.' + }); + motiveEvs.push(add({ + id: 'mot_lookalike', side: 'field', at: { type: 'person', cid: ctx.witnessFrom(ctx.citizens.filter(c => ctx.unitBuilding(c.home) === ctx.unitBuilding(victim.home) && c.home !== victim.home)).id }, needsIdentity: true, + title: 'Свідчення сусідів', tag: 'мотив', + detail: `«${victim.first}? Царство небесне… Знаєте, ${victim.first} і ${tTarget.first} — їх вічно плутали: той самий одяг, той самий зріст, той самий під'їзд. Навіть листоноша плутав.»` + })); + motiveEvs.push(add({ + id: 'mot_target', side: 'field', at: { type: 'person', cid: tTarget.id }, requires: ['mot_lookalike'], + title: `Розмова: ${tTarget.name}`, tag: 'мотив', + detail: `Блідне. «Це мені писали. Місяць погроз з анонімного акаунта „${accName}": „поверни, або пошкодуєш". Я нікому не ${ctx.g(tTarget, 'винен', 'винна')}! Ось, дивіться самі» — і простягає телефон.`, + gives: { motive: true } + })); + motiveEvs.push(add({ + id: 'mot_account', side: 'analyst', at: { type: 'sys', sys: 'phone' }, requires: ['mot_target'], puzzle: b64Puzzle, + title: `Деанон акаунта «${accName}»`, tag: 'мотив', + detail: `Запит до платформи повернув експорт. Розкодоване поле власника: ${killer.name}, номер ${killer.phone}. Погрози на адресу ${tTarget.name} писа${ctx.g(killer, 'в', 'ла')} саме ${ctx.g(killer, 'він', 'вона')} — через давній борг за розбите в ДТП авто.`, + gives: { pointsTo: killer.id, motive: true } + })); + const recapWhy = `Справжня ціль — ${tTarget.name}: місяці погроз через давній борг, і автор погроз — ${killer.name}. У темряві ${ctx.g(killer, 'вбивця сплутав', 'вбивця сплутала')} жертву з ціллю: той самий одяг, той самий зріст, той самий під'їзд.`; + const herringLead = `А ще сусіди шепочуться: «${herring.name} з ${victim.first} давно на ножах — через паркування, через сміття, через усе».`; + return { motiveId, recapWhy, herringLead }; +} + +module.exports = { generate }; diff --git a/chapter3/casegen/motives/oldcrime.js b/chapter3/casegen/motives/oldcrime.js new file mode 100644 index 0000000..458c1fe --- /dev/null +++ b/chapter3/casegen/motives/oldcrime.js @@ -0,0 +1,32 @@ +function generate(ctx) { + const { r, add, addPuzzle, motiveEvs, victim, killer, herring } = ctx; + const motiveId = 'Старі гріхи'; + motiveEvs.push(add({ + id: 'mot_confession', side: 'field', at: { type: 'unit', unit: victim.home, slot: 'письмовий стіл' }, needsIdentity: true, + title: 'Незакінчений лист', tag: 'мотив', + detail: 'Лист без адресата, перекреслений і початий заново: «Я більше не можу мовчати про те, що ми зробили у 2019-му. Сторож нас не виказав навіть у лікарні. Якщо не зізнаєшся ти — зізнаюсь я.»', + gives: { motive: true } + })); + motiveEvs.push(add({ + id: 'mot_arch2019', side: 'analyst', at: { type: 'sys', sys: 'crim' }, q: { sys: 'crim', kind: 'archive', match: ['2019', 'хвиля'] }, requires: ['mot_confession'], + title: 'Архів: справа 2019 року', tag: 'мотив', + detail: `Крадіжка каси артілі «Хвиля»: сторож дістав травму голови, за рік помер. Підозрювали двох вантажників — недоведено, справу закрито. Прізвища в матеріалах: ${victim.last} і ${killer.last}. Адреса другого збігається з нинішньою: ${killer.home}.`, + gives: { pointsTo: killer.id, motive: true } + })); + motiveEvs.push(add({ + id: 'mot_bank_old', side: 'analyst', at: { type: 'sys', sys: 'bank' }, q: { sys: 'bank', match: killer.name }, requires: ['mot_arch2019'], + title: 'Виписки за 2019 рік', tag: 'мотив', + detail: `Архів банку: восени 2019-го на рахунок ${killer.name} тричі заходила готівка — разом близько чверті вкраденої каси. Задекларованих доходів під ці суми немає.`, + gives: { motive: true } + })); + motiveEvs.push(add({ + id: 'mot_oldtimer', side: 'field', at: { type: 'person', cid: ctx.witnessFrom(ctx.citizens.filter(c => c.job === 'пенсіонер')).id }, needsIdentity: true, + title: 'Пам\'ять старожила', tag: 'мотив', + detail: `«Ці двоє? Нерозлийвода були, разом вантажниками в артілі. А після тієї історії зі сторожем — ані слова одне одному, роками. Таке буває тільки після грошей або гріха. Або обох.»` + })); + const recapWhy = `У 2019-му двоє вантажників винесли касу артілі; сторож, що їх упізнав, помер за рік. Жертва вирішила зізнатися — і подзвонила спільнику. Спільник — ${killer.name} — ${ctx.g(killer, 'вирішив', 'вирішила')} інакше.`; + const herringLead = `«Питаєте про старе? ${herring.name} тоді теж біля артілі ${ctx.g(herring, 'крутився', 'крутилася')}, усі знали. Темна історія.»`; + return { motiveId, recapWhy, herringLead }; +} + +module.exports = { generate }; diff --git a/chapter3/casegen/motives/ransom.js b/chapter3/casegen/motives/ransom.js new file mode 100644 index 0000000..c203b3a --- /dev/null +++ b/chapter3/casegen/motives/ransom.js @@ -0,0 +1,38 @@ +function generate(ctx) { + const { r, add, addPuzzle, motiveEvs, victim, killer, herring } = ctx; + const motiveId = 'Викуп'; + const relative = ctx.witnessFrom(ctx.citizens.filter(c => c.home !== victim.home)); + motiveEvs.push(add({ + id: 'mot_lair', side: 'field', at: { type: 'spot', building: 'pier', spot: 'елінг' }, + title: 'Підготовлене лігво', tag: 'мотив', + detail: 'В елінгу за брезентом — матрац, ланцюг із замком, каністра води, відро. Усе нове, з цінниками. Хтось готувався тримати тут людину. Живою.', + gives: { motive: true } + })); + motiveEvs.push(add({ + id: 'mot_supplies', side: 'field', at: { type: 'person', cid: ctx.witnessFrom(ctx.citizens.filter(c => c.workplace === 'shop')).id }, needsIdentity: true, + title: 'Свідчення з мінімаркету', tag: 'мотив', + detail: `«Дивна закупівля була днями: стяжки, скотч, брезент, ланцюг — усе разом. Хто? Не пригадаю обличчя, але платили карткою, це точно в терміналі лишилось.»` + })); + motiveEvs.push(add({ + id: 'mot_card', side: 'analyst', at: { type: 'sys', sys: 'bank' }, q: { sys: 'bank', match: ['термінал', ctx.district.buildings.find(b => b.id === 'shop').name] }, requires: ['mot_supplies'], + title: 'Термінал мінімаркету', tag: 'мотив', + detail: `Транзакція по картці за ту закупівлю: ${killer.name}. Стяжки, скотч, брезент і ланцюг — за три дні до смерті жертви.`, + gives: { pointsTo: killer.id, motive: true } + })); + motiveEvs.push(add({ + id: 'mot_ransom_note', side: 'field', at: { type: 'unit', unit: killer.home, slot: 'письмовий стіл' }, + title: 'Чернетка вимоги викупу', tag: 'мотив', + detail: `Аркуш із друкованими літерами, тричі переписаний: «200 000 за життя. Поліція = труна.» Адресат — рідня жертви. Лист так і не знадобився: викрадення пішло не за планом.`, + gives: { motive: true } + })); + add({ + id: 'mot_relative', side: 'field', at: { type: 'person', cid: relative.id }, needsIdentity: true, + title: `Розмова: ${relative.name}`, tag: 'мотив', + detail: `${ctx.g(relative, 'Далекий родич', 'Далека родичка')} жертви. «За день до того був дзвінок із прихованого номера. Мовчали. Дихали. Я ще пожартувала: „Може, викрасти хочуть?" Господи.»` + }); + const recapWhy = `План був інший: викрасти живою людину, тримати в елінгу і взяти з рідні 200 000. Але жертва пручалася — і план скінчився в перші ж хвилини. Автор плану — ${killer.name}.`; + const herringLead = `«${herring.name} нещодавно в барі розпитува${ctx.g(herring, 'в', 'ла')}, хто в районі має гроші і хто живе сам. Тоді здалося балачкою.»`; + return { motiveId, recapWhy, herringLead }; +} + +module.exports = { generate }; diff --git a/chapter3/casegen/motives/serial.js b/chapter3/casegen/motives/serial.js new file mode 100644 index 0000000..ead471f --- /dev/null +++ b/chapter3/casegen/motives/serial.js @@ -0,0 +1,45 @@ +const { pick } = require('../rng'); +const N = require('../names'); +const { SIGNATURES } = require('../constants'); + +function generate(ctx) { + const { r, add, addPuzzle, motiveEvs, victim, killer, herring, atScene, citizens } = ctx; + const motiveId = 'Серійний патерн'; + if (!killer.tattoo) killer.tattoo = pick(r, N.TATTOOS); + for (const c of citizens) if (c !== killer && c.tattoo === killer.tattoo) c.tattoo = null; + const sign = pick(r, SIGNATURES); + add({ + id: 'sf_signature', side: 'field', at: atScene, + title: 'Підпис на тілі', tag: 'патерн', + detail: `Деталь, яка не дає спокою: ${sign.text}. Це не випадковість і не пограбування. Це підпис. Такі речі повторюються.` + }); + motiveEvs.push(add({ + id: 'mot_arch_serial', side: 'analyst', at: { type: 'sys', sys: 'crim' }, q: { sys: 'crim', kind: 'archive', match: [sign.key, 'підпис'] }, requires: ['sf_signature'], + title: 'Архів: справа 2021 року', tag: 'мотив', + detail: `Пошук за підписом дав збіг: 2021 рік, сусідній район, нерозкрите вбивство — і той самий підпис: ${sign.text}. Тоді вирішили, що це дрібниця, і не внесли в орієнтування. Це не перше його полювання.`, + gives: { motive: true } + })); + motiveEvs.push(add({ + id: 'mot_sketch', side: 'analyst', at: { type: 'sys', sys: 'crim' }, q: { sys: 'crim', kind: 'archive', match: [sign.key, 'підпис', '2021'] }, requires: ['mot_arch_serial'], + title: 'Свідок 2021 року: прикмети', tag: 'мотив', + detail: `У справі 2021-го є свідчення: біля місця бачили постать у ${killer.coat}, а на передпліччі — татуювання: ${killer.tattoo}. Свідка тоді не сприйняли серйозно.`, + gives: { filter: { attr: 'coat', val: killer.coat } } + })); + motiveEvs.push(add({ + id: 'mot_tattoo_db', side: 'analyst', at: { type: 'sys', sys: 'db' }, q: { sys: 'db', match: killer.tattoo }, requires: ['mot_sketch'], + title: 'Пошук за татуюванням', tag: 'вбивця', + detail: `Запит по базі мешканців: «${killer.tattoo}» — єдиний збіг у районі: ${killer.name}, прож. ${killer.home}.`, + gives: { pointsTo: killer.id } + })); + motiveEvs.push(add({ + id: 'mot_trophies', side: 'field', at: { type: 'unit', unit: killer.home, slot: 'шафа' }, + title: 'Коробка з трофеями', tag: 'мотив', + detail: 'Взуттєва коробка на антресолі: жіночий годинник із чужим гравіюванням, стара запальничка, окуляри — і чохол від телефона жертви. Кожна річ — чиясь. Кожна річ — трофей.', + gives: { motive: true } + })); + const recapWhy = `Мотиву в людському сенсі не було. Був патерн: підпис (${sign.text}) уже з'являвся у 2021-му, і тоді його не помітили. ${killer.name} ${ctx.g(killer, 'обирав', 'обирала')} тих, хто живе сам і повертається затемна.`; + const herringLead = `«Моторошне питаєте? ${herring.name} ночами гуляє районом і фотографує чужі вікна. Сусіди двічі таке бачили.»`; + return { motiveId, recapWhy, herringLead }; +} + +module.exports = { generate }; diff --git a/chapter3/casegen/motives/stalker.js b/chapter3/casegen/motives/stalker.js new file mode 100644 index 0000000..1547391 --- /dev/null +++ b/chapter3/casegen/motives/stalker.js @@ -0,0 +1,35 @@ +const { estRange } = require('../rng'); + +function generate(ctx) { + const { r, add, addPuzzle, motiveEvs, victim, killer, herring } = ctx; + const motiveId = 'Одержимість'; + motiveEvs.push(add({ + id: 'mot_letters', side: 'field', at: { type: 'unit', unit: victim.home, slot: 'шафа' }, needsIdentity: true, + title: 'Пачка листів без підпису', tag: 'мотив', + detail: `Перев'язана шпагатом пачка — десятки листів, однаковий почерк, без підпису: «я бачу тебе щодня», «ти всміхаєшся не мені», «я вмію чекати». Найстаріший — дворічної давності. Останній закінчується інакше: «чекати я більше не буду».`, + gives: { motive: true } + })); + motiveEvs.push(add({ + id: 'mot_complaint', side: 'analyst', at: { type: 'sys', sys: 'crim' }, q: { sys: 'crim', kind: 'archive', match: [victim.last, victim.name] }, needsIdentity: true, + title: 'Торішня заява жертви', tag: 'мотив', + detail: `Заява про переслідування, торік, руху не дали: «невідома особа стежить, лишає квіти під дверима». Прикмети з заяви: ${killer.coat}, зріст приблизно ${estRange(r, killer.height)} см.`, + gives: { filter: { attr: 'coat', val: killer.coat }, motive: true } + })); + motiveEvs.push(add({ + id: 'mot_flowers', side: 'field', at: { type: 'person', cid: ctx.witnessFrom(ctx.citizens.filter(c => c.workplace === 'shop')).id }, requires: ['mot_complaint'], + title: 'Свідчення з мінімаркету', tag: 'мотив', + detail: `«Три гвоздики щоп'ятниці, завжди готівкою, завжди мовчки. Хто? Та ${killer.name} же. От, думаю собі, людина постійна.»`, + gives: { pointsTo: killer.id } + })); + motiveEvs.push(add({ + id: 'mot_shrine', side: 'field', at: { type: 'unit', unit: killer.home, slot: 'шафа' }, + title: 'Стіна фотографій', tag: 'мотив', + detail: 'На внутрішньому боці дверцят шафи — десятки фото жертви, знятих здалеку: біля дому, на набережній, крізь вітрину. Дати на звороті — два роки спостережень.', + gives: { motive: true } + })); + const recapWhy = `Два роки листів без відповіді, квіти під дверима, фото здалеку. Автор листів — ${killer.name}. Одержимість скінчилась там, де закінчилось «я вмію чекати».`; + const herringLead = `«Хочете про дивних? ${herring.name} вічно сидить біля вікна з біноклем. Каже — «за птахами». Ага, за птахами.»`; + return { motiveId, recapWhy, herringLead }; +} + +module.exports = { generate }; diff --git a/chapter3/casegen/motives/witness.js b/chapter3/casegen/motives/witness.js new file mode 100644 index 0000000..86abd26 --- /dev/null +++ b/chapter3/casegen/motives/witness.js @@ -0,0 +1,39 @@ +const { md5 } = require('../rng'); + +function generate(ctx) { + const { r, add, addPuzzle, motiveEvs, victim, killer, herring, atScene } = ctx; + const motiveId = 'Свідок злочину'; + const pin = String(victim.birthYear); + const pinPuzzle = addPuzzle({ + id: 'pz_pin', kind: 'pin', prompt: 'Телефон жертви заблоковано 4-значним PIN-кодом.', + hash: md5(pin), answer: pin, hint: 'Люди ставлять те, що не забудуть: спробуйте рік народження з досьє.' + }); + add({ + id: 'sf_phone_locked', side: 'field', at: atScene, + title: 'Телефон жертви (заблокований)', tag: 'мотив', + detail: 'Екран у павутинні тріщин, але апарат вмикається. PIN. Аналітик може спробувати підібрати.' + }); + motiveEvs.push(add({ + id: 'mot_photos', side: 'analyst', at: { type: 'lab' }, requires: ['sf_phone_locked'], needsIdentity: true, puzzle: pinPuzzle, + title: 'Фото в телефоні жертви', tag: 'мотив', + detail: `Нічні знімки: постать у ${killer.coat} заносить велосипеди в елінг на причалі. Обличчя не видно. Дата — за чотири дні до вбивства.`, + gives: { filter: { attr: 'coat', val: killer.coat } } + })); + motiveEvs.push(add({ + id: 'mot_boathouse', side: 'field', at: { type: 'spot', building: 'pier', spot: 'елінг' }, requires: ['mot_photos'], + title: 'Обшук елінгу', tag: 'мотив', + detail: 'Шість велосипедів зі спиляними номерами рам, добрі замки зрізані болторізом. На стіні — оголошення «продам недорого» з номером.', + gives: { motive: true } + })); + motiveEvs.push(add({ + id: 'mot_ads', side: 'analyst', at: { type: 'sys', sys: 'phone' }, q: { sys: 'phone', kind: 'owner', match: killer.phone }, requires: ['mot_boathouse'], + title: 'Номер з оголошення', tag: 'мотив', + detail: `Номер з оголошення в елінгу зареєстровано: ${killer.name}, прож. ${killer.home}. Жертва сфотографувала крадія — і крадій це помітив.`, + gives: { pointsTo: killer.id, motive: true } + })); + const recapWhy = `Жертва випадково сфотографувала нічні «рейси» з краденими велосипедами. Крадій — ${killer.name}. Свідка прибрали.`; + const herringLead = `«Кажуть, ${herring.name} вечорами вічно біля елінгу — разів п'ять там ${ctx.g(herring, 'крутився', 'крутилася')}. Чого — хтозна.»`; + return { motiveId, recapWhy, herringLead }; +} + +module.exports = { generate }; diff --git a/chapter3/casegen/mutators.js b/chapter3/casegen/mutators.js new file mode 100644 index 0000000..0c29fb2 --- /dev/null +++ b/chapter3/casegen/mutators.js @@ -0,0 +1,39 @@ +// casegen/mutators.js — мутатори справ: двійник, анонімний лист +const { pick, chance } = require('./rng'); +const N = require('./names'); + +function applyDoppelganger(ctx) { + const { r, citizens, killer, victim, herring, district, coveredB, notHousehold } = ctx; + + // Знаходимо двійника: не вбивця, не жертва, не herring, та сама стать, схожий зріст/одяг + const candidates = citizens.filter(c => + c.id !== killer.id && c.id !== victim.id && c.id !== herring.id && + c.sex === killer.sex && + notHousehold(c) && + Math.abs(c.height - killer.height) <= 5 + ); + + if (candidates.length === 0) return null; + + const doppelganger = pick(r, candidates); + + // Підлаштовуємо одяг та взуття, щоб збігалися з убивцею + doppelganger.coat = killer.coat; + doppelganger.shoe = killer.shoe; + doppelganger.height = killer.height + pick(r, [-2, -1, 0, 1, 2]); // незначна різниця в зрості + + // Двійник бреше про свій вечір (наприклад, був у гостях у одруженої особи і не хоче зізнатися) + doppelganger.liar = true; + doppelganger.claim = pick(r, district.buildings.filter(b => b.id !== doppelganger.evening && b.id !== 'station').map(b => b.id)); + doppelganger.excuse = pick(r, N.EXCUSES); + + // Знаходимо свідка, який може підтвердити справжнє алібі двійника + const mate = citizens.find(o => o.id !== victim.id && o.id !== killer.id && o.id !== herring.id && o.evening === doppelganger.evening); + if (mate) { + doppelganger.mate = mate.id; + } + + return doppelganger; +} + +module.exports = { applyDoppelganger }; diff --git a/chapter3/casegen/rng.js b/chapter3/casegen/rng.js new file mode 100644 index 0000000..ef6d581 --- /dev/null +++ b/chapter3/casegen/rng.js @@ -0,0 +1,76 @@ +// casegen/rng.js — детермінована випадковість, шифри, паролі +const crypto = require('crypto'); +const N = require('./names'); + +// ---------- RNG ---------- +function hashSeed(str) { + let h = 2166136261 >>> 0; + for (const c of String(str)) { h ^= c.codePointAt(0); h = Math.imul(h, 16777619); } + return h >>> 0; +} +function mulberry32(a) { + return function () { + a |= 0; a = (a + 0x6D2B79F5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} +const ri = (r, a, b) => a + Math.floor(r() * (b - a + 1)); +const estRange = (r, actual, lo = 3, hi = 5) => `${actual - ri(r, lo, hi)}–${actual + ri(r, lo, hi)}`; +const pick = (r, arr) => arr[Math.floor(r() * arr.length)]; +const chance = (r, p) => r() < p; +function shuffle(r, arr) { + const a = arr.slice(); + for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(r() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } + return a; +} + +// ---------- Шифри ---------- +const UKR = 'абвгґдеєжзиіїйклмнопрстуфхцчшщьюя'; +function caesar(text, k) { + return [...text].map(ch => { + const lower = ch.toLowerCase(), i = UKR.indexOf(lower); + if (i < 0) return ch; + const out = UKR[(i + k + UKR.length) % UKR.length]; + return ch === lower ? out : out.toUpperCase(); + }).join(''); +} +const b64 = t => Buffer.from(t, 'utf8').toString('base64'); +const md5 = t => crypto.createHash('md5').update(t, 'utf8').digest('hex'); + +// ---------- Паролі ---------- +const TRANSLIT = { а:'a',б:'b',в:'v',г:'h',ґ:'g',д:'d',е:'e',є:'ie',ж:'zh',з:'z',и:'y',і:'i',ї:'i',й:'i',к:'k',л:'l',м:'m',н:'n',о:'o',п:'p',р:'r',с:'s',т:'t',у:'u',ф:'f',х:'kh',ц:'ts',ч:'ch',ш:'sh',щ:'shch',ь:'',ю:'iu',я:'ia' }; +const translit = s => [...String(s).toLowerCase()].map(c => TRANSLIT[c] ?? c).join('').replace(/[^a-z0-9]/g, ''); + +const COMMON_PASSWORDS = ['sunshine', 'dragon', 'monkey', 'princess', 'football', 'iloveyou', + 'superman', 'batman', 'master', 'hunter', 'freedom', 'trustno1', 'whatever', 'ginger', + 'flower', 'summer', 'winter', 'orange', 'purple', 'silver', 'letmein', 'qwerty', 'pepper', + 'cookie', 'tigger', 'jasmine', 'soccer', 'maggie', 'hannah', 'ashley', 'chelsea', 'butterfly', + 'password', 'shadow', 'michael', 'jessica', 'charlie', 'daniel', 'jordan', 'matrix', 'falcon', + 'phoenix', 'rainbow', 'diamond', 'banana', 'cheese', 'coffee', 'chocolate', 'midnight', + 'october', 'november', 'chester', 'oliver', 'ranger', 'harley', 'buster', 'george', 'sammy', + 'pumpkin', 'muffin', 'peanut', 'smokey', 'bandit', 'lucky', 'angel', 'lovely', 'sunflower', + 'babygirl', 'bubbles', 'snickers', 'chicken', 'turtle', 'rabbit', 'dolphin', 'junior', + 'dexter', 'guitar', 'hammer', 'winner', 'copper', 'marina', 'sierra', 'tucker', 'willow', + 'zombie', 'viking', 'thunder', 'topgun', 'maverick', 'eclipse', 'scorpion', 'panther']; + +function genPassword(r, victim) { + const yr = String(victim.birthYear), yr2 = yr.slice(2); + const personal = []; + if (victim.dog) personal.push( + { answer: translit(victim.dog), hint: 'кличка собаки латиницею (див. досьє)' }, + { answer: translit(victim.dog) + yr, hint: 'кличка собаки латиницею + повний рік народження' }); + personal.push( + { answer: translit(victim.first) + yr, hint: `ім'я латиницею + повний рік народження` }, + { answer: translit(victim.last) + yr2, hint: 'прізвище латиницею + дві цифри року народження' }, + { answer: translit(victim.first) + translit(victim.last), hint: `ім'я та прізвище латиницею разом` }); + if (chance(r, 0.5)) { + const p = pick(r, personal); + return { ...p, style: 'personal', personal: true }; + } + return { answer: pick(r, COMMON_PASSWORDS), style: 'random', personal: false, + hint: 'жодного зв\'язку з жертвою — тільки перебір за словником (rockyou.txt)' }; +} + +module.exports = { hashSeed, mulberry32, ri, estRange, pick, chance, shuffle, UKR, caesar, b64, md5, translit, COMMON_PASSWORDS, genPassword }; diff --git a/chapter3/casegen/test.js b/chapter3/casegen/test.js index 338c48a..9e75ad9 100644 --- a/chapter3/casegen/test.js +++ b/chapter3/casegen/test.js @@ -156,8 +156,8 @@ function checkCase(seed) { assert.ok(sol.herringDisproved, 'хибний слід можна спростувати'); // -- сліди лежать на місці злочину, а не падають у дошку самі - assert.ok(cs.startingFacts.every(id => ['sf_scene', 'sf_report'].includes(id)), - 'стартовий лише брифінг: ' + cs.startingFacts); + assert.ok(cs.startingFacts.every(id => ['sf_scene', 'sf_report', 'anonymous_letter'].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 + ' знаходиться обшуком'); diff --git a/chapter3/test-server.js b/chapter3/test-server.js index 46cf5e4..8e59f6a 100644 --- a/chapter3/test-server.js +++ b/chapter3/test-server.js @@ -190,7 +190,7 @@ async function playToTheEnd(session, accusation) { 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, + assert.ok(fresh.state && !fresh.state.ended && fresh.state.found.length <= 3, 'видалений сейв = справа з нуля, на дошці лише брифінг: ' + (fresh.state && fresh.state.found.length)); fresh.ws.close(); await playToTheEnd('TEST2', 'wrong');