// Fair-play валідатор генератора: на сотнях сідів кожна справа мусить бути // розв'язною від стартових фактів, а кожен хибний слід — спростовним. const assert = require('assert'); const { generateCase, solve, checkAccusation, caesar, md5, translit, COMMON_PASSWORDS } = require('./index'); const SEEDS = 300; let fails = 0; function checkCase(seed) { const cs = generateCase(seed); const byId = id => cs.citizens.find(c => c.id === id); const victim = byId(cs.victim), killer = byId(cs.finale.killer), herring = byId(cs.herring); // -- детермінізм assert.strictEqual(JSON.stringify(cs), JSON.stringify(generateCase(seed)), 'детермінізм: той самий seed — та сама справа'); // -- базова консистентність світу assert.ok(victim && killer && herring, 'фігуранти існують'); assert.notStrictEqual(victim.id, killer.id, 'жертва не вбивця'); assert.notStrictEqual(herring.id, killer.id, 'хибний слід не вбивця'); assert.ok(victim.home !== killer.home, 'вбивця не живе з жертвою'); assert.ok(!cs.citizens.some(c => c !== victim && c.home === victim.home), 'жертва живе сама (інакше особу впізнали б одразу)'); const uniq = (arr, what) => assert.strictEqual(new Set(arr).size, arr.length, 'унікальність: ' + what); uniq(cs.citizens.map(c => c.name), 'імена'); uniq(cs.citizens.map(c => c.phone), 'телефони'); uniq(cs.citizens.map(c => c.dna.join(',')), 'ДНК-профілі'); assert.ok(cs.citizens.length >= 20 && cs.citizens.length <= 60, 'населення в розумних межах: ' + cs.citizens.length); // -- геометрія району: все в межах світу, зазори між будівлями, дороги вільні const inter = (a, b, pad) => a.x < b.x + b.w + pad && b.x < a.x + a.w + pad && a.y < b.y + b.h + pad && b.y < a.y + a.h + pad; const bl = cs.district.buildings; assert.strictEqual(bl.length, 12, 'усі 12 будівель розміщено'); for (const b of bl) { assert.ok(b.pos.x >= 20 && b.pos.y >= 20 && b.pos.x + b.pos.w <= 980 && b.pos.y + b.pos.h <= 616, 'будівля в межах світу: ' + b.id); for (const rd of cs.district.roads) assert.ok(!inter(b.pos, rd, 6), 'будівля не на дорозі: ' + b.id); } for (let i = 0; i < bl.length; i++) for (let j = i + 1; j < bl.length; j++) assert.ok(!inter(bl[i].pos, bl[j].pos, 24), `зазор ≥24 між ${bl[i].id} і ${bl[j].id}`); assert.strictEqual(cs.district.towers.length, 3, 'три вежі звязку'); for (const must of ['park', 'pier', 'bar']) assert.ok(cs.district.cameras.some(c => c.covers.includes(must)), 'камера покриває ' + must); for (const slot of ['письмовий стіл', 'шафа', 'кухня', 'ванна кімната', 'під ліжком']) assert.ok(cs.district.homeSlots.includes(slot), 'обовязковий слот житла: ' + slot); // -- вечір злочину: правда/покази, брехуни; стосунки для допитів for (const c of cs.citizens) { assert.ok(c.evening && c.claim, 'у кожного є evening і claim: ' + c.id); if (!c.liar && c.id !== cs.finale.killer) assert.strictEqual(c.claim, c.evening, 'чесні кажуть правду'); } assert.ok(killer.liar && killer.claim !== cs.scene.building && killer.evening === cs.scene.building, 'вбивця був на місці злочину, а каже інше'); assert.ok(cs.district.cameras.some(c => c.covers.includes(killer.claim)), '«алібі» вбивці під камерою — його можна зламати'); for (const c of cs.citizens) if (c.liar && c.id !== cs.finale.killer && c.id !== cs.accomplice) assert.ok(c.excuse, 'у брехуна є виправдання на повторний допит'); // -- свідок алібі: мусить бути там само, підтверджувати це на власному допиті // і не бути фігурантом справи (інакше «спитайте X» зливає, хто в справі). for (const c of cs.citizens) { if (!c.mate) continue; const mt = byId(c.mate); assert.ok(mt, `${seed}/${c.name}: свідок алібі не існує`); assert.strictEqual(mt.evening, c.evening, `${seed}/${c.name}: свідок алібі був в іншому місці`); assert.strictEqual(mt.claim, mt.evening, `${seed}/${c.name}: свідок алібі сам заперечує його покази`); assert.ok(![cs.victim, cs.finale.killer, cs.accomplice].includes(mt.id), `${seed}/${c.name}: свідок алібі — фігурант справи`); assert.ok(c.liar && c.id !== cs.finale.killer && c.id !== cs.accomplice, `${seed}/${c.name}: свідок алібі є в того, хто не брехав`); } // -- зламане алібі: сплячий доказ, недосяжний симулятором, з вагою підтвердження const ab = cs.evidence.alibi_broken; assert.ok(ab && ab.special === 'alibi' && ab.gives.confirms, 'сплячий доказ алібі існує'); // -- застарілі реєстри: лише в нефігурантів for (const c of cs.citizens) if (c.regShoe != null || c.regCoat != null) assert.ok(![cs.victim, cs.finale.killer, cs.herring, cs.decoyOwner, cs.accomplice].includes(c.id), 'реєстр фігурантів завжди точний'); // -- мутатори: кожен лишає справу чесною if (cs.mutator === 'gloves') { assert.ok(!cs.evidence.sf_dna && !cs.evidence.lab_dna_match, 'рукавички: ДНК вбивці немає'); assert.ok(cs.dna2 ? !cs.evidence.sf_gloves : cs.evidence.sf_gloves, 'рукавички: або пояснення, або невинний зразок як пастка'); } // -- другий (невинний) ДНК-зразок: чужа присутність, а не доказ вини if (cs.dna2) { assert.ok(![cs.victim, cs.finale.killer, cs.herring, cs.accomplice].includes(cs.dna2.owner), 'власник другого зразка — не фігурант'); assert.strictEqual(cs.evidence.lab_dna_match2.gives.pointsTo, cs.dna2.owner, 'збіг вказує на власника'); assert.ok(!cs.evidence.lab_dna_match2.gives.confirms, 'невинний збіг не підтверджує вбивцю'); if (cs.dna2.why === 'brawl') { const ow = byId(cs.dna2.owner); assert.ok(cs.citizens.some(c => (c.gossip || []).some(l => l.includes(ow.name))), 'про денну бійку є чутка — історію можна перевірити'); } assert.notStrictEqual(cs.dnaDesc, cs.dna2Desc, 'описи зразків різні'); } if (cs.mutator === 'accomplice') { assert.strictEqual(cs.evidence.lab_dna_match.gives.pointsTo, cs.accomplice, 'ДНК вказує на спільника'); assert.ok(!cs.evidence.lab_dna_match.gives.confirms, 'ДНК спільника — не підтвердження вбивці'); assert.ok(cs.evidence.acc_confess && cs.evidence.acc_confess.gives.confirms, 'зізнання спільника підтверджує вбивцю'); } if (cs.mutator === 'frame') { assert.strictEqual(cs.evidence.dec_planted.gives.pointsTo, cs.herring, 'підкинута річ вказує на хибний слід'); assert.ok(cs.evidence.planted_exp, 'експертиза викриває підкидання'); } assert.ok(cs.district.cameras.some(c => c.covers.includes(herring.evening)), 'вечір хибного сліду під камерою — алібі доводиться'); // -- хибний мотив: повна власна гілка, іншого типу, ніж мотив вбивці, без gives.motive assert.ok(cs.evidence.fm_lead && cs.evidence.fm_talk, 'у хибного підозрюваного є власна історія'); assert.strictEqual(cs.evidence.fm_lead.gives.pointsTo, cs.herring, 'хибний мотив вказує на хибного підозрюваного'); assert.ok(!cs.evidence.fm_lead.gives.motive && !cs.evidence.fm_talk.gives.motive, 'хибний мотив не зараховується прокурору як доведений мотив'); assert.notStrictEqual(cs.falseMotive.label, cs.finale.motive, 'тип хибного мотиву інший, ніж у вбивці'); assert.ok(cs.finale.recap.includes(herring.first), 'рекап пояснює, що хибний підозрюваний не вбивав'); assert.ok(Array.isArray(cs.relations), 'стосунки згенеровано'); assert.ok(cs.citizens.some(c => c.id === cs.decoyOwner) && ![cs.victim, cs.finale.killer, cs.herring].includes(cs.decoyOwner), 'власник фейкового сліду — не фігурант'); // -- кожен sys-доказ досяжний: або через запит-форму, або авторозкриттям після шифру for (const ev of Object.values(cs.evidence)) if (ev.at.type === 'sys' && !ev.puzzle) assert.ok(ev.q && ev.q.sys, `${ev.id}: sys-доказ мусить мати довідник запиту`); // -- дрібниці та плітки const junk = Object.values(cs.evidence).filter(e => e.tag === 'дрібниця'); assert.ok(junk.length >= 9, 'дрібниць вистачає: ' + junk.length); assert.ok(cs.citizens.filter(c => c.id !== cs.victim).every(c => c.gossip && c.gossip.length >= 1), 'у кожного живого є плітка'); assert.ok(cs.citizens.some(c => (c.gossip || []).some(l => l.includes(killer.coat))), 'правдива чутка про одяг вбивці існує'); if (cs.templateId === 'serial') assert.strictEqual(cs.citizens.filter(c => c.tattoo === killer.tattoo).length, 1, 'тату серійника унікальне в базі'); // -- усі свідки/контакти — не фігуранти справи for (const ev of Object.values(cs.evidence)) if (ev.at.type === 'person') assert.ok(![victim.id, killer.id].includes(ev.at.cid), `${seed}/${ev.id}: свідок не може бути жертвою чи вбивцею`); // -- узгодження роду: суфікс жіночого роду клеївся до чоловічої форми // («писав» + «ла» = «писавла»). Ловимо весь клас однією сіткою по всьому тексту. const walk = (o, path) => { if (typeof o === 'string') { // -в + -ла; (?![сдш]) береже «влада/власний/влаштувати» всередині слова const m = o.match(/[а-яіїєґ]вла(?![сдш])/i); assert.ok(!m, `${seed}${path}: зліплена форма «${m && m[0]}» у: ${o.slice(0, 90)}`); } else if (o && typeof o === 'object') for (const [k, v] of Object.entries(o)) walk(v, `${path}.${k}`); }; walk(cs, ''); // -- виправдання брехунів роздаються `pick`ом незалежно від статі, тож у них // не має бути минулого часу від першої особи («був», «пив», «посварився»): // жінка на зізнанні сказала б це в чоловічому роді. Сітка вище цього не ловить — // форма граматично правильна, просто не того роду. const M_PAST = /(?:^|[.!?…]\s+|(?:^|\s)(?:я|і|й|та|то|вже|не|ще)\s+)[а-яіїєґ']*[аяиіуюе]в(?:ся|сь)?(?=$|[\s.,!?;:»…])/i; for (const c of cs.citizens) { if (!c.excuse) continue; const m = c.excuse.match(M_PAST); assert.ok(!m, `${seed}/${c.id}: виправдання в чоловічому роді «${m && m[0].trim()}»: ${c.excuse}`); } // -- розв'язність від стартових фактів const sol = solve(cs); assert.ok(sol.identity, 'особу жертви можна встановити'); if (cs.caseType === 'murder') assert.ok(sol.methodKnown, 'спосіб смерті доведений'); else { assert.ok(sol.lairProven, 'місце утримання можна довести'); assert.ok(cs.finale.whereOptions.includes(cs.finale.where), 'місце є серед варіантів'); } assert.ok(sol.motiveProven, 'мотив доведений'); assert.ok(sol.killerConfirmed, 'вбивцю можна підтвердити (ДНК або відбиток)'); assert.ok(sol.herringLeadFound, 'хибний слід справді трапляється гравцям'); assert.ok(sol.herringDisproved, 'хибний слід можна спростувати'); // -- сліди лежать на місці злочину, а не падають у дошку самі 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 + ' знаходиться обшуком'); assert.strictEqual(cs.evidence[id].at.building, cs.scene.building, id + ' — саме на місці злочину'); } assert.ok(!sol.reached.has('alibi_broken'), 'сплячий доказ не відкривається сам собою'); if (cs.dna2) assert.ok(sol.reached.has('sf_dna2') && sol.reached.has('lab_dna_match2'), 'другий зразок досяжний'); assert.ok(sol.reached.has('fm_lead') && sol.reached.has('fm_talk'), 'хибний мотив досяжний'); // -- цифровий слід: граф дзвінків, тиша вбивці, пристрої з шаруватим вмістом assert.ok(Array.isArray(cs.calls) && cs.phoneOff, 'граф дзвінків згенеровано'); for (const x of cs.calls) { assert.ok(x.a !== cs.victim && x.b !== cs.victim, 'мертві не дзвонять у графі побуту'); if (x.a === cs.finale.killer || x.b === cs.finale.killer) assert.ok(x.t < cs.phoneOff.off - 10 || x.t > cs.phoneOff.on, 'вбивця мовчить довкола часу злочину'); } if (cs.accomplice) assert.ok(cs.calls.some(x => (x.a === cs.finale.killer && x.b === cs.accomplice) || (x.b === cs.finale.killer && x.a === cs.accomplice)), 'дзвінок вбивця↔спільник існує'); assert.ok(cs.evidence.dev_k && cs.evidence.dev_k_data && cs.evidence.dev_h && cs.evidence.dev_h_data, 'пристрої вбивці й хибного підозрюваного вилучаються'); assert.strictEqual(!!cs.evidence.dev_a, !!cs.accomplice, 'пристрій спільника — лише коли він є'); for (const suf of ['k', 'h', 'a', 'v']) { const d = cs.evidence['dev_' + suf + '_data']; if (!d) continue; assert.ok(d.puzzle && cs.puzzles.some(pz => pz.id === d.puzzle), 'вміст пристрою за PIN/паролем'); assert.ok(!Object.keys(d.gives).length, 'вміст пристрою — штрихи, не механіка'); assert.ok(sol.reached.has('dev_' + suf) && sol.reached.has('dev_' + suf + '_data'), 'пристрій досяжний: ' + suf); devLayers[d.detail.startsWith('Чисто') ? 'clean' : 'rich'] = (devLayers[d.detail.startsWith('Чисто') ? 'clean' : 'rich'] || 0) + 1; } // -- сліди розмиті: після самого огляду місця коло ще широке const wide = cs.citizens.filter(c => c.id !== cs.victim && Math.abs(c.shoe - killer.shoe) <= 1); assert.ok(wide.length >= 8, `після огляду місця підозрюваних ≥8, зараз ${wide.length}`); assert.strictEqual(cs.evidence.sf_footprint.gives.filter.tol, 1, 'відбиток взуття — діапазон, не точний розмір'); // -- фейкові сліди: існують, нічого не дають механіці assert.ok(cs.evidence.dec_footprint && cs.evidence.dec_butt, 'фейкові сліди на місці існують'); assert.strictEqual(Object.keys(cs.evidence.dec_footprint.gives).length, 0, 'фейковий відбиток не дає фільтрів'); assert.strictEqual(Object.keys(cs.evidence.dec_butt.gives).length, 0, 'фейкові недопалки не дають фільтрів'); // -- тріангуляція: запит через оператора, ціль сервер рахує на живо assert.ok(cs.evidence.rec_triangulate && cs.evidence.rec_triangulate.q.kind === 'locate', 'тріангуляція викликається запитом «де зараз номер»'); assert.ok(sol.reached.has('rec_triangulate'), 'тріангуляція досяжна'); // -- фільтри чесні: вбивця і хибний слід проходять крізь них, коло не завелике const cand = sol.finalCandidates; assert.ok(cand.some(c => c.id === killer.id), 'вбивця відповідає всім слідам з місця злочину'); assert.ok(cand.some(c => c.id === herring.id), 'хибний слід теж проходить фільтри (інакше він не хибний слід)'); assert.ok(cand.length >= 2 && cand.length <= 6, `фільтри лишають 2–6 кандидатів, зараз ${cand.length}`); // -- шифро-завдання чесні й розв'язні (усі типи) for (const p of cs.puzzles) { if (p.kind === 'caesar') assert.strictEqual(caesar(p.payload, -p.shift), p.answer, 'Цезар розшифровується'); if (p.kind === 'b64') assert.strictEqual(Buffer.from(p.payload, 'base64').toString('utf8'), p.answer, 'base64 розкодовується'); if (p.kind === 'pin' || p.kind === 'md5') assert.strictEqual(md5(p.answer), p.hash, 'хеш відповідає відповіді'); if (p.kind === 'pin' && p.id === 'pz_pin') assert.strictEqual(p.answer, String(victim.birthYear), 'PIN виводиться з досьє'); if (p.kind === 'pin' && p.id.startsWith('pz_dev_')) assert.ok(cs.citizens.some(c => String(c.birthYear) === p.answer), 'PIN пристрою — рік народження власника'); if (p.kind === 'md5' && p.id === 'pz_md5') { // пароль завжди латиниця (щоб реально крекнути), стиль — особистий або випадковий assert.ok(/^[a-z0-9]+$/.test(p.answer), 'пароль ноутбука — латиниця: ' + p.answer); if (p.style === 'random') assert.ok(COMMON_PASSWORDS.includes(p.answer), 'випадковий пароль — реальний запис зі словника rockyou'); else { // особистий: виводиться з досьє (кличка/ім'я/прізвище латиницею ± рік) const yr = String(victim.birthYear), parts = [translit(victim.first), translit(victim.last), victim.dog && translit(victim.dog)].filter(Boolean); assert.ok(parts.some(pt => p.answer.includes(pt)), 'особистий пароль містить транслітероване особисте дане'); assert.ok(cs.evidence.id_dossier.detail.includes(victim.name.split(' ')[0]), 'ім\'я жертви є в досьє'); } } } // -- звинувачення: правильна повна відповідь приймається, кожна зламана ланка — ні const pinnedAll = [...sol.reached]; const rightHow = cs.caseType === 'kidnap' ? cs.finale.where : cs.finale.method; const wrongHow = cs.caseType === 'kidnap' ? cs.finale.whereOptions.find(w => w !== cs.finale.where) : (cs.finale.method === 'Отруєння' ? 'Ніж' : 'Отруєння'); const right = { who: killer.name, how: rightHow, why: cs.finale.motive }; assert.ok(checkAccusation(cs, right, pinnedAll).ok, 'правильне звинувачення з доказами приймається'); assert.ok(!checkAccusation(cs, { ...right, who: herring.name }, pinnedAll).ok, 'хибний підозрюваний відхиляється'); assert.ok(!checkAccusation(cs, { ...right, how: wrongHow }, pinnedAll).ok, 'хибний спосіб/місце відхиляється'); assert.ok(!checkAccusation(cs, { ...right, why: 'Пограбування' }, pinnedAll).ok, 'хибний мотив відхиляється'); assert.ok(!checkAccusation(cs, right, []).ok, 'без закріплених доказів прокурор не підписує'); assert.ok(checkAccusation(cs, { who: killer.name.toUpperCase(), how: right.how, why: right.why }, pinnedAll).ok, 'регістр імені не важливий'); // -- варіанти фіналу містять правильні відповіді if (cs.caseType === 'murder') assert.ok(cs.finale.methodOptions.includes(cs.finale.method), 'спосіб є серед варіантів'); assert.ok(cs.finale.motiveOptions.includes(cs.finale.motive), 'мотив є серед варіантів'); const md5pz = cs.puzzles.find(p => p.id === 'pz_md5'); if (md5pz) pwStyles[md5pz.style]++; muts[cs.mutator || 'none'] = (muts[cs.mutator || 'none'] || 0) + 1; if (cs.dna2) muts['dna2:' + cs.dna2.why] = (muts['dna2:' + cs.dna2.why] || 0) + 1; return cs.caseType + ':' + cs.templateId; } const seen = {}, pwStyles = { personal: 0, random: 0 }, muts = {}, devLayers = {}; for (let i = 0; i < SEEDS; i++) { const seed = 'seed-' + i; try { const t = checkCase(seed); seen[t] = (seen[t] || 0) + 1; } catch (e) { fails++; console.error(`✗ ${seed}: ${e.message}`); if (fails > 5) break; } } assert.ok(Object.keys(seen).length >= 11, 'генератор реально використовує всі шаблони мотивів: ' + JSON.stringify(seen)); assert.ok(Object.keys(seen).some(k => k.startsWith('kidnap:')) && Object.keys(seen).some(k => k.startsWith('murder:')), 'обидва типи справ трапляються: ' + JSON.stringify(seen)); assert.ok(pwStyles.personal && pwStyles.random, 'алгоритм пароля дає і особисті, і випадкові варіанти: ' + JSON.stringify(pwStyles)); if (fails) { console.error(`\n${fails} збоїв — генератор нечесний.`); process.exit(1); } console.log(`✓ ${SEEDS} сідів: кожна справа розв'язна, хибні сліди спростовні, шифри чесні.`); console.log(' Розподіл шаблонів:', JSON.stringify(seen)); console.log(' Стилі паролів (ноутбук):', JSON.stringify(pwStyles)); console.log(' Мутатори:', JSON.stringify(muts)); assert.ok(muts.accomplice && muts.gloves && muts.frame && muts.none, 'усі мутатори трапляються'); assert.ok(muts['dna2:brawl'] && muts['dna2:finder'] && muts['dna2:passer'], 'усі види невинної ДНК трапляються'); assert.ok(devLayers.clean && devLayers.rich, 'пристрої бувають і чистими, і з вмістом: ' + JSON.stringify(devLayers));