2PlayerGame/detective-game/casegen/index.js

729 lines
51 KiB
JavaScript
Raw Normal View History

2026-07-23 17:03:26 +03:00
// casegen — генератор справ для розділу 3. Чистий JS, без залежностей і DOM.
// generateCase(seed) → детермінована справа: мешканці, жертва, вбивця, мотив,
// граф доказів (спланований від розв'язку назад), хибні сліди (кожен — спростовний),
// шифро-завдання. solve() — симулятор досяжності для fair-play тестів.
2026-08-15 14:07:39 +03:00
const crypto = require('crypto');
const { makeDistrict, unitBuilding } = require('./district');
2026-07-23 17:03:26 +03:00
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');
2026-07-23 17:03:26 +03:00
// ---------- Генерація справи ----------
2026-08-15 14:07:39 +03:00
// Код кімнати (сесія) → сід справи. Хешуємо, щоб близькі коди («ABC», «ABC1»)
// давали геть різні справи. Єдине джерело правди: і сервер, і тести сіють цим.
function caseSeed(code) { return crypto.createHash('sha512').update(String(code)).digest('hex'); }
2026-07-23 17:03:26 +03:00
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)));
};
2026-07-23 17:03:26 +03:00
// повний набір слідів (взуття ±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
});
2026-07-23 17:03:26 +03:00
// -- мутатор справи: зрідка правила змінюються, щоб маршрут не заучувався
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);
}
2026-07-23 17:03:26 +03:00
// -- фейкові сліди на місці: воно публічне, і не кожен слід стосується справи
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;
2026-07-23 17:03:26 +03:00
}
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;
2026-07-23 17:03:26 +03:00
}
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;
2026-07-23 17:03:26 +03:00
}
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;
2026-07-23 17:03:26 +03:00
}
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;
2026-07-23 17:03:26 +03:00
}
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;
2026-07-23 17:03:26 +03:00
}
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;
2026-07-23 17:03:26 +03:00
}
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;
2026-07-23 17:03:26 +03:00
}
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;
2026-07-23 17:03:26 +03:00
}
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;
2026-07-23 17:03:26 +03:00
}
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;
2026-07-23 17:03:26 +03:00
}
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;
2026-07-23 17:03:26 +03:00
}
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;
2026-07-23 17:03:26 +03:00
}
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;
2026-07-23 17:03:26 +03:00
}
// місце утримання: знайти можна лише за зачіпкою, навмання обшукати всі кутки не вийде
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
});
2026-07-23 17:03:26 +03:00
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}.`,
2026-08-15 14:07:39 +03:00
gives: { confirms: true, pointsTo: killer.id } // чіткий доказ: годиться і в звинувачення, і на ордер
2026-07-23 17:03:26 +03:00
});
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}.`) +
' Коли і чому ДНК потрапила на місце — аналіз не скаже.',
2026-08-15 14:07:39 +03:00
// accomplice: ДНК належить спільнику → вказує на нього (твіст лишаємо).
// інакше збіг прямо називає вбивцю → чіткий доказ: і в звинувачення, і на ордер.
gives: mutator === 'accomplice' ? { pointsTo: accomplice.id } : { confirms: true, pointsTo: killer.id }
2026-07-23 17:03:26 +03:00
});
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
2026-07-23 17:03:26 +03:00
});
// -- фінал
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' ? 'повний відбиток зі знаряддя' : 'ДНК з місця'} і камера, що впіймала зріст. База мешканців зробила решту. Справу закрито.`
};
2026-08-15 14:07:39 +03:00
// Мотив у прямій мові + монетка 50/50: чи проговорить вбивця мотив на зізнанні
// (для реалізму). Draw ОСТАННІЙ у генерації — після нього r не чіпаємо, тож
// наявні сейви лишаються тією ж справою, лише з новими полями фіналу.
finale.recapWhy = recapWhy;
finale.revealsMotive = chance(r, 0.5);
2026-07-23 17:03:26 +03:00
// 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
};
}
2026-08-15 14:07:39 +03:00
module.exports = { generateCase, caseSeed, solve, checkAccusation, caesar, md5, translit, COMMON_PASSWORDS };