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>; /** short human-readable badges for the UI, e.g. "Speed 0", "−2 Str" */ badges: string[]; } /** 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[]): 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 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 >= 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; } 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); } } // 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]}`); } badges.push(...extraBadges); return { speed: speedZero ? 0 : speed, attackModifier, attacksAgainstModifier, incapacitated, saveModifiers, abilityCheckDisadvantage, statusPenalties, badges, }; } /** * 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; }