// End-of-term review: a score from the whole term (not just the last day), a letter // grade, the historian's title and the decisions that mattered most. // Pure: works for a finished term and for a game that ended early. import { clamp } from "./state.js"; import { indexCards } from "./cards.js"; // Names usable in the `min` / `max` conditions of endgame.json titles. export const METRIC_IDS = [ "order", "stability", "civilRights", "opinion", "economy", "hardPower", "softPower", "unrest", "armyLoyalty", "growth", "unemployment", "inflation", "debt", "score", "years", ]; const mean = (xs) => (xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : 0); const AVERAGED = ["order", "stability", "civilRights", "opinion", "economy", "hardPower", "softPower", "unrest", "armyLoyalty", "growth", "unemployment", "inflation"]; // Averages over the whole term, plus the final debt ratio. With `lastYearShare` the average // leans on the last 52 weekly samples: the historian judges what you leave behind, so a // boom bought on credit does not pay for itself. function termAverages(state, lastYearShare = 0) { const s = state.history.series; const m = {}; for (const id of AVERAGED) { const all = s[id] ?? []; m[id] = lastYearShare > 0 ? (1 - lastYearShare) * mean(all) + lastYearShare * mean(all.slice(-52)) : mean(all); } m.debt = s.debtRatio?.at(-1) ?? 0; m.years = state.day / 365; return m; } function computeScore(m, bal) { const sc = bal.score; let base = 0; for (const [id, w] of Object.entries(sc.weights)) base += w * (id === "calm" ? 100 - m.unrest : m[id]); const bonus = (cfg, value) => clamp((cfg.ref - value) * cfg.per, cfg.min, cfg.max); const total = base + bonus(sc.debt, m.debt) + clamp((m.growth - sc.growth.ref) * sc.growth.per, sc.growth.min, sc.growth.max) + bonus(sc.unemployment, m.unemployment); return clamp(total, 0, 100); } const gradeOf = (score, bal) => bal.score.grades.find(([min]) => score >= min)?.[1] ?? "F"; // How strongly one effect moves the country, in rough "points". Used to rank decisions. export function effectImpact(e) { const d = Math.abs(e.delta ?? 0); if (e.stat !== undefined || e.sector !== undefined) { if (e.bias) return d * clamp((e.days ?? 365) / 90, 1, 8); return d; } if (e.revenue !== undefined) return d * (e.revenue === "other" ? 8 : 2); if (e.oneOff !== undefined) return Math.abs(e.oneOff) * 5; if (e.extraSpending !== undefined) return Math.abs(e.extraSpending) * 8; return 0; } export const optionImpact = (option) => (option.effects ?? []).reduce((sum, e) => sum + effectImpact(e), 0); function topDecisions(state, cards, count) { const byId = indexCards(cards); return state.cardHistory .map((h) => { const card = byId.get(h.id); return card ? { id: h.id, day: h.day, choice: h.choice, impact: optionImpact(card[h.choice]) } : null; }) .filter(Boolean) .sort((a, b) => b.impact - a.impact || a.day - b.day) .slice(0, count); } function meets(entry, m) { const c = entry.conditions ?? {}; for (const [k, v] of Object.entries(c.min ?? {})) if (!(m[k] >= v)) return false; for (const [k, v] of Object.entries(c.max ?? {})) if (!(m[k] <= v)) return false; return true; } // endgame: the parsed data/endgame.json (its `titles` list is in priority order). export function buildReview(state, bal, cards, endgame) { const metrics = termAverages(state); let score = computeScore(termAverages(state, bal.score.lastYearShare ?? 0), bal); if (state.over && state.over.type !== "term") score = Math.min(score, bal.score.overCap); metrics.score = score; const earlyEnd = state.over && state.over.type !== "term" ? state.over.type : null; const title = endgame.titles.find((t) => (t.over ?? null) === earlyEnd && (earlyEnd || meets(t, metrics))); return { over: state.over, metrics, score, grade: earlyEnd ? "F" : gradeOf(score, bal), titleId: title?.id ?? null, topDecisions: topDecisions(state, cards, bal.score.topDecisions), }; }