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