feat: Cards writings and balancing (M8)

This commit is contained in:
gauvainboiche
2026-10-25 18:30:43 +01:00
parent 04193e2013
commit c2103c32e7
43 changed files with 25930 additions and 397 deletions
+4 -1
View File
@@ -5,7 +5,8 @@
// title, body, yes: Option, no: Option }
// Option: { label, effects: [...see modifiers.js], setFlags?, clearFlags?, followUp?: { id, inDays } }
// Condition stats: any indicator, growth/unemployment/inflation, "debt" (% of GDP),
// "deficit" (% of GDP) and "q:<sector>" (sector quality).
// "deficit" (% of GDP), "q:<sector>" (sector quality), "ratio:<sector>" (budget / need)
// and "tax:<income|corporate|vat>" (rate).
import { IND_IDS, VAR_IDS, clamp } from "./state.js";
import { computeFiscal } from "./economy.js";
@@ -20,6 +21,8 @@ function statValue(state, key, fiscal) {
if (key === "debt") return fiscal.debtRatio;
if (key === "deficit") return fiscal.deficit;
if (key.startsWith("q:")) return state.sectors[key.slice(2)]?.quality ?? NaN;
if (key.startsWith("ratio:")) { const s = state.sectors[key.slice(6)]; return s ? s.alloc / s.need : NaN; }
if (key.startsWith("tax:")) return state.economy.taxes[key.slice(4)] ?? NaN;
return NaN; // unknown key: never eligible (content tests catch typos)
}
+3
View File
@@ -19,6 +19,8 @@ export function computeFiscal(state, bal) {
const e = state.economy;
let revenue = e.otherRevenue;
for (const id of TAX_IDS) revenue += taxRevenue(id, e.taxes[id], bal);
// Receipts follow the cycle: a slump costs revenue, a boom adds some.
revenue += (bal.fiscal?.growthSens ?? 0) * (e.growth - state.ref.vars.growth);
const debtRatio = (e.debt / e.gdp) * 100;
const rate = interestRate(debtRatio, bal);
@@ -85,4 +87,5 @@ export function setBudget(state, bal, { alloc = {}, taxes = {} } = {}) {
export function confirmBudget(state) {
state.pending.review = false;
state.risk.emergency = false;
}
+15 -2
View File
@@ -4,6 +4,7 @@ import { STATE_VERSION, IND_IDS, VAR_IDS, qualityFromRatio } from "./state.js";
import { computeFiscal, computeDevs, advanceEconomy } from "./economy.js";
import { applyShifts, ageBias } from "./modifiers.js";
import { stepSectors } from "./sectors.js";
import { stepWorld } from "./world.js";
import { stepIndicators, stepVars } from "./indicators.js";
import { checkGameOver } from "./gameover.js";
@@ -43,6 +44,7 @@ export function createGame({ scenario, balance, seed = Date.now(), countryName,
sectors,
indicators: { ...scenario.indicators },
modifiers: [],
world: { cycle: 0 }, // global business cycle, see world.js
flags: {},
cardHistory: [],
pendingCard: null,
@@ -51,7 +53,8 @@ export function createGame({ scenario, balance, seed = Date.now(), countryName,
// Any true flag here pauses time (see advanceDay).
pending: { profile: !PRONOUNS.includes(pronouns), weeklyCard: true, review: true, alert: false },
over: null,
risk: { revolution: 0, coup: 0, impeachment: 0, defaultZone: false }, // days in each danger zone
// days in each danger zone; emergency = an unscheduled budget session is open (see advanceDay)
risk: { revolution: 0, coup: 0, impeachment: 0, defaultZone: false, emergency: false, lastEmergency: -999 },
alerts: [], // urgent telegrams waiting to be read, oldest first
history: { t: [], series: {} },
log: [],
@@ -111,6 +114,7 @@ export function advanceDay(state, bal) {
if (isWaiting(state)) return { advanced: false, blocked: "pending", events: null };
applyShifts(state, bal);
stepWorld(state, bal);
const fiscal = computeFiscal(state, bal);
const devs = computeDevs(state, bal, fiscal);
@@ -123,6 +127,7 @@ export function advanceDay(state, bal) {
state.day += 1;
const events = { weeklyCard: false, review: false, termEnd: false, gameOver: null };
const alertsBefore = state.alerts.length;
const fallen = checkGameOver(state, bal);
if (fallen) {
state.over = { type: fallen, day: state.day };
@@ -135,7 +140,15 @@ export function advanceDay(state, bal) {
} else {
if (state.day % bal.time.historyEvery === 0) recordHistory(state, bal);
if (state.day % bal.time.daysPerWeek === 0) state.pending.weeklyCard = events.weeklyCard = true;
if (state.day % bal.time.daysPerQuarter === 0) state.pending.review = events.review = true;
if (state.day % bal.time.daysPerQuarter === 0) {
state.pending.review = events.review = true;
state.risk.emergency = false;
} else if (state.alerts.length > alertsBefore && state.day - state.risk.lastEmergency >= bal.gameOver.emergencyCooldown) {
// A new danger warning: the cabinet meets at once, so the player can react before the next quarter.
state.pending.review = events.review = true;
state.risk.emergency = true;
state.risk.lastEmergency = state.day;
}
}
return { advanced: true, blocked: null, events };
}
+1 -1
View File
@@ -43,7 +43,7 @@ export function checkGameOver(state, bal) {
}
const inImpeachment = ind.civilRights < g.impeachment.civilRightsBelow && ind.opinion < g.impeachment.opinionBelow;
const inCoup = ind.armyLoyalty < g.coup.armyBelow && ind.stability < g.coup.stabilityBelow;
const inCoup = (ind.armyLoyalty < g.coup.armyBelow && ind.stability < g.coup.stabilityBelow) || ind.armyLoyalty < (g.coup.armyAlone ?? 0);
const inRevolution = ind.unrest > g.revolution.unrestAbove && ind.opinion < g.revolution.opinionBelow;
track("impeachment", inImpeachment, "reset");
track("coup", inCoup, 1);
+10 -4
View File
@@ -6,11 +6,17 @@ import { IND_IDS, VAR_IDS, clamp } from "./state.js";
import { biasFor } from "./modifiers.js";
import { nextNoise } from "./rng.js";
function computeTarget(def, base, group, key, state, devs) {
function computeTarget(def, base, group, key, state, devs, bal) {
let t = base;
const gain = group === "ind" ? bal.gain ?? 1 : 1;
for (const [id, w] of Object.entries(def.sectors ?? {})) {
t += w * (state.sectors[id].quality - state.ref.sectors[id]);
t += gain * w * (state.sectors[id].quality - state.ref.sectors[id]);
}
// Going big pays off: extra weight for quality above `from` (excess) or below it (short).
for (const [id, [from, w]] of Object.entries(def.excess ?? {})) t += w * Math.max(0, state.sectors[id].quality - from);
for (const [id, [from, w]] of Object.entries(def.short ?? {})) t -= w * Math.max(0, from - state.sectors[id].quality);
for (const [k, w] of Object.entries(def.world ?? {})) t += w * state.world[k];
if (def.drift) t += ((def.drift * (bal.driftScale ?? 1)) * state.day) / 365;
for (const [id, w] of Object.entries(def.alloc ?? {})) {
const s = state.sectors[id];
t += w * (s.alloc / s.need - state.ref.ratio[id]);
@@ -28,7 +34,7 @@ export function stepVars(state, bal, devs) {
const next = {};
for (const id of VAR_IDS) {
const def = bal.vars[id];
const target = computeTarget(def, state.ref.vars[id], "var", id, state, devs);
const target = computeTarget(def, state.ref.vars[id], "var", id, state, devs, bal);
const v = state.economy[id];
next[id] = clamp(v + (target - v) / def.tau + nextNoise(state, def.noise), def.min, def.max);
}
@@ -39,7 +45,7 @@ export function stepIndicators(state, bal, devs) {
const next = {};
for (const id of IND_IDS) {
const def = bal.indicators[id];
const target = computeTarget(def, state.ref.ind[id], "ind", id, state, devs);
const target = computeTarget(def, state.ref.ind[id], "ind", id, state, devs, bal);
const v = state.indicators[id];
next[id] = clamp(v + (target - v) / def.tau + nextNoise(state, def.noise ?? bal.noise.indicator), 0, 100);
}
+10 -5
View File
@@ -13,12 +13,17 @@ export const METRIC_IDS = [
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 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 ["order", "stability", "civilRights", "opinion", "economy", "hardPower", "softPower", "unrest", "armyLoyalty", "growth", "unemployment", "inflation"]) {
m[id] = mean(s[id] ?? []);
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;
@@ -76,7 +81,7 @@ function meets(entry, m) {
// 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);
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;
+2
View File
@@ -7,6 +7,8 @@ import { biasFor } from "./modifiers.js";
export function stepSectors(state, bal, fiscal) {
const s = bal.sector;
for (const [id, sec] of Object.entries(state.sectors)) {
// Needs grow with population, ageing and prices: standing still buys less each year.
sec.need *= Math.pow(1 + (bal.needGrowth?.[id] ?? 0), 1 / 365);
let target = qualityFromRatio(sec.alloc / sec.need, bal) + biasFor(state, "sector", id);
if (id === "industry" && fiscal.debtRatio > s.industryDebtPenaltyFrom) {
target -= s.industryDebtPenaltyPerPoint * (fiscal.debtRatio - s.industryDebtPenaltyFrom);
+1 -1
View File
@@ -1,6 +1,6 @@
// Shared constants and tiny helpers. No game logic here.
export const STATE_VERSION = 1;
export const STATE_VERSION = 2;
export const IND_IDS = [
"order", "stability", "civilRights", "opinion", "economy",
+5
View File
@@ -63,6 +63,9 @@ export function restoreState(s, { scenarios, balance, cards }) {
if (m.type === "shift" ? !(isNum(m.perDay) && isNum(m.remaining, 1)) : !(m.type === "bias" && isNum(m.value) && (m.remaining === null || isNum(m.remaining, 1)))) return fail();
}
// World cycle
if (!isObj(s.world) || !isNum(s.world.cycle, -50, 50)) return fail();
// Decisions and flags
if (!isObj(s.flags) || !Object.values(s.flags).every((v) => isNum(v))) return fail();
if (!Array.isArray(s.cardHistory)) return fail();
@@ -77,6 +80,8 @@ export function restoreState(s, { scenarios, balance, cards }) {
for (const k of PENDING_KINDS) s.pending[k] = s.pending[k] === true;
if (!Array.isArray(s.alerts) || !s.alerts.every((a) => isObj(a) && ALERT_KINDS.includes(a.kind) && isInt(a.day, 0, balance.time.termDays))) return fail();
if (!isObj(s.risk) || ![s.risk.revolution, s.risk.coup, s.risk.impeachment].every((v) => isNum(v, 0)) || typeof s.risk.defaultZone !== "boolean") return fail();
s.risk.emergency = s.risk.emergency === true;
if (!isNum(s.risk.lastEmergency)) s.risk.lastEmergency = -999;
if (s.over !== null && !(isObj(s.over) && OVER_TYPES.includes(s.over.type) && isInt(s.over.day, 0, balance.time.termDays))) return fail();
// History for the charts: one equal-length series per tracked value
+18
View File
@@ -0,0 +1,18 @@
// The world outside the country: a slow business cycle with occasional shocks (global
// slumps, rare booms). It moves growth and inflation, so even a perfect budget meets
// good and bad years. state.world.cycle is roughly -4 (deep global recession) to +4.
import { clamp } from "./state.js";
import { nextFloat, nextNoise } from "./rng.js";
export function stepWorld(state, bal) {
const w = bal.world;
const s = state.world;
s.cycle -= s.cycle / w.tau;
s.cycle += nextNoise(state, w.noise);
if (nextFloat(state) < w.shockChancePerDay) {
const size = w.shock * (0.6 + 0.8 * nextFloat(state));
s.cycle += nextFloat(state) < w.boomShare ? size * 0.6 : -size;
}
s.cycle = clamp(s.cycle, -w.max, w.max);
}
+2 -1
View File
@@ -70,8 +70,9 @@
"tax.corporate": "Corporate tax",
"tax.vat": "VAT",
"ledger.title": "Treasury ledger",
"ledger.emergency": "Emergency session: the cabinet has met after the warning. Changes take effect at once.",
"ledger.spending": "Spending, % of GDP",
"ledger.spendingHint": "The tick on each line marks what the sector needs.",
"ledger.spendingHint": "The tick on each line marks what the sector needs. Needs grow a little every year.",
"ledger.totalAlloc": "Allocated to sectors",
"ledger.revenue": "Revenue: tax rates",
"ledger.revenueHint": "The tick marks the rate where revenue peaks. Beyond it, you collect less.",
+2 -1
View File
@@ -69,9 +69,10 @@
"tax.income": "Impôt revenu",
"tax.corporate": "Impôt sociétés",
"tax.vat": "TVA",
"ledger.emergency": "Séance d'urgence : le cabinet s'est réuni après l'alerte. Les changements s'appliquent aussitôt.",
"ledger.title": "Registre du Trésor",
"ledger.spending": "Dépenses, % du PIB",
"ledger.spendingHint": "Le repère sur chaque ligne marque le besoin du secteur.",
"ledger.spendingHint": "Le repère sur chaque ligne marque le besoin du secteur. Les besoins augmentent un peu chaque année.",
"ledger.totalAlloc": "Alloué aux secteurs",
"ledger.revenue": "Recettes : taux d'imposition",
"ledger.revenueHint": "Le repère marque le taux où les recettes sont maximales. Au-delà, vous collectez moins.",
+1
View File
@@ -242,6 +242,7 @@ function devGame() {
prepareWeek();
if (s.pendingCard) answerCard(s, balance, cards, nextFloat(s) < 0.5 ? "yes" : "no");
advanceDay(s, balance);
if (params.has("untilAlert") && s.pending.alert) break; // stop on the first danger warning
}
if (cardsById.has(params.get("card"))) {
s.pendingCard = { id: params.get("card"), day: s.day };
+4 -1
View File
@@ -43,7 +43,10 @@ export function encodeSave(state) {
// Upgrades older saves one version at a time. Add an entry here whenever STATE_VERSION
// changes: MIGRATIONS[n] turns a version n state into a version n+1 state.
export const MIGRATIONS = {};
export const MIGRATIONS = {
// v2 added the world cycle: older saves start it at neutral.
1: (s) => ({ ...s, version: 2, world: { cycle: 0 } }),
};
export function migrate(state) {
if (!Number.isInteger(state?.version)) return { ok: false, error: "format" };
+1
View File
@@ -73,6 +73,7 @@ export function createLedger(root, scenario, { onConfirm }) {
<div class="ledger__book">
<article class="ledger__page ledger__page--left">
<h2>${t("ledger.title")}</h2>
${state.risk.emergency ? `<p class="ledger__emergency">${t("ledger.emergency")}</p>` : ""}
<p class="ledger__sub">${t("ledger.spending")}. ${t("ledger.spendingHint")}</p>
<div class="ledger__rows">${sectorRows}</div>
<footer class="ledger__total">${t("ledger.totalAlloc")} <b data-total></b></footer>
+2
View File
@@ -19,6 +19,8 @@ const LEVELS = [
["army.low", (s, f) => s.indicators.armyLoyalty <= 35, 1.7],
["inflation.high", (s, f) => s.economy.inflation >= 6, 1.5],
["deficit.high", (s, f) => f.deficit >= 8, 1.5],
["world.slump", (s) => (s.world?.cycle ?? 0) <= -1.8, 1.4],
["world.boom", (s) => (s.world?.cycle ?? 0) >= 1.8, 1.2],
];
function candidates(state, tpl) {