/** * Reference engine for RECOMMENDED and the seven, as agreed on 24 and 27 October 2026. * * Pure functions with no I/O, written to be ported into * useFacetRecommendations.ts (deriveCompactCards) and methodRecommendations.ts. * The README in this package states the same rule in words. */ import communicationFacets from "../../data/create/customRule/communication.json"; import conflictFacets from "../../data/create/customRule/conflictManagement.json"; import decisionFacets from "../../data/create/customRule/decisionApproaches.json"; import jobsFile from "../../data/create/customRule/_jobs.json"; import membershipFacets from "../../data/create/customRule/membership.json"; import type { CardEngineEntry, DeckEngine, DeckId, DeckRecommendation, FacetFile, GroupAnswers, JobsFile, Kind, MatchedFacet, RankTier, RecommendedCard, RecommendationReason, SizeBand, TraitKey, } from "./deckTypes"; export interface DeckData { deck: DeckId; facets: FacetFile; engine: DeckEngine; config: Pick< JobsFile, "displayLimit" | "maxAddedInSeven" | "tierOrder" | "traitKeys" | "sizeAnswers" >; } export interface RecommendOptions { /** How many recommended cards show before See all. A screen that shows five passes 5. */ limit?: number; } interface Evaluated { id: string; entry: CardEngineEntry; eligible: boolean; matched: MatchedFacet[]; fitsAnswer?: TraitKey; openedBy?: TraitKey; score: number; otherKindHits: number; } /** Normalizes setup answers. A group with other kinds and no main kind gets the first as main. */ export function normalizeAnswers(answers: GroupAnswers): Required { const others = [...(answers.otherKinds ?? [])]; const mainKind = answers.mainKind ?? others.shift() ?? null; return { size: answers.size ?? null, mainKind, otherKinds: others.filter((k) => k !== mainKind), scale: [...new Set(answers.scale ?? [])], stage: answers.stage ?? null, traits: answers.traits ?? {}, }; } function sizeBandOf(size: string | null, data: DeckData): SizeBand | null { if (!size) return null; const band = data.config.sizeAnswers[size]; if (!band) throw new Error(`Unknown size answer ${size}`); return band; } function evaluate( id: string, data: DeckData, a: Required, band: SizeBand | null, ticked: Set, ): Evaluated { const f = data.facets[id]; const entry = data.engine.cards[id]; if (!f || !entry) throw new Error(`Card ${id} is missing from the facet or jobs file`); const traits = entry.traits ?? {}; const fitsSize = band ? f.size[band] : true; const fitsKind = a.mainKind ? f.orgType[a.mainKind] : true; const openedBy = (traits.opens ?? []).find((k) => ticked.has(k)); const isBeside = (traits.beside ?? []).length > 0; // Each facet group counts once. Scale earns its point only if the card fits // every scale the group ticked (27 Oct, call 2). const fitsScale = a.scale.length > 0 && a.scale.every((s) => f.scale[s]); const fitsStage = a.stage !== null && f.maturity[a.stage]; const fitsAnswer = (traits.fits ?? []).find((k) => ticked.has(k)); const matched: MatchedFacet[] = []; if (band && fitsSize) matched.push("size"); if (a.mainKind && fitsKind) matched.push("kind"); if (fitsScale) matched.push("scale"); if (fitsStage) matched.push("stage"); return { id, entry, // Size and the main kind decide eligibility, or a ticked answer opens the card. // A card an answer adds never competes for a job. eligible: !isBeside && fitsSize && (fitsKind || openedBy !== undefined), matched, fitsAnswer, openedBy, // Inside a job, scale, stage and the one point from answers decide (27 Oct, call 1). score: (fitsScale ? 1 : 0) + (fitsStage ? 1 : 0) + (fitsAnswer ? 1 : 0), otherKindHits: a.otherKinds.filter((k: Kind) => f.orgType[k]).length, }; } /** Best first. Score, then other kinds, then row order inside the job. */ function compareCandidates(x: Evaluated, y: Evaluated): number { return ( y.score - x.score || y.otherKindHits - x.otherKindHits || x.entry.priority - y.entry.priority ); } /** What separated the winner from the runner-up. */ function decidedBy(c: Evaluated[]): RecommendedCard["decidedBy"] { if (c.length < 2) return "only"; if (c[0].score !== c[1].score) return "score"; if (c[0].otherKindHits !== c[1].otherKindHits) return "otherKinds"; return "rowOrder"; } export function recommendDeck( data: DeckData, answers: GroupAnswers, options: RecommendOptions = {}, ): DeckRecommendation { const limit = options.limit ?? data.config.displayLimit; const a = normalizeAnswers(answers); const band = sizeBandOf(a.size, data); const ticked = new Set(data.config.traitKeys.filter((k) => a.traits[k] === true)); const jobs = data.engine.jobs; const jobIndex = new Map(jobs.map((j, i) => [j.id, i])); const allInDeckOrder = deckOrder(data); const anyFacet = band !== null || a.mainKind !== null || a.scale.length > 0 || a.stage !== null; const fallback = (): DeckRecommendation => ({ deck: data.deck, fallback: true, recommended: [], shown: [], waiting: [], seeAll: allInDeckOrder, }); if (!anyFacet) return fallback(); const cardIds = Object.keys(data.engine.cards); const evaluated = new Map(cardIds.map((id) => [id, evaluate(id, data, a, band, ticked)])); const tierRank = (t: RankTier) => data.config.tierOrder.indexOf(t); const recommended: (RecommendedCard & { sortKey: number[]; showKey: number[] })[] = []; // One card per job that has an eligible card. The runner-up is its alternative. for (const job of jobs) { if (job.displayTier === "none") continue; const candidates = [...evaluated.values()] .filter((e) => e.entry.job === job.id && e.eligible) .sort(compareCandidates); const winner = candidates[0]; if (!winner) continue; const reason: RecommendationReason = winner.openedBy && !winner.matched.includes("kind") ? { kind: "opened", matched: winner.matched, answer: winner.openedBy } : { kind: "fit", matched: winner.matched, ...(winner.fitsAnswer ? { answer: winner.fitsAnswer } : {}) }; recommended.push({ id: winner.id, job: job.id, showsWith: job.id, tier: job.displayTier as RankTier, tag: winner.entry.everyRuleHas ? "everyRuleHas" : "recommended", reason, ...(candidates[1] ? { alternative: candidates[1].id } : {}), score: winner.score, decidedBy: decidedBy(candidates), sortKey: [tierRank(job.displayTier as RankTier), job.rank ?? 0, 0], showKey: [jobIndex.get(job.id)!, 0, 0], }); } // Cards a ticked answer adds beside a job, whatever the group's size or kind. const added = data.engine.addedOrder.filter((id) => { const e = evaluated.get(id)!; return (e.entry.traits?.beside ?? []).some((k) => ticked.has(k)); }); added.forEach((id, i) => { const e = evaluated.get(id)!; const besideJob = e.entry.traits!.besideJob!; const answer = e.entry.traits!.beside!.find((k) => ticked.has(k))!; const inSevenSlot = i < data.config.maxAddedInSeven; recommended.push({ id, job: e.entry.job, showsWith: besideJob, tier: "answers", tag: e.entry.everyRuleHas ? "everyRuleHas" : "recommended", reason: { kind: "added", answer }, // The first added cards rank right after the safety jobs, any more rank last. sortKey: inSevenSlot ? [tierRank("answers"), 0, i] : [data.config.tierOrder.length, 0, i], showKey: [jobIndex.get(besideJob)!, 1, i], }); }); if (recommended.length === 0) return fallback(); const compareKeys = (x: number[], y: number[]) => { for (let i = 0; i < x.length; i++) if (x[i] !== y[i]) return x[i] - y[i]; return 0; }; // Rank decides which cards show. Deck order decides where they show. const ranked = [...recommended].sort((x, y) => compareKeys(x.sortKey, y.sortKey)); const shownIds = new Set(ranked.slice(0, limit).map((r) => r.id)); const strip = ({ sortKey: _s, showKey: _w, ...card }: (typeof recommended)[number]): RecommendedCard => card; const inShowOrder = [...recommended].sort((x, y) => compareKeys(x.showKey, y.showKey)); const shown = inShowOrder.filter((r) => shownIds.has(r.id)).map(strip); const waiting = inShowOrder.filter((r) => !shownIds.has(r.id)).map(strip); const recommendedIds = new Set(recommended.map((r) => r.id)); return { deck: data.deck, fallback: false, recommended: ranked.map(strip), shown, waiting, seeAll: [...waiting.map((r) => r.id), ...allInDeckOrder.filter((id) => !recommendedIds.has(id))], }; } /** Every card in deck order, job by job, then by priority inside each job. */ export function deckOrder(data: DeckData): string[] { const jobIndex = new Map(data.engine.jobs.map((j, i) => [j.id, i])); const key = (id: string) => { const e = data.engine.cards[id]; return (jobIndex.get(e.job) ?? data.engine.jobs.length) * 1000 + e.priority; }; return Object.keys(data.engine.cards).sort((x, y) => key(x) - key(y)); } /** * The template path (27 Oct). A group that starts from a template answers no setup * question and gets no recommendations. Each deck shows the template's cards first, * in the template's order, with no tag and no seven-card cut, then the rest of the * deck in deck order. templateCardIds come from templateChips.json. */ const jobs = jobsFile as JobsFile; const FACETS: Record = { decisionApproaches: decisionFacets as FacetFile, membership: membershipFacets as FacetFile, conflictManagement: conflictFacets as FacetFile, communication: communicationFacets as FacetFile, }; /** One deck's facets, jobs and display config, ready for {@link recommendDeck}. */ export function deckData(deck: DeckId): DeckData { return { deck, facets: FACETS[deck], engine: jobs.decks[deck], config: { displayLimit: jobs.displayLimit, maxAddedInSeven: jobs.maxAddedInSeven, tierOrder: jobs.tierOrder, traitKeys: jobs.traitKeys, sizeAnswers: jobs.sizeAnswers, }, }; } export function templateDeck(data: DeckData, templateCardIds: string[]): { deck: DeckId; first: string[]; rest: string[] } { const live = new Set(Object.keys(data.engine.cards)); const first = [...new Set(templateCardIds)].filter((id) => live.has(id)); const chosen = new Set(first); return { deck: data.deck, first, rest: deckOrder(data).filter((id) => !chosen.has(id)) }; }