diff --git a/src/features/combat/EncounterTracker.tsx b/src/features/combat/EncounterTracker.tsx index 8b3e518..1f1eaf8 100644 --- a/src/features/combat/EncounterTracker.tsx +++ b/src/features/combat/EncounterTracker.tsx @@ -89,8 +89,8 @@ export function EncounterTracker({ encounter, campaign }: { encounter: Encounter /** Advance the turn; remind (don't roll) when a downed PC's turn begins. */ const advanceTurn = () => { - mutate(nextTurn); - const upcoming = currentCombatant(nextTurn(encounter)); + mutate((e) => nextTurn(e, campaign.system)); + const upcoming = currentCombatant(nextTurn(encounter, campaign.system)); if (upcoming && upcoming.kind !== 'monster' && upcoming.hp.current <= 0 && pcCharacter(upcoming)) { void encountersRepo.mutate(encounter.id, (e) => logEvent(e, `${upcoming.name} is down — make a death saving throw.`)); } diff --git a/src/lib/combat/engine.ts b/src/lib/combat/engine.ts index ac63e49..6806ab6 100644 --- a/src/lib/combat/engine.ts +++ b/src/lib/combat/engine.ts @@ -1,4 +1,6 @@ import type { Combatant, Encounter } from '@/lib/schemas/encounter'; +import type { SystemId } from '@/lib/rules'; +import { tickConditionsEndOfTurn } from '@/lib/mechanics/creatureState'; /** * Pure combat-state transitions. Every function returns a NEW encounter and @@ -64,14 +66,30 @@ export function endEncounter(enc: Encounter): Encounter { /** * Advance to the next combatant; wrapping past the end increments the round. * Ticks the newly-active combatant's timed conditions and logs expirations. + * + * When `system` is provided, the combatant whose turn is *ending* also has its + * end-of-turn condition decay applied (PF2e Frightened drops by 1). */ -export function nextTurn(enc: Encounter): Encounter { +export function nextTurn(enc: Encounter, system?: SystemId): Encounter { if (enc.combatants.length === 0) return { ...enc, turnIndex: 0 }; - const atEnd = enc.turnIndex >= enc.combatants.length - 1; - const turnIndex = atEnd ? 0 : enc.turnIndex + 1; - const round = atEnd ? enc.round + 1 : enc.round; - let next: Encounter = { ...enc, turnIndex, round }; + // End-of-turn auto-decay for the combatant whose turn is ending (pf2e Frightened). + let from: Encounter = enc; + if (system) { + const outgoing = enc.combatants[enc.turnIndex]; + if (outgoing) { + const decayed = tickConditionsEndOfTurn(system, outgoing.conditions); + if (decayed !== outgoing.conditions) { + from = { ...enc, combatants: enc.combatants.map((c, i) => (i === enc.turnIndex ? { ...c, conditions: decayed } : c)) }; + } + } + } + + const atEnd = from.turnIndex >= from.combatants.length - 1; + const turnIndex = atEnd ? 0 : from.turnIndex + 1; + const round = atEnd ? from.round + 1 : from.round; + + let next: Encounter = { ...from, turnIndex, round }; if (atEnd) next = logEvent(next, `— Round ${round} —`); // Tick the combatant whose turn is now beginning. diff --git a/src/lib/mechanics/conditionEffects.ts b/src/lib/mechanics/conditionEffects.ts index 822f638..0802887 100644 --- a/src/lib/mechanics/conditionEffects.ts +++ b/src/lib/mechanics/conditionEffects.ts @@ -6,6 +6,13 @@ import type { AbilityKey, AdvState } from './types'; * hardcoded `if`s in combat code. `deriveState` (creatureState.ts) folds these * into a single query the tracker and dice roller ask. */ +/** + * Which statistic family a PF2e value-scaled status penalty applies to. Different + * families are independent (they target different rolls) and must never be compared + * against each other — clumsy hits Dex-based rolls, enfeebled hits Str-based rolls, etc. + */ +export type PenaltyTarget = 'all' | 'str' | 'dex' | 'con' | 'mental'; + export interface ConditionEffect { /** sets effective speed to 0 */ speedZero?: boolean; @@ -19,8 +26,11 @@ export interface ConditionEffect { saves?: Partial>; /** cannot take actions or reactions */ incapacitated?: boolean; - /** pf2e: a numeric status penalty scaling with the condition's value */ - statusPenalty?: boolean; + /** + * pf2e: a value-scaled status penalty applied to this statistic family. The + * penalty magnitude is the condition's value; family decides which rolls it hits. + */ + statusPenalty?: PenaltyTarget; } const ALL_PHYS: Partial> = { str: 'auto-fail', dex: 'auto-fail' }; @@ -48,22 +58,24 @@ export const CONDITION_EFFECTS_5E: Record = { */ export const CONDITION_EFFECTS_PF2E: Record = { blinded: { attacksAgainst: 'advantage' }, - clumsy: { statusPenalty: true }, - drained: { statusPenalty: true }, + // Each valued condition penalises a DIFFERENT statistic family (never compared): + clumsy: { statusPenalty: 'dex' }, // AC, Reflex, Dex attacks/skills + drained: { statusPenalty: 'con' }, // Fortitude (also reduces HP — applied separately) + enfeebled: { statusPenalty: 'str' }, // Str attacks/damage, Athletics + stupefied: { statusPenalty: 'mental' }, // spell attacks/DCs, mental checks + frightened: { statusPenalty: 'all' }, // every check and DC; decays 1/turn + sickened: { statusPenalty: 'all' }, // every check and DC dazzled: { attacksAgainst: 'advantage' }, - enfeebled: { statusPenalty: true }, - fatigued: { statusPenalty: true }, - frightened: { statusPenalty: true }, + // fatigued (flat -1 AC/saves) and slowed (action loss) are NOT value-scaled — see deriveState. + fatigued: {}, + slowed: {}, grabbed: { speedZero: true, attacksAgainst: 'advantage' }, immobilized: { speedZero: true }, paralyzed: { incapacitated: true, speedZero: true, attacksAgainst: 'advantage' }, petrified: { incapacitated: true, speedZero: true }, prone: { attacksAgainst: 'advantage' }, restrained: { speedZero: true, incapacitated: false, attacksAgainst: 'advantage' }, - sickened: { statusPenalty: true }, - slowed: { statusPenalty: true }, stunned: { incapacitated: true }, - stupefied: { statusPenalty: true }, unconscious: { incapacitated: true, speedZero: true, attacksAgainst: 'advantage' }, 'off-guard': { attacksAgainst: 'advantage' }, }; diff --git a/src/lib/mechanics/creatureState.test.ts b/src/lib/mechanics/creatureState.test.ts index 5b7c6c8..fbf08ac 100644 --- a/src/lib/mechanics/creatureState.test.ts +++ b/src/lib/mechanics/creatureState.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { deriveState } from './creatureState'; +import { deriveState, tickConditionsEndOfTurn } from './creatureState'; import { concentrationDC } from './cast'; import type { Condition } from '@/lib/schemas/common'; @@ -44,18 +44,46 @@ describe('deriveState (5e)', () => { expect(s).toMatchObject({ speed: 30, attackModifier: 'normal', incapacitated: false }); expect(s.badges).toEqual([]); }); + + it('exhaustion applies cumulative level effects (3 = ability-check + attack + save disadvantage, speed halved)', () => { + const s = deriveState('5e', 30, [cond('Exhaustion', 3)]); + expect(s.abilityCheckDisadvantage).toBe(true); + expect(s.attackModifier).toBe('disadvantage'); + expect(s.saveModifiers.wis).toBe('disadvantage'); + expect(s.speed).toBe(15); // halved at level 2+ + expect(s.badges).toContain('Exhaustion 3'); + }); + + it('exhaustion 5 zeroes speed', () => { + expect(deriveState('5e', 30, [cond('Exhaustion', 5)]).speed).toBe(0); + }); + + it('prone badge is range-qualified (advantage melee, disadvantage ranged)', () => { + const s = deriveState('5e', 30, [cond('Prone')]); + expect(s.badges).toContain('Attacked: adv. melee / disadv. ranged'); + }); }); describe('deriveState (pf2e)', () => { - it('frightened contributes a numeric status penalty equal to its value', () => { + it('frightened is an all-checks penalty equal to its value', () => { const s = deriveState('pf2e', 25, [cond('Frightened', 2)]); - expect(s.statusPenalty).toBe(2); - expect(s.badges).toContain('−2 status'); + expect(s.statusPenalties.all).toBe(2); + expect(s.badges).toContain('−2 all checks'); }); - it('status penalties do not stack — worst value wins', () => { - const s = deriveState('pf2e', 25, [cond('Frightened', 1), cond('Sickened', 3)]); - expect(s.statusPenalty).toBe(3); + it('same-family penalties take the worst; different families stay independent', () => { + const both = deriveState('pf2e', 25, [cond('Frightened', 1), cond('Sickened', 3)]); + expect(both.statusPenalties.all).toBe(3); // both target "all" → worst wins + const mixed = deriveState('pf2e', 25, [cond('Enfeebled', 2), cond('Clumsy', 1)]); + expect(mixed.statusPenalties.str).toBe(2); // Str-based + expect(mixed.statusPenalties.dex).toBe(1); // Dex-based — NOT merged with enfeebled + expect(mixed.badges).toEqual(expect.arrayContaining(['−2 Str', '−1 Dex/AC'])); + }); + + it('slowed is action loss, not a check penalty', () => { + const s = deriveState('pf2e', 25, [cond('Slowed', 2)]); + expect(s.statusPenalties).toEqual({}); + expect(s.badges).toContain('Slowed 2 (−2 actions)'); }); it('grabbed zeroes speed and grants advantage to attackers', () => { @@ -63,6 +91,13 @@ describe('deriveState (pf2e)', () => { expect(s.speed).toBe(0); expect(s.attacksAgainstModifier).toBe('advantage'); }); + + it('frightened decays by 1 at end of turn, removed at 0', () => { + expect(tickConditionsEndOfTurn('pf2e', [cond('Frightened', 2)])).toEqual([{ name: 'Frightened', value: 1 }]); + expect(tickConditionsEndOfTurn('pf2e', [cond('Frightened', 1)])).toEqual([]); + // 5e frightened (unvalued) is untouched + expect(tickConditionsEndOfTurn('5e', [cond('Frightened')])).toEqual([{ name: 'Frightened' }]); + }); }); describe('concentrationDC', () => { diff --git a/src/lib/mechanics/creatureState.ts b/src/lib/mechanics/creatureState.ts index 2d55fac..987e5a6 100644 --- a/src/lib/mechanics/creatureState.ts +++ b/src/lib/mechanics/creatureState.ts @@ -1,10 +1,10 @@ -import type { SystemId } from '@/lib/rules'; +import { ABILITY_KEYS, type SystemId } from '@/lib/rules'; import type { Condition } from '@/lib/schemas/common'; -import { conditionEffects } from './conditionEffects'; +import { conditionEffects, type PenaltyTarget } from './conditionEffects'; import type { AbilityKey, AdvState } from './types'; export interface MechanicalState { - /** effective speed after conditions (0 if any condition zeroes it) */ + /** 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; @@ -16,9 +16,13 @@ export interface MechanicalState { saveModifiers: Partial>; /** disadvantage on ability checks (5e) */ abilityCheckDisadvantage: boolean; - /** pf2e: aggregated numeric status penalty (worst single value, penalties don't stack) */ - statusPenalty: number; - /** short human-readable badges for the UI, e.g. "Speed 0", "Disadv. attacks" */ + /** + * 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[]; } @@ -30,6 +34,14 @@ function combineAdv(a: AdvState, b: AdvState): AdvState { 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 @@ -41,48 +53,113 @@ function combineAdv(a: AdvState, b: AdvState): AdvState { */ 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 statusPenalty = 0; + let allSavesDisadvantage = false; + let prone5e = false; + const statusPenalties: Partial> = {}; const saveModifiers: Partial> = {}; + const extraBadges: string[] = []; for (const cond of conditions) { - const eff = table[cond.name.trim().toLowerCase()]; + 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: status penalties don't stack — take the worst single value. - if (eff.statusPenalty && cond.value !== undefined) statusPenalty = Math.max(statusPenalty, cond.value); + // 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') badges.push('Attacked w/ adv.'); - if (statusPenalty > 0) badges.push(`−${statusPenalty} status`); + 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 : baseSpeed, + speed: speedZero ? 0 : speed, attackModifier, attacksAgainstModifier, incapacitated, saveModifiers, abilityCheckDisadvantage, - statusPenalty, + 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; +} diff --git a/src/lib/mechanics/index.ts b/src/lib/mechanics/index.ts index 70837c9..a4c709d 100644 --- a/src/lib/mechanics/index.ts +++ b/src/lib/mechanics/index.ts @@ -18,8 +18,8 @@ export { normalizeMonsterDefenses, normalizeMonsterDefensesPf2e, type Pf2eDefens export { castSpell, dropConcentration, concentrationDC } from './cast'; export { spendResource, regainResource } from './resources'; export { rollDeathSave, type DeathSaveOutcome } from './deathSaves'; -export { conditionEffects, CONDITION_EFFECTS_5E, CONDITION_EFFECTS_PF2E, type ConditionEffect } from './conditionEffects'; -export { deriveState, type MechanicalState } from './creatureState'; +export { conditionEffects, CONDITION_EFFECTS_5E, CONDITION_EFFECTS_PF2E, type ConditionEffect, type PenaltyTarget } from './conditionEffects'; +export { deriveState, tickConditionsEndOfTurn, type MechanicalState } from './creatureState'; let spellMechCache: Map | null = null; let armorMechCache: Map | null = null;