First commit, done on M6 from the PLAN.MD

This commit is contained in:
gauvainboiche
2026-10-25 17:34:44 +01:00
parent d5dc2a3901
commit 04193e2013
86 changed files with 8396 additions and 0 deletions
+104
View File
@@ -0,0 +1,104 @@
// Weekly decision cards: drawing, eligibility and applying the player's answer.
//
// Card: { id, category, doc, sender, weight?, once?, cooldownDays?, followUpOnly?,
// conditions?: { min:{stat:n}, max:{stat:n}, flags:[], notFlags:[], dayMin, dayMax },
// 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).
import { IND_IDS, VAR_IDS, clamp } from "./state.js";
import { computeFiscal } from "./economy.js";
import { applyEffect } from "./modifiers.js";
import { pickWeighted } from "./rng.js";
export const indexCards = (cards) => new Map(cards.map((c) => [c.id, c]));
function statValue(state, key, fiscal) {
if (IND_IDS.includes(key)) return state.indicators[key];
if (VAR_IDS.includes(key)) return state.economy[key];
if (key === "debt") return fiscal.debtRatio;
if (key === "deficit") return fiscal.deficit;
if (key.startsWith("q:")) return state.sectors[key.slice(2)]?.quality ?? NaN;
return NaN; // unknown key: never eligible (content tests catch typos)
}
function meetsConditions(card, state, fiscal) {
const c = card.conditions;
if (!c) return true;
if (c.dayMin !== undefined && state.day < c.dayMin) return false;
if (c.dayMax !== undefined && state.day > c.dayMax) return false;
for (const [k, v] of Object.entries(c.min ?? {})) if (!(statValue(state, k, fiscal) >= v)) return false;
for (const [k, v] of Object.entries(c.max ?? {})) if (!(statValue(state, k, fiscal) <= v)) return false;
for (const f of c.flags ?? []) if (!state.flags[f]) return false;
for (const f of c.notFlags ?? []) if (state.flags[f]) return false;
return true;
}
// A sector (or indicator) in trouble makes cards about it more likely.
function boostFor(card, state, bal) {
const b = bal.cards.boost[card.category];
if (!b) return 1;
const v = b.sector ? state.sectors[b.sector].quality : state.indicators[b.indicator];
const below = bal.cards.boostBelow;
return 1 + bal.cards.boostMax * clamp((below - v) / below, 0, 1);
}
export function drawCard(state, bal, cards) {
const fiscal = computeFiscal(state, bal);
const lastAnswered = new Map();
for (const h of state.cardHistory) lastAnswered.set(h.id, h.day);
const recent = state.cardHistory.slice(-bal.cards.recentWindow).map((h) => h.category);
const pool = [];
const weights = [];
for (const card of cards) {
if (card.followUpOnly) continue;
const last = lastAnswered.get(card.id);
if (last !== undefined) {
if (card.once !== false) continue;
if (state.day - last < (card.cooldownDays ?? bal.cards.defaultCooldown)) continue;
}
if (!meetsConditions(card, state, fiscal)) continue;
const seen = recent.filter((c) => c === card.category).length;
pool.push(card);
weights.push((card.weight ?? 1) * boostFor(card, state, bal) * Math.pow(bal.cards.recentPenalty, seen));
}
return pickWeighted(state, pool, weights);
}
// Called once a weekly pause is pending: puts the card to answer in state.pendingCard.
// A due follow-up always comes first. With no eligible card the week is simply empty.
export function ensureCard(state, bal, cards) {
if (!state.pending.weeklyCard || state.pendingCard) return null;
const byId = indexCards(cards);
let card = null;
const due = state.followUps.findIndex((f) => f.due <= state.day);
if (due >= 0) card = byId.get(state.followUps.splice(due, 1)[0].id) ?? null;
card ??= drawCard(state, bal, cards);
if (!card) {
state.pending.weeklyCard = false;
return null;
}
state.pendingCard = { id: card.id, day: state.day };
return card;
}
export function answerCard(state, bal, cards, choice) {
if (!state.pendingCard || (choice !== "yes" && choice !== "no")) return { ok: false };
const card = indexCards(cards).get(state.pendingCard.id);
if (!card) return { ok: false };
const option = card[choice];
for (const effect of option.effects ?? []) applyEffect(state, bal, effect);
for (const f of option.setFlags ?? []) state.flags[f] = state.day;
for (const f of option.clearFlags ?? []) delete state.flags[f];
if (option.followUp) state.followUps.push({ id: option.followUp.id, due: state.day + option.followUp.inDays });
state.cardHistory.push({ id: card.id, category: card.category, day: state.day, choice });
state.pendingCard = null;
state.pending.weeklyCard = false;
return { ok: true, card };
}