// casegen — генератор справ для розділу 3. Чистий JS, без залежностей і DOM. // generateCase(seed) → детермінована справа: мешканці, жертва, вбивця, мотив, // граф доказів (спланований від розв'язку назад), хибні сліди (кожен — спростовний), // шифро-завдання. solve() — симулятор досяжності для fair-play тестів. const { makeDistrict, unitBuilding } = require('./district'); const N = require('./names'); const { hashSeed, mulberry32, ri, estRange, pick, chance, shuffle, UKR, caesar, b64, md5, translit, COMMON_PASSWORDS, genPassword } = require('./rng'); 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) { const r = mulberry32(hashSeed(seed)); const district = makeDistrict(r); const citizens = makeCitizens(r, district); const byId = id => citizens.find(c => c.id === id); const g = (c, m, f) => (c.sex === 'ч' ? m : f); // рід у дієсловах; відмінювання імен обходимо конструкціями з називним // -- вибір шаблону мотиву, жертви, вбивці const singles = citizens.filter(c => !citizens.some(o => o !== c && o.home === c.home) && c.age >= 25); const married = citizens.filter(c => c.spouse); const victim = pick(r, singles); let killer, tSpouse = null, tTarget = null; const notHousehold = c => c.id !== victim.id && c.home !== victim.home; // тип справи: вбивство або викрадення (людина ще жива — її можна врятувати) const caseType = chance(r, 0.25) ? 'kidnap' : 'murder'; // шаблон мотиву: перший придатний із перетасованого списку let templateId = 'debt'; if (caseType === 'kidnap') templateId = pick(r, ['k_ransom', 'k_obsession', 'k_debt']); else for (const t of shuffle(r, ['jealousy', 'debt', 'blackmail', 'witness', 'mistaken', 'inherit', 'oldcrime', 'stalker', 'business', 'serial', 'ransom'])) { if (t === 'jealousy' && !married.some(c => notHousehold(c) && notHousehold(byId(c.spouse)))) continue; if (t === 'oldcrime' && !citizens.some(c => notHousehold(c) && c.age >= 30)) continue; templateId = t; break; } if (templateId === 'jealousy') { killer = pick(r, married.filter(c => notHousehold(c) && notHousehold(byId(c.spouse)))); tSpouse = byId(killer.spouse); } else if (templateId === 'oldcrime') { killer = pick(r, citizens.filter(c => notHousehold(c) && c.age >= 30)); } else if (templateId === 'mistaken') { const neighbors = citizens.filter(c => notHousehold(c) && unitBuilding(c.home) === unitBuilding(victim.home) && Math.abs(c.height - victim.height) <= 8); tTarget = neighbors.length ? pick(r, neighbors) : pick(r, citizens.filter(notHousehold)); tTarget.coat = victim.coat; // їх плутали через однаковий одяг killer = pick(r, citizens.filter(c => notHousehold(c) && c.id !== tTarget.id && c.home !== tTarget.home)); } else { killer = pick(r, citizens.filter(c => notHousehold(c) && c.age >= 21)); } const method = pick(r, METHODS); const scene = pick(r, district.crimeScenes); const lair = caseType === 'kidnap' ? pick(r, LAIRS) : null; const sceneCam = district.cameras.find(cam => cam.covers.includes(scene.building)); const crimeTime = `${ri(r, 22, 23)}:${pick(r, ['10', '25', '40'])}`; const atScene = { type: 'spot', building: scene.building, spot: scene.spot }; // свідки не можуть бути фігурантами справи let accomplice = null; // призначається мутатором нижче; свідком бути не може const isFigure = c => [victim.id, killer.id, tSpouse && tSpouse.id, tTarget && tTarget.id, accomplice && accomplice.id].includes(c.id); // -- сліди вбивці на місці: розмиті. Взуття читається як діапазон ±1, // тож після самого лише огляду місця коло мусить лишатися широким (≥8). const nearShoe = c => c.id !== victim.id && Math.abs(c.shoe - killer.shoe) <= 1; for (const c of shuffle(r, citizens)) { if (citizens.filter(nearShoe).length >= 8) break; if (c.id !== victim.id && !nearShoe(c)) c.shoe = Math.max(35, Math.min(46, killer.shoe + ri(r, -1, 1))); } // -- хибний слід: громадянин, що збігається за слідами const herring = pick(r, citizens.filter(c => c.id !== killer.id && notHousehold(c) && (!tTarget || c.id !== tTarget.id) && c.sex === killer.sex)); 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) { if (c === killer || c === herring) continue; while (matchesTraces(c) && citizens.filter(matchesTraces).length > 4) c.coat = pick(r, N.COATS); } const coveredB = [...new Set(district.cameras.flatMap(c => c.covers))]; const otherB = b => pick(r, district.buildings.filter(x => x.id !== b && x.id !== 'station')).id; // -- вечір злочину: налаштування evening/claim/liars через модуль setupEvening({ r, citizens, victim, killer, herring, district, scene, coveredB, pick, chance, unitBuilding, otherB }); // -- мутатор справи: зрідка правила змінюються, щоб маршрут не заучувався let mutator = null; if (caseType === 'murder') { if (chance(r, 0.35)) mutator = pick(r, ['accomplice', 'gloves', 'frame']); } else if (chance(r, 0.25)) mutator = 'frame'; if (mutator === 'accomplice') { accomplice = pick(r, citizens.filter(c => !isFigure(c) && c.home !== killer.home && c.home !== victim.home)); accomplice.evening = scene.building; accomplice.liar = true; accomplice.claim = otherB(scene.building); accomplice.excuse = null; accomplice.mate = null; } // -- другий ДНК-зразок (~30%): невинна ДНК на місці злочину. ДНК каже лише ЧИЯ // вона — не КОЛИ і не ЧОМУ. Бійка вдень, очевидець, що знайшов тіло, перехожий. let dna2 = null; if (chance(r, 0.3)) { const why = pick(r, ['brawl', 'brawl', 'finder', 'passer']); const owner2 = pick(r, citizens.filter(c => !isFigure(c) && c.id !== herring.id && c.home !== victim.home)); dna2 = { owner: owner2, why }; if (why === 'brawl') dna2.reason = pick(r, ['через борг у пару сотень', 'через розлите пиво', 'через давню образу', 'через чергу і гострий язик', 'бо обоє були нетверезі, слово за слово']); } // -- побутові стосунки поза справою: пул відповідей на «що скажете про X?» const REL_KINDS = ['давній конфлікт через дрібницю', 'міцна дружба ще зі школи', 'позичали одне одному гроші', 'разом рибалять вихідними', 'сварка за паркування, не вітаються']; const relations = []; for (let i = 0; i < ri(r, 4, 7); i++) { const a = pick(r, citizens), b = pick(r, citizens); if (a === b || a.id === victim.id || b.id === victim.id) continue; if (relations.some(p => (p.a === a.id && p.b === b.id) || (p.a === b.id && p.b === a.id))) continue; relations.push({ a: a.id, b: b.id, kind: pick(r, REL_KINDS) }); } // -- граф дзвінків вечора злочину: побут між родиною/колегами/парами стосунків. // Вбивця: тиша — апарат «поза мережею» довкола часу злочину. Спільник: дзвінок вбивці. const crimeM = (() => { const [h, mi] = crimeTime.split(':').map(Number); return h * 60 + mi; })(); const calls = []; const addCall = (a, b, t) => a && b && a.id !== b.id && calls.push({ a: a.id, b: b.id, t }); for (const c of citizens) { if (c.id === victim.id || !chance(r, 0.45)) continue; const other = c.spouse ? byId(c.spouse) : pick(r, citizens.filter(o => o !== c && o.id !== victim.id && (o.last === c.last || (o.workplace && o.workplace === c.workplace)))) || null; if (other) addCall(c, other, ri(r, 1140, 1430)); } for (const pr of relations) if (chance(r, 0.5)) addCall(byId(pr.a), byId(pr.b), ri(r, 1140, 1430)); const phoneOff = { off: crimeM - ri(r, 15, 35), on: crimeM + ri(r, 60, 200) }; for (let i = calls.length - 1; i >= 0; i--) { const x = calls[i]; if ((x.a === killer.id || x.b === killer.id) && x.t >= phoneOff.off - 10 && x.t <= phoneOff.on) calls.splice(i, 1); } if (accomplice) addCall(killer, accomplice, crimeM - ri(r, 80, 140)); // -- докази const E = {}; const add = ev => { E[ev.id] = Object.assign({ requires: [], gives: {} }, ev); return ev.id; }; const puzzles = []; const addPuzzle = p => { puzzles.push(p); return p.id; }; // стартові факти const startingFacts = []; if (caseType === 'kidnap') { startingFacts.push(add({ id: 'sf_scene', side: 'field', at: { type: 'start' }, title: `Місце викрадення: ${scene.name}`, tag: 'місце', detail: `${pick(r, N.WEATHER)}. Сумку помітив ${pick(r, N.DISCOVERERS)}: ${scene.name} — перекинута сумка, зламаний ремінець годинника, сліди волочіння до проїзду. Боротьба була короткою — близько ${crimeTime}. Тіла немає, і це добра новина: людину забрали ${g(victim, 'живим', 'живою')}. Сліди самі до відділка не прийдуть — огляньте місце.` })); add({ id: 'sf_footprint', side: 'field', at: atScene, title: 'Відбиток взуття', tag: 'слід', detail: `Відбиток підошви поверх слідів волочіння, край розмитий. Розмір ${estRange(r, killer.shoe, 1, 2)}. Взуття зниклої особи — інше.`, gives: { filter: { attr: 'shoe', val: killer.shoe, tol: 1 } } }); } if (caseType === 'murder') startingFacts.push(add({ id: 'sf_scene', side: 'field', at: { type: 'start' }, title: `Місце злочину: ${scene.name}`, tag: 'місце', detail: `${pick(r, N.WEATHER)}. Тіло ${dna2 && dna2.why === 'finder' ? `${g(dna2.owner, 'помітив', 'помітила')} ${dna2.owner.name} (${g(dna2.owner, 'він', 'вона')} і викликає поліцію щоранку бігаючи тут)` : 'помітив ' + pick(r, N.DISCOVERERS)}: ${scene.name}. Документів немає, телефон розбитий, кишені майже порожні. Смерть настала близько ${crimeTime}. Сліди самі до відділка не прийдуть — огляньте місце.` })); if (caseType === 'murder') add({ id: 'sf_body', side: 'field', at: atScene, title: 'Огляд тіла', tag: 'жертва', detail: `${victim.sex === 'ч' ? 'Чоловік' : 'Жінка'}, на вигляд ${estRange(r, victim.age)} років, зріст ${victim.height} см.` + (victim.tattoo ? ` Особлива прикмета: татуювання — ${victim.tattoo}.` : ' Особливих прикмет немає.'), gives: { victimFacts: true } }); if (caseType === 'murder') add({ id: 'sf_footprint', side: 'field', at: atScene, title: 'Відбиток взуття', tag: 'слід', detail: `Відбиток підошви поруч із тілом, змазаний. Розмір ${estRange(r, killer.shoe, 1, 2)}. Зі взуттям жертви не збігається.`, gives: { filter: { attr: 'shoe', val: killer.shoe, tol: 1 } } }); // -- ДНК на місці: описи знахідок перемішуються між власниками ВИПАДКОВО. // «Кров на манжеті» може належати і вбивці, і людині з денної бійки в барі: // походження зразка — не доказ, лише факт присутності. const cap = t => t.charAt(0).toUpperCase() + t.slice(1); const DNA_SPOTS = shuffle(r, caseType === 'murder' ? ['волосина з цибулиною на комірі жертви', 'кров на манжеті сорочки жертви — і група не її', 'свіжий недопалок зі слідами слини за півкроку від тіла', 'волосся, вирване з коренем, у ремінці годинника', 'мікрочастки шкіри під нігтями жертви', 'зіжмакана серветка зі слідами поту в урні поруч'] : ['волосся, вирване з коренем, у зламаному ремінці годинника', 'свіжа кров на розірваній лямці сумки', 'свіжий недопалок зі слідами слини біля слідів волочіння', 'зіжмакана серветка зі слідами поту в урні поруч']); const dnaDesc = DNA_SPOTS[0], dna2Desc = DNA_SPOTS[1]; const dnaAdds = []; if (!(caseType === 'murder' && mutator === 'gloves')) dnaAdds.push(() => add({ id: 'sf_dna', side: 'field', at: atScene, title: 'Зразок ДНК', tag: 'слід', detail: `${cap(dnaDesc)}. Це чиясь ДНК. Лабораторія скаже, чия саме, — а коли і чому вона тут опинилася, з'ясовувати вам.`, gives: { dnaSample: true } })); if (dna2) dnaAdds.push(() => add({ id: 'sf_dna2', side: 'field', at: atScene, title: 'Зразок ДНК', tag: 'слід', detail: `${cap(dna2Desc)}. Це чиясь ДНК. Лабораторія скаже, чия саме, — а коли і чому вона тут опинилася, з'ясовувати вам.`, gives: { dnaSample2: true } })); for (const f of shuffle(r, dnaAdds)) f(); if (caseType === 'murder' && mutator === 'gloves' && !dna2) add({ id: 'sf_gloves', side: 'field', at: atScene, title: 'Жодного біологічного сліду', tag: 'слід', 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))); add({ id: 'dec_footprint', side: 'field', at: atScene, title: 'Відбиток взуття', tag: 'слід', detail: `Відбиток підошви, напівзатертий, край розмитий. Розмір ${estRange(r, passer.shoe, 1, 2)}. Протектор гладкий, каблук збитий на один бік.` }); add({ id: 'dec_butt', side: 'field', at: atScene, title: 'Старі недопалки', tag: 'слід', detail: 'Кілька недопалків різних марок біля лавки. Комусь тут не сидиться без цигарки.' }); if (mutator === 'frame') { add({ id: 'dec_planted', side: 'field', at: atScene, title: 'Гравірована запальничка', tag: 'слід', detail: `Біля самого місця — доглянута бензинова запальничка з гравіюванням «${herring.first[0]}. ${herring.last[0]}.». Надто чиста як для речі, загубленої надворі.`, gives: { pointsTo: herring.id } }); add({ id: 'planted_exp', side: 'analyst', at: { type: 'sys', sys: 'crim' }, q: { sys: 'crim', kind: 'expert' }, requires: ['dec_planted'], title: 'Експертиза запальнички', tag: 'слід', detail: 'На запальничці немає ЖОДНОГО відбитка — навіть власника. Її ретельно протерли і поклали так, щоб знайшли. Це не загублена річ. Це підкинута річ.' }); } // -- реєстри місцями застарілі: у кількох людей у базі неактуальні пальто/розмір. // Правду показує лише очний огляд за ордером. Фігуранти в реєстрі завжди точні — // інакше фільтри за слідами стали б нечесними. const protectedIds = new Set([victim.id, killer.id, herring.id, passer.id, tSpouse && tSpouse.id, tTarget && tTarget.id, accomplice && accomplice.id].filter(Boolean)); for (const c of shuffle(r, citizens.filter(c => !protectedIds.has(c.id))).slice(0, ri(r, 2, 4))) { if (chance(r, 0.5)) c.regCoat = pick(r, N.COATS.filter(x => x !== c.coat)); else c.regShoe = Math.max(35, Math.min(46, c.shoe + pick(r, [-2, -1, 1, 2]))); } // криміналістика і камери — доступні одразу з термінала if (caseType === 'murder') add({ id: 'lab_autopsy', side: 'analyst', at: { type: 'sys', sys: 'crim' }, q: { sys: 'crim', kind: 'expert' }, title: 'Висновок розтину', tag: 'спосіб', detail: `Причина смерті: ${method.autopsy}. Час смерті: ${crimeTime} ± 20 хв. Група крові жертви: ${victim.blood}. Під нігтями — чужі волокна тканини, як у ${killer.coat}. Жертва була в ${victim.coat}.`, gives: { method: true, filter: { attr: 'coat', val: killer.coat } } }); else add({ id: 'lab_traces', side: 'analyst', at: { type: 'sys', sys: 'crim' }, q: { sys: 'crim', kind: 'expert' }, title: 'Аналіз слідів боротьби', tag: 'місце', detail: `Висновок криміналістів: опір тривав секунди, слідів крові немає — людину знерухомили й потягли до проїзду. На розірваній лямці сумки — чужі волокна, як у ${killer.coat}. Викрадач діяв сам і мав куди везти: місце утримання підготовлене заздалегідь.`, gives: { filter: { attr: 'coat', val: killer.coat } } }); if (sceneCam) add({ id: 'rec_cctv', side: 'analyst', at: { type: 'sys', sys: 'cctv' }, q: { sys: 'cctv', building: scene.building }, title: `Архів: ${sceneCam.name}`, tag: 'слід', detail: caseType === 'murder' ? `${crimeTime}: постать у темному швидко йде від місця. Обличчя не видно, кадр зернистий. Зріст за розміткою: приблизно ${estRange(r, killer.height, 6, 10)} см.` : `${crimeTime}: постать у темному веде другу людину, що обвисла на плечі, в бік проїзду. Обличчя не видно, кадр зернистий. Зріст за розміткою: приблизно ${estRange(r, killer.height, 6, 10)} см.`, gives: { filter: { attr: 'height', val: killer.height, tol: 10 } } }); // -- Акт I: встановлення особи жертви (два незалежні шляхи) — лише для вбивства; // у викраденні особа відома із заяви про зникнення const idPaths = caseType === 'murder' ? shuffle(r, ['keyring', 'phone_scrap', victim.tattoo ? 'tattoo' : 'phone_scrap']) .filter((v, i, a) => a.indexOf(v) === i).slice(0, 2) : []; if (idPaths.includes('keyring')) { add({ id: 'sf_keyring', side: 'field', at: atScene, title: 'В\'язка ключів із биркою', tag: 'особа', detail: `У внутрішній кишені — ключі з паперовою биркою: «${victim.home}». Схоже на номер квартири. Питання — в якому будинку.`, gives: { key: victim.home } }); add({ id: 'id_docs', side: 'field', at: { type: 'unit', unit: victim.home, slot: 'письмовий стіл' }, title: 'Документи в квартирі', tag: 'особа', requires: ['sf_keyring'], detail: `Паспорт у шухляді: ${victim.name}, ${victim.birthYear} р.н. Ключ підійшов до квартири ${victim.home}. Тепер у жертви є ім'я.`, gives: { identity: true } }); } if (idPaths.includes('phone_scrap')) { const contact = witnessFrom(citizens.filter(c => c.home !== victim.home)); add({ id: 'sf_phone_scrap', side: 'field', at: atScene, title: 'Папірець із номером', tag: 'особа', detail: `У задній кишені — вирваний аркуш: «${contact.phone}» і приписка «подзвонити ввечері».` }); add({ id: 'id_number_owner', side: 'analyst', at: { type: 'sys', sys: 'phone' }, q: { sys: 'phone', kind: 'owner', match: contact.phone }, requires: ['sf_phone_scrap'], title: 'Власник номера', tag: 'особа', detail: `Номер зареєстровано: ${contact.name}, прож. ${contact.home}, ${district.buildings.find(b => b.id === unitBuilding(contact.home)).name}. Варто поговорити.` }); add({ id: 'id_contact', side: 'field', at: { type: 'person', cid: contact.id }, requires: ['id_number_owner'], title: `Розмова: ${contact.name}`, tag: 'особа', detail: `«Мій номер? Я ${g(contact, 'лишив', 'лишила')} його одній людині. Ім'я тієї людини — ${victim.name}. Ми домовлялися про зустріч, але ${g(victim, 'він так і не подзвонив', 'вона так і не подзвонила')}…» Опис збігається з тілом.`, gives: { identity: true } }); } if (idPaths.includes('tattoo')) { for (const c of citizens) if (c !== victim && c.tattoo === victim.tattoo) c.tattoo = null; // «один запис» мусить бути правдою add({ id: 'id_clinic', side: 'analyst', at: { type: 'sys', sys: 'db' }, q: { sys: 'db', match: victim.tattoo }, requires: ['sf_body'], title: 'Пошук за прикметами', tag: 'особа', detail: `У базі мешканців з позначкою «${victim.tattoo}» — один запис: ${victim.name}, ${victim.birthYear} р.н. Особу встановлено.`, gives: { identity: true } }); } // досьє жертви (після ідентифікації) — база для PIN, паролів та ордерів if (templateId === 'blackmail' && !victim.dog) victim.dog = pick(r, N.DOG_NAMES); add({ id: 'id_dossier', side: 'analyst', at: { type: 'sys', sys: 'db' }, q: { sys: 'db', match: victim.name }, needsIdentity: true, title: `Досьє: ${victim.name}`, tag: 'особа', detail: `${victim.name}, ${victim.birthYear} р.н., прож. ${victim.home}. ${victim.job === 'тимчасово не працює' ? 'Офіційно не працює.' : 'Робота: ' + victim.job + '.'}${victim.dog ? ` Тримає собаку на кличку ${victim.dog}.` : ''}` }); // -- мотив (за шаблоном) + хибний слід const motiveEvs = []; let motiveId, recapWhy, herringLead; if (templateId === 'jealousy') { 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') { 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') { 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') { 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') { 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') { 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') { 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') { 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') { 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') { 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') { 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') { 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') { 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') { 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; } // місце утримання: знайти можна лише за зачіпкою, навмання обшукати всі кутки не вийде if (caseType === 'kidnap') add({ id: 'lair_signs', side: 'field', at: { type: 'spot', building: lair.building, spot: lair.spot }, requires: ['mot_lairclue'], title: `Місце утримання: ${lair.label}`, tag: 'місце', detail: 'Новий навісний замок. Крізь щілину видно матрац, пляшки з водою — і рух. Тут тримають людину. Самим не штурмувати: подавайте рапорт (ХТО / ДЕ / ЧОМУ) — група захоплення готова.', gives: { lair: true } }); // -- Тріангуляція: запит «де зараз номер» у оператора. Ціль — жива позиція // вбивці на момент запиту, сервер рахує відстані до веж у реальному часі. add({ id: 'rec_triangulate', side: 'analyst', at: { type: 'sys', sys: 'phone' }, q: { sys: 'phone', kind: 'locate', match: killer.phone }, needsIdentity: true, special: 'triangulate', title: 'Тріангуляція невідомого номера', tag: 'слід', detail: `Номер ${killer.phone}, що засвітився біля місця події, запеленговано: перетин трьох веж вказав будинок — він у звіті. Коло звузилось до тих, хто там буває. Порівняйте їх зі слідами (взуття, зріст, одяг, ДНК).` }); // -- хибний слід: зачіпка і спростування add({ id: 'her_lead', side: 'field', at: { type: 'person', cid: witnessFrom(citizens.filter(c => c.workplace === 'diner' || c.workplace === 'shop')).id }, needsIdentity: true, title: 'Чутка з району', tag: 'підозра', detail: herringLead + ` (${herring.name}: ${herring.height} см, взуття ${herring.shoe} — під прикмети пасує.)`, gives: { pointsTo: herring.id } }); // -- хибний мотив: у хибного підозрюваного — ВЛАСНИЙ справжній конфлікт із жертвою, // з доказами, як у повноцінної теорії. Але він не вбивав. Тип конфлікту завжди // інший, ніж мотив вбивці, — щоб дві теорії не зливались в одну. const ded = caseType === 'kidnap' ? 'зникнення' : 'смерті'; const fmWitness = witnessFrom(citizens.filter(c => unitBuilding(c.home) === unitBuilding(herring.home) || (c.workplace && c.workplace === herring.workplace))); const fmSlot = shuffle(r, district.homeSlots).find(sl => !Object.values(E).some(e => e.at.type === 'unit' && e.at.unit === victim.home && e.at.slot === sl)) || 'кухня'; const FM_KINDS = [ { label: 'Борг', admit: `я ${g(herring, 'винен був', 'винна була')} ${g(victim, 'йому', 'їй')} гроші і не віддавав`, lead: () => add({ id: 'fm_lead', side: 'analyst', at: { type: 'sys', sys: 'bank' }, q: { sys: 'bank', match: herring.name }, needsIdentity: true, title: `Виписка: ${herring.name}`, tag: 'підозра', detail: `Щомісячні перекази на рахунок ${victim.name} раптово припинилися пів року тому. Коментар до останнього: «далі не зможу, вибач». Борг, який перестали платити.`, gives: { pointsTo: herring.id } }), talk: () => add({ id: 'fm_talk', side: 'field', at: { type: 'person', cid: fmWitness.id }, needsIdentity: true, title: `Про борг: ${fmWitness.name}`, tag: 'підозра', detail: `«Гроші між ними стояли давно: ${herring.first} позичи${g(herring, 'в', 'ла')} у ${victim.first} чималу суму. Віддавати не було з чого, і обоє про це знали.»` }) }, { label: 'Ревнощі', admit: `я писа${g(herring, 'в', 'ла')} ті записки, і остання була злою`, lead: () => add({ id: 'fm_lead', side: 'field', at: { type: 'unit', unit: victim.home, slot: fmSlot }, needsIdentity: true, title: 'Пачка записок без конверта', tag: 'підозра', detail: `Перев'язані гумкою записки, підписані «${herring.first[0]}.»: спершу ніжні, далі гіркіші, остання — «ти ще пошкодуєш, що сміявся». Почерк рівний, натиск сильний.`, gives: { pointsTo: herring.id } }), talk: () => add({ id: 'fm_talk', side: 'field', at: { type: 'person', cid: witnessFrom(citizens.filter(c => c.workplace === 'bar')).id }, needsIdentity: true, title: 'Свідчення з бару', tag: 'підозра', detail: `«${herring.first}? Та пів бару знало, що ${g(herring, 'він', 'вона')} до ${victim.first} небайдуж${herring.sex === 'ж' ? 'а' : 'ий'}. А ${victim.first} лише відмахувався. Було в цьому щось недобре.»` }) }, { label: 'Сусідська війна', admit: `ми справді гризлися через скарги, і я ${g(herring, 'казав', 'казала')} зайве`, lead: () => add({ id: 'fm_lead', side: 'analyst', at: { type: 'sys', sys: 'crim' }, q: { sys: 'crim', kind: 'archive', match: [herring.last, herring.name] }, needsIdentity: true, title: `Скарги: ${herring.last} ↔ ${victim.last}`, tag: 'підозра', detail: `Сім взаємних скарг за два роки: шум, залиття, сміття. Останню жертва подала за тиждень до ${ded}. У тексті скарги ${herring.name} — фраза «я цьому покладу край».`, gives: { pointsTo: herring.id } }), talk: () => add({ id: 'fm_talk', 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: `«Та вони роками гризлися! Комісії ходили, акти складали. Востаннє ${herring.first} крича${g(herring, 'в', 'ла')} на сходах так, що чув весь під'їзд.»` }) }, { label: 'Розбите авто', admit: `${g(victim, 'він', 'вона')} справді розби${g(victim, 'в', 'ла')} мені крило і відмови${g(victim, 'вся', 'лася')} платити`, lead: () => add({ id: 'fm_lead', side: 'field', at: { type: 'unit', unit: victim.home, slot: fmSlot }, needsIdentity: true, title: 'Лист від адвоката', tag: 'підозра', detail: `Офіційна вимога: компенсувати ремонт авто ${herring.name} після дрібної ДТП у дворі. Поверх листа — розмашисте, рукою жертви: «нічого не платитиму».`, gives: { pointsTo: herring.id } }), talk: () => add({ id: 'fm_talk', side: 'field', at: { type: 'person', cid: witnessFrom(citizens.filter(c => c.workplace === 'garage')).id }, needsIdentity: true, title: 'Свідчення з автомайстерні', tag: 'підозра', detail: `«Привози${g(herring, 'в', 'ла')} авто з пом'ятим крилом і клекот${g(herring, 'ів', 'іла')}: мовляв, ${victim.first} заплатить за все. Ми ще сміялися — хто ж так борги вибиває.»` }) } ]; const fm = shuffle(r, FM_KINDS).find(f => f.label !== motiveId) || FM_KINDS[0]; fm.lead(); fm.talk(); 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'], title: `Перевірка: ${herring.name}`, tag: 'алібі', detail: `Архів камер: ${crimeTime} — ${herring.name} у кадрі біля «${district.buildings.find(b => b.id === herring.evening).name.split(',')[0]}», далеко від місця злочину. Обличчя чітке. Це не ${herring.sex === 'ч' ? 'він' : 'вона'}.`, gives: { disproves: herring.id } }); // -- знаряддя і підтвердження вбивці (у викраденні знаряддя немає — лише ДНК) const weaponInHome = chance(r, 0.3); const dump = pick(r, district.dumpSpots); if (caseType === 'murder') add({ id: 'ev_weapon', side: 'field', at: weaponInHome ? { type: 'unit', unit: killer.home, slot: 'під ліжком' } : { type: 'spot', building: dump.building, spot: dump.spot }, requires: weaponInHome ? [] : ['lab_autopsy'], title: `Знаряддя: ${method.weapon}`, tag: 'спосіб', detail: `${method.weapon.charAt(0).toUpperCase() + method.weapon.slice(1)}${weaponInHome ? ', нашвидкуруч захований удома' : `, викинутий поспіхом (${dump.spot})`}. Сліди відповідають висновку розтину. На поверхні — один повний відбиток пальця.`, gives: { weapon: true } }); if (caseType === 'murder') add({ id: 'lab_print_match', side: 'analyst', at: { type: 'lab' }, requires: ['ev_weapon'], title: 'Повний відбиток зі знаряддя', tag: 'вбивця', detail: `Відбиток зі знаряддя порівняно з базою (звузьте кандидатів і зіставте вручну). Збіг за ${ri(r, 12, 16)} точками: ${killer.name}.`, gives: { confirms: true } }); if (!(caseType === 'murder' && mutator === 'gloves')) add({ id: 'lab_dna_match', side: 'analyst', at: { type: 'lab' }, requires: ['sf_dna'], special: 'dna', title: `Збіг ДНК: ${dnaDesc.split(',')[0]}`, tag: 'вбивця', detail: (mutator === 'accomplice' ? `Електрофорез (${dnaDesc}): повний збіг доріжок — ${accomplice.name}. Дивно: на кого вказують взуття і тканина — це ІНША людина.` : `Електрофорез (${dnaDesc}): повний збіг доріжок — ${killer.name}.`) + ' Коли і чому ДНК потрапила на місце — аналіз не скаже.', gives: mutator === 'accomplice' ? { pointsTo: accomplice.id } : { confirms: true } }); if (dna2) add({ id: 'lab_dna_match2', side: 'analyst', at: { type: 'lab' }, requires: ['sf_dna2'], special: 'dna2', title: `Збіг ДНК: ${dna2Desc.split(',')[0]}`, tag: 'слід', detail: `Електрофорез (${dna2Desc}): повний збіг доріжок — ${dna2.owner.name}. Коли і чому ДНК потрапила на місце — аналіз не скаже.`, gives: { pointsTo: dna2.owner.id } }); if (mutator === 'accomplice') add({ id: 'acc_confess', side: 'field', at: { type: 'person', cid: accomplice.id }, requires: ['lab_dna_match'], title: `Зізнання спільника: ${accomplice.name}`, tag: 'вбивця', detail: `Побачивши висновок ДНК, ${accomplice.name} осідає на стілець. «Я лише ${g(accomplice, 'стояв', 'стояла')} на сторожі і ${g(accomplice, 'допоміг', 'допомогла')} прибрати. Задум і рука — ${killer.name}. Присягаюся.» Свідчення під протокол.`, gives: { pointsTo: killer.id, confirms: true, motive: true } }); // -- зламане алібі: третій шлях підтвердження. Це «сплячий» доказ: його відкриває // лише сервер, коли записана на допиті брехня вбивці суперечить витягнутому архіву камер. // Виглядає так само, як «алібі не сходиться» для будь-кого іншого (сервер відкриває // такі й для невинних брехунів) — щоб поява картки не спойлерила винного одразу. add({ id: 'alibi_broken', side: 'analyst', at: { type: 'lab' }, special: 'alibi', title: `Алібі не сходиться: ${killer.name}`, tag: 'слід', detail: `${killer.name} на допиті ${g(killer, 'заявив', 'заявила')}, що ${g(killer, 'був', 'була')} біля «${district.buildings.find(b => b.id === killer.claim).name.split(',')[0]}», але витягнутий архів камер за той вечір ${g(killer, 'його', 'її')} там не показує. Хтось збрехав про свій вечір — з'ясуйте чому. Брехня про алібі ще не робить винним.`, gives: { pointsTo: killer.id, confirms: true } }); // журнал дзвінків (там, де вбивця контактував із жертвою) if (['jealousy', 'debt', 'blackmail'].includes(templateId)) add({ id: 'rec_calls', side: 'analyst', at: { type: 'sys', sys: 'phone' }, q: { sys: 'phone', kind: 'calls', match: victim.phone }, needsIdentity: true, title: 'Дзвінки жертви за останню добу', tag: 'зв\'язок', detail: `Три номери. Останній вхідний о ${ri(r, 20, 21)}:4${ri(r, 0, 9)} — 47 секунд, номер ${killer.phone} (${killer.name}).`, gives: { pointsTo: killer.id } }); // -- дрібниці: у кожному кутку щось лежить; справжнє і мотлох — упереміш, фільтруйте самі const nb = () => witnessFrom(citizens); // випадковий не-фігурант для згадки в тексті setupJunk({ r, citizens, victim, killer, herring, caseType, district, add, E, g, witnessFrom, dna2, nb, pick, shuffle, chance }); // -- фінал const finale = caseType === 'kidnap' ? { killer: killer.id, where: lair.label, motive: motiveId, whereOptions: LAIRS.map(l => l.label), motiveOptions: MOTIVE_OPTIONS, recap: `${recapWhy} Місце утримання: ${lair.label}. Штурм о четвертій ранку: замок зрізали за вісім секунд, ${g(victim, 'заручника винесли живим', 'заручницю винесли живою')}. ${killer.name} — затримання на місці, без опору. ` + `Сліди не збрехали: відбиток взуття ${killer.shoe} розміру, вирване волосся з ДНК і камера, що впіймала зріст. Людина вдома. Справу закрито.` } : { killer: killer.id, method: method.label, motive: motiveId, methodOptions: METHOD_OPTIONS, motiveOptions: MOTIVE_OPTIONS, recap: `${recapWhy} Увечері ${killer.sex === 'ч' ? 'він підстеріг' : 'вона підстерегла'} жертву (${scene.name}), знаряддя — ${method.weapon}. ` + `Сліди виказали: волокна одягу, відбиток взуття ${killer.shoe} розміру, ` + `${mutator === 'gloves' ? 'повний відбиток зі знаряддя' : 'ДНК з місця'} і камера, що впіймала зріст. База мешканців зробила решту. Справу закрито.` }; // admit — пряма мова хибного підозрюваного (див. FM_KINDS), а не третьоособовий // зворот: цитуємо, а не вклеюємо в речення. finale.recap += ` ${herring.first} визна${g(herring, 'в', 'ла')}: «${fm.admit}» — та до злочину стосунку не ${g(herring, 'мав', 'мала')}.`; const introText = caseType === 'kidnap' ? `${scene.name}: сліди боротьби — і жодної людини. Зникла особа: ${victim.name}, ${victim.age} р., прож. ${victim.home}. Заручник, імовірно, ще живий. З'ясуйте, ХТО забрав людину, ДЕ її тримають і ЧОМУ. Викрадача немає в жодному списку підозрюваних — його збирають по слідах і базі мешканців. У вас один штурм: помилка в імені чи місці — і все марно.` : `Ранок. ${scene.name} — тіло без документів. Ніхто не заявляв про зникнення. Акт I: з'ясуйте, ХТО загинув. Акт II: знайдіть, ХТО це зробив, ЯК і ЧОМУ. Вбивці немає у списку підозрюваних — його треба зібрати по слідах і базі мешканців. У вас одне звинувачення. Помилка — і вас звільнять.`; return { seed: String(seed), title: 'Невпізнаний', district, citizens, puzzles, victim: victim.id, evidence: E, startingFacts, finale, templateId, caseType, introText, herring: herring.id, scene, crimeTime, relations, decoyOwner: passer.id, mutator, accomplice: accomplice && accomplice.id, falseMotive: { label: fm.label, admit: fm.admit }, calls, phoneOff, dna2: dna2 && { owner: dna2.owner.id, why: dna2.why, reason: dna2.reason || null }, dnaDesc, dna2Desc: dna2 ? dna2Desc : null }; } module.exports = { generateCase, solve, checkAccusation, caesar, md5, translit, COMMON_PASSWORDS };