// Reading a career back out of its history. // // Everything here is derived generically from the turn record and the pack's // display map — no path is named. A pack with different stats, or none of them // called money, gets the same retrospective for free. /** Paths the pack formats as money, in display order. */ function moneyPaths(pack) { return Object.entries(pack.display ?? {}) .filter(([, rule]) => rule.format === 'money') .sort((a, b) => (a[1].order ?? 99) - (b[1].order ?? 99)) .map(([path]) => path); } /** Every value a path passed through, turn by turn. */ function trace(history, path) { const values = []; for (const record of history) { for (const change of [...record.changes, ...record.upkeep]) { if (change.path === path && typeof change.to === 'number') values.push(change.to); } } return values; } export function summarize(pack, state) { const history = state.history ?? []; const primary = moneyPaths(pack)[0] ?? null; const primaryTrace = primary ? trace(history, primary) : []; const byLabel = new Map(); for (const record of history) { byLabel.set(record.optionLabel, (byLabel.get(record.optionLabel) ?? 0) + 1); } const favourite = [...byLabel.entries()].sort((a, b) => b[1] - a[1])[0] ?? null; const netByTurn = history.map((record) => ({ turn: record.turn, label: record.optionLabel, net: [...record.changes, ...record.upkeep] .filter((c) => c.path === primary && typeof c.to === 'number') .reduce((sum, c) => sum + (c.to - c.from), 0), })); const ranked = [...netByTurn].sort((a, b) => b.net - a.net); const lockedEncounters = history.reduce( (sum, record) => sum + record.offered.filter((o) => !o.available).length, 0, ); // How much of the run was new ground and how much was familiar. The ratio of // turns to distinct situations is the honest measure of whether a pack has // enough content for the length of run it invites. const eventCounts = new Map(); for (const record of history) { eventCounts.set(record.eventId, (eventCounts.get(record.eventId) ?? 0) + 1); } const counts = [...eventCounts.values()]; const mostSeen = [...eventCounts.entries()].sort((a, b) => b[1] - a[1])[0] ?? null; const stagesVisited = []; for (const record of history) { if (stagesVisited.at(-1) !== record.stage) stagesVisited.push(record.stage); } return { turns: history.length, stage: state.stage, notable: state.notable ?? [], primaryPath: primary, peak: primaryTrace.length ? Math.max(...primaryTrace) : null, trough: primaryTrace.length ? Math.min(...primaryTrace) : null, daysInTheRed: primaryTrace.filter((value) => value < 0).length, favouriteAction: favourite ? { label: favourite[0], count: favourite[1] } : null, bestDay: ranked[0]?.net > 0 ? ranked[0] : null, worstDay: ranked.at(-1)?.net < 0 ? ranked.at(-1) : null, lockedEncounters, eventsSeen: eventCounts.size, eventsTotal: (pack.events ?? []).length, eventCounts, repeatedSituations: counts.filter((n) => n > 1).length, averageTimesSeen: eventCounts.size ? history.length / eventCounts.size : 0, mostSeen: mostSeen ? { eventId: mostSeen[0], count: mostSeen[1] } : null, stagesVisited, }; }