Fix condition mechanics: per-statistic PF2e penalties, 5e exhaustion, slowed, frightened decay
- 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>
This commit is contained in:
@@ -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. */
|
/** Advance the turn; remind (don't roll) when a downed PC's turn begins. */
|
||||||
const advanceTurn = () => {
|
const advanceTurn = () => {
|
||||||
mutate(nextTurn);
|
mutate((e) => nextTurn(e, campaign.system));
|
||||||
const upcoming = currentCombatant(nextTurn(encounter));
|
const upcoming = currentCombatant(nextTurn(encounter, campaign.system));
|
||||||
if (upcoming && upcoming.kind !== 'monster' && upcoming.hp.current <= 0 && pcCharacter(upcoming)) {
|
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.`));
|
void encountersRepo.mutate(encounter.id, (e) => logEvent(e, `${upcoming.name} is down — make a death saving throw.`));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import type { Combatant, Encounter } from '@/lib/schemas/encounter';
|
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
|
* 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.
|
* Advance to the next combatant; wrapping past the end increments the round.
|
||||||
* Ticks the newly-active combatant's timed conditions and logs expirations.
|
* 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 };
|
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} —`);
|
if (atEnd) next = logEvent(next, `— Round ${round} —`);
|
||||||
|
|
||||||
// Tick the combatant whose turn is now beginning.
|
// Tick the combatant whose turn is now beginning.
|
||||||
|
|||||||
@@ -6,6 +6,13 @@ import type { AbilityKey, AdvState } from './types';
|
|||||||
* hardcoded `if`s in combat code. `deriveState` (creatureState.ts) folds these
|
* hardcoded `if`s in combat code. `deriveState` (creatureState.ts) folds these
|
||||||
* into a single query the tracker and dice roller ask.
|
* 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 {
|
export interface ConditionEffect {
|
||||||
/** sets effective speed to 0 */
|
/** sets effective speed to 0 */
|
||||||
speedZero?: boolean;
|
speedZero?: boolean;
|
||||||
@@ -19,8 +26,11 @@ export interface ConditionEffect {
|
|||||||
saves?: Partial<Record<AbilityKey, 'disadvantage' | 'auto-fail'>>;
|
saves?: Partial<Record<AbilityKey, 'disadvantage' | 'auto-fail'>>;
|
||||||
/** cannot take actions or reactions */
|
/** cannot take actions or reactions */
|
||||||
incapacitated?: boolean;
|
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<Record<AbilityKey, 'auto-fail'>> = { str: 'auto-fail', dex: 'auto-fail' };
|
const ALL_PHYS: Partial<Record<AbilityKey, 'auto-fail'>> = { str: 'auto-fail', dex: 'auto-fail' };
|
||||||
@@ -48,22 +58,24 @@ export const CONDITION_EFFECTS_5E: Record<string, ConditionEffect> = {
|
|||||||
*/
|
*/
|
||||||
export const CONDITION_EFFECTS_PF2E: Record<string, ConditionEffect> = {
|
export const CONDITION_EFFECTS_PF2E: Record<string, ConditionEffect> = {
|
||||||
blinded: { attacksAgainst: 'advantage' },
|
blinded: { attacksAgainst: 'advantage' },
|
||||||
clumsy: { statusPenalty: true },
|
// Each valued condition penalises a DIFFERENT statistic family (never compared):
|
||||||
drained: { statusPenalty: true },
|
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' },
|
dazzled: { attacksAgainst: 'advantage' },
|
||||||
enfeebled: { statusPenalty: true },
|
// fatigued (flat -1 AC/saves) and slowed (action loss) are NOT value-scaled — see deriveState.
|
||||||
fatigued: { statusPenalty: true },
|
fatigued: {},
|
||||||
frightened: { statusPenalty: true },
|
slowed: {},
|
||||||
grabbed: { speedZero: true, attacksAgainst: 'advantage' },
|
grabbed: { speedZero: true, attacksAgainst: 'advantage' },
|
||||||
immobilized: { speedZero: true },
|
immobilized: { speedZero: true },
|
||||||
paralyzed: { incapacitated: true, speedZero: true, attacksAgainst: 'advantage' },
|
paralyzed: { incapacitated: true, speedZero: true, attacksAgainst: 'advantage' },
|
||||||
petrified: { incapacitated: true, speedZero: true },
|
petrified: { incapacitated: true, speedZero: true },
|
||||||
prone: { attacksAgainst: 'advantage' },
|
prone: { attacksAgainst: 'advantage' },
|
||||||
restrained: { speedZero: true, incapacitated: false, attacksAgainst: 'advantage' },
|
restrained: { speedZero: true, incapacitated: false, attacksAgainst: 'advantage' },
|
||||||
sickened: { statusPenalty: true },
|
|
||||||
slowed: { statusPenalty: true },
|
|
||||||
stunned: { incapacitated: true },
|
stunned: { incapacitated: true },
|
||||||
stupefied: { statusPenalty: true },
|
|
||||||
unconscious: { incapacitated: true, speedZero: true, attacksAgainst: 'advantage' },
|
unconscious: { incapacitated: true, speedZero: true, attacksAgainst: 'advantage' },
|
||||||
'off-guard': { attacksAgainst: 'advantage' },
|
'off-guard': { attacksAgainst: 'advantage' },
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { deriveState } from './creatureState';
|
import { deriveState, tickConditionsEndOfTurn } from './creatureState';
|
||||||
import { concentrationDC } from './cast';
|
import { concentrationDC } from './cast';
|
||||||
import type { Condition } from '@/lib/schemas/common';
|
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).toMatchObject({ speed: 30, attackModifier: 'normal', incapacitated: false });
|
||||||
expect(s.badges).toEqual([]);
|
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)', () => {
|
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)]);
|
const s = deriveState('pf2e', 25, [cond('Frightened', 2)]);
|
||||||
expect(s.statusPenalty).toBe(2);
|
expect(s.statusPenalties.all).toBe(2);
|
||||||
expect(s.badges).toContain('−2 status');
|
expect(s.badges).toContain('−2 all checks');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('status penalties do not stack — worst value wins', () => {
|
it('same-family penalties take the worst; different families stay independent', () => {
|
||||||
const s = deriveState('pf2e', 25, [cond('Frightened', 1), cond('Sickened', 3)]);
|
const both = deriveState('pf2e', 25, [cond('Frightened', 1), cond('Sickened', 3)]);
|
||||||
expect(s.statusPenalty).toBe(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', () => {
|
it('grabbed zeroes speed and grants advantage to attackers', () => {
|
||||||
@@ -63,6 +91,13 @@ describe('deriveState (pf2e)', () => {
|
|||||||
expect(s.speed).toBe(0);
|
expect(s.speed).toBe(0);
|
||||||
expect(s.attacksAgainstModifier).toBe('advantage');
|
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', () => {
|
describe('concentrationDC', () => {
|
||||||
|
|||||||
@@ -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 type { Condition } from '@/lib/schemas/common';
|
||||||
import { conditionEffects } from './conditionEffects';
|
import { conditionEffects, type PenaltyTarget } from './conditionEffects';
|
||||||
import type { AbilityKey, AdvState } from './types';
|
import type { AbilityKey, AdvState } from './types';
|
||||||
|
|
||||||
export interface MechanicalState {
|
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;
|
speed: number;
|
||||||
/** advantage state on THIS creature's attack rolls (5e) */
|
/** advantage state on THIS creature's attack rolls (5e) */
|
||||||
attackModifier: AdvState;
|
attackModifier: AdvState;
|
||||||
@@ -16,9 +16,13 @@ export interface MechanicalState {
|
|||||||
saveModifiers: Partial<Record<AbilityKey, 'disadvantage' | 'auto-fail'>>;
|
saveModifiers: Partial<Record<AbilityKey, 'disadvantage' | 'auto-fail'>>;
|
||||||
/** disadvantage on ability checks (5e) */
|
/** disadvantage on ability checks (5e) */
|
||||||
abilityCheckDisadvantage: boolean;
|
abilityCheckDisadvantage: boolean;
|
||||||
/** pf2e: aggregated numeric status penalty (worst single value, penalties don't stack) */
|
/**
|
||||||
statusPenalty: number;
|
* pf2e: value-scaled status penalties, by statistic family. Penalties within a
|
||||||
/** short human-readable badges for the UI, e.g. "Speed 0", "Disadv. attacks" */
|
* 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[];
|
badges: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,6 +34,14 @@ function combineAdv(a: AdvState, b: AdvState): AdvState {
|
|||||||
return 'normal'; // one advantage + one disadvantage cancel
|
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
|
* 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
|
* 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 {
|
export function deriveState(system: SystemId, baseSpeed: number, conditions: Condition[]): MechanicalState {
|
||||||
const table = conditionEffects(system);
|
const table = conditionEffects(system);
|
||||||
|
let speed = baseSpeed;
|
||||||
let speedZero = false;
|
let speedZero = false;
|
||||||
let attackModifier: AdvState = 'normal';
|
let attackModifier: AdvState = 'normal';
|
||||||
let attacksAgainstModifier: AdvState = 'normal';
|
let attacksAgainstModifier: AdvState = 'normal';
|
||||||
let incapacitated = false;
|
let incapacitated = false;
|
||||||
let abilityCheckDisadvantage = false;
|
let abilityCheckDisadvantage = false;
|
||||||
let statusPenalty = 0;
|
let allSavesDisadvantage = false;
|
||||||
|
let prone5e = false;
|
||||||
|
const statusPenalties: Partial<Record<PenaltyTarget, number>> = {};
|
||||||
const saveModifiers: Partial<Record<AbilityKey, 'disadvantage' | 'auto-fail'>> = {};
|
const saveModifiers: Partial<Record<AbilityKey, 'disadvantage' | 'auto-fail'>> = {};
|
||||||
|
const extraBadges: string[] = [];
|
||||||
|
|
||||||
for (const cond of conditions) {
|
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) continue;
|
||||||
if (eff.speedZero) speedZero = true;
|
if (eff.speedZero) speedZero = true;
|
||||||
if (eff.incapacitated) incapacitated = true;
|
if (eff.incapacitated) incapacitated = true;
|
||||||
if (eff.abilityChecks) abilityCheckDisadvantage = true;
|
if (eff.abilityChecks) abilityCheckDisadvantage = true;
|
||||||
if (eff.attackRolls) attackModifier = combineAdv(attackModifier, eff.attackRolls);
|
if (eff.attackRolls) attackModifier = combineAdv(attackModifier, eff.attackRolls);
|
||||||
if (eff.attacksAgainst) attacksAgainstModifier = combineAdv(attacksAgainstModifier, eff.attacksAgainst);
|
if (eff.attacksAgainst) attacksAgainstModifier = combineAdv(attacksAgainstModifier, eff.attacksAgainst);
|
||||||
|
if (system === '5e' && name === 'prone') prone5e = true;
|
||||||
if (eff.saves) {
|
if (eff.saves) {
|
||||||
for (const [ability, level] of Object.entries(eff.saves) as [AbilityKey, 'disadvantage' | 'auto-fail'][]) {
|
for (const [ability, level] of Object.entries(eff.saves) as [AbilityKey, 'disadvantage' | 'auto-fail'][]) {
|
||||||
// auto-fail dominates disadvantage
|
// auto-fail dominates disadvantage
|
||||||
if (level === 'auto-fail' || saveModifiers[ability] === undefined) saveModifiers[ability] = level;
|
if (level === 'auto-fail' || saveModifiers[ability] === undefined) saveModifiers[ability] = level;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// pf2e: status penalties don't stack — take the worst single value.
|
// pf2e: value-scaled penalty per statistic family — worst value within a family.
|
||||||
if (eff.statusPenalty && cond.value !== undefined) statusPenalty = Math.max(statusPenalty, cond.value);
|
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[] = [];
|
const badges: string[] = [];
|
||||||
if (speedZero) badges.push('Speed 0');
|
if (speedZero) badges.push('Speed 0');
|
||||||
|
else if (speed !== baseSpeed) badges.push(`Speed ${speed}`);
|
||||||
if (incapacitated) badges.push('Incapacitated');
|
if (incapacitated) badges.push('Incapacitated');
|
||||||
if (attackModifier === 'disadvantage') badges.push('Disadv. attacks');
|
if (attackModifier === 'disadvantage') badges.push('Disadv. attacks');
|
||||||
if (attackModifier === 'advantage') badges.push('Adv. attacks');
|
if (attackModifier === 'advantage') badges.push('Adv. attacks');
|
||||||
if (attacksAgainstModifier === 'advantage') badges.push('Attacked w/ adv.');
|
if (attacksAgainstModifier === 'advantage') {
|
||||||
if (statusPenalty > 0) badges.push(`−${statusPenalty} status`);
|
// 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 {
|
return {
|
||||||
speed: speedZero ? 0 : baseSpeed,
|
speed: speedZero ? 0 : speed,
|
||||||
attackModifier,
|
attackModifier,
|
||||||
attacksAgainstModifier,
|
attacksAgainstModifier,
|
||||||
incapacitated,
|
incapacitated,
|
||||||
saveModifiers,
|
saveModifiers,
|
||||||
abilityCheckDisadvantage,
|
abilityCheckDisadvantage,
|
||||||
statusPenalty,
|
statusPenalties,
|
||||||
badges,
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ export { normalizeMonsterDefenses, normalizeMonsterDefensesPf2e, type Pf2eDefens
|
|||||||
export { castSpell, dropConcentration, concentrationDC } from './cast';
|
export { castSpell, dropConcentration, concentrationDC } from './cast';
|
||||||
export { spendResource, regainResource } from './resources';
|
export { spendResource, regainResource } from './resources';
|
||||||
export { rollDeathSave, type DeathSaveOutcome } from './deathSaves';
|
export { rollDeathSave, type DeathSaveOutcome } from './deathSaves';
|
||||||
export { conditionEffects, CONDITION_EFFECTS_5E, CONDITION_EFFECTS_PF2E, type ConditionEffect } from './conditionEffects';
|
export { conditionEffects, CONDITION_EFFECTS_5E, CONDITION_EFFECTS_PF2E, type ConditionEffect, type PenaltyTarget } from './conditionEffects';
|
||||||
export { deriveState, type MechanicalState } from './creatureState';
|
export { deriveState, tickConditionsEndOfTurn, type MechanicalState } from './creatureState';
|
||||||
|
|
||||||
let spellMechCache: Map<string, SpellMechanics> | null = null;
|
let spellMechCache: Map<string, SpellMechanics> | null = null;
|
||||||
let armorMechCache: Map<string, ArmorMechanics> | null = null;
|
let armorMechCache: Map<string, ArmorMechanics> | null = null;
|
||||||
|
|||||||
Reference in New Issue
Block a user