import { ABILITY_KEYS, type SystemId } from '@/lib/rules'; import type { Condition } from '@/lib/schemas/common'; import { conditionEffects, type PenaltyTarget } from './conditionEffects'; import type { AbilityKey, AdvState } from './types'; export interface MechanicalState { /** effective speed after conditions (0 if zeroed, halved by exhaustion 2-4, etc.) */ speed: number; /** advantage state on THIS creature's attack rolls (5e) */ attackModifier: AdvState; /** advantage state on attacks made AGAINST this creature (5e) */ attacksAgainstModifier: AdvState; /** cannot take actions/reactions */ incapacitated: boolean; /** per-ability save penalties (auto-fail dominates disadvantage) */ saveModifiers: Partial>; /** disadvantage on ability checks (5e) */ abilityCheckDisadvantage: boolean; /** * pf2e: value-scaled status penalties, by statistic family. Penalties within a * family don't stack (worst value wins); different families are independent and * apply to different rolls, so they are tracked separately — never summed. */ statusPenalties: Partial>; /** * Reduction to maximum HP from conditions: 5e exhaustion 4 halves the max; pf2e * drained N subtracts level × N. 0 when no reduction applies (or when the caller * passes no hpMax/level context). */ hpMaxReduction: number; /** short human-readable badges for the UI, e.g. "Speed 0", "−2 Str" */ badges: string[]; } /** Optional numeric context so deriveState can compute HP-max reductions. */ export interface DeriveContext { /** the creature's *base* maximum HP (before reduction), for 5e exhaustion 4. */ hpMax?: number; /** the creature's level, for pf2e drained (level × value). */ level?: number; } /** Combine two advantage states by the 5e rule: opposing sources cancel to normal. */ function combineAdv(a: AdvState, b: AdvState): AdvState { if (a === b) return a; if (a === 'normal') return b; if (b === 'normal') return a; return 'normal'; // one advantage + one disadvantage cancel } const PENALTY_LABEL: Record = { all: 'all checks', str: 'Str', dex: 'Dex/AC', con: 'Fort', mental: 'spell/mental', }; /** * Fold a creature's conditions into a single mechanical read-model. Pure and * synchronous — combat, the dice roller, and the player view all call this * (the player view ships the same table and derives effects locally from the * broadcast condition names, so no derived state crosses the wire). * * Unknown / homebrew condition names contribute nothing (they remain visible * labels), exactly as before. */ export function deriveState(system: SystemId, baseSpeed: number, conditions: Condition[], ctx?: DeriveContext): MechanicalState { const table = conditionEffects(system); let speed = baseSpeed; let speedZero = false; let attackModifier: AdvState = 'normal'; let attacksAgainstModifier: AdvState = 'normal'; let incapacitated = false; let abilityCheckDisadvantage = false; let allSavesDisadvantage = false; let hpMaxReduction = 0; let prone5e = false; const statusPenalties: Partial> = {}; const saveModifiers: Partial> = {}; const extraBadges: string[] = []; for (const cond of conditions) { const name = cond.name.trim().toLowerCase(); // --- 5e Exhaustion: cumulative level-based effects (PHB). --- if (system === '5e' && name === 'exhaustion' && cond.value) { const lvl = cond.value; if (lvl >= 1) abilityCheckDisadvantage = true; if (lvl >= 2) speed = Math.min(speed, Math.floor(baseSpeed / 2)); // speed halved if (lvl >= 3) { attackModifier = combineAdv(attackModifier, 'disadvantage'); allSavesDisadvantage = true; } if (lvl >= 4 && ctx?.hpMax) hpMaxReduction = Math.floor(ctx.hpMax / 2); // HP maximum halved if (lvl >= 5) speedZero = true; // speed 0 extraBadges.push(lvl >= 6 ? 'Exhaustion 6 (dead)' : `Exhaustion ${lvl}`); continue; } // --- pf2e Slowed: action loss only, no roll penalty. --- if (system === 'pf2e' && name === 'slowed') { extraBadges.push(cond.value ? `Slowed ${cond.value} (−${cond.value} actions)` : 'Slowed'); continue; } // --- pf2e Fatigued: flat −1 to AC and saves (not value-scaled). --- if (system === 'pf2e' && name === 'fatigued') { extraBadges.push('Fatigued (−1 AC & saves)'); continue; } // --- pf2e Quickened: grants one extra action; no roll penalty. --- if (system === 'pf2e' && name === 'quickened') { extraBadges.push('Quickened (+1 action)'); continue; } // --- pf2e Persistent Damage: the human rolls it; we surface the procedure. --- if (system === 'pf2e' && name === 'persistent damage') { extraBadges.push(cond.value ? `Persistent damage ${cond.value} (apply at end of turn, then DC 15 flat check to end)` : 'Persistent damage (apply at end of turn, then DC 15 flat check to end)'); continue; } const eff = table[name]; if (!eff) continue; if (eff.speedZero) speedZero = true; if (eff.incapacitated) incapacitated = true; if (eff.abilityChecks) abilityCheckDisadvantage = true; if (eff.attackRolls) attackModifier = combineAdv(attackModifier, eff.attackRolls); if (eff.attacksAgainst) attacksAgainstModifier = combineAdv(attacksAgainstModifier, eff.attacksAgainst); if (system === '5e' && name === 'prone') prone5e = true; if (eff.saves) { for (const [ability, level] of Object.entries(eff.saves) as [AbilityKey, 'disadvantage' | 'auto-fail'][]) { // auto-fail dominates disadvantage if (level === 'auto-fail' || saveModifiers[ability] === undefined) saveModifiers[ability] = level; } } // pf2e: value-scaled penalty per statistic family — worst value within a family. if (eff.statusPenalty && cond.value !== undefined) { const fam = eff.statusPenalty; statusPenalties[fam] = Math.max(statusPenalties[fam] ?? 0, cond.value); } // pf2e Drained N also reduces maximum HP by level × N. if (system === 'pf2e' && name === 'drained' && cond.value && ctx?.level) { hpMaxReduction += ctx.level * cond.value; } } // 5e exhaustion 3+ imposes disadvantage on all saves (unless already auto-fail). if (allSavesDisadvantage) { for (const a of ABILITY_KEYS) if (saveModifiers[a] !== 'auto-fail') saveModifiers[a] = 'disadvantage'; } const badges: string[] = []; if (speedZero) badges.push('Speed 0'); else if (speed !== baseSpeed) badges.push(`Speed ${speed}`); if (incapacitated) badges.push('Incapacitated'); if (attackModifier === 'disadvantage') badges.push('Disadv. attacks'); if (attackModifier === 'advantage') badges.push('Adv. attacks'); if (attacksAgainstModifier === 'advantage') { // Prone is range-dependent: melee attackers have advantage, ranged have disadvantage. badges.push(prone5e ? 'Attacked: adv. melee / disadv. ranged' : 'Attacked w/ adv.'); } for (const [fam, val] of Object.entries(statusPenalties) as [PenaltyTarget, number][]) { if (val > 0) badges.push(`−${val} ${PENALTY_LABEL[fam]}`); } if (hpMaxReduction > 0) badges.push(`Max HP −${hpMaxReduction}`); badges.push(...extraBadges); return { speed: speedZero ? 0 : speed, attackModifier, attacksAgainstModifier, incapacitated, saveModifiers, abilityCheckDisadvantage, statusPenalties, hpMaxReduction, badges, }; } /** * Effective maximum HP after condition-based reductions: 5e exhaustion 4 halves the * maximum; pf2e Drained N subtracts level × N. Pure — the sheet and tracker show this * reduced max but never mutate the stored `hp.max`, matching the surface-don't-apply * rule. Returns the reduced max plus human-readable reasons for the UI to explain it. */ export function deriveEffectiveMaxHp(input: { system: SystemId; baseMaxHp: number; level: number; /** 5e exhaustion level (from defenses), if tracked separately from conditions. */ exhaustion?: number; conditions: Condition[]; }): { max: number; reduction: number; reasons: string[] } { let reduction = 0; const reasons: string[] = []; if (input.system === '5e' && (input.exhaustion ?? 0) >= 4) { reduction += Math.floor(input.baseMaxHp / 2); reasons.push('Exhaustion 4 — maximum HP halved'); } if (input.system === 'pf2e') { const drained = input.conditions.find((c) => c.name.trim().toLowerCase() === 'drained')?.value ?? 0; if (drained > 0) { const drop = input.level * drained; reduction += drop; reasons.push(`Drained ${drained} — −${drop} maximum HP`); } } return { max: Math.max(1, input.baseMaxHp - reduction), reduction, reasons }; } /** * End-of-turn auto-decay for valued conditions. In PF2e, Frightened decreases by 1 * at the end of each of the affected creature's turns (removed at 0). Returns a new * conditions array; callers persist it when a combatant's turn ends. */ export function tickConditionsEndOfTurn(system: SystemId, conditions: Condition[]): Condition[] { if (system !== 'pf2e') return conditions; let changed = false; const next: Condition[] = []; for (const c of conditions) { if (c.name.trim().toLowerCase() === 'frightened' && c.value !== undefined) { changed = true; const value = c.value - 1; if (value > 0) next.push({ ...c, value }); // value 0 → condition ends, drop it } else { next.push(c); } } return changed ? next : conditions; }