Something went wrong. Try again.
A Social Deduction / Deception Game
Something went wrong. Try again.
8.2 kB · 180 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181// Mystery construction (PURE) — the §5 generator core, rebuilt around the rule of 3 (see DEDUCTION.md).// This file is the feasibility test the pool-size pressure test runs against: given a set of suspects,// can we build 3 incriminating fragments whose match sets are size-3, overlap pairwise by 2, and// intersect to exactly the culprit? (The earlier puzzle.ts intersection model is retired.)//// A "fragment" is an attire token a debris/station clue can phrase ("a piece of a red shirt"). A// suspect MATCHES a fragment iff that token is in their attire. We pick the culprit's tokens as the// 3 fragments and assemble a 5-suspect line-up so the match sets form the overlap-by-2 lattice://// S1 = {C, w12, w1} S2 = {C, w12, w23} S3 = {C, w23, w3}// counts: C:3 w12:2 w23:2 w1:1 w3:1 → culprit in all 3, every innocent in ≤2.
import type { SuspectAttributes } from './types.js'
/** * Attire tokens a fragment can name, at three granularities from one item (DEDUCTION.md): * title → "a Basic T-Shirt" `cat#title#Basic T-Shirt` * colour → "a red shirt" `cat#color#red` * both → "a red Basic T-Shirt" `cat#both#Basic T-Shirt#red` * Colour and combined tokens are emitted ONLY when the item was actually recolored — verified by a * populated `colors.main` (→ `colorBucket`). Fixed-palette items (Elfen Ears, Popcorn, Vampire Fangs, * any `ears`) never carry a main colour and so yield a title fragment only. The combined token is the * most specific (smallest match set) — the dial for keeping match sets tight when common items * (Simple Pants, Basic T-Shirt) would otherwise over-match. */export function tokensOf(s: SuspectAttributes): Set<string> { const t = new Set<string>() const push = (category: string, title: string, bucket?: string) => { t.add(`${category}#title#${title}`) if (bucket) { t.add(`${category}#color#${bucket}`) t.add(`${category}#both#${title}#${bucket}`) } } for (const w of s.worn) push(w.category, w.title, w.colorBucket) for (const h of s.held) push('held', h.title, h.colorBucket) return t}
/** Display nouns for generator categories, used when phrasing a fragment as English. */const CATEGORY_NOUN: Record<string, string> = { headwear: 'hat', hair: 'hair', bangs: 'bangs', beard: 'beard', facial: 'facial hair', glasses: 'glasses', ears: 'earrings', necklaces: 'necklace', tops: 'shirt', bottoms: 'pants', outfits: 'outfit', feet: 'shoes', shoes: 'shoes', cape: 'cape', gloves: 'gloves', wings: 'wings', tail: 'tail',}
/** * Render an attire token (from `tokensOf`) as the "a piece of a ___" clue phrasing: * `tops#title#Basic Shirt` → "a piece of a Basic Shirt" * `tops#color#red` → "a piece of a red shirt" * `tops#both#Basic Shirt#red` → "a piece of a red Basic Shirt" * Held items phrase by their own name ("a piece of a Coffee Mug"), since there's no useful noun for a * hand-held thing. The inverse of `tokensOf`'s encoding. */export function phraseFragmentToken(token: string): string { const [cat, kind, ...rest] = token.split('#') if (cat === 'held') { if (kind === 'both') return `a piece of a ${rest[1]} ${rest[0]}` if (kind === 'color') return `a piece of a ${rest[0]} item` return `a piece of a ${rest[0]}` } // Hair (and bangs) titles are *styles* ("Basic Short"), not nouns — append the noun so the clue reads // as hair even when it's "weird" ("a piece of Basic Short hair", not "a piece of a Basic Short"). if (cat === 'hair' || cat === 'bangs') { const noun = CATEGORY_NOUN[cat]! if (kind === 'color') return `a piece of ${rest[0]} ${noun}` if (kind === 'both') return `a piece of ${rest[1]} ${rest[0]} ${noun}` return `a piece of ${rest[0]} ${noun}` } const noun = CATEGORY_NOUN[cat!] ?? cat if (kind === 'color') return `a piece of a ${rest[0]} ${noun}` if (kind === 'both') return `a piece of a ${rest[1]} ${rest[0]}` return `a piece of a ${rest[0]}`}
export interface OverlapMystery { culpritDid: string /** The 3 incriminating fragment tokens, in order f1,f2,f3. */ fragments: [string, string, string] /** Line-up dids (the 5 implicated suspects: C, w12, w23, w1, w3). */ lineup: string[] /** Match set (dids) for each fragment, within the line-up. */ matchSets: [string[], string[], string[]]}
export interface ConstructOptions { /** How many distinct suspects to try as the culprit before giving up. */ culpritTries?: number}
type Index = { tokenToDids: Map<string, Set<string>> byDid: Map<string, SuspectAttributes>}
function index(suspects: SuspectAttributes[]): Index { const tokenToDids = new Map<string, Set<string>>() const byDid = new Map<string, SuspectAttributes>() for (const s of suspects) { byDid.set(s.did, s) for (const tok of tokensOf(s)) { let set = tokenToDids.get(tok) if (!set) tokenToDids.set(tok, (set = new Set())) set.add(s.did) } } return { tokenToDids, byDid }}
function shuffle<T>(arr: T[], rng: () => number): T[] { const out = arr.slice() for (let i = out.length - 1; i > 0; i--) { const j = Math.floor(rng() * (i + 1)) ;[out[i], out[j]] = [out[j]!, out[i]!] } return out}
/** * Try to build the overlap-by-2 incriminating structure (DEDUCTION.md §5) from `suspects`. Returns the * first valid construction found, or null if this set is too sparse/uniform to support one. Pure and * deterministic given `rng`. */export function buildOverlapMystery( suspects: SuspectAttributes[], rng: () => number, opts: ConstructOptions = {},): OverlapMystery | null { if (suspects.length < 5) return null const { tokenToDids } = index(suspects) const has = (tok: string, did: string) => tokenToDids.get(tok)?.has(did) ?? false
const culpritTries = opts.culpritTries ?? suspects.length const candidates = shuffle(suspects, rng).slice(0, culpritTries)
for (const C of candidates) { // Culprit fragments worth using: their tokens shared with ≥1 other suspect. const frags = [...tokensOf(C)].filter((tok) => (tokenToDids.get(tok)?.size ?? 0) >= 2) if (frags.length < 3) continue
// Try unordered triples, each token taking a turn as the "middle" fragment f2 (the one that shares // a witness with both neighbours). f1/f3 are symmetric, so one ordering per (f2, {f1,f3}) suffices. for (let a = 0; a < frags.length; a++) { for (let b = a + 1; b < frags.length; b++) { for (let c = b + 1; c < frags.length; c++) { const triple = [frags[a]!, frags[b]!, frags[c]!] for (let mid = 0; mid < 3; mid++) { const f2 = triple[mid]! const [f1, f3] = triple.filter((_, i) => i !== mid) as [string, string]
const others = suspects.filter((s) => s.did !== C.did).map((s) => s.did) // Membership classes (mutually exclusive → any picks are distinct). const w12 = others.find((d) => has(f1, d) && has(f2, d) && !has(f3, d)) const w23 = others.find((d) => has(f2, d) && has(f3, d) && !has(f1, d)) const w1 = others.find((d) => has(f1, d) && !has(f2, d) && !has(f3, d)) const w3 = others.find((d) => has(f3, d) && !has(f1, d) && !has(f2, d)) if (!w12 || !w23 || !w1 || !w3) continue
const lineup = [C.did, w12, w23, w1, w3] const matchSets: [string[], string[], string[]] = [ lineup.filter((d) => has(f1, d)), lineup.filter((d) => has(f2, d)), lineup.filter((d) => has(f3, d)), ] return { culpritDid: C.did, fragments: [f1, f2, f3], lineup, matchSets } } } } } } return null}
/** Verify the rule of 3 holds for a built mystery: ∩ of the 3 match sets = {culprit}, each set ≥2. */export function verifyRuleOfThree(m: OverlapMystery): boolean { const [s1, s2, s3] = m.matchSets if (s1.length < 2 || s2.length < 2 || s3.length < 2) return false const inAll = s1.filter((d) => s2.includes(d) && s3.includes(d)) return inAll.length === 1 && inAll[0] === m.culpritDid}