feat: Cards writings and balancing (M8)
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user