2PlayerGame/chapter3/casegen/test.js

279 lines
22 KiB
JavaScript
Raw Normal View History

2026-07-23 17:03:26 +03:00
// 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, 'у брехуна є виправдання на повторний допит');
// -- зламане алібі: сплячий доказ, недосяжний симулятором, з вагою підтвердження
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, '');
// -- розв'язність від стартових фактів
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'].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, `фільтри лишають 26 кандидатів, зараз ${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));