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

117 lines
5.6 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 waiting = new Set(state.inbox.map((e) => e.id));
// the last few categories seen, answered or still in the tray
const recent = [...state.cardHistory, ...state.inbox].slice(-bal.cards.recentWindow).map((h) => h.category);
const pool = [];
const weights = [];
for (const card of cards) {
if (card.followUpOnly || waiting.has(card.id)) continue;
if (card.scenarios && !card.scenarios.includes(state.scenarioId)) 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 when a new week starts (state.pending.weeklyCard): draws the week's proposal and puts it
// at the bottom of the in-tray (state.inbox). A due follow-up always comes first. With no eligible
// card the week is simply empty. Proposals pile up without stopping the clock; the game only waits
// once the tray holds bal.cards.inboxMax of them (pending.inboxFull), until one is answered.
export function ensureCard(state, bal, cards) {
if (!state.pending.weeklyCard) return null;
state.pending.weeklyCard = false;
const byId = indexCards(cards);
const waiting = new Set(state.inbox.map((e) => e.id));
let card = null;
const due = state.followUps.findIndex((f) => f.due <= state.day && !waiting.has(f.id));
if (due >= 0) card = byId.get(state.followUps.splice(due, 1)[0].id) ?? null;
card ??= drawCard(state, bal, cards);
if (!card) return null;
state.inbox.push({ id: card.id, day: state.day, category: card.category });
if (state.inbox.length >= bal.cards.inboxMax) state.pending.inboxFull = true;
return card;
}
// Answers the proposal `id` (default: the one on top of the tray, the oldest).
export function answerCard(state, bal, cards, choice, id) {
if (choice !== "yes" && choice !== "no") return { ok: false };
const at = id === undefined ? 0 : state.inbox.findIndex((e) => e.id === id);
const entry = state.inbox[at];
if (!entry) return { ok: false };
const card = indexCards(cards).get(entry.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.inbox.splice(at, 1);
state.pending.inboxFull = state.inbox.length >= bal.cards.inboxMax;
return { ok: true, card };
}