feat: clue safety-net engine (essential-gap + solvability)

This commit is contained in:
2026-07-20 16:44:44 +01:00
parent bc8e23c5f6
commit f89a218eae
2 changed files with 51 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
export function analyzeClues(clues = [], revealed = []) {
const seen = new Set(revealed);
const essential = clues.filter((c) => c.essential);
const missingEssential = essential.filter((c) => !seen.has(c.id));
return {
total: clues.length,
revealedCount: clues.filter((c) => seen.has(c.id)).length,
essentialTotal: essential.length,
essentialRevealed: essential.length - missingEssential.length,
missingEssential,
solvable: missingEssential.length === 0,
};
}
+38
View File
@@ -0,0 +1,38 @@
import { describe, it, expect } from 'vitest';
import { analyzeClues } from '../../src/clues/safety-net.js';
const CLUES = [
{ id: 'c1', label: 'The door-cam still', essential: true, fallback: 'The radio names the time' },
{ id: 'c2', label: "Priya's phone", essential: true, fallback: 'The neighbour saw her leave' },
{ id: 'c3', label: 'A nice-to-have colour detail', essential: false },
];
describe('analyzeClues', () => {
it('reports counts and solvability from the revealed set', () => {
const r = analyzeClues(CLUES, ['c1']);
expect(r.total).toBe(3);
expect(r.revealedCount).toBe(1);
expect(r.essentialTotal).toBe(2);
expect(r.essentialRevealed).toBe(1);
expect(r.solvable).toBe(false);
expect(r.missingEssential.map((c) => c.id)).toEqual(['c2']);
expect(r.missingEssential[0].fallback).toBe('The neighbour saw her leave');
});
it('is solvable once every essential clue is revealed', () => {
const r = analyzeClues(CLUES, ['c1', 'c2']);
expect(r.solvable).toBe(true);
expect(r.missingEssential).toEqual([]);
});
it('treats an empty clue list as vacuously solvable', () => {
const r = analyzeClues([], []);
expect(r.solvable).toBe(true);
expect(r.essentialTotal).toBe(0);
expect(r.total).toBe(0);
});
it('defaults revealed to empty', () => {
expect(analyzeClues(CLUES).essentialRevealed).toBe(0);
});
});