2PlayerGame/detective-game/casegen/test.js
UndecimoDia 2c887ca451 fixes
2026-08-15 15:37:54 +03:00

303 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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, `фільтри лишають 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));