First commit, done on M6 from the PLAN.MD

This commit is contained in:
gauvainboiche
2026-10-25 17:34:44 +01:00
parent d5dc2a3901
commit 04193e2013
86 changed files with 8396 additions and 0 deletions
+93
View File
@@ -0,0 +1,93 @@
// 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);
// Averages over the whole term, plus the final debt ratio.
function termAverages(state) {
const s = state.history.series;
const m = {};
for (const id of ["order", "stability", "civilRights", "opinion", "economy", "hardPower", "softPower", "unrest", "armyLoyalty", "growth", "unemployment", "inflation"]) {
m[id] = mean(s[id] ?? []);
}
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(metrics, 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),
};
}