Files
Country-Game---The-Desk/web/js/engine/score.js
T

99 lines
4.0 KiB
JavaScript

// 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),
};
}