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

97 lines
5.7 KiB
JavaScript

// Checks a game loaded from a save (autosave, slot or imported string) before the game
// resumes from it, and repairs what can be repaired. A hostile or damaged save must never
// get as far as the UI: every number is finite and in range, every id is known.
import { STATE_VERSION, IND_IDS, VAR_IDS, TAX_IDS } from "./state.js";
import { PRONOUNS } from "./game.js";
const DEV_KEYS = ["growth", "unemployment", "inflation", "debt", "deficit", "taxPressure", "taxGrowthDrag", "crackdown"];
const HISTORY_SERIES = ["debtRatio", "deficit", ...IND_IDS, ...VAR_IDS];
const OVER_TYPES = ["term", "revolution", "coup", "default", "impeachment"];
const ALERT_KINDS = ["revolution", "coup", "impeachment", "default"];
const PENDING_KINDS = ["profile", "weeklyCard", "review", "alert"];
const isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
const isNum = (v, lo = -Infinity, hi = Infinity) => typeof v === "number" && Number.isFinite(v) && v >= lo && v <= hi;
const isInt = (v, lo, hi) => Number.isInteger(v) && v >= lo && v <= hi;
const sameKeys = (obj, keys) => isObj(obj) && Object.keys(obj).length === keys.length && keys.every((k) => k in obj);
const allNums = (obj, keys, lo, hi) => sameKeys(obj, keys) && keys.every((k) => isNum(obj[k], lo, hi));
// Returns { ok: true, state } (repaired in place) or { ok: false, error: "invalid" | "scenario" | "version" }.
export function restoreState(s, { scenarios, balance, cards }) {
const fail = (error = "invalid") => ({ ok: false, error });
if (!isObj(s) || s.version !== STATE_VERSION) return fail("version");
const scenario = scenarios[s.scenarioId];
if (!scenario) return fail("scenario");
const lim = balance.limits;
const cardIds = new Set(cards.map((c) => c.id));
const sectorIds = Object.keys(scenario.sectors);
const modGroups = { ind: IND_IDS, var: VAR_IDS, sector: sectorIds };
// Scalars
if (!isInt(s.day, 0, balance.time.termDays)) return fail();
if (!isInt(s.rngState, 0, 0xffffffff) || !isInt(s.seed, 0, 0xffffffff)) return fail();
if (s.speed !== 0 && !balance.time.speeds.includes(s.speed)) return fail();
if (s.startDate !== scenario.startDate) return fail();
if (!PRONOUNS.includes(s.pronouns)) return fail();
if (typeof s.countryName !== "string" || s.countryName.length < 1 || s.countryName.length > 60) return fail();
if (s.lang !== "en" && s.lang !== "fr") s.lang = "en";
// Economy, sectors, indicators
const e = s.economy;
if (!isObj(e) || !isNum(e.gdp, 1e-6) || !isNum(e.debt, 0) || !isNum(e.otherRevenue) || !isNum(e.extraSpending)) return fail();
if (!isNum(e.growth, -100, 100) || !isNum(e.unemployment, 0, 100) || !isNum(e.inflation, -100, 1000)) return fail();
if (!allNums(e.taxes, TAX_IDS, 0, 500)) return fail();
if (!sameKeys(s.sectors, sectorIds)) return fail();
for (const id of sectorIds) {
const x = s.sectors[id];
if (!isObj(x) || !isNum(x.need, 1e-6) || !isNum(x.alloc, 0, x.need * lim.allocMaxRatio + 1e-9) || !isNum(x.quality, 0, 100)) return fail();
}
if (!allNums(s.indicators, IND_IDS, 0, 100)) return fail();
// Reference values captured at the start of the game
const r = s.ref;
if (!isObj(r) || !allNums(r.taxes, TAX_IDS, 0, 500) || !allNums(r.ind, IND_IDS, 0, 100)) return fail();
if (!allNums(r.vars, VAR_IDS) || !allNums(r.sectors, sectorIds) || !allNums(r.ratio, sectorIds) || !allNums(r.dev, DEV_KEYS)) return fail();
// Timed modifiers
if (!Array.isArray(s.modifiers)) return fail();
for (const m of s.modifiers) {
if (!isObj(m) || !(m.group in modGroups) || !modGroups[m.group].includes(m.key)) return fail();
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();
}
// Decisions and flags
if (!isObj(s.flags) || !Object.values(s.flags).every((v) => isNum(v))) return fail();
if (!Array.isArray(s.cardHistory)) return fail();
for (const h of s.cardHistory) {
if (!isObj(h) || typeof h.id !== "string" || typeof h.category !== "string" || !isInt(h.day, 0, balance.time.termDays) || (h.choice !== "yes" && h.choice !== "no")) return fail();
}
if (!Array.isArray(s.followUps) || !s.followUps.every((f) => isObj(f) && typeof f.id === "string" && isNum(f.due))) return fail();
if (s.pendingCard !== null && !(isObj(s.pendingCard) && typeof s.pendingCard.id === "string" && isInt(s.pendingCard.day, 0, balance.time.termDays))) return fail();
// Pauses, alerts and endings
if (!isObj(s.pending)) return fail();
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();
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
const h = s.history;
if (!isObj(h) || !Array.isArray(h.t) || !isObj(h.series) || !h.t.every((d) => isInt(d, 0, balance.time.termDays))) return fail();
for (const id of HISTORY_SERIES) {
const series = h.series[id];
if (!Array.isArray(series) || series.length !== h.t.length || !series.every((v) => isNum(v))) return fail();
}
if (!Array.isArray(s.log)) return fail();
// Repairs: content that changed since the save was made
s.followUps = s.followUps.filter((f) => cardIds.has(f.id));
if (s.pendingCard && !cardIds.has(s.pendingCard.id)) s.pendingCard = null;
s.pending.alert = s.alerts.length > 0;
return { ok: true, state: s };
}