1438 lines
122 KiB
JavaScript
1438 lines
122 KiB
JavaScript
|
|
// 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) лишає 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);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// -- вечір злочину: де хто був насправді (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 };
|