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
+62 -2
View File
@@ -170,6 +170,7 @@ assert.equal(E.advanceDay(s, bal).blocked, "over");
const drawn = new Set();
for (let guard = 0; guard < 6000 && !gg.over; guard++) {
if (gg.pending.review) E.confirmBudget(gg);
while (gg.pending.alert) E.dismissAlert(gg);
if (gg.pending.weeklyCard) {
const card = E.ensureCard(gg, bal, real);
if (card) { drawn.add(card.id); E.answerCard(gg, bal, real, E.nextFloat(gg) < 0.5 ? "yes" : "no"); }
@@ -179,7 +180,7 @@ assert.equal(E.advanceDay(s, bal).blocked, "over");
return { gg, drawn };
};
const a = play(11), b = play(11);
assert.ok(a.gg.over && a.gg.over.type === "term", "random play reaches the end of the term");
assert.ok(a.gg.over, "random play ends one way or another (the term, or a fall: passive play is risky since M8)");
assert.deepEqual(a.gg, b.gg, "same seed and answers give the same game");
assert.ok(a.drawn.size >= 40, "most of the pool is seen in a run: " + a.drawn.size);
assert.equal(a.gg.cardHistory.length, a.gg.cardHistory.filter((h) => real.some((c) => c.id === h.id)).length);
@@ -274,7 +275,7 @@ assert.equal(E.advanceDay(s, bal).blocked, "over");
const calm = run(21);
assert.equal(calm.over.type, "term");
const rv = E.buildReview(calm, bal, real, endgame);
assert.ok(rv.score > 40 && rv.score < 80, "a hands-off term scores mid-range: " + rv.score);
assert.ok(rv.score > 20 && rv.score < 80, "a hands-off term scores in range (low since M8): " + rv.score);
assert.ok(["S", "A", "B", "C", "D", "F"].includes(rv.grade));
assert.ok(endgame.titles.some((x) => x.id === rv.titleId && !x.over), "a term title");
assert.ok(rv.topDecisions.length > 0 && rv.topDecisions.length <= 5);
@@ -432,4 +433,63 @@ assert.equal(E.advanceDay(s, bal).blocked, "over");
assert.deepEqual(readSave(angry, KEYS.auto).state, clone(g));
}
// M8: world cycle, growing needs, card conditions on choices, coup from neglect, v1 saves
{
// the world cycle moves, stays within bounds and is part of the saved state
let w = fresh(); open(w);
let lo = 0, hi = 0;
for (let i = 0; i < 1500; i++) { run(w, 1); lo = Math.min(lo, w.world.cycle); hi = Math.max(hi, w.world.cycle); }
assert.ok(hi - lo > 1, "the cycle moves: " + (hi - lo));
assert.ok(lo >= -bal.world.max && hi <= bal.world.max);
// needs grow, so a frozen budget buys less quality
const n0 = fresh().sectors.health.need;
const g1 = fresh(); run(g1, 365);
assert.ok(Math.abs(g1.sectors.health.need / n0 - (1 + bal.needGrowth.health)) < 1e-6, "health need grows by its yearly rate");
// conditions can read the player's choices: budget ratio and tax rate
const c = fresh(); open(c);
const cond = (conditions) => E.drawCard(c, bal, [{ id: "x", category: "crises", conditions, yes: {}, no: {} }]);
assert.equal(cond({ min: { "ratio:police": 1.5 } }), null, "police at its starting ratio is below 1.5");
assert.equal(cond({ max: { "ratio:police": 1.5 } })?.id, "x");
c.sectors.police.alloc = c.sectors.police.need * 2;
assert.equal(cond({ min: { "ratio:police": 1.5 } })?.id, "x");
assert.equal(cond({ min: { "tax:income": 31 } }), null);
c.economy.taxes.income = 45;
assert.equal(cond({ min: { "tax:income": 31 } })?.id, "x");
assert.equal(cond({ min: { "ratio:nope": 0 } }), null, "unknown sector is never eligible");
// severe army neglect alone is enough for a coup (stability fine)
const k = fresh(); open(k);
for (let i = 0; i < 400 && !k.over; i++) { k.indicators.armyLoyalty = bal.gameOver.coup.armyAlone - 1; k.indicators.stability = 90; while (k.pending.alert) E.dismissAlert(k); run(k, 1); }
assert.equal(k.over?.type, "coup");
// a new danger warning opens an emergency budget session, at most once per cooldown
const em = fresh(); open(em);
em.indicators.unrest = 95; em.indicators.opinion = 2;
E.advanceDay(em, bal);
assert.equal(em.alerts.length, 1);
assert.equal(em.pending.review, true, "the warning opens the budget");
assert.equal(em.risk.emergency, true);
E.confirmBudget(em);
assert.equal(em.risk.emergency, false, "signing closes the emergency session");
while (em.pending.alert) E.dismissAlert(em);
// leave the zone, re-enter it inside the cooldown: a second alert but no second session
em.indicators.unrest = 20; em.indicators.opinion = 60;
for (let i = 0; i < 3; i++) { if (em.pending.weeklyCard) E.clearPending(em, "weeklyCard"); E.advanceDay(em, bal); }
em.indicators.unrest = 95; em.indicators.opinion = 2;
if (em.pending.weeklyCard) E.clearPending(em, "weeklyCard");
E.advanceDay(em, bal);
assert.equal(em.alerts.length, 1, "a second warning");
assert.equal(em.pending.review, false, "no second session inside the cooldown");
// a version-1 save is upgraded with a neutral world and then resumes
const clone = (x) => JSON.parse(JSON.stringify(x));
const content = { scenarios: { default: scn }, balance: bal, cards: [] };
const v1 = clone(fresh()); delete v1.world; v1.version = 1;
const up = decodeSave(encodeSave(v1));
assert.equal(up.ok, true); assert.equal(up.state.version, 2); assert.deepEqual(up.state.world, { cycle: 0 });
assert.equal(E.restoreState(up.state, content).ok, true);
}
console.log("all engine checks passed");