2PlayerGame/chapter3/casegen/index.js
2026-07-23 17:03:26 +03:00

1437 lines
122 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// casegen — генератор справ для розділу 3. Чистий JS, без залежностей і DOM.
// generateCase(seed) → детермінована справа: мешканці, жертва, вбивця, мотив,
// граф доказів (спланований від розв'язку назад), хибні сліди (кожен — спростовний),
// шифро-завдання. solve() — симулятор досяжності для fair-play тестів.
const crypto = require('crypto');
const { makeDistrict, unitBuilding, allUnits, allJobs } = 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: 'Підсобні приміщення їдальні' }
];
// ---------- Генерація справи ----------
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);
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).
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)));
// повний набір слідів (взуття ±1, тканина, зріст ±10) лишає 26 кандидатів у фіналі
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);
}
// -- вечір злочину: де хто був насправді (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;
}
// -- мутатор справи: зрідка правила змінюються, щоб маршрут не заучувався
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: 'Ані волосини, ані недопалка, ані слини. На пряжці — змазані сліди латексних рукавичок. Вбивця знав, що таке ДНК, і не залишив її. Доведеться йти іншими шляхами: відбиток зі знаряддя — або зламане алібі.'
});
// -- фейкові сліди на місці: воно публічне, і не кожен слід стосується справи
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') {
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, 'влаштував', 'влаштувала')} скандал через залите авто — усі чули погрози.»`;
}
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} — сусіди кажуть, «теж у боргах, теж сварилися». Щоправда, поруч із тією сумою в зошиті стоїть слово, яке варто прочитати уважно.`;
}
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}.`;
}
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, 'крутився', 'крутилася')}. Чого — хтозна.»`;
}
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} давно на ножах — через паркування, через сміття, через усе».`;
}
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, 'йому', 'їй')}`;
}
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, 'крутився', 'крутилася')}, усі знали. Темна історія.»`;
}
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} вічно сидить біля вікна з біноклем. Каже — «за птахами». Ага, за птахами.»`;
}
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} на склад маркету заходить частіше, ніж туди завозять хліб. Просто кажу.»`;
}
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} ночами гуляє районом і фотографує чужі вікна. Сусіди двічі таке бачили.»`;
}
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, 'в', 'ла')}, хто в районі має гроші і хто живе сам. Тоді здалося балачкою.»`;
}
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, 'в', 'ла')}, хто в районі має гроші і хто живе сам. Тоді здалося балачкою.»`;
}
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} вічно сидить біля вікна з біноклем. Каже — «за птахами». Ага, за птахами.»`;
}
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} десь фігурує — теж, кажуть, позичали одне одному. Хто кому винен — спитайте самі.»`;
}
// місце утримання: знайти можна лише за зачіпкою, навмання обшукати всі кутки не вийде
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();
// -- пристрої фігурантів: польовий вилучає (ордер), аналітик ламає 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(' ')
: 'Чисто: банк, погода, пасьянс. Або власник обережний, або йому справді нічого ховати.'
});
}
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); // випадковий не-фігурант для згадки в тексті
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}. Розборонили швидко, обоє розійшлися своїми ногами.`);
// -- фінал
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
};
}
// ---------- Симулятор досяжності (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 };