2PlayerGame/chapter3/casegen/index.js

715 lines
50 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

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