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