f602c832e6
- PF2e clumsy/enfeebled/drained/stupefied now penalise their correct, independent statistic family (Dex/Str/Con/mental) instead of collapsing into one max'd '−N status'. Frightened/sickened are 'all checks'. Distinct badges per family. - Slowed is action loss only (no roll penalty); fatigued shows flat −1 AC/saves — neither is value-scaled anymore. - 5e Exhaustion now derives its cumulative level effects (ability-check disadvantage L1, speed halved L2, attack + all-save disadvantage L3, speed 0 L5, dead L6). - 5e Prone badge is range-qualified (adv. melee / disadv. ranged). - New tickConditionsEndOfTurn: PF2e Frightened decays by 1 at end of turn (wired into nextTurn via the combat tracker), removed at 0. MechanicalState.statusPenalty (single number) → statusPenalties (per-family map). Tests updated + added. Deferred: Drained HP/max-HP reduction (needs creature level threaded into the damage flow). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
166 lines
6.7 KiB
TypeScript
166 lines
6.7 KiB
TypeScript
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<Record<AbilityKey, 'disadvantage' | 'auto-fail'>>;
|
||
/** 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<Record<PenaltyTarget, number>>;
|
||
/** 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<PenaltyTarget, string> = {
|
||
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<Record<PenaltyTarget, number>> = {};
|
||
const saveModifiers: Partial<Record<AbilityKey, 'disadvantage' | 'auto-fail'>> = {};
|
||
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;
|
||
}
|