// Overview of the card pool, for reviewing content and balance. // node tools/card-stats.mjs whole pool // node tools/card-stats.mjs economy one category in detail (adds the longest texts) import fs from "node:fs"; import { fileURLToPath } from "node:url"; import { optionImpact } from "../web/js/engine/index.js"; const dir = fileURLToPath(new URL("../web/data/cards/", import.meta.url)); const files = JSON.parse(fs.readFileSync(dir + "index.json", "utf8")).files; const only = process.argv[2]; const pool = files.flatMap((f) => JSON.parse(fs.readFileSync(dir + f, "utf8"))); const cards = only ? pool.filter((c) => c.category === only) : pool; if (!cards.length) { console.error("no cards for", only); process.exit(1); } const count = (xs, key) => xs.reduce((m, x) => ((m[key(x)] = (m[key(x)] ?? 0) + 1), m), {}); const mean = (xs) => (xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : 0); const fmt = (n) => n.toFixed(2); const table = (o) => Object.entries(o).map(([k, v]) => `${k} ${v}`).join(", "); console.log(`cards: ${cards.length}${only ? ` in ${only}` : ` in ${files.length} categories`}`); if (!only) console.log("per category:", table(count(cards, (c) => c.category))); console.log("doc types:", table(count(cards, (c) => c.doc))); console.log("senders:", table(count(cards, (c) => c.sender))); console.log("follow-up only:", cards.filter((c) => c.followUpOnly).length, "| repeatable:", cards.filter((c) => c.once === false).length); const bucket = (c) => { const d = c.conditions?.dayMin ?? 0; if (d < 90) return "start"; if (d < 365) return "y1"; if (d < 730) return "y2"; if (d < 1095) return "y3"; return "y4+"; }; console.log("available from:", table(count(cards, bucket))); console.log("with conditions:", cards.filter((c) => c.conditions).length, "| with stat conditions:", cards.filter((c) => c.conditions?.min || c.conditions?.max).length); const set = new Set(); const used = new Set(); let links = 0; for (const c of cards) { for (const side of ["yes", "no"]) { for (const f of c[side].setFlags ?? []) set.add(f); if (c[side].followUp) links++; } for (const f of [...(c.conditions?.flags ?? []), ...(c.conditions?.notFlags ?? [])]) used.add(f); } console.log(`flags set: ${set.size} | flags used: ${used.size} | follow-up links: ${links}`); // Balance: what an average card does to the country, for each answer const stat = (c, side, pick) => (c[side].effects ?? []).filter(pick).reduce((s, e) => s + (e.delta ?? e.oneOff ?? e.extraSpending ?? 0), 0); const row = (label, f) => console.log(label.padEnd(34), `yes ${fmt(mean(cards.map((c) => f(c, "yes"))))}`.padEnd(12), `no ${fmt(mean(cards.map((c) => f(c, "no"))))}`.padEnd(12), `coin flip ${fmt(mean(cards.map((c) => (f(c, "yes") + f(c, "no")) / 2)))}`); console.log("\naverage per card (instant, spread and bias deltas added up):"); row("impact (size of the effects)", (c, s) => optionImpact(c[s])); for (const id of ["opinion", "stability", "economy", "order", "civilRights", "unrest"]) row(`${id}`, (c, s) => stat(c, s, (e) => e.stat === id)); row("one-off cost, % of GDP", (c, s) => stat(c, s, (e) => e.oneOff !== undefined)); row("permanent extra spending, % GDP", (c, s) => stat(c, s, (e) => e.extraSpending !== undefined)); row("tax rate points (income+corp+vat)", (c, s) => stat(c, s, (e) => ["income", "corporate", "vat"].includes(e.revenue))); row("other revenue, % of GDP", (c, s) => stat(c, s, (e) => e.revenue === "other")); const effects = cards.flatMap((c) => ["yes", "no"].flatMap((s) => c[s].effects ?? [])); console.log("\neffects per option:", fmt(effects.length / (cards.length * 2)), "| with bias:", effects.filter((e) => e.bias).length, "| spread over time:", effects.filter((e) => e.days && !e.bias).length, "| instant:", effects.filter((e) => (e.stat || e.sector) && !e.days && !e.bias).length); const long = cards.map((c) => ({ id: c.id, en: c.body.en.length, fr: c.body.fr.length })).filter((x) => x.en > 360 || x.fr > 400); if (long.length) console.log("\nlong bodies (en > 360 or fr > 400 chars):", long.map((x) => `${x.id} (${x.en}/${x.fr})`).join(", ")); const dupTitles = Object.entries(count(cards, (c) => c.title.en.toLowerCase())).filter(([, n]) => n > 1); if (dupTitles.length) console.log("duplicate English titles:", dupTitles.map(([t]) => t).join("; ")); const labelDup = cards.filter((c) => c.yes.label.en === c.no.label.en); if (labelDup.length) console.log("same label on both options:", labelDup.map((c) => c.id).join(", "));