108 lines
4.7 KiB
JavaScript
108 lines
4.7 KiB
JavaScript
// 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), "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";
|
|
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;
|
|
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)
|
|
}
|
|
|
|
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 };
|
|
}
|