refactoring casegen + new anonymous letter and copy
This commit is contained in:
parent
155bddda0e
commit
645566b295
26 changed files with 1239 additions and 872 deletions
70
chapter3/casegen/accusation.js
Normal file
70
chapter3/casegen/accusation.js
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
// casegen/accusation.js — симулятор досяжності та перевірка звинувачення
|
||||
const RT = require('./runtime');
|
||||
|
||||
function solve(cs) {
|
||||
const E = cs.evidence;
|
||||
const st = RT.newState();
|
||||
let changed = true;
|
||||
while (changed) {
|
||||
changed = false;
|
||||
for (const cid of st.pointed) if (!st.warrants.includes(cid)) { st.warrants.push(cid); changed = true; }
|
||||
for (const ev of Object.values(E)) {
|
||||
if (st.found.includes(ev.id) || !RT.available(cs, st, ev, { ignorePuzzles: true })) continue;
|
||||
RT.applyFound(cs, st, ev); changed = true;
|
||||
}
|
||||
}
|
||||
const has = pred => Object.values(E).some(ev => st.found.includes(ev.id) && pred(ev));
|
||||
return {
|
||||
reached: new Set(st.found), identity: st.identity,
|
||||
killerConfirmed: has(ev => ev.gives.confirms),
|
||||
methodKnown: has(ev => ev.gives.method),
|
||||
motiveProven: has(ev => ev.gives.motive),
|
||||
herringDisproved: has(ev => ev.gives.disproves === cs.herring),
|
||||
herringLeadFound: st.found.includes('her_lead'),
|
||||
lairProven: has(ev => ev.gives.lair),
|
||||
finalCandidates: RT.candidates(cs, st)
|
||||
};
|
||||
}
|
||||
|
||||
function checkAccusation(cs, ans, pinned) {
|
||||
const E = cs.evidence, killer = cs.citizens.find(c => c.id === cs.finale.killer);
|
||||
const has = pred => pinned.some(id => E[id] && pred(E[id]));
|
||||
const hasConfirmFor = (name) => pinned.some(id => {
|
||||
const ev = E[id];
|
||||
if (!ev || !ev.gives || !ev.gives.confirms) return false;
|
||||
if (ev.gives.pointsTo) {
|
||||
const target = cs.citizens.find(c => c.id === ev.gives.pointsTo);
|
||||
return target && target.name.toLowerCase() === name;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
const accused = (ans.who || '').trim().toLowerCase();
|
||||
if (accused !== killer.name.toLowerCase())
|
||||
return { ok: false, hint: 'Ім\'я не сходиться з доказами: сліди з місця злочину (взуття, тканина, зріст, ДНК) звужують коло — і хтось із перевірених має підтверджене алібі.' };
|
||||
if (cs.caseType === 'kidnap') {
|
||||
if (ans.how !== cs.finale.where)
|
||||
return { ok: false, hint: 'Місце не сходиться. ДЕ саме вказують знахідки: бирка ключа, план, чек за замок?' };
|
||||
if (ans.why !== cs.finale.motive)
|
||||
return { ok: false, hint: 'Мотив не той. ЩО пов\'язувало викрадача зі зниклою особою?' };
|
||||
if (!hasConfirmFor(accused))
|
||||
return { ok: false, hint: 'Особу викрадача не підтверджено: потрібен збіг ДНК або спростоване алібі на дошці.' };
|
||||
if (!has(ev => ev.gives.motive))
|
||||
return { ok: false, hint: 'Мотив не підкріплено жодним закріпленим доказом.' };
|
||||
if (!has(ev => ev.gives.lair))
|
||||
return { ok: false, hint: 'Місце утримання не підкріплено закріпленим доказом — штурм наосліп ніхто не санкціонує.' };
|
||||
return { ok: true };
|
||||
}
|
||||
if (ans.how !== cs.finale.method)
|
||||
return { ok: false, hint: 'Спосіб суперечить висновку розтину. Перечитайте, що саме сказав патологоанатом.' };
|
||||
if (ans.why !== cs.finale.motive)
|
||||
return { ok: false, hint: 'Мотив не той. ЩО пов\'язувало цих двох людей за тиждень до смерті?' };
|
||||
if (!hasConfirmFor(accused))
|
||||
return { ok: false, hint: 'Немає прямого підтвердження особи: потрібен збіг ДНК, повний відбиток зі знаряддя або спростоване алібі на дошці.' };
|
||||
if (!has(ev => ev.gives.motive))
|
||||
return { ok: false, hint: 'Мотив не підкріплено жодним закріпленим доказом.' };
|
||||
if (!has(ev => ev.gives.method))
|
||||
return { ok: false, hint: 'Спосіб смерті не підкріплено: закріпіть висновок розтину.' };
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
module.exports = { solve, checkAccusation };
|
||||
55
chapter3/casegen/anonymous_letter.js
Normal file
55
chapter3/casegen/anonymous_letter.js
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
// casegen/anonymous_letter.js — генерація анонімного листа
|
||||
const { pick, chance } = require('./rng');
|
||||
|
||||
function generateAnonymousLetter(ctx) {
|
||||
const { r, killer, victim, citizens, add } = ctx;
|
||||
|
||||
const letterType = pick(r, [1, 2, 3]);
|
||||
let detail = '';
|
||||
|
||||
if (letterType === 1) {
|
||||
const hints = [
|
||||
`Шукайте серед тих, хто носить ${killer.coat}.`,
|
||||
`Вбивця — ${killer.sex === 'ч' ? 'чоловік' : 'жінка'} на зріст приблизно ${killer.height} см.`,
|
||||
`Той, хто це зробив, має татуювання — "${killer.tattoo}".`,
|
||||
`Вбивця працює ${killer.job === 'тимчасово не працює' ? 'без роботи' : killer.job + 'ом'}.`,
|
||||
`Слід шукати серед мешканців будинку ${killer.home}.`
|
||||
].filter(h => !h.includes('undefined') && !h.includes('null'));
|
||||
if (hints.length === 0) return null;
|
||||
detail = pick(r, hints) + ' Це все, що я можу сказати.';
|
||||
} else if (letterType === 2) {
|
||||
const innocent = pick(r, citizens.filter(c => c.id !== killer.id && c.id !== victim.id));
|
||||
const falseHints = [
|
||||
`Шукайте серед тих, хто носить ${innocent.coat}.`,
|
||||
`Вбивця — ${innocent.sex === 'ч' ? 'чоловік' : 'жінка'} на зріст приблизно ${innocent.height} см.`,
|
||||
`Той, хто це зробив, має татуювання — "${innocent.tattoo}".`,
|
||||
`Вбивця працює ${innocent.job === 'тимчасово не працює' ? 'без роботи' : innocent.job + 'ом'}.`,
|
||||
`Слід шукати серед мешканців будинку ${innocent.home}.`
|
||||
].filter(h => !h.includes('undefined') && !h.includes('null'));
|
||||
if (falseHints.length === 0) return null;
|
||||
detail = pick(r, falseHints) + ' Не дайте йому втекти.';
|
||||
} else {
|
||||
const neutralHints = [
|
||||
'Злочин стався близько півночі, але не раніше.',
|
||||
'Жертва не була випадковою — її знали.',
|
||||
'Місце злочину обране не випадково.',
|
||||
'Слідів більше, ніж здається на перший погляд.',
|
||||
'Дехто в районі знає більше, ніж говорить.'
|
||||
];
|
||||
detail = pick(r, neutralHints);
|
||||
}
|
||||
|
||||
if (!detail) return null;
|
||||
|
||||
return add({
|
||||
id: 'anonymous_letter',
|
||||
side: 'analyst',
|
||||
at: { type: 'start' },
|
||||
title: 'Анонімний лист',
|
||||
tag: 'підказка',
|
||||
detail: `Лист без підпису, надісланий до відділку: «${detail}». Походження невідоме.`,
|
||||
gives: {}
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { generateAnonymousLetter };
|
||||
68
chapter3/casegen/citizens.js
Normal file
68
chapter3/casegen/citizens.js
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
// casegen/citizens.js — генерація мешканців району
|
||||
const N = require('./names');
|
||||
const { allUnits, allJobs } = require('./district');
|
||||
const { pick, chance, shuffle, ri } = require('./rng');
|
||||
|
||||
const BLOOD = ['O(I) Rh+', 'O(I) Rh+', 'A(II) Rh+', 'A(II) Rh+', 'B(III) Rh+', 'AB(IV) Rh+', 'O(I) Rh−', 'A(II) Rh−'];
|
||||
const PRINTS = ['дуга', 'петля', 'петля', 'завиток', 'завиток'];
|
||||
|
||||
function makeCitizens(r, district) {
|
||||
const citizens = [];
|
||||
const usedNames = new Set(), usedPhones = new Set(), usedDna = new Set();
|
||||
let nextId = 1;
|
||||
|
||||
function person(sex, last, ageRange) {
|
||||
let first, full;
|
||||
do { first = pick(r, sex === 'ч' ? N.MALE : N.FEMALE); full = `${first} ${last}`; } while (usedNames.has(full));
|
||||
usedNames.add(full);
|
||||
const age = ri(r, ageRange[0], ageRange[1]);
|
||||
const height = sex === 'ч' ? ri(r, 165, 195) : ri(r, 152, 183);
|
||||
let phone;
|
||||
do { phone = `+380${pick(r, ['50', '63', '66', '67', '68', '73', '95', '96', '97', '98', '99'])}${String(ri(r, 0, 9999999)).padStart(7, '0')}`; } while (usedPhones.has(phone));
|
||||
usedPhones.add(phone);
|
||||
let dna;
|
||||
do { dna = Array.from({ length: 8 }, () => ri(r, 0, 15)); } while (usedDna.has(dna.join(',')));
|
||||
usedDna.add(dna.join(','));
|
||||
return {
|
||||
id: 'c' + nextId++, name: full, first, last, sex, age, birthYear: 2026 - age,
|
||||
blood: pick(r, BLOOD), height,
|
||||
shoe: Math.max(35, Math.min(46, Math.round(height * 0.24) + ri(r, -1, 1))),
|
||||
printType: pick(r, PRINTS), printSeed: ri(r, 1, 2 ** 30), coat: pick(r, N.COATS),
|
||||
smoker: chance(r, 0.3), dog: chance(r, 0.2) ? pick(r, N.DOG_NAMES) : null,
|
||||
tattoo: chance(r, 0.25) ? pick(r, N.TATTOOS) : null,
|
||||
phone, dna, home: null, job: null, workplace: null, spouse: null
|
||||
};
|
||||
}
|
||||
|
||||
const unitList = allUnits(district);
|
||||
const sizes = {};
|
||||
shuffle(r, unitList.filter(u => u[0] !== 'C')).forEach((u, i) => sizes[u] = i < 3 ? 1 : pick(r, [1, 2, 2, 3, 3, 4]));
|
||||
for (const u of unitList) if (u[0] === 'C') sizes[u] = pick(r, [1, 1, 2]);
|
||||
for (const unit of unitList) {
|
||||
const dorm = unit[0] === 'C';
|
||||
const size = sizes[unit];
|
||||
const last = pick(r, N.SURNAMES);
|
||||
if (size === 1) {
|
||||
const p = person(pick(r, ['ч', 'ж']), last, dorm ? [19, 45] : [25, 74]);
|
||||
p.home = unit; citizens.push(p);
|
||||
} else {
|
||||
const a = person('ч', last, [26, 68]);
|
||||
const b = person('ж', chance(r, 0.7) ? last : pick(r, N.SURNAMES), [Math.max(22, a.age - 8), Math.min(70, a.age + 8)]);
|
||||
if (!dorm) { a.spouse = b.id; b.spouse = a.id; }
|
||||
a.home = b.home = unit; citizens.push(a, b);
|
||||
for (let k = 2; k < size; k++) {
|
||||
const kid = person(pick(r, ['ч', 'ж']), last, [18, Math.max(19, a.age - 22)]);
|
||||
kid.home = unit; citizens.push(kid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const jobs = shuffle(r, allJobs(district));
|
||||
const workers = shuffle(r, citizens.filter(c => c.age <= 65));
|
||||
workers.slice(0, jobs.length).forEach((c, i) => { c.job = jobs[i].title; c.workplace = jobs[i].building; });
|
||||
for (const c of citizens) if (!c.job)
|
||||
c.job = c.age > 62 ? 'пенсіонер' : c.age < 23 ? 'студент' : 'тимчасово не працює';
|
||||
return citizens;
|
||||
}
|
||||
|
||||
module.exports = { makeCitizens };
|
||||
29
chapter3/casegen/constants.js
Normal file
29
chapter3/casegen/constants.js
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
// casegen/constants.js — довідники фіналу
|
||||
const METHODS = [
|
||||
{ id: 'stab', label: 'Ніж', autopsy: 'дві колото-різані рани; знаряддя — вузький клинок ~2 см', weapon: 'кухонний ніж із вузьким лезом' },
|
||||
{ id: 'blunt', label: 'Тупий предмет', autopsy: 'тупа травма голови; слід шестигранної грані', weapon: 'розвідний ключ' },
|
||||
{ id: 'strangle', label: 'Удушення', autopsy: 'странгуляційна борозна; у рані — синтетичні волокна шнура', weapon: 'капроновий шнур' },
|
||||
{ id: 'poison', label: 'Отруєння', autopsy: 'слідів боротьби немає; токсикологія — летальна доза барбітуратів у алкоголі', weapon: 'пляшка коньяку з домішкою' }
|
||||
];
|
||||
const METHOD_OPTIONS = ['Ніж', 'Тупий предмет', 'Удушення', 'Отруєння', 'Падіння з висоти'];
|
||||
const MOTIVE_OPTIONS = ['Ревнощі', 'Борг', 'Шантаж', 'Свідок злочину', 'Помилкова ціль', 'Пограбування',
|
||||
'Спадок і страховка', 'Старі гріхи', 'Одержимість', 'Кримінальний бізнес', 'Серійний патерн', 'Викуп'];
|
||||
const SIGNATURES = [
|
||||
{ text: 'чорна стрічка, акуратно зав\'язана на зап\'ясті', key: 'стрічка' },
|
||||
{ text: 'крейдяне коло, дбайливо обведене довкола тіла', key: 'коло' },
|
||||
{ text: 'дитяча іграшкова машинка, вкладена в кишеню', key: 'машинка' },
|
||||
{ text: 'гральна карта — піковий валет — під долонею', key: 'валет' },
|
||||
{ text: 'засушена фіалка, вкладена між пальцями', key: 'фіалка' },
|
||||
{ text: 'зупинений годинник, переведений на 3:33', key: 'годинник' },
|
||||
{ text: 'ґудзик, відрізаний і покладений на повіку', key: 'ґудзик' },
|
||||
{ text: 'аркуш нотного паперу з єдиною нотою', key: 'нота' },
|
||||
{ text: 'ключ без бородки, вкладений у долоню', key: 'ключ' }];
|
||||
const LAIRS = [
|
||||
{ building: 'pier', spot: 'елінг', label: 'Елінг на причалі' },
|
||||
{ building: 'shop', spot: 'склад', label: 'Склад мінімаркету' },
|
||||
{ building: 'bar', spot: 'підсобка', label: 'Підсобка бару' },
|
||||
{ building: 'garage', spot: 'яма', label: 'Оглядова яма автомайстерні' },
|
||||
{ building: 'diner', spot: 'роздягальня персоналу', label: 'Підсобні приміщення їдальні' }
|
||||
];
|
||||
|
||||
module.exports = { METHODS, METHOD_OPTIONS, MOTIVE_OPTIONS, SIGNATURES, LAIRS };
|
||||
86
chapter3/casegen/devices.js
Normal file
86
chapter3/casegen/devices.js
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
// casegen/devices.js — телефони та ноутбуки фігурантів, PIN/паролі, вміст
|
||||
const { chance, pick, shuffle, ri, md5 } = require('./rng');
|
||||
const { genPassword } = require('./rng');
|
||||
|
||||
function setupDevices(ctx) {
|
||||
const { r, citizens, killer, herring, victim, accomplice, caseType, templateId, district, add, addPuzzle, byId, E, method, fm } = ctx;
|
||||
|
||||
const HIST_COMMON = ['погода на тиждень', 'розклад маршруток', 'рецепт як у їдальні', 'дешеві авіаквитки',
|
||||
'чому болить спина зранку', 'серіали за вечір список', 'як позбутися мурах', 'гороскоп терези'];
|
||||
const CHAT_COMMON = ['«Купи хліба» — «Вже йду»', '«Ти де?» — «Затримуюсь, не чекайте»',
|
||||
'«Завтра зміна з восьмої» — «Прийду»', '«Мама дзвонила?» — «Подзвоню ввечері»'];
|
||||
|
||||
const histFor = who => {
|
||||
if (who === killer) return caseType === 'kidnap'
|
||||
? pick(r, [['навісні замки які надійніші', 'скільки людина може без їжі'], ['чи пишуть камери вночі', 'зняти готівку без банкомата']])
|
||||
: method.id === 'poison'
|
||||
? [['барбітурати дозування', 'через скільки діє снодійне'], ['алкоголь і снодійне сумісність', 'чи виявляє розтин отруту']][ri(r, 0, 1)]
|
||||
: pick(r, [['чи бачать камери вночі', 'як поводитись на допиті'], ['квитки в один бік дешево', 'строк давності тяжких злочинів']]);
|
||||
if (who === herring) return {
|
||||
'Борг': ['мікропозика без відмови', 'як швидко заробити'],
|
||||
'Ревнощі': ['як повернути кохану людину', 'чому мене не помічають'],
|
||||
'Сусідська війна': ['зразок скарги на сусіда', 'куди скаржитись на шум'],
|
||||
'Розбите авто': ['вартість ремонту крила', 'як стягнути збитки з винуватця']
|
||||
}[fm.label];
|
||||
if (accomplice && who === accomplice) return ['чи можна відкликати свідчення', 'адвокат недорого консультація'];
|
||||
return {
|
||||
jealousy: ['готелі подобово поруч'], debt: ['шаблон розписки про борг'], blackmail: ['анонімна пошта створити'],
|
||||
witness: ['куди повідомити про крадіжку'], stalker: ['як зафіксувати переслідування'], inherit: ['зразок заповіту'],
|
||||
oldcrime: ['строк давності злочинів'], mistaken: ['як змінити номер телефону'], business: ['митні правила дрібні партії'],
|
||||
serial: ['самооборона курси'], ransom: ['страхування життя умови'], k_ransom: ['страхування життя умови'],
|
||||
k_obsession: ['як зафіксувати переслідування'], k_debt: ['шаблон розписки про борг']
|
||||
}[templateId] || [];
|
||||
};
|
||||
|
||||
const usedDevSlots = new Set();
|
||||
const devFigures = [[killer, 'k'], [herring, 'h']];
|
||||
if (accomplice) devFigures.push([accomplice, 'a']);
|
||||
if (caseType === 'murder' && !['jealousy', 'witness', 'blackmail'].includes(templateId)) devFigures.push([victim, 'v']);
|
||||
|
||||
for (const [who, suf] of devFigures) {
|
||||
const isPhone = chance(r, 0.5);
|
||||
const taken = sl => usedDevSlots.has(who.home + '|' + sl) ||
|
||||
Object.values(E).some(e => e.at.type === 'unit' && e.at.unit === who.home && e.at.slot === sl);
|
||||
const slot = shuffle(r, district.homeSlots).find(sl => !taken(sl)) || 'комора';
|
||||
usedDevSlots.add(who.home + '|' + slot);
|
||||
let pz;
|
||||
if (isPhone) {
|
||||
const pin = String(who.birthYear);
|
||||
pz = addPuzzle({
|
||||
id: 'pz_dev_' + suf, kind: 'pin', hash: md5(pin), answer: pin,
|
||||
prompt: `Вилучений телефон (${who.name}): заблоковано 4-значним PIN.`,
|
||||
hint: 'Люди ставлять те, що не забудуть. Рік народження власника є в реєстрі мешканців.'
|
||||
});
|
||||
} else {
|
||||
const pw = genPassword(r, who);
|
||||
pz = addPuzzle({
|
||||
id: 'pz_dev_' + suf, kind: 'md5', hash: md5(pw.answer), answer: pw.answer, style: pw.style,
|
||||
prompt: `Вилучений ноутбук (${who.name}): вхід за паролем. Хеш (md5): ${md5(pw.answer)}.`,
|
||||
hint: (pw.personal ? 'Особистий пароль — щось про власника, латиницею (див. реєстр): ' : 'Пароль випадковий — лише словниковий перебір: ') + pw.hint
|
||||
});
|
||||
}
|
||||
add({
|
||||
id: 'dev_' + suf, side: 'field', at: { type: 'unit', unit: who.home, slot },
|
||||
title: `${isPhone ? 'Телефон' : 'Ноутбук'}: ${who.name}`, tag: 'слід',
|
||||
detail: `${isPhone ? 'Телефон' : 'Ноутбук'} у житлі ${who.name}. Вилучено для лабораторії: ${isPhone ? 'заблокований PIN-кодом' : 'вхід за паролем'} — далі справа аналітика.`
|
||||
});
|
||||
const parts = [];
|
||||
if (chance(r, 0.3)) {
|
||||
const lines = shuffle(r, [...histFor(who), ...shuffle(r, HIST_COMMON).slice(0, 3)]).slice(0, 4);
|
||||
parts.push('Історія браузера: ' + lines.map(l => `«${l}»`).join(', ') + '.');
|
||||
}
|
||||
if (chance(r, 0.3)) {
|
||||
const mateC = who.spouse ? byId(who.spouse) : pick(r, citizens.filter(o => o !== who && o.id !== victim.id));
|
||||
parts.push(`Останні чати: побут із ${mateC.name}. ` + pick(r, CHAT_COMMON) +
|
||||
(who === killer ? ` І одне непрочитане того вечора: «Подзвони, як будеш удома» — без відповіді.` : ''));
|
||||
}
|
||||
add({
|
||||
id: 'dev_' + suf + '_data', side: 'analyst', at: { type: 'lab' }, requires: ['dev_' + suf], puzzle: pz,
|
||||
title: `Вміст пристрою: ${who.name}`, tag: 'слід',
|
||||
detail: parts.length ? parts.join(' ')
|
||||
: 'Чисто: банк, погода, пасьянс. Або власник обережний, або йому справді нічого ховати.'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { setupDevices };
|
||||
33
chapter3/casegen/evening.js
Normal file
33
chapter3/casegen/evening.js
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
// casegen/evening.js — налаштування вечора злочину:
|
||||
// де хто був насправді (evening) і що каже (claim), брехуни, спільники
|
||||
const { chance, pick } = require('./rng');
|
||||
const { unitBuilding } = require('./district');
|
||||
const N = require('./names');
|
||||
|
||||
function setupEvening(ctx) {
|
||||
const { r, citizens, victim, killer, herring, district, scene, coveredB } = ctx;
|
||||
|
||||
for (const c of citizens) {
|
||||
c.evening = c.workplace && chance(r, 0.2) ? c.workplace
|
||||
: chance(r, 0.2) ? pick(r, ['bar', 'diner']) : unitBuilding(c.home);
|
||||
c.liar = chance(r, 0.15);
|
||||
}
|
||||
victim.evening = scene.building; victim.liar = false;
|
||||
killer.evening = scene.building; killer.liar = true;
|
||||
|
||||
herring.evening = pick(r, coveredB.filter(b => b !== scene.building && b !== 'station')) || unitBuilding(herring.home);
|
||||
herring.liar = false;
|
||||
|
||||
const otherB = b => pick(r, district.buildings.filter(x => x.id !== b && x.id !== 'station')).id;
|
||||
for (const c of citizens) c.claim = c.liar ? otherB(c.evening) : c.evening;
|
||||
killer.claim = pick(r, coveredB.filter(b => b !== scene.building && b !== 'station')) || otherB(scene.building);
|
||||
|
||||
for (const c of citizens) {
|
||||
if (!c.liar || c === killer) continue;
|
||||
c.excuse = pick(r, N.EXCUSES);
|
||||
const m2 = citizens.find(o => o !== c && o.id !== victim.id && o.id !== killer.id && o.evening === c.evening);
|
||||
c.mate = m2 ? m2.id : null;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { setupEvening };
|
||||
File diff suppressed because it is too large
Load diff
100
chapter3/casegen/junk.js
Normal file
100
chapter3/casegen/junk.js
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
// casegen/junk.js — фейкові сліди, дрібниці, плітки
|
||||
const { pick, shuffle, chance } = require('./rng');
|
||||
const N = require('./names');
|
||||
|
||||
function setupJunk(ctx) {
|
||||
const { r, citizens, victim, killer, herring, caseType, district, add, E, g, witnessFrom, dna2 } = ctx;
|
||||
|
||||
const nb = () => witnessFrom(citizens);
|
||||
const dogOwner = citizens.find(c => c.dog && c.id !== victim.id);
|
||||
const junkTexts = shuffle(r, [
|
||||
{ t: 'Чек із бару', d: 'Два еспресо і коньяк, 21:47. На звороті олівцем — номер телефону без імені. Половина цифр затерта.' },
|
||||
{ t: 'Стара запальничка', d: `Бензинова, з гравіюванням «${pick(r, ['С.', 'М.', 'В.', 'Т.'])}». Давно не заправлялась.` },
|
||||
{ t: 'Флаєр', d: '«Ремонт взуття за годину». Усі відривні номери відірвані — популярний швець.' },
|
||||
{ t: 'Порожня пляшка', d: 'З-під дешевого вина, ще липка. Поруч — два пластикові стаканчики.' },
|
||||
{ t: 'Список покупок', d: 'Молоко, батарейки, свічки, скотч. Почерк квапливий.' },
|
||||
{ t: 'Одна рукавичка', d: 'Шкіряна, права, добротна. Ліва, вочевидь, досі гріє чиюсь кишеню.' },
|
||||
{ t: 'Прострочена квитанція', d: `Комуналка на ім'я «${nb().name}», третє нагадування. Хтось не любить платити вчасно.` },
|
||||
{ t: 'Записник із адресами', d: `Половина сторінок вирвана. Серед уцілілих записів — «${nb().name}» і закреслений номер.` },
|
||||
{ t: 'Білети в кіно', d: 'Два білети двомісячної давності, сеанс 21:30, місця поруч. І обгортка від шоколаду.' },
|
||||
{ t: 'Іржавий ключ', d: 'Не підходить до жодних дверей поблизу. Хтось носив його «про всяк випадок» роками.' },
|
||||
{ t: 'Оголошення про пса', d: dogOwner ? `«Загубився пес на кличку ${dogOwner.dog}. Винагорода.» Судячи з дати — пес давно вдома.` : '«Загубився пес. Винагорода.» Без прикмет — оптимістично.' },
|
||||
{ t: 'Стос старих газет', d: 'В одній обведено оголошення про оренду квартири. Олівець аж продавив папір.' },
|
||||
{ t: 'Візитка таксі', d: 'Від руки написано час: «23:15». Ні дати, ні імені.' },
|
||||
{ t: 'Розбитий парасоль', d: 'І чек на новий — того ж дня, з маркету. Практична людина.' },
|
||||
{ t: 'Гребінець із монограмою', d: 'Кістяний, старий, доглянутий. Літера затерта до нечитабельності.' },
|
||||
{ t: 'Театральна програмка', d: 'Гастролі обласного театру, третій ряд. На полях — сердечко олівцем.' },
|
||||
{ t: 'Ключниця з трьома ключами', d: 'Жоден не підписаний. Хтось досі відчиняє двері навпомацки.' },
|
||||
{ t: 'Обгортка від валідолу', d: 'Свіжа. Комусь тут нещодавно було недобре — або дуже нервово.' },
|
||||
{ t: 'Дитячий малюнок', d: 'Будинок, сонце і три постаті. Складений учетверо і затертий на згинах — його носили з собою.' },
|
||||
{ t: 'Лотерейний білет', d: 'Не виграшний. Закреслений хрест-навхрест із таким натиском, що порвався папір.' },
|
||||
{ t: 'Окуляри з тріснутим склом', d: 'Сильні діоптрії. Власник без них як без рук — але по них не повернувся.' },
|
||||
{ t: 'Розклад електричок', d: 'Обведено останній нічний рейс. Двічі.' },
|
||||
{ t: 'Порожній футляр від скрипки', d: 'Оксамит витертий. Або інструмент продали, або носять у ньому щось інше.' },
|
||||
{ t: 'Пакетик насіння', d: 'Чорнобривці. Строк придатності вийшов три роки тому. Хтось усе не наважиться посадити.' },
|
||||
{ t: 'Квитанція з ломбарду', d: `Здано «годинник чол. позолоч.». Викуп — до кінця місяця. Сума немала.` },
|
||||
{ t: 'Зламана рація', d: 'Дитяча, з наліпкою блискавки. Друга з пари десь чекає на звʼязок.' },
|
||||
{ t: 'Листівка без марки', d: '«Вітаю з ювілеєм!» — написано, підписано, не надіслано. Вже роки як.' },
|
||||
{ t: 'Шматок пакувального шпагату', d: 'Свіжий зріз, гострим ножем. Таким перевʼязують коробки. Або щось інше.' },
|
||||
{ t: 'Кишеньковий календарик', d: 'Позаминулий рік. Пʼятниці обведені. Усі до однієї.' },
|
||||
{ t: 'Металева фляжка', d: 'Порожня, з увігнутим боком. Пахне не чаєм.' }
|
||||
]);
|
||||
|
||||
const junkSpots = [];
|
||||
for (const b of district.buildings) for (const sp of (b.spots || [])) junkSpots.push({ type: 'spot', building: b.id, spot: sp });
|
||||
const chosen = shuffle(r, junkSpots).slice(0, 8);
|
||||
const usedSlots = new Set(Object.values(E).filter(e => e.at.type === 'unit').map(e => e.at.unit + '|' + e.at.slot));
|
||||
for (const [unit, n] of [[victim.home, 2], [killer.home, 1], [herring.home, 1]]) {
|
||||
let placed = 0;
|
||||
for (const slot of shuffle(r, district.homeSlots)) {
|
||||
if (placed >= n) break;
|
||||
if (usedSlots.has(unit + '|' + slot)) continue;
|
||||
usedSlots.add(unit + '|' + slot); chosen.push({ type: 'unit', unit, slot }); placed++;
|
||||
}
|
||||
}
|
||||
chosen.forEach((at, i) => {
|
||||
const j = junkTexts[i % junkTexts.length];
|
||||
add({ id: 'junk_' + i, side: 'field', at, title: j.t, tag: 'дрібниця', detail: j.d });
|
||||
});
|
||||
|
||||
// Плітки
|
||||
const GOSSIP = [
|
||||
() => `Кажуть, ${nb().first} ${nb().last} і ${nb().first} не вітаються після сварки за паркування.`,
|
||||
() => `${nb().name} нібито у великих боргах за комуналку. Але то таке, люди всяке кажуть.`,
|
||||
() => `Подейкують, ${nb().name} шукає нову роботу — на старій щось не склалося.`,
|
||||
() => `${nb().name} щовечора в барі. Щовечора!`,
|
||||
() => `Бачили, як ${nb().name} кричить у телефон просто посеред вулиці. Аж луна йшла.`,
|
||||
() => `У «Каштані» хтось заливає сусідів знизу, і всі чомусь думають на ${nb().name}.`,
|
||||
() => 'На складі маркету вночі інколи світиться. Сторож каже — миші. Ага, миші з ліхтариком.',
|
||||
() => `${nb().name} нібито бачили на набережній о третій ночі. Безсоння, кажуть.`,
|
||||
() => 'У гуртожитку знову міняли замки — хтось утретє загубив ключі.',
|
||||
() => `Подейкують, ${nb().name} збирає гроші на авто. Або на переїзд. Або на весілля. Версії різняться.`,
|
||||
() => `${nb().name} виносить сміття о п'ятій ранку. Щодня. Хто взагалі стільки смітить?`,
|
||||
() => { const p = nb(); return `Кажуть, ${p.name} писа${g(p, 'в', 'ла')} скарги на пів району. Пише красиво, з посиланнями на закони.`; },
|
||||
() => `У ${nb().first} з ${nb().first} щось було. Або є. Або буде. Двір слідкує.`,
|
||||
() => `${nb().name} тримає на балконі голубів. Сусіди знизу мають окрему думку про це.`,
|
||||
() => 'На набережній вечорами хтось грає на трубі. Гарно грає. Сумно.',
|
||||
() => { const p = nb(); return `${p.name} нібито ви${g(p, 'грав', 'грала')} у лотерею і мовчить. Мовчить підозріло добре.`; },
|
||||
() => { const p = nb(); return `Бачили, як ${p.name} годує всіх котів району. Клички ${g(p, 'дав', 'дала')} кожному. Веде список.`; },
|
||||
() => 'У клініці кажуть, що нічний охоронець спить. Охоронець каже, що медитує.',
|
||||
() => { const p = nb(); return `${p.name} посвари${g(p, 'вся', 'лася')} з усім будинком через антену на даху. Війна триває.`; },
|
||||
() => `Подейкують, ${nb().name} ходить до бару, але не п'є — сидить і слухає чужі розмови.`,
|
||||
() => 'В автомайстерні беруть «за терміновість» удвічі. Терміново тепер усе.',
|
||||
() => `${nb().name} щонеділі кудись їздить першим автобусом. Питати незручно, а цікаво.`,
|
||||
() => 'Хтось ночами переставляє лавку в парку. Комунальники здалися.'
|
||||
];
|
||||
for (const c of citizens) {
|
||||
if (c.id === victim.id) { c.gossip = []; continue; }
|
||||
c.gossip = [pick(r, GOSSIP)()];
|
||||
if (chance(r, 0.4)) c.gossip.push(pick(r, GOSSIP)());
|
||||
}
|
||||
const wit = nb();
|
||||
wit.gossip.push(caseType === 'kidnap'
|
||||
? `Пізно того вечора хтось у ${killer.coat} тягнув щось важке в бік проїзду. ${g(wit, 'Подумав', 'Подумала')} — килим. А тепер думаю — не килим.`
|
||||
: `Пізно того вечора хтось у ${killer.coat} швидко йшов від місця, де потім знайшли тіло. Може, здалося.`);
|
||||
nb().gossip.push(`У квартирі ${killer.home} останнім часом світло горить до третьої ночі. Комусь не спиться.`);
|
||||
if (dna2 && dna2.why === 'brawl')
|
||||
nb().gossip.push(`Удень перед тим бачили, як ${dna2.owner.name} ${g(dna2.owner, 'зчепився', 'зчепилася')} з ${victim.first} ${victim.last} у барі — ${dna2.reason}. Розборонили швидко, обоє розійшлися своїми ногами.`);
|
||||
}
|
||||
|
||||
module.exports = { setupJunk };
|
||||
36
chapter3/casegen/motives/blackmail.js
Normal file
36
chapter3/casegen/motives/blackmail.js
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
const { genPassword, pick } = require('../rng');
|
||||
|
||||
function generate(ctx) {
|
||||
const { r, add, addPuzzle, motiveEvs, victim, killer, herring, district } = ctx;
|
||||
const motiveId = 'Шантаж';
|
||||
const pw = genPassword(r, victim);
|
||||
const pwPuzzle = addPuzzle({
|
||||
id: 'pz_md5', kind: 'md5', hash: require('../rng').md5(pw.answer), answer: pw.answer, style: pw.style,
|
||||
prompt: `Ноутбук жертви. Логін відкритий, пароль — ні. Хеш пароля (md5): ${require('../rng').md5(pw.answer)}.`,
|
||||
hint: (pw.personal
|
||||
? 'Схоже на «особистий» пароль — щось про власника, латиницею. Досьє в поміч: '
|
||||
: 'Пароль не пов\'язаний із жертвою — лише перебір за словником (rockyou.txt). ') + pw.hint
|
||||
});
|
||||
const secret = pick(r, [
|
||||
`у березні ${killer.name} ${ctx.g(killer, 'збив', 'збила')} пішохода на трасі й ${ctx.g(killer, 'поїхав', 'поїхала')}; бампер ремонтували «без записів»`,
|
||||
`диплом, за яким ${killer.name} ${ctx.g(killer, 'отримав', 'отримала')} роботу (${killer.job}), — куплений`,
|
||||
`${killer.name} роками ${ctx.g(killer, 'зливав', 'зливала')} касу на роботі повз облік`
|
||||
]);
|
||||
motiveEvs.push(add({
|
||||
id: 'mot_laptop', side: 'field', at: { type: 'unit', unit: victim.home, slot: 'письмовий стіл' }, needsIdentity: true, puzzle: pwPuzzle,
|
||||
title: 'Ноутбук жертви', tag: 'мотив',
|
||||
detail: `Чернетки листів без підпису: «Я знаю, що ${secret}. 10 000 щомісяця — і ніхто не дізнається». Остання чернетка різкіша: «Ціна подвоюється».`,
|
||||
gives: { pointsTo: killer.id, motive: true }
|
||||
}));
|
||||
motiveEvs.push(add({
|
||||
id: 'mot_cash', side: 'analyst', at: { type: 'sys', sys: 'bank' }, q: { sys: 'bank', match: killer.name }, requires: ['mot_laptop'],
|
||||
title: 'Готівкові зняття', tag: 'мотив',
|
||||
detail: `Рахунок ${killer.name}: десяте число кожного місяця — зняття 10 000 готівкою, банкомат біля ${district.buildings.find(b => b.id === 'shop').name}. Півроку поспіль. Останній місяць — 20 000.`,
|
||||
gives: { pointsTo: killer.id, motive: true }
|
||||
}));
|
||||
const recapWhy = `Жертва знала чужу таємницю і брала за мовчання гроші. Таємниця така: ${secret}. Подвоєна «ціна мовчання» стала вироком самому шантажисту.`;
|
||||
const herringLead = `Серед чернеток є і другий адресат: «Я знаю про ваші оборудки», — а хто в районі має «оборудки», всі скажуть одразу: ${herring.name}.`;
|
||||
return { motiveId, recapWhy, herringLead };
|
||||
}
|
||||
|
||||
module.exports = { generate };
|
||||
40
chapter3/casegen/motives/business.js
Normal file
40
chapter3/casegen/motives/business.js
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
const { caesar, ri } = require('../rng');
|
||||
|
||||
function generate(ctx) {
|
||||
const { r, add, addPuzzle, motiveEvs, victim, killer, herring, district } = ctx;
|
||||
const motiveId = 'Кримінальний бізнес';
|
||||
const shift2 = ri(r, 3, 9);
|
||||
const plain2 = `Частка ${killer.last} ${killer.first[0]}. — сорок відсотків. Не сплачено двічі. Точка — склад маркету.`;
|
||||
const czPuzzle2 = addPuzzle({
|
||||
id: 'pz_caesar', kind: 'caesar', prompt: 'Зошит обліку, писаний нечитабельними літерами.',
|
||||
payload: caesar(plain2, shift2), answer: plain2, shift: shift2, hint: 'Схоже на шифр зсуву (Цезар): переберіть усі зсуви абетки.'
|
||||
});
|
||||
motiveEvs.push(add({
|
||||
id: 'mot_ledger', side: 'field', at: { type: 'unit', unit: victim.home, slot: 'кухня' }, needsIdentity: true, puzzle: czPuzzle2,
|
||||
title: 'Зошит подвійної бухгалтерії', tag: 'мотив',
|
||||
detail: 'У бляшанці з-під кави — зошит: колонки сум без підписів і шифровані нотатки. Легальних доходів на такі колонки жертва не мала. Розшифруйте — і стане видно, з ким ділили.',
|
||||
gives: { pointsTo: killer.id, motive: true }
|
||||
}));
|
||||
motiveEvs.push(add({
|
||||
id: 'mot_stash', side: 'field', at: { type: 'spot', building: 'shop', spot: 'склад' }, requires: ['mot_ledger'],
|
||||
title: 'Тайник на складі', tag: 'мотив',
|
||||
detail: 'За стелажем із мінералкою — коробки без маркування, лише код маркером. Той самий код — у зошиті жертви. Контрабандний товар ішов через район тихо і давно.',
|
||||
gives: { motive: true }
|
||||
}));
|
||||
motiveEvs.push(add({
|
||||
id: 'mot_bank_biz', side: 'analyst', at: { type: 'sys', sys: 'bank' }, q: { sys: 'bank', match: [victim.name, killer.name] }, needsIdentity: true,
|
||||
title: 'Синхронні внески', tag: 'мотив',
|
||||
detail: `Два рахунки, однакові готівкові внески в ті самі дати, місяць за місяцем: рахунок жертви — і рахунок ${killer.name}. Партнерство, якого офіційно не існувало. Останні два місяці вносила тільки жертва.`,
|
||||
gives: { pointsTo: killer.id, motive: true }
|
||||
}));
|
||||
motiveEvs.push(add({
|
||||
id: 'mot_bar_corner', side: 'field', at: { type: 'person', cid: ctx.witnessFrom(ctx.citizens.filter(c => c.workplace === 'bar')).id }, needsIdentity: true,
|
||||
title: 'Свідчення з бару', tag: 'мотив',
|
||||
detail: `«Ці двоє завжди сідали в кутку, рахували щось у зошиті. Останнім часом — на підвищених тонах. Слово «частка» я чув частіше, ніж «будьмо».»`
|
||||
}));
|
||||
const recapWhy = `Через район тихо йшов контрабандний товар; частки ділили за зошитом. Коли жертва двічі не заплатила партнерську частку, партнер — ${killer.name} — ${ctx.g(killer, 'закрив', 'закрила')} баланс назавжди.`;
|
||||
const herringLead = `«Шукаєте, хто мутить? ${herring.name} на склад маркету заходить частіше, ніж туди завозять хліб. Просто кажу.»`;
|
||||
return { motiveId, recapWhy, herringLead };
|
||||
}
|
||||
|
||||
module.exports = { generate };
|
||||
36
chapter3/casegen/motives/debt.js
Normal file
36
chapter3/casegen/motives/debt.js
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
// casegen/motives/debt.js — мотив "Борг"
|
||||
const { caesar } = require('../rng');
|
||||
|
||||
function generate(ctx) {
|
||||
const { r, add, addPuzzle, motiveEvs, victim, killer, herring, citizens, witnessFrom } = ctx;
|
||||
const motiveId = 'Борг';
|
||||
const shift = ctx.ri(r, 3, 9);
|
||||
const plain = `${killer.last} ${killer.first[0]}. — 84 000, прострочено. ${herring.last} ${herring.first[0]}. — 9 000, повернуто.`;
|
||||
const czPuzzle = addPuzzle({
|
||||
id: 'pz_caesar', kind: 'caesar', prompt: 'Зошит, у якому кожен рядок — беззмістовний набір літер.',
|
||||
payload: caesar(plain, shift), answer: plain, shift, hint: 'Схоже на шифр зсуву (Цезар): переберіть усі зсуви абетки.'
|
||||
});
|
||||
motiveEvs.push(add({
|
||||
id: 'mot_notebook', side: 'field', at: { type: 'unit', unit: victim.home, slot: 'під ліжком' }, needsIdentity: true, puzzle: czPuzzle,
|
||||
title: 'Зошит із шифром', tag: 'мотив',
|
||||
detail: `Під ліжком — бляшана коробка: готівка і зошит, списаний шифром. ${victim.name} давав гроші під відсоток. Розшифруйте — і побачите боржників.`,
|
||||
gives: { pointsTo: killer.id, motive: true }
|
||||
}));
|
||||
motiveEvs.push(add({
|
||||
id: 'mot_bank', side: 'analyst', at: { type: 'sys', sys: 'bank' }, q: { sys: 'bank', match: victim.name }, needsIdentity: true,
|
||||
title: 'Виписка з рахунку жертви', tag: 'мотив',
|
||||
detail: `Щомісячні перекази по 7 000 (відправник: ${killer.name}) — і тиша останні три місяці. Останнє повідомлення до переказу: «це все, більше нема».`,
|
||||
gives: { pointsTo: killer.id, motive: true }
|
||||
}));
|
||||
motiveEvs.push(add({
|
||||
id: 'mot_neighbor', side: 'field', at: { type: 'person', cid: witnessFrom(citizens.filter(c => ctx.unitBuilding(c.home) === ctx.unitBuilding(victim.home) && c.home !== victim.home)).id }, needsIdentity: true,
|
||||
title: 'Свідчення сусіда', tag: 'мотив',
|
||||
detail: `«За стіною кричали про гроші. ${killer.sex === 'ч' ? 'Чоловічий' : 'Жіночий'} голос повторював: „Я не маю чим, зрозумій, не маю чим"».`,
|
||||
gives: { motive: true }
|
||||
}));
|
||||
const recapWhy = `Кредитором був не банк: гроші під відсоток давала жертва. Боржник на 84 тисячі — ${killer.name}, віддавати не було чим. Борг закрили інакше.`;
|
||||
const herringLead = `У зошиті жертви є й ${herring.name} — сусіди кажуть, «теж у боргах, теж сварилися». Щоправда, поруч із тією сумою в зошиті стоїть слово, яке варто прочитати уважно.`;
|
||||
return { motiveId, recapWhy, herringLead };
|
||||
}
|
||||
|
||||
module.exports = { generate };
|
||||
33
chapter3/casegen/motives/inherit.js
Normal file
33
chapter3/casegen/motives/inherit.js
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
function generate(ctx) {
|
||||
const { r, add, addPuzzle, motiveEvs, victim, killer, herring } = ctx;
|
||||
const motiveId = 'Спадок і страховка';
|
||||
const kin = ctx.g(killer, 'племінник', 'племінниця');
|
||||
motiveEvs.push(add({
|
||||
id: 'mot_will', side: 'field', at: { type: 'unit', unit: victim.home, slot: 'шафа' }, needsIdentity: true,
|
||||
title: 'Чернетка нового заповіту', tag: 'мотив',
|
||||
detail: 'У шафі, під білизною — чернетка заповіту, датована минулим тижнем: усе майно — благодійному фонду. Підпис не завірений нотаріусом. Хтось дуже не хотів, щоб це було завірено.',
|
||||
gives: { motive: true }
|
||||
}));
|
||||
motiveEvs.push(add({
|
||||
id: 'mot_notary', side: 'analyst', at: { type: 'sys', sys: 'db' }, q: { sys: 'db', match: victim.name }, needsIdentity: true,
|
||||
title: 'Реєстр нотаріуса', tag: 'мотив',
|
||||
detail: `Чинний заповіт від 2022 року: єдиний спадкоємець — ${kin} жертви, ${killer.name}. Запис про нову зустріч у нотаріуса стояв на наступний тиждень. Зустріч не відбулася.`,
|
||||
gives: { pointsTo: killer.id, motive: true }
|
||||
}));
|
||||
motiveEvs.push(add({
|
||||
id: 'mot_insur', side: 'analyst', at: { type: 'sys', sys: 'bank' }, q: { sys: 'bank', match: victim.name }, needsIdentity: true,
|
||||
title: 'Страховий поліс', tag: 'мотив',
|
||||
detail: `Страхування життя на 900 000, бенефіціар — ${killer.name}. Найцікавіше: останній рік внески платив не власник поліса, а сам бенефіціар. Інвестиція.`,
|
||||
gives: { pointsTo: killer.id, motive: true }
|
||||
}));
|
||||
motiveEvs.push(add({
|
||||
id: 'mot_kin_row', side: 'field', at: { type: 'person', cid: ctx.witnessFrom(ctx.citizens.filter(c => ctx.unitBuilding(c.home) === ctx.unitBuilding(victim.home) && c.home !== victim.home)).id }, needsIdentity: true,
|
||||
title: 'Свідчення сусіда', tag: 'мотив',
|
||||
detail: `«Тижнів зо два тому на сходах кричали. Другий голос: „Викреслиш мене — пошкодуєш!" Гримнули двері. Я не з тих, хто підслуховує, але сходи в нас лункі.»`
|
||||
}));
|
||||
const recapWhy = `Чинний заповіт 2022 року називав єдиного спадкоємця: ${killer.name}. Новий заповіт, який усе віддавав фонду, лишалося тільки завірити. Спадщина і страховка на 900 000 переважили.`;
|
||||
const herringLead = `«А ${herring.name} усе розпитує, скільки коштують квартири в будинку жертви. І нащо воно ${ctx.g(herring, 'йому', 'їй')}?»`;
|
||||
return { motiveId, recapWhy, herringLead };
|
||||
}
|
||||
|
||||
module.exports = { generate };
|
||||
40
chapter3/casegen/motives/jealousy.js
Normal file
40
chapter3/casegen/motives/jealousy.js
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
// casegen/motives/jealousy.js — мотив "Ревнощі"
|
||||
const { md5, caesar } = require('../rng');
|
||||
|
||||
function generate(ctx) {
|
||||
const { r, add, addPuzzle, motiveEvs, victim, killer, herring, citizens, tSpouse } = ctx;
|
||||
const motiveId = 'Ревнощі';
|
||||
const pin = String(victim.birthYear);
|
||||
const pinPuzzle = addPuzzle({
|
||||
id: 'pz_pin', kind: 'pin', prompt: 'Телефон жертви заблоковано 4-значним PIN-кодом.',
|
||||
hash: md5(pin), answer: pin, hint: 'Люди ставлять те, що не забудуть: спробуйте рік народження з досьє.'
|
||||
});
|
||||
add({
|
||||
id: 'sf_phone_locked', side: 'field', at: { type: 'spot', building: ctx.atScene.building, spot: ctx.atScene.spot },
|
||||
title: 'Телефон жертви (заблокований)', tag: 'мотив',
|
||||
detail: 'Екран розбитий, але телефон живий. Заблокований PIN-кодом. Аналітик може спробувати підібрати.'
|
||||
});
|
||||
motiveEvs.push(add({
|
||||
id: 'mot_messages', side: 'analyst', at: { type: 'lab' }, requires: ['sf_phone_locked'], needsIdentity: true, puzzle: pinPuzzle,
|
||||
title: 'Листування в телефоні', tag: 'мотив',
|
||||
detail: `Місяці переписки з номером ${tSpouse.phone}: зустрічі, готелі, «коли ${tSpouse.sex === 'ч' ? 'він' : 'вона'} у відрядженні». Номер зареєстровано: ${tSpouse.name}.`,
|
||||
gives: { pointsTo: tSpouse.id }
|
||||
}));
|
||||
motiveEvs.push(add({
|
||||
id: 'mot_confess', side: 'field', at: { type: 'person', cid: tSpouse.id }, requires: ['mot_messages'],
|
||||
title: `Розмова: ${tSpouse.name}`, tag: 'мотив',
|
||||
detail: `Довге мовчання. «Так, ми зустрічалися. Тиждень тому ${killer.first} ${ctx.g(killer, 'знайшов', 'знайшла')} листування. ${ctx.g(killer, 'Сказав', 'Сказала')}: „Я це закінчу". Я ${ctx.g(tSpouse, 'думав', 'думала')} — про шлюб…»`,
|
||||
gives: { pointsTo: killer.id, motive: true }
|
||||
}));
|
||||
motiveEvs.push(add({
|
||||
id: 'mot_witness', side: 'field', at: { type: 'person', cid: ctx.witnessFrom(citizens.filter(c => c.workplace === 'bar')).id }, needsIdentity: true,
|
||||
title: 'Свідчення бармена', tag: 'мотив',
|
||||
detail: `«${victim.first}? Бували тут. У четвер ${ctx.g(victim, 'сидів', 'сиділа')} за стійкою, коли ${ctx.g(killer, 'влетів', 'влетіла')} ${killer.name} — ${ctx.g(killer, 'кричав', 'кричала')} щось про «не лізь у мою сім'ю». Ледь розборонили.»`,
|
||||
gives: { pointsTo: killer.id, motive: true }
|
||||
}));
|
||||
const recapWhy = `${killer.name} ${ctx.g(killer, 'дізнався', 'дізналася')} про роман. Другою стороною роману були ${tSpouse.first} і жертва. Пробачення не сталося.`;
|
||||
const herringLead = `«Та в ${ctx.g(victim, 'нього', 'неї')} був конфлікт! ${herring.name} з ${herring.home} місяць тому ${ctx.g(herring, 'влаштував', 'влаштувала')} скандал через залите авто — усі чули погрози.»`;
|
||||
return { motiveId, recapWhy, herringLead };
|
||||
}
|
||||
|
||||
module.exports = { generate };
|
||||
47
chapter3/casegen/motives/kidnap_debt.js
Normal file
47
chapter3/casegen/motives/kidnap_debt.js
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
const { caesar, ri } = require('../rng');
|
||||
|
||||
function generate(ctx) {
|
||||
const { r, add, addPuzzle, motiveEvs, victim, killer, herring, startingFacts, lair } = ctx;
|
||||
const motiveId = 'Борг';
|
||||
const rep = ctx.witnessFrom(ctx.citizens.filter(c => c.home !== victim.home));
|
||||
const shiftK = ri(r, 3, 9);
|
||||
const plainK = `Винен ${killer.last} ${killer.first[0]}. — дев'яносто тисяч. Прострочено двічі. Каже: відпрацюєш.`;
|
||||
const czK = addPuzzle({
|
||||
id: 'pz_caesar', kind: 'caesar', prompt: 'Нотатки зниклої особи — суцільний беззмістовний набір літер.',
|
||||
payload: caesar(plainK, shiftK), answer: plainK, shift: shiftK, hint: 'Схоже на шифр зсуву (Цезар): переберіть усі зсуви абетки.'
|
||||
});
|
||||
startingFacts.push(add({
|
||||
id: 'sf_report', side: 'field', at: { type: 'start' },
|
||||
title: 'Заява про зникнення', tag: 'жертва',
|
||||
detail: `${rep.name} ${ctx.g(rep, 'заявив', 'заявила')}: ${victim.name} ${ctx.g(victim, 'зник', 'зникла')} по дорозі додому. «За день до того ${ctx.g(victim, 'він просив', 'вона просила')} у мене грошей — багато і терміново. Сказали тільки: „інакше буде зле".»`,
|
||||
gives: { identity: true }
|
||||
}));
|
||||
motiveEvs.push(add({
|
||||
id: 'mot_iou', side: 'field', at: { type: 'unit', unit: victim.home, slot: 'письмовий стіл' }, puzzle: czK,
|
||||
title: 'Шифровані нотатки боржника', tag: 'мотив',
|
||||
detail: 'У шухляді — розписка на 90 000 без імені кредитора і нотатник, писаний шифром. Людина боялась навіть власних записів. Розшифруйте — і кредитор матиме ім\'я.',
|
||||
gives: { pointsTo: killer.id, motive: true }
|
||||
}));
|
||||
motiveEvs.push(add({
|
||||
id: 'mot_bank_k', side: 'analyst', at: { type: 'sys', sys: 'bank' }, q: { sys: 'bank', match: victim.name },
|
||||
title: 'Виписка зниклої особи', tag: 'мотив',
|
||||
detail: `Щомісячні перекази з рахунку жертви (отримувач: ${killer.name}) припинились три місяці тому. Останній коментар до переказу: «решту віддам, чесно».`,
|
||||
gives: { pointsTo: killer.id, motive: true }
|
||||
}));
|
||||
motiveEvs.push(add({
|
||||
id: 'mot_threat', side: 'field', at: { type: 'person', cid: ctx.witnessFrom(ctx.citizens.filter(c => ctx.unitBuilding(c.home) === ctx.unitBuilding(victim.home) && c.home !== victim.home)).id },
|
||||
title: 'Свідчення сусіда', tag: 'мотив',
|
||||
detail: `«Тиждень тому під дверима стояв хтось і цідив крізь зуби: „Тиждень. Або поїдеш зі мною і відпрацюєш". Я в чужі справи не лізу, але голос був недобрий.»`
|
||||
}));
|
||||
add({
|
||||
id: 'mot_lairclue', side: 'field', at: { type: 'unit', unit: killer.home, slot: 'під ліжком' },
|
||||
title: 'Ключ із биркою', tag: 'місце',
|
||||
detail: `Під ліжком — ящик з інструментами, а в ньому ключ від навісного замка зі свіжою паперовою биркою: «${lair.label}». Замок із комплекту — той самий, що зник із полиці маркету.`,
|
||||
gives: { lair: true }
|
||||
});
|
||||
const recapWhy = `Борг у 90 000, прострочений двічі. «Відпрацюєш» виявилось буквальним: ${killer.name} ${ctx.g(killer, 'вирішив', 'вирішила')} утримувати боржника, поки рідня не збере гроші.`;
|
||||
const herringLead = `«У зошитах жертви і ${herring.name} десь фігурує — теж, кажуть, позичали одне одному. Хто кому винен — спитайте самі.»`;
|
||||
return { motiveId, recapWhy, herringLead };
|
||||
}
|
||||
|
||||
module.exports = { generate };
|
||||
40
chapter3/casegen/motives/kidnap_obsession.js
Normal file
40
chapter3/casegen/motives/kidnap_obsession.js
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
function generate(ctx) {
|
||||
const { r, add, addPuzzle, motiveEvs, victim, killer, herring, startingFacts, lair } = ctx;
|
||||
const motiveId = 'Одержимість';
|
||||
const rep = ctx.witnessFrom(victim.workplace ? ctx.citizens.filter(c => c.workplace === victim.workplace) : ctx.citizens);
|
||||
startingFacts.push(add({
|
||||
id: 'sf_report', side: 'field', at: { type: 'start' },
|
||||
title: 'Заява про зникнення', tag: 'жертва',
|
||||
detail: `${rep.name} ${ctx.g(rep, 'заявив', 'заявила')}: ${victim.name} другий день не виходить на зв'язок, телефон вимкнений, двері зачинені. «Це не в ${ctx.g(victim, 'його', 'її')} стилі. І ще: останнім часом ${ctx.g(victim, 'він скаржився', 'вона скаржилася')}, що за ${ctx.g(victim, 'ним', 'нею')} хтось ходить.»`,
|
||||
gives: { identity: true }
|
||||
}));
|
||||
motiveEvs.push(add({
|
||||
id: 'mot_letters', side: 'field', at: { type: 'unit', unit: victim.home, slot: 'шафа' },
|
||||
title: 'Пачка листів без підпису', tag: 'мотив',
|
||||
detail: 'Десятки листів, однаковий почерк: «я бачу тебе щодня», «ти всміхаєшся не мені», «я вмію чекати». Останній інакший: «скоро ти зрозумієш, що нам ніхто не потрібен».',
|
||||
gives: { motive: true }
|
||||
}));
|
||||
motiveEvs.push(add({
|
||||
id: 'mot_complaint', side: 'analyst', at: { type: 'sys', sys: 'crim' }, q: { sys: 'crim', kind: 'archive', match: [victim.last, victim.name] },
|
||||
title: 'Торішня заява зниклої особи', tag: 'мотив',
|
||||
detail: `Заява про переслідування, руху не дали: «невідома особа стежить, лишає квіти під дверима». Прикмети: ${killer.coat}, зріст приблизно ${ctx.estRange(r, killer.height)} см.`,
|
||||
gives: { filter: { attr: 'coat', val: killer.coat }, motive: true }
|
||||
}));
|
||||
motiveEvs.push(add({
|
||||
id: 'mot_flowers', side: 'field', at: { type: 'person', cid: ctx.witnessFrom(ctx.citizens.filter(c => c.workplace === 'shop')).id }, requires: ['mot_complaint'],
|
||||
title: 'Свідчення з мінімаркету', tag: 'мотив',
|
||||
detail: `«Три гвоздики щоп'ятниці, завжди готівкою, завжди мовчки. Хто? Та ${killer.name} же. От, думаю собі, людина постійна.»`,
|
||||
gives: { pointsTo: killer.id }
|
||||
}));
|
||||
add({
|
||||
id: 'mot_lairclue', side: 'field', at: { type: 'unit', unit: killer.home, slot: 'шафа' },
|
||||
title: 'План «нашого дому»', tag: 'місце',
|
||||
detail: `На внутрішньому боці дверцят — фото ${victim.name}, зняті здалеку. І аркуш, накреслений від руки: матрац, лампа, засув. Підпис під планом: «${lair.label}». Усе обведено сердечком.`,
|
||||
gives: { lair: true, motive: true }
|
||||
});
|
||||
const recapWhy = `Два роки листів, квітів і фото здалеку. Коли «я вмію чекати» скінчилось, ${killer.name} ${ctx.g(killer, 'вирішив', 'вирішила')}: якщо не разом — то під замком.`;
|
||||
const herringLead = `«Хочете про дивних? ${herring.name} вічно сидить біля вікна з біноклем. Каже — «за птахами». Ага, за птахами.»`;
|
||||
return { motiveId, recapWhy, herringLead };
|
||||
}
|
||||
|
||||
module.exports = { generate };
|
||||
39
chapter3/casegen/motives/kidnap_ransom.js
Normal file
39
chapter3/casegen/motives/kidnap_ransom.js
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
function generate(ctx) {
|
||||
const { r, add, addPuzzle, motiveEvs, victim, killer, herring, startingFacts, lair, district } = ctx;
|
||||
const motiveId = 'Викуп';
|
||||
const rep = ctx.witnessFrom(ctx.citizens.filter(c => c.home !== victim.home));
|
||||
startingFacts.push(add({
|
||||
id: 'sf_report', side: 'field', at: { type: 'start' },
|
||||
title: 'Заява про викрадення', tag: 'жертва',
|
||||
detail: `${rep.name} (рідня зниклої особи) ${ctx.g(rep, 'приніс', 'принесла')} записку зі своєї поштової скриньки: «200 000 — і побачите ${ctx.g(victim, 'його', 'її')} ${ctx.g(victim, 'живим', 'живою')}. Поліція = кінець». Зникла особа: ${victim.name}, ${victim.age} р.`,
|
||||
gives: { identity: true, motive: true }
|
||||
}));
|
||||
motiveEvs.push(add({
|
||||
id: 'mot_note_exp', side: 'analyst', at: { type: 'sys', sys: 'crim' }, q: { sys: 'crim', kind: 'expert' }, requires: ['sf_report'],
|
||||
title: 'Експертиза записки', tag: 'мотив',
|
||||
detail: 'Друковані літери через трафарет, папір — зі звичайного офісного стосу. Жодних відбитків: писали в рукавичках. Готувалися заздалегідь і не поспішали.',
|
||||
gives: { motive: true }
|
||||
}));
|
||||
motiveEvs.push(add({
|
||||
id: 'mot_supplies', side: 'field', at: { type: 'person', cid: ctx.witnessFrom(ctx.citizens.filter(c => c.workplace === 'shop')).id },
|
||||
title: 'Свідчення з мінімаркету', tag: 'мотив',
|
||||
detail: `«Дивна закупівля була: стяжки, скотч, брезент, навісний замок — усе разом. Обличчя не пригадаю, але платили карткою — термінал пам'ятає все.»`
|
||||
}));
|
||||
motiveEvs.push(add({
|
||||
id: 'mot_card', side: 'analyst', at: { type: 'sys', sys: 'bank' }, q: { sys: 'bank', match: ['термінал', district.buildings.find(b => b.id === 'shop').name] }, requires: ['mot_supplies'],
|
||||
title: 'Термінал мінімаркету', tag: 'мотив',
|
||||
detail: `Транзакція за ту закупівлю: ${killer.name}. Стяжки, скотч і новий замок — за два дні до викрадення.`,
|
||||
gives: { pointsTo: killer.id, motive: true }
|
||||
}));
|
||||
add({
|
||||
id: 'mot_lairclue', side: 'field', at: { type: 'unit', unit: killer.home, slot: 'письмовий стіл' },
|
||||
title: 'Чек і нотатка', tag: 'місце',
|
||||
detail: `У кошику для паперів — зіжмаканий чек за замок і аркуш із розрахунком: «200 000 − борги = вистачить». На звороті олівцем — «${lair.label}» і час обходів охорони.`,
|
||||
gives: { lair: true, motive: true }
|
||||
});
|
||||
const recapWhy = `План був простий і холодний: викрасти, сховати, взяти з рідні 200 000. Автор плану — ${killer.name}.`;
|
||||
const herringLead = `«${herring.name} нещодавно в барі розпитува${ctx.g(herring, 'в', 'ла')}, хто в районі має гроші і хто живе сам. Тоді здалося балачкою.»`;
|
||||
return { motiveId, recapWhy, herringLead };
|
||||
}
|
||||
|
||||
module.exports = { generate };
|
||||
33
chapter3/casegen/motives/mistaken.js
Normal file
33
chapter3/casegen/motives/mistaken.js
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
const { ri, b64 } = require('../rng');
|
||||
|
||||
function generate(ctx) {
|
||||
const { r, add, addPuzzle, motiveEvs, victim, killer, herring, tTarget, district } = ctx;
|
||||
const motiveId = 'Помилкова ціль';
|
||||
const accName = 'night_owl_' + ri(r, 100, 999);
|
||||
const b64Puzzle = addPuzzle({
|
||||
id: 'pz_b64', kind: 'b64', prompt: 'Експорт бази месенджера: поле власника акаунта — беззмістовний рядок символів.',
|
||||
payload: b64(`owner=${killer.name}; phone=${killer.phone}`), answer: `owner=${killer.name}; phone=${killer.phone}`, hint: 'Це не шифр, а кодування Base64 — розкодуйте.'
|
||||
});
|
||||
motiveEvs.push(add({
|
||||
id: 'mot_lookalike', side: 'field', at: { type: 'person', cid: ctx.witnessFrom(ctx.citizens.filter(c => ctx.unitBuilding(c.home) === ctx.unitBuilding(victim.home) && c.home !== victim.home)).id }, needsIdentity: true,
|
||||
title: 'Свідчення сусідів', tag: 'мотив',
|
||||
detail: `«${victim.first}? Царство небесне… Знаєте, ${victim.first} і ${tTarget.first} — їх вічно плутали: той самий одяг, той самий зріст, той самий під'їзд. Навіть листоноша плутав.»`
|
||||
}));
|
||||
motiveEvs.push(add({
|
||||
id: 'mot_target', side: 'field', at: { type: 'person', cid: tTarget.id }, requires: ['mot_lookalike'],
|
||||
title: `Розмова: ${tTarget.name}`, tag: 'мотив',
|
||||
detail: `Блідне. «Це мені писали. Місяць погроз з анонімного акаунта „${accName}": „поверни, або пошкодуєш". Я нікому не ${ctx.g(tTarget, 'винен', 'винна')}! Ось, дивіться самі» — і простягає телефон.`,
|
||||
gives: { motive: true }
|
||||
}));
|
||||
motiveEvs.push(add({
|
||||
id: 'mot_account', side: 'analyst', at: { type: 'sys', sys: 'phone' }, requires: ['mot_target'], puzzle: b64Puzzle,
|
||||
title: `Деанон акаунта «${accName}»`, tag: 'мотив',
|
||||
detail: `Запит до платформи повернув експорт. Розкодоване поле власника: ${killer.name}, номер ${killer.phone}. Погрози на адресу ${tTarget.name} писа${ctx.g(killer, 'в', 'ла')} саме ${ctx.g(killer, 'він', 'вона')} — через давній борг за розбите в ДТП авто.`,
|
||||
gives: { pointsTo: killer.id, motive: true }
|
||||
}));
|
||||
const recapWhy = `Справжня ціль — ${tTarget.name}: місяці погроз через давній борг, і автор погроз — ${killer.name}. У темряві ${ctx.g(killer, 'вбивця сплутав', 'вбивця сплутала')} жертву з ціллю: той самий одяг, той самий зріст, той самий під'їзд.`;
|
||||
const herringLead = `А ще сусіди шепочуться: «${herring.name} з ${victim.first} давно на ножах — через паркування, через сміття, через усе».`;
|
||||
return { motiveId, recapWhy, herringLead };
|
||||
}
|
||||
|
||||
module.exports = { generate };
|
||||
32
chapter3/casegen/motives/oldcrime.js
Normal file
32
chapter3/casegen/motives/oldcrime.js
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
function generate(ctx) {
|
||||
const { r, add, addPuzzle, motiveEvs, victim, killer, herring } = ctx;
|
||||
const motiveId = 'Старі гріхи';
|
||||
motiveEvs.push(add({
|
||||
id: 'mot_confession', side: 'field', at: { type: 'unit', unit: victim.home, slot: 'письмовий стіл' }, needsIdentity: true,
|
||||
title: 'Незакінчений лист', tag: 'мотив',
|
||||
detail: 'Лист без адресата, перекреслений і початий заново: «Я більше не можу мовчати про те, що ми зробили у 2019-му. Сторож нас не виказав навіть у лікарні. Якщо не зізнаєшся ти — зізнаюсь я.»',
|
||||
gives: { motive: true }
|
||||
}));
|
||||
motiveEvs.push(add({
|
||||
id: 'mot_arch2019', side: 'analyst', at: { type: 'sys', sys: 'crim' }, q: { sys: 'crim', kind: 'archive', match: ['2019', 'хвиля'] }, requires: ['mot_confession'],
|
||||
title: 'Архів: справа 2019 року', tag: 'мотив',
|
||||
detail: `Крадіжка каси артілі «Хвиля»: сторож дістав травму голови, за рік помер. Підозрювали двох вантажників — недоведено, справу закрито. Прізвища в матеріалах: ${victim.last} і ${killer.last}. Адреса другого збігається з нинішньою: ${killer.home}.`,
|
||||
gives: { pointsTo: killer.id, motive: true }
|
||||
}));
|
||||
motiveEvs.push(add({
|
||||
id: 'mot_bank_old', side: 'analyst', at: { type: 'sys', sys: 'bank' }, q: { sys: 'bank', match: killer.name }, requires: ['mot_arch2019'],
|
||||
title: 'Виписки за 2019 рік', tag: 'мотив',
|
||||
detail: `Архів банку: восени 2019-го на рахунок ${killer.name} тричі заходила готівка — разом близько чверті вкраденої каси. Задекларованих доходів під ці суми немає.`,
|
||||
gives: { motive: true }
|
||||
}));
|
||||
motiveEvs.push(add({
|
||||
id: 'mot_oldtimer', side: 'field', at: { type: 'person', cid: ctx.witnessFrom(ctx.citizens.filter(c => c.job === 'пенсіонер')).id }, needsIdentity: true,
|
||||
title: 'Пам\'ять старожила', tag: 'мотив',
|
||||
detail: `«Ці двоє? Нерозлийвода були, разом вантажниками в артілі. А після тієї історії зі сторожем — ані слова одне одному, роками. Таке буває тільки після грошей або гріха. Або обох.»`
|
||||
}));
|
||||
const recapWhy = `У 2019-му двоє вантажників винесли касу артілі; сторож, що їх упізнав, помер за рік. Жертва вирішила зізнатися — і подзвонила спільнику. Спільник — ${killer.name} — ${ctx.g(killer, 'вирішив', 'вирішила')} інакше.`;
|
||||
const herringLead = `«Питаєте про старе? ${herring.name} тоді теж біля артілі ${ctx.g(herring, 'крутився', 'крутилася')}, усі знали. Темна історія.»`;
|
||||
return { motiveId, recapWhy, herringLead };
|
||||
}
|
||||
|
||||
module.exports = { generate };
|
||||
38
chapter3/casegen/motives/ransom.js
Normal file
38
chapter3/casegen/motives/ransom.js
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
function generate(ctx) {
|
||||
const { r, add, addPuzzle, motiveEvs, victim, killer, herring } = ctx;
|
||||
const motiveId = 'Викуп';
|
||||
const relative = ctx.witnessFrom(ctx.citizens.filter(c => c.home !== victim.home));
|
||||
motiveEvs.push(add({
|
||||
id: 'mot_lair', side: 'field', at: { type: 'spot', building: 'pier', spot: 'елінг' },
|
||||
title: 'Підготовлене лігво', tag: 'мотив',
|
||||
detail: 'В елінгу за брезентом — матрац, ланцюг із замком, каністра води, відро. Усе нове, з цінниками. Хтось готувався тримати тут людину. Живою.',
|
||||
gives: { motive: true }
|
||||
}));
|
||||
motiveEvs.push(add({
|
||||
id: 'mot_supplies', side: 'field', at: { type: 'person', cid: ctx.witnessFrom(ctx.citizens.filter(c => c.workplace === 'shop')).id }, needsIdentity: true,
|
||||
title: 'Свідчення з мінімаркету', tag: 'мотив',
|
||||
detail: `«Дивна закупівля була днями: стяжки, скотч, брезент, ланцюг — усе разом. Хто? Не пригадаю обличчя, але платили карткою, це точно в терміналі лишилось.»`
|
||||
}));
|
||||
motiveEvs.push(add({
|
||||
id: 'mot_card', side: 'analyst', at: { type: 'sys', sys: 'bank' }, q: { sys: 'bank', match: ['термінал', ctx.district.buildings.find(b => b.id === 'shop').name] }, requires: ['mot_supplies'],
|
||||
title: 'Термінал мінімаркету', tag: 'мотив',
|
||||
detail: `Транзакція по картці за ту закупівлю: ${killer.name}. Стяжки, скотч, брезент і ланцюг — за три дні до смерті жертви.`,
|
||||
gives: { pointsTo: killer.id, motive: true }
|
||||
}));
|
||||
motiveEvs.push(add({
|
||||
id: 'mot_ransom_note', side: 'field', at: { type: 'unit', unit: killer.home, slot: 'письмовий стіл' },
|
||||
title: 'Чернетка вимоги викупу', tag: 'мотив',
|
||||
detail: `Аркуш із друкованими літерами, тричі переписаний: «200 000 за життя. Поліція = труна.» Адресат — рідня жертви. Лист так і не знадобився: викрадення пішло не за планом.`,
|
||||
gives: { motive: true }
|
||||
}));
|
||||
add({
|
||||
id: 'mot_relative', side: 'field', at: { type: 'person', cid: relative.id }, needsIdentity: true,
|
||||
title: `Розмова: ${relative.name}`, tag: 'мотив',
|
||||
detail: `${ctx.g(relative, 'Далекий родич', 'Далека родичка')} жертви. «За день до того був дзвінок із прихованого номера. Мовчали. Дихали. Я ще пожартувала: „Може, викрасти хочуть?" Господи.»`
|
||||
});
|
||||
const recapWhy = `План був інший: викрасти живою людину, тримати в елінгу і взяти з рідні 200 000. Але жертва пручалася — і план скінчився в перші ж хвилини. Автор плану — ${killer.name}.`;
|
||||
const herringLead = `«${herring.name} нещодавно в барі розпитува${ctx.g(herring, 'в', 'ла')}, хто в районі має гроші і хто живе сам. Тоді здалося балачкою.»`;
|
||||
return { motiveId, recapWhy, herringLead };
|
||||
}
|
||||
|
||||
module.exports = { generate };
|
||||
45
chapter3/casegen/motives/serial.js
Normal file
45
chapter3/casegen/motives/serial.js
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
const { pick } = require('../rng');
|
||||
const N = require('../names');
|
||||
const { SIGNATURES } = require('../constants');
|
||||
|
||||
function generate(ctx) {
|
||||
const { r, add, addPuzzle, motiveEvs, victim, killer, herring, atScene, citizens } = ctx;
|
||||
const motiveId = 'Серійний патерн';
|
||||
if (!killer.tattoo) killer.tattoo = pick(r, N.TATTOOS);
|
||||
for (const c of citizens) if (c !== killer && c.tattoo === killer.tattoo) c.tattoo = null;
|
||||
const sign = pick(r, SIGNATURES);
|
||||
add({
|
||||
id: 'sf_signature', side: 'field', at: atScene,
|
||||
title: 'Підпис на тілі', tag: 'патерн',
|
||||
detail: `Деталь, яка не дає спокою: ${sign.text}. Це не випадковість і не пограбування. Це підпис. Такі речі повторюються.`
|
||||
});
|
||||
motiveEvs.push(add({
|
||||
id: 'mot_arch_serial', side: 'analyst', at: { type: 'sys', sys: 'crim' }, q: { sys: 'crim', kind: 'archive', match: [sign.key, 'підпис'] }, requires: ['sf_signature'],
|
||||
title: 'Архів: справа 2021 року', tag: 'мотив',
|
||||
detail: `Пошук за підписом дав збіг: 2021 рік, сусідній район, нерозкрите вбивство — і той самий підпис: ${sign.text}. Тоді вирішили, що це дрібниця, і не внесли в орієнтування. Це не перше його полювання.`,
|
||||
gives: { motive: true }
|
||||
}));
|
||||
motiveEvs.push(add({
|
||||
id: 'mot_sketch', side: 'analyst', at: { type: 'sys', sys: 'crim' }, q: { sys: 'crim', kind: 'archive', match: [sign.key, 'підпис', '2021'] }, requires: ['mot_arch_serial'],
|
||||
title: 'Свідок 2021 року: прикмети', tag: 'мотив',
|
||||
detail: `У справі 2021-го є свідчення: біля місця бачили постать у ${killer.coat}, а на передпліччі — татуювання: ${killer.tattoo}. Свідка тоді не сприйняли серйозно.`,
|
||||
gives: { filter: { attr: 'coat', val: killer.coat } }
|
||||
}));
|
||||
motiveEvs.push(add({
|
||||
id: 'mot_tattoo_db', side: 'analyst', at: { type: 'sys', sys: 'db' }, q: { sys: 'db', match: killer.tattoo }, requires: ['mot_sketch'],
|
||||
title: 'Пошук за татуюванням', tag: 'вбивця',
|
||||
detail: `Запит по базі мешканців: «${killer.tattoo}» — єдиний збіг у районі: ${killer.name}, прож. ${killer.home}.`,
|
||||
gives: { pointsTo: killer.id }
|
||||
}));
|
||||
motiveEvs.push(add({
|
||||
id: 'mot_trophies', side: 'field', at: { type: 'unit', unit: killer.home, slot: 'шафа' },
|
||||
title: 'Коробка з трофеями', tag: 'мотив',
|
||||
detail: 'Взуттєва коробка на антресолі: жіночий годинник із чужим гравіюванням, стара запальничка, окуляри — і чохол від телефона жертви. Кожна річ — чиясь. Кожна річ — трофей.',
|
||||
gives: { motive: true }
|
||||
}));
|
||||
const recapWhy = `Мотиву в людському сенсі не було. Був патерн: підпис (${sign.text}) уже з'являвся у 2021-му, і тоді його не помітили. ${killer.name} ${ctx.g(killer, 'обирав', 'обирала')} тих, хто живе сам і повертається затемна.`;
|
||||
const herringLead = `«Моторошне питаєте? ${herring.name} ночами гуляє районом і фотографує чужі вікна. Сусіди двічі таке бачили.»`;
|
||||
return { motiveId, recapWhy, herringLead };
|
||||
}
|
||||
|
||||
module.exports = { generate };
|
||||
35
chapter3/casegen/motives/stalker.js
Normal file
35
chapter3/casegen/motives/stalker.js
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
const { estRange } = require('../rng');
|
||||
|
||||
function generate(ctx) {
|
||||
const { r, add, addPuzzle, motiveEvs, victim, killer, herring } = ctx;
|
||||
const motiveId = 'Одержимість';
|
||||
motiveEvs.push(add({
|
||||
id: 'mot_letters', side: 'field', at: { type: 'unit', unit: victim.home, slot: 'шафа' }, needsIdentity: true,
|
||||
title: 'Пачка листів без підпису', tag: 'мотив',
|
||||
detail: `Перев'язана шпагатом пачка — десятки листів, однаковий почерк, без підпису: «я бачу тебе щодня», «ти всміхаєшся не мені», «я вмію чекати». Найстаріший — дворічної давності. Останній закінчується інакше: «чекати я більше не буду».`,
|
||||
gives: { motive: true }
|
||||
}));
|
||||
motiveEvs.push(add({
|
||||
id: 'mot_complaint', side: 'analyst', at: { type: 'sys', sys: 'crim' }, q: { sys: 'crim', kind: 'archive', match: [victim.last, victim.name] }, needsIdentity: true,
|
||||
title: 'Торішня заява жертви', tag: 'мотив',
|
||||
detail: `Заява про переслідування, торік, руху не дали: «невідома особа стежить, лишає квіти під дверима». Прикмети з заяви: ${killer.coat}, зріст приблизно ${estRange(r, killer.height)} см.`,
|
||||
gives: { filter: { attr: 'coat', val: killer.coat }, motive: true }
|
||||
}));
|
||||
motiveEvs.push(add({
|
||||
id: 'mot_flowers', side: 'field', at: { type: 'person', cid: ctx.witnessFrom(ctx.citizens.filter(c => c.workplace === 'shop')).id }, requires: ['mot_complaint'],
|
||||
title: 'Свідчення з мінімаркету', tag: 'мотив',
|
||||
detail: `«Три гвоздики щоп'ятниці, завжди готівкою, завжди мовчки. Хто? Та ${killer.name} же. От, думаю собі, людина постійна.»`,
|
||||
gives: { pointsTo: killer.id }
|
||||
}));
|
||||
motiveEvs.push(add({
|
||||
id: 'mot_shrine', side: 'field', at: { type: 'unit', unit: killer.home, slot: 'шафа' },
|
||||
title: 'Стіна фотографій', tag: 'мотив',
|
||||
detail: 'На внутрішньому боці дверцят шафи — десятки фото жертви, знятих здалеку: біля дому, на набережній, крізь вітрину. Дати на звороті — два роки спостережень.',
|
||||
gives: { motive: true }
|
||||
}));
|
||||
const recapWhy = `Два роки листів без відповіді, квіти під дверима, фото здалеку. Автор листів — ${killer.name}. Одержимість скінчилась там, де закінчилось «я вмію чекати».`;
|
||||
const herringLead = `«Хочете про дивних? ${herring.name} вічно сидить біля вікна з біноклем. Каже — «за птахами». Ага, за птахами.»`;
|
||||
return { motiveId, recapWhy, herringLead };
|
||||
}
|
||||
|
||||
module.exports = { generate };
|
||||
39
chapter3/casegen/motives/witness.js
Normal file
39
chapter3/casegen/motives/witness.js
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
const { md5 } = require('../rng');
|
||||
|
||||
function generate(ctx) {
|
||||
const { r, add, addPuzzle, motiveEvs, victim, killer, herring, atScene } = ctx;
|
||||
const motiveId = 'Свідок злочину';
|
||||
const pin = String(victim.birthYear);
|
||||
const pinPuzzle = addPuzzle({
|
||||
id: 'pz_pin', kind: 'pin', prompt: 'Телефон жертви заблоковано 4-значним PIN-кодом.',
|
||||
hash: md5(pin), answer: pin, hint: 'Люди ставлять те, що не забудуть: спробуйте рік народження з досьє.'
|
||||
});
|
||||
add({
|
||||
id: 'sf_phone_locked', side: 'field', at: atScene,
|
||||
title: 'Телефон жертви (заблокований)', tag: 'мотив',
|
||||
detail: 'Екран у павутинні тріщин, але апарат вмикається. PIN. Аналітик може спробувати підібрати.'
|
||||
});
|
||||
motiveEvs.push(add({
|
||||
id: 'mot_photos', side: 'analyst', at: { type: 'lab' }, requires: ['sf_phone_locked'], needsIdentity: true, puzzle: pinPuzzle,
|
||||
title: 'Фото в телефоні жертви', tag: 'мотив',
|
||||
detail: `Нічні знімки: постать у ${killer.coat} заносить велосипеди в елінг на причалі. Обличчя не видно. Дата — за чотири дні до вбивства.`,
|
||||
gives: { filter: { attr: 'coat', val: killer.coat } }
|
||||
}));
|
||||
motiveEvs.push(add({
|
||||
id: 'mot_boathouse', side: 'field', at: { type: 'spot', building: 'pier', spot: 'елінг' }, requires: ['mot_photos'],
|
||||
title: 'Обшук елінгу', tag: 'мотив',
|
||||
detail: 'Шість велосипедів зі спиляними номерами рам, добрі замки зрізані болторізом. На стіні — оголошення «продам недорого» з номером.',
|
||||
gives: { motive: true }
|
||||
}));
|
||||
motiveEvs.push(add({
|
||||
id: 'mot_ads', side: 'analyst', at: { type: 'sys', sys: 'phone' }, q: { sys: 'phone', kind: 'owner', match: killer.phone }, requires: ['mot_boathouse'],
|
||||
title: 'Номер з оголошення', tag: 'мотив',
|
||||
detail: `Номер з оголошення в елінгу зареєстровано: ${killer.name}, прож. ${killer.home}. Жертва сфотографувала крадія — і крадій це помітив.`,
|
||||
gives: { pointsTo: killer.id, motive: true }
|
||||
}));
|
||||
const recapWhy = `Жертва випадково сфотографувала нічні «рейси» з краденими велосипедами. Крадій — ${killer.name}. Свідка прибрали.`;
|
||||
const herringLead = `«Кажуть, ${herring.name} вечорами вічно біля елінгу — разів п'ять там ${ctx.g(herring, 'крутився', 'крутилася')}. Чого — хтозна.»`;
|
||||
return { motiveId, recapWhy, herringLead };
|
||||
}
|
||||
|
||||
module.exports = { generate };
|
||||
39
chapter3/casegen/mutators.js
Normal file
39
chapter3/casegen/mutators.js
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
// casegen/mutators.js — мутатори справ: двійник, анонімний лист
|
||||
const { pick, chance } = require('./rng');
|
||||
const N = require('./names');
|
||||
|
||||
function applyDoppelganger(ctx) {
|
||||
const { r, citizens, killer, victim, herring, district, coveredB, notHousehold } = ctx;
|
||||
|
||||
// Знаходимо двійника: не вбивця, не жертва, не herring, та сама стать, схожий зріст/одяг
|
||||
const candidates = citizens.filter(c =>
|
||||
c.id !== killer.id && c.id !== victim.id && c.id !== herring.id &&
|
||||
c.sex === killer.sex &&
|
||||
notHousehold(c) &&
|
||||
Math.abs(c.height - killer.height) <= 5
|
||||
);
|
||||
|
||||
if (candidates.length === 0) return null;
|
||||
|
||||
const doppelganger = pick(r, candidates);
|
||||
|
||||
// Підлаштовуємо одяг та взуття, щоб збігалися з убивцею
|
||||
doppelganger.coat = killer.coat;
|
||||
doppelganger.shoe = killer.shoe;
|
||||
doppelganger.height = killer.height + pick(r, [-2, -1, 0, 1, 2]); // незначна різниця в зрості
|
||||
|
||||
// Двійник бреше про свій вечір (наприклад, був у гостях у одруженої особи і не хоче зізнатися)
|
||||
doppelganger.liar = true;
|
||||
doppelganger.claim = pick(r, district.buildings.filter(b => b.id !== doppelganger.evening && b.id !== 'station').map(b => b.id));
|
||||
doppelganger.excuse = pick(r, N.EXCUSES);
|
||||
|
||||
// Знаходимо свідка, який може підтвердити справжнє алібі двійника
|
||||
const mate = citizens.find(o => o.id !== victim.id && o.id !== killer.id && o.id !== herring.id && o.evening === doppelganger.evening);
|
||||
if (mate) {
|
||||
doppelganger.mate = mate.id;
|
||||
}
|
||||
|
||||
return doppelganger;
|
||||
}
|
||||
|
||||
module.exports = { applyDoppelganger };
|
||||
76
chapter3/casegen/rng.js
Normal file
76
chapter3/casegen/rng.js
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
// casegen/rng.js — детермінована випадковість, шифри, паролі
|
||||
const crypto = require('crypto');
|
||||
const N = require('./names');
|
||||
|
||||
// ---------- RNG ----------
|
||||
function hashSeed(str) {
|
||||
let h = 2166136261 >>> 0;
|
||||
for (const c of String(str)) { h ^= c.codePointAt(0); h = Math.imul(h, 16777619); }
|
||||
return h >>> 0;
|
||||
}
|
||||
function mulberry32(a) {
|
||||
return function () {
|
||||
a |= 0; a = (a + 0x6D2B79F5) | 0;
|
||||
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
const ri = (r, a, b) => a + Math.floor(r() * (b - a + 1));
|
||||
const estRange = (r, actual, lo = 3, hi = 5) => `${actual - ri(r, lo, hi)}–${actual + ri(r, lo, hi)}`;
|
||||
const pick = (r, arr) => arr[Math.floor(r() * arr.length)];
|
||||
const chance = (r, p) => r() < p;
|
||||
function shuffle(r, arr) {
|
||||
const a = arr.slice();
|
||||
for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(r() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; }
|
||||
return a;
|
||||
}
|
||||
|
||||
// ---------- Шифри ----------
|
||||
const UKR = 'абвгґдеєжзиіїйклмнопрстуфхцчшщьюя';
|
||||
function caesar(text, k) {
|
||||
return [...text].map(ch => {
|
||||
const lower = ch.toLowerCase(), i = UKR.indexOf(lower);
|
||||
if (i < 0) return ch;
|
||||
const out = UKR[(i + k + UKR.length) % UKR.length];
|
||||
return ch === lower ? out : out.toUpperCase();
|
||||
}).join('');
|
||||
}
|
||||
const b64 = t => Buffer.from(t, 'utf8').toString('base64');
|
||||
const md5 = t => crypto.createHash('md5').update(t, 'utf8').digest('hex');
|
||||
|
||||
// ---------- Паролі ----------
|
||||
const TRANSLIT = { а:'a',б:'b',в:'v',г:'h',ґ:'g',д:'d',е:'e',є:'ie',ж:'zh',з:'z',и:'y',і:'i',ї:'i',й:'i',к:'k',л:'l',м:'m',н:'n',о:'o',п:'p',р:'r',с:'s',т:'t',у:'u',ф:'f',х:'kh',ц:'ts',ч:'ch',ш:'sh',щ:'shch',ь:'',ю:'iu',я:'ia' };
|
||||
const translit = s => [...String(s).toLowerCase()].map(c => TRANSLIT[c] ?? c).join('').replace(/[^a-z0-9]/g, '');
|
||||
|
||||
const COMMON_PASSWORDS = ['sunshine', 'dragon', 'monkey', 'princess', 'football', 'iloveyou',
|
||||
'superman', 'batman', 'master', 'hunter', 'freedom', 'trustno1', 'whatever', 'ginger',
|
||||
'flower', 'summer', 'winter', 'orange', 'purple', 'silver', 'letmein', 'qwerty', 'pepper',
|
||||
'cookie', 'tigger', 'jasmine', 'soccer', 'maggie', 'hannah', 'ashley', 'chelsea', 'butterfly',
|
||||
'password', 'shadow', 'michael', 'jessica', 'charlie', 'daniel', 'jordan', 'matrix', 'falcon',
|
||||
'phoenix', 'rainbow', 'diamond', 'banana', 'cheese', 'coffee', 'chocolate', 'midnight',
|
||||
'october', 'november', 'chester', 'oliver', 'ranger', 'harley', 'buster', 'george', 'sammy',
|
||||
'pumpkin', 'muffin', 'peanut', 'smokey', 'bandit', 'lucky', 'angel', 'lovely', 'sunflower',
|
||||
'babygirl', 'bubbles', 'snickers', 'chicken', 'turtle', 'rabbit', 'dolphin', 'junior',
|
||||
'dexter', 'guitar', 'hammer', 'winner', 'copper', 'marina', 'sierra', 'tucker', 'willow',
|
||||
'zombie', 'viking', 'thunder', 'topgun', 'maverick', 'eclipse', 'scorpion', 'panther'];
|
||||
|
||||
function genPassword(r, victim) {
|
||||
const yr = String(victim.birthYear), yr2 = yr.slice(2);
|
||||
const personal = [];
|
||||
if (victim.dog) personal.push(
|
||||
{ answer: translit(victim.dog), hint: 'кличка собаки латиницею (див. досьє)' },
|
||||
{ answer: translit(victim.dog) + yr, hint: 'кличка собаки латиницею + повний рік народження' });
|
||||
personal.push(
|
||||
{ answer: translit(victim.first) + yr, hint: `ім'я латиницею + повний рік народження` },
|
||||
{ answer: translit(victim.last) + yr2, hint: 'прізвище латиницею + дві цифри року народження' },
|
||||
{ answer: translit(victim.first) + translit(victim.last), hint: `ім'я та прізвище латиницею разом` });
|
||||
if (chance(r, 0.5)) {
|
||||
const p = pick(r, personal);
|
||||
return { ...p, style: 'personal', personal: true };
|
||||
}
|
||||
return { answer: pick(r, COMMON_PASSWORDS), style: 'random', personal: false,
|
||||
hint: 'жодного зв\'язку з жертвою — тільки перебір за словником (rockyou.txt)' };
|
||||
}
|
||||
|
||||
module.exports = { hashSeed, mulberry32, ri, estRange, pick, chance, shuffle, UKR, caesar, b64, md5, translit, COMMON_PASSWORDS, genPassword };
|
||||
|
|
@ -156,8 +156,8 @@ function checkCase(seed) {
|
|||
assert.ok(sol.herringDisproved, 'хибний слід можна спростувати');
|
||||
|
||||
// -- сліди лежать на місці злочину, а не падають у дошку самі
|
||||
assert.ok(cs.startingFacts.every(id => ['sf_scene', 'sf_report'].includes(id)),
|
||||
'стартовий лише брифінг: ' + cs.startingFacts);
|
||||
assert.ok(cs.startingFacts.every(id => ['sf_scene', 'sf_report', 'anonymous_letter'].includes(id)),
|
||||
'стартовий лише брифінг: ' + cs.startingFacts);
|
||||
for (const id of ['sf_footprint', 'sf_dna', 'sf_dna2', 'sf_gloves']) {
|
||||
if (!cs.evidence[id]) continue;
|
||||
assert.strictEqual(cs.evidence[id].at.type, 'spot', id + ' знаходиться обшуком');
|
||||
|
|
|
|||
|
|
@ -190,7 +190,7 @@ async function playToTheEnd(session, accusation) {
|
|||
const fresh = client('field'); fresh.session = SEED;
|
||||
await fresh.open;
|
||||
await waitFor(() => fresh.state != null);
|
||||
assert.ok(fresh.state && !fresh.state.ended && fresh.state.found.length <= 2,
|
||||
assert.ok(fresh.state && !fresh.state.ended && fresh.state.found.length <= 3,
|
||||
'видалений сейв = справа з нуля, на дошці лише брифінг: ' + (fresh.state && fresh.state.found.length));
|
||||
fresh.ws.close();
|
||||
await playToTheEnd('TEST2', 'wrong');
|
||||
|
|
|
|||
Loading…
Reference in a new issue