9ecd817bc6
- Inventory + currency + encumbrance vs carrying capacity - Class resources with correct short/long/daily rest recovery (applyRest) - Spellcasting: slots (+pact), known/prepared spells, derived DC + attack - Defenses: 5e death saves/inspiration/exhaustion, pf2e dying/wounded/hero points - Derived weapon attacks + passive perception via extended RulesSystem - Dexie v2 migration backfills new fields; debounced save flushes on pagehide - 9 new unit tests, new character-depth e2e; all green Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
116 lines
3.5 KiB
TypeScript
116 lines
3.5 KiB
TypeScript
import type {
|
|
AbilityKey,
|
|
CharacterRulesInput,
|
|
DerivedSkill,
|
|
ProficiencyRank,
|
|
RestOption,
|
|
RulesSystem,
|
|
} from '../types';
|
|
import { ABILITY_KEYS } from '../types';
|
|
import {
|
|
ABILITY_ABBR,
|
|
ABILITY_LABELS,
|
|
abilityModifier,
|
|
defaultAbilityScores,
|
|
formatDamage,
|
|
} from '../abilities';
|
|
import { DND5E_SKILLS } from './skills';
|
|
|
|
const DND5E_RESTS: readonly RestOption[] = [
|
|
{ id: 'short', label: 'Short Rest', restoresHp: false, restoresSlots: false, restoresPact: true, recovers: ['short'] },
|
|
{ id: 'long', label: 'Long Rest', restoresHp: true, restoresSlots: true, restoresPact: true, recovers: ['short', 'long'], reduceExhaustion: true },
|
|
];
|
|
|
|
/** Proficiency bonus by character level (PHB table): +2 at 1, +6 at 17. */
|
|
export function proficiencyBonus(level: number): number {
|
|
const lvl = Math.max(1, Math.min(20, Math.floor(level) || 1));
|
|
return 2 + Math.floor((lvl - 1) / 4);
|
|
}
|
|
|
|
/**
|
|
* In 5e a skill/save is either proficient or not, with "expert" meaning
|
|
* expertise (double proficiency). We map PF2e-style ranks onto that.
|
|
*/
|
|
function profMultiplier(rank: ProficiencyRank): number {
|
|
if (rank === 'untrained') return 0;
|
|
if (rank === 'trained') return 1;
|
|
return 2; // expert/master/legendary => expertise
|
|
}
|
|
|
|
export const dnd5e: RulesSystem = {
|
|
id: '5e',
|
|
label: 'D&D 5e',
|
|
abilities: ABILITY_KEYS,
|
|
abilityLabels: ABILITY_LABELS,
|
|
skills: DND5E_SKILLS,
|
|
|
|
abilityModifier,
|
|
defaultAbilityScores,
|
|
|
|
proficiencyValue(level, rank) {
|
|
return proficiencyBonus(level) * profMultiplier(rank);
|
|
},
|
|
|
|
initiativeModifier({ abilities }) {
|
|
return abilityModifier(abilities.dex);
|
|
},
|
|
|
|
skillModifiers(input: CharacterRulesInput): DerivedSkill[] {
|
|
const pb = proficiencyBonus(input.level);
|
|
return DND5E_SKILLS.map((s) => {
|
|
const rank = input.skillRanks?.[s.key] ?? 'untrained';
|
|
return {
|
|
key: s.key,
|
|
label: s.label,
|
|
ability: s.ability,
|
|
modifier: abilityModifier(input.abilities[s.ability]) + pb * profMultiplier(rank),
|
|
};
|
|
});
|
|
},
|
|
|
|
saveModifiers(input): Record<AbilityKey, number> {
|
|
const pb = proficiencyBonus(input.level);
|
|
const out = {} as Record<AbilityKey, number>;
|
|
for (const a of ABILITY_KEYS) {
|
|
const rank = input.saveRanks?.[a] ?? 'untrained';
|
|
out[a] = abilityModifier(input.abilities[a]) + (rank !== 'untrained' ? pb : 0);
|
|
}
|
|
return out;
|
|
},
|
|
|
|
baseArmorClass(input) {
|
|
// Unarmored AC. Heavy armor etc. is represented as armorBonus on top.
|
|
return 10 + abilityModifier(input.abilities.dex) + (input.armorBonus ?? 0);
|
|
},
|
|
|
|
spellSaveDc(input, ability) {
|
|
return 8 + proficiencyBonus(input.level) + abilityModifier(input.abilities[ability]);
|
|
},
|
|
|
|
spellAttackBonus(input, ability) {
|
|
return proficiencyBonus(input.level) + abilityModifier(input.abilities[ability]);
|
|
},
|
|
|
|
passivePerception(input) {
|
|
const rank = input.skillRanks?.['perception'] ?? 'untrained';
|
|
return 10 + abilityModifier(input.abilities.wis) + proficiencyBonus(input.level) * profMultiplier(rank);
|
|
},
|
|
|
|
weaponAttack(input, weapon) {
|
|
const abilityMod = abilityModifier(input.abilities[weapon.ability]);
|
|
const item = weapon.itemBonus ?? 0;
|
|
const toHit = abilityMod + proficiencyBonus(input.level) * profMultiplier(weapon.rank) + item;
|
|
const dmgMod = (weapon.addAbilityToDamage !== false ? abilityMod : 0) + item;
|
|
return { toHit, damage: formatDamage(weapon.damageDice, dmgMod) };
|
|
},
|
|
|
|
carryingCapacity(input) {
|
|
const str = input.abilities.str;
|
|
return { encumbered: str * 5, max: str * 15, unit: 'lb' };
|
|
},
|
|
|
|
restOptions: DND5E_RESTS,
|
|
};
|
|
|
|
export { ABILITY_ABBR };
|