PF2e builder: boost-based ability generation (replaces incorrect 5e arrays)
PF2e characters were generated with the 5e method (standard array / 27-pt point-buy / 4d6) and never applied ancestry/background/class attribute boosts, so every derived stat was wrong. The PF2e ability step is now boost-based: - Every score starts at 10; ancestry fixed boosts + flaw, ancestry free boost(s), background (choose-one + free), class key ability, and 4 free boosts are applied with the +2-below-18 / +1 rule (pf2eApplyBoosts). - Boost data parsed from the bundled ancestry/background fields (parseAncestryBoosts, parseBackgroundBoosts, parseFlaw). Defaults are a legal, class-favoring assignment; same-source duplicate boosts are disabled in the pickers (PF2e doesn't allow them). - Live ability cards show the computed scores. 5e keeps array/point-buy/manual. Verified in-app (Dwarf Alchemist Acolyte → STR 14/DEX 12/CON 14/INT 18/WIS 12/CHA 8, flaw + fixed boosts correct). Pure core has 8 unit tests. 5e path unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,7 @@ import { type Campaign, type Character, type SpellEntry, newSpellEntry } from '@
|
|||||||
import { charactersRepo } from '@/lib/db/repositories';
|
import { charactersRepo } from '@/lib/db/repositories';
|
||||||
import {
|
import {
|
||||||
getSystem, ABILITY_ABBR, abilityModifier, buildCharacter, getClassDef, SYSTEM_OPTIONS,
|
getSystem, ABILITY_ABBR, abilityModifier, buildCharacter, getClassDef, SYSTEM_OPTIONS,
|
||||||
|
pf2eApplyBoosts, parseAncestryBoosts, parseBackgroundBoosts, parseFlaw,
|
||||||
type AbilityKey, type AbilityScores, type ProficiencyRank, type SystemId,
|
type AbilityKey, type AbilityScores, type ProficiencyRank, type SystemId,
|
||||||
} from '@/lib/rules';
|
} from '@/lib/rules';
|
||||||
import { STANDARD_ARRAY, rollAbilityScores, pointBuyRemaining, POINT_BUY_MIN, POINT_BUY_MAX } from '@/lib/rules/abilityGen';
|
import { STANDARD_ARRAY, rollAbilityScores, pointBuyRemaining, POINT_BUY_MIN, POINT_BUY_MAX } from '@/lib/rules/abilityGen';
|
||||||
@@ -33,6 +34,12 @@ interface Origin {
|
|||||||
asiBonuses?: Partial<AbilityScores>;
|
asiBonuses?: Partial<AbilityScores>;
|
||||||
/** skill keys this origin grants as trained (background skills, racial proficiencies). */
|
/** skill keys this origin grants as trained (background skills, racial proficiencies). */
|
||||||
skillGrants?: string[];
|
skillGrants?: string[];
|
||||||
|
/** pf2e ancestry boosts: specific abilities + count of free boosts. */
|
||||||
|
ancestryBoosts?: { fixed: AbilityKey[]; free: number };
|
||||||
|
/** pf2e legacy ancestry flaw (-2), if any. */
|
||||||
|
ancestryFlaw?: AbilityKey;
|
||||||
|
/** pf2e background boosts: choose-one options + free-boost count. */
|
||||||
|
backgroundBoosts?: { options: AbilityKey[]; free: number };
|
||||||
}
|
}
|
||||||
interface SpellOpt { name: string; level: number; meta: string }
|
interface SpellOpt { name: string; level: number; meta: string }
|
||||||
|
|
||||||
@@ -119,19 +126,27 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
|
|||||||
setOrigins(dedupeByName(rs.map((r) => {
|
setOrigins(dedupeByName(rs.map((r) => {
|
||||||
const hp = typeof r.hp === 'number' ? r.hp : undefined;
|
const hp = typeof r.hp === 'number' ? r.hp : undefined;
|
||||||
const speed = (r.speed as { land?: number } | undefined)?.land;
|
const speed = (r.speed as { land?: number } | undefined)?.land;
|
||||||
const attrArr = Array.isArray(r.attribute) ? (r.attribute as string[]).filter((a) => a !== 'Free') : [];
|
const ancestryBoosts = parseAncestryBoosts(r.attribute);
|
||||||
const attrSummary = attrArr.map((a) => `+${String(a).slice(0, 3)}`).join('/');
|
const ancestryFlaw = parseFlaw((r as Record<string, unknown>).attribute_flaw ?? (r as Record<string, unknown>).flaw);
|
||||||
const meta = [attrSummary, hp !== undefined ? `${hp} HP` : ''].filter(Boolean).join(' · ');
|
const fixedSummary = ancestryBoosts.fixed.map((a) => `+${ABILITY_ABBR[a]}`).join('/');
|
||||||
|
const freeSummary = ancestryBoosts.free ? `+${ancestryBoosts.free} free` : '';
|
||||||
|
const meta = [fixedSummary, freeSummary, ancestryFlaw ? `−${ABILITY_ABBR[ancestryFlaw]}` : '', hp !== undefined ? `${hp} HP` : ''].filter(Boolean).join(' · ');
|
||||||
return {
|
return {
|
||||||
name: String(r.name),
|
name: String(r.name),
|
||||||
desc: briefOverview(String((r.summary ?? r.text) ?? '')),
|
desc: briefOverview(String((r.summary ?? r.text) ?? '')),
|
||||||
...(hp !== undefined ? { hp } : {}),
|
...(hp !== undefined ? { hp } : {}),
|
||||||
...(speed ? { speed } : {}),
|
...(speed ? { speed } : {}),
|
||||||
|
ancestryBoosts,
|
||||||
|
...(ancestryFlaw ? { ancestryFlaw } : {}),
|
||||||
meta,
|
meta,
|
||||||
};
|
};
|
||||||
})));
|
})));
|
||||||
});
|
});
|
||||||
void loadPf2e('backgrounds').then((bs) => on && setBackgrounds(dedupeByName(bs.map((b) => ({ name: String(b.name), desc: briefOverview(String((b.description ?? b.text) ?? '')) })))));
|
void loadPf2e('backgrounds').then((bs) => on && setBackgrounds(dedupeByName(bs.map((b) => ({
|
||||||
|
name: String(b.name),
|
||||||
|
desc: briefOverview(String((b.description ?? b.text) ?? '')),
|
||||||
|
backgroundBoosts: parseBackgroundBoosts(b.attribute),
|
||||||
|
})))));
|
||||||
}
|
}
|
||||||
return () => { on = false; };
|
return () => { on = false; };
|
||||||
// sys.skills is a stable singleton keyed by `system`, so `system` covers it.
|
// sys.skills is a stable singleton keyed by `system`, so `system` covers it.
|
||||||
@@ -164,15 +179,59 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
|
|||||||
if (m === 'standard') { setPool([...STANDARD_ARRAY]); setAssignment(ABILITIES.map((_, i) => i)); }
|
if (m === 'standard') { setPool([...STANDARD_ARRAY]); setAssignment(ABILITIES.map((_, i) => i)); }
|
||||||
if (m === 'roll') { setPool(rollAbilityScores(createRng())); setAssignment(ABILITIES.map((_, i) => i)); }
|
if (m === 'roll') { setPool(rollAbilityScores(createRng())); setAssignment(ABILITIES.map((_, i) => i)); }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ---- pf2e ability boosts (start at 10; ancestry/background/class/free boosts) ----
|
||||||
|
const boostSlots = useMemo(() => {
|
||||||
|
if (system !== 'pf2e') return null;
|
||||||
|
const anc = selectedOrigin?.ancestryBoosts ?? { fixed: [], free: 0 };
|
||||||
|
const bg = selectedBackground?.backgroundBoosts ?? { options: [], free: 0 };
|
||||||
|
const keyOpts = (classDef?.keyAbilities ?? selectedClass?.keyAbilities ?? []) as AbilityKey[];
|
||||||
|
const slots: { id: string; label: string; options: AbilityKey[] }[] = [];
|
||||||
|
for (let i = 0; i < anc.free; i++) slots.push({ id: `anc-free-${i}`, label: 'Ancestry free boost', options: ABILITIES });
|
||||||
|
if (bg.options.length) slots.push({ id: 'bg-choice', label: 'Background boost', options: bg.options });
|
||||||
|
for (let i = 0; i < bg.free; i++) slots.push({ id: `bg-free-${i}`, label: 'Background free boost', options: ABILITIES });
|
||||||
|
if (keyOpts.length) slots.push({ id: 'class', label: 'Class key ability', options: keyOpts });
|
||||||
|
for (let i = 0; i < 4; i++) slots.push({ id: `free-${i}`, label: 'Free boost', options: ABILITIES });
|
||||||
|
return { fixed: anc.fixed, flaw: selectedOrigin?.ancestryFlaw, slots };
|
||||||
|
}, [system, selectedOrigin, selectedBackground, classDef, selectedClass]);
|
||||||
|
|
||||||
|
const [boostPicks, setBoostPicks] = useState<Record<string, AbilityKey>>({});
|
||||||
|
// A boost slot's "source" — boosts within one source must target different abilities.
|
||||||
|
const boostSource = (id: string) => (id.startsWith('anc') ? 'ancestry' : id.startsWith('bg') ? 'background' : id === 'class' ? 'class' : 'free');
|
||||||
|
// Seed a legal default assignment (distinct within each source) when slots change.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!boostSlots) return;
|
||||||
|
const keyOpts = (classDef?.keyAbilities ?? []) as AbilityKey[];
|
||||||
|
const favored = keyOpts[0] ?? 'str';
|
||||||
|
const order: AbilityKey[] = [favored, ...ABILITIES.filter((a) => a !== favored)];
|
||||||
|
const used: Record<string, Set<AbilityKey>> = { ancestry: new Set(boostSlots.fixed), background: new Set(), class: new Set(), free: new Set() };
|
||||||
|
const next: Record<string, AbilityKey> = {};
|
||||||
|
for (const s of boostSlots.slots) {
|
||||||
|
const u = used[boostSource(s.id)]!;
|
||||||
|
const pick = (s.options.length < 6 ? s.options : order).find((o) => !u.has(o))
|
||||||
|
?? order.find((o) => !u.has(o)) ?? s.options[0] ?? favored;
|
||||||
|
next[s.id] = pick;
|
||||||
|
u.add(pick);
|
||||||
|
}
|
||||||
|
setBoostPicks(next);
|
||||||
|
}, [boostSlots, classDef]);
|
||||||
|
|
||||||
|
const pf2eAbilities = useMemo(() => {
|
||||||
|
if (!boostSlots) return null;
|
||||||
|
const chosen = boostSlots.slots.map((s) => boostPicks[s.id]).filter((b): b is AbilityKey => !!b);
|
||||||
|
return pf2eApplyBoosts([...boostSlots.fixed, ...chosen], boostSlots.flaw);
|
||||||
|
}, [boostSlots, boostPicks]);
|
||||||
|
|
||||||
const abilities = useMemo<AbilityScores>(() => {
|
const abilities = useMemo<AbilityScores>(() => {
|
||||||
|
if (system === 'pf2e') return pf2eAbilities ?? { str: 10, dex: 10, con: 10, int: 10, wis: 10, cha: 10 };
|
||||||
const out = {} as AbilityScores;
|
const out = {} as AbilityScores;
|
||||||
ABILITIES.forEach((a, i) => { out[a] = usesPool ? (pool[assignment[i]!] ?? 10) : pb[i]!; });
|
ABILITIES.forEach((a, i) => { out[a] = usesPool ? (pool[assignment[i]!] ?? 10) : pb[i]!; });
|
||||||
// 5e: fold the chosen race's ability score increases into the final scores.
|
// 5e: fold the chosen race's ability score increases into the final scores.
|
||||||
if (system === '5e' && selectedOrigin?.asiBonuses) {
|
if (selectedOrigin?.asiBonuses) {
|
||||||
for (const a of ABILITIES) out[a] += selectedOrigin.asiBonuses[a] ?? 0;
|
for (const a of ABILITIES) out[a] += selectedOrigin.asiBonuses[a] ?? 0;
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}, [usesPool, pool, assignment, pb, system, selectedOrigin]);
|
}, [system, pf2eAbilities, usesPool, pool, assignment, pb, selectedOrigin]);
|
||||||
const poolValid = !usesPool || new Set(assignment).size === ABILITIES.length;
|
const poolValid = !usesPool || new Set(assignment).size === ABILITIES.length;
|
||||||
const pbValid = method !== 'pointbuy' || pointBuyRemaining(pb) >= 0;
|
const pbValid = method !== 'pointbuy' || pointBuyRemaining(pb) >= 0;
|
||||||
|
|
||||||
@@ -269,7 +328,7 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
|
|||||||
const stepValid: Record<string, boolean> = {
|
const stepValid: Record<string, boolean> = {
|
||||||
Class: !!selectedClass,
|
Class: !!selectedClass,
|
||||||
Origin: true,
|
Origin: true,
|
||||||
Abilities: poolValid && pbValid,
|
Abilities: system === 'pf2e' ? true : poolValid && pbValid,
|
||||||
Skills: skills.length === Math.min(skillCount, skillOptions.length),
|
Skills: skills.length === Math.min(skillCount, skillOptions.length),
|
||||||
Spells: true,
|
Spells: true,
|
||||||
Review: true,
|
Review: true,
|
||||||
@@ -430,6 +489,51 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
|
|||||||
|
|
||||||
{stepName === 'Abilities' && (
|
{stepName === 'Abilities' && (
|
||||||
<div>
|
<div>
|
||||||
|
{system === 'pf2e' && boostSlots && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-sm text-muted">PF2e scores are built from <span className="text-ink">boosts</span>: each starts at 10, and a boost adds +2 (or +1 once a score reaches 18). Assign your free boosts below.</p>
|
||||||
|
{(boostSlots.fixed.length > 0 || boostSlots.flaw) && (
|
||||||
|
<div className="flex flex-wrap items-center gap-1 text-xs">
|
||||||
|
<span className="text-muted">{selectedOrigin?.name ?? 'Ancestry'}:</span>
|
||||||
|
{boostSlots.fixed.map((a, i) => <span key={i} className="rounded bg-accent/10 px-1.5 py-0.5 font-medium text-accent">+{ABILITY_ABBR[a]}</span>)}
|
||||||
|
{boostSlots.flaw && <span className="rounded bg-danger/10 px-1.5 py-0.5 font-medium text-danger">−{ABILITY_ABBR[boostSlots.flaw]}</span>}
|
||||||
|
<span className="text-faint">(fixed)</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{boostSlots.slots.length > 0 && (
|
||||||
|
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||||
|
{boostSlots.slots.map((s) => {
|
||||||
|
// Abilities already taken by OTHER slots in the same source (can't double-boost).
|
||||||
|
const taken = new Set<AbilityKey>(boostSource(s.id) === 'ancestry' ? boostSlots.fixed : []);
|
||||||
|
for (const o of boostSlots.slots) {
|
||||||
|
if (o.id !== s.id && boostSource(o.id) === boostSource(s.id) && boostPicks[o.id]) taken.add(boostPicks[o.id]!);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<label key={s.id} className="text-xs text-muted">
|
||||||
|
{s.label}
|
||||||
|
<Select className="mt-0.5" aria-label={s.label} value={boostPicks[s.id] ?? s.options[0]} onChange={(e) => setBoostPicks((p) => ({ ...p, [s.id]: e.target.value as AbilityKey }))}>
|
||||||
|
{s.options.map((o) => <option key={o} value={o} disabled={taken.has(o) && boostPicks[s.id] !== o}>{ABILITY_ABBR[o]}{taken.has(o) ? ' ·' : ''}</option>)}
|
||||||
|
</Select>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="grid grid-cols-3 gap-2 sm:grid-cols-6">
|
||||||
|
{ABILITIES.map((a) => {
|
||||||
|
const isKey = selectedClass?.keyAbilities.includes(a);
|
||||||
|
return (
|
||||||
|
<div key={a} className={cn('rounded-lg border bg-surface p-2 text-center', isKey ? 'border-accent/60' : 'border-line')}>
|
||||||
|
<div className="flex items-center justify-center gap-0.5 smallcaps">{ABILITY_ABBR[a]}{isKey && <Star size={10} className="text-accent" aria-hidden />}</div>
|
||||||
|
<div className="font-display text-lg font-semibold text-ink">{abilities[a]}</div>
|
||||||
|
<div className="text-xs text-accent">{formatModifier(abilityModifier(abilities[a]))}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{system !== 'pf2e' && (<>
|
||||||
<div className="mb-3 flex flex-wrap gap-1">
|
<div className="mb-3 flex flex-wrap gap-1">
|
||||||
{([['standard', 'Standard array'], ['pointbuy', 'Point buy'], ['roll', '4d6 drop lowest'], ['manual', 'Manual']] as const).map(([m, label]) => (
|
{([['standard', 'Standard array'], ['pointbuy', 'Point buy'], ['roll', '4d6 drop lowest'], ['manual', 'Manual']] as const).map(([m, label]) => (
|
||||||
<button key={m} onClick={() => chooseMethod(m)} className={cn('rounded-md px-3 py-1.5 text-sm', method === m ? 'bg-accent text-accent-ink' : 'bg-elevated text-muted hover:text-ink')}>{label}</button>
|
<button key={m} onClick={() => chooseMethod(m)} className={cn('rounded-md px-3 py-1.5 text-sm', method === m ? 'bg-accent text-accent-ink' : 'bg-elevated text-muted hover:text-ink')}>{label}</button>
|
||||||
@@ -491,6 +595,7 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
</>)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -19,5 +19,6 @@ export const SYSTEM_OPTIONS: { id: SystemId; label: string }[] = [
|
|||||||
export * from './types';
|
export * from './types';
|
||||||
export * from './progression';
|
export * from './progression';
|
||||||
export { abilityModifier, ABILITY_ABBR, ABILITY_LABELS, formatDamage } from './abilities';
|
export { abilityModifier, ABILITY_ABBR, ABILITY_LABELS, formatDamage } from './abilities';
|
||||||
|
export { pf2eApplyBoosts, parseAncestryBoosts, parseBackgroundBoosts, parseFlaw, abilityWord } from './pf2e/abilities';
|
||||||
export { applyRest } from './rest';
|
export { applyRest } from './rest';
|
||||||
export { getConditions, CONDITIONS_5E, CONDITIONS_PF2E, type ConditionDef } from './conditions';
|
export { getConditions, CONDITIONS_5E, CONDITIONS_PF2E, type ConditionDef } from './conditions';
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { pf2eApplyBoosts, parseAncestryBoosts, parseBackgroundBoosts, parseFlaw } from './abilities';
|
||||||
|
|
||||||
|
describe('pf2eApplyBoosts', () => {
|
||||||
|
it('each boost adds +2 below 18', () => {
|
||||||
|
// Human Fighter: ancestry 2 free (str, con), background (str, free dex), class str, 4 free (str, con, dex, wis)
|
||||||
|
const scores = pf2eApplyBoosts(['str', 'con', 'str', 'dex', 'str', 'str', 'con', 'dex', 'wis']);
|
||||||
|
expect(scores.str).toBe(18); // 10 +2+2+2+2 (four boosts) = 18
|
||||||
|
expect(scores.con).toBe(14); // two boosts
|
||||||
|
expect(scores.dex).toBe(14); // two boosts
|
||||||
|
expect(scores.wis).toBe(12); // one boost
|
||||||
|
expect(scores.int).toBe(10);
|
||||||
|
expect(scores.cha).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies a flaw before boosts', () => {
|
||||||
|
const scores = pf2eApplyBoosts(['con'], 'cha');
|
||||||
|
expect(scores.cha).toBe(8); // flaw -2
|
||||||
|
expect(scores.con).toBe(12);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a 5th boost on one ability only adds +1 (already at 18)', () => {
|
||||||
|
const scores = pf2eApplyBoosts(['str', 'str', 'str', 'str', 'str']);
|
||||||
|
expect(scores.str).toBe(19); // 18 then +1
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseAncestryBoosts', () => {
|
||||||
|
it('reads fixed boosts plus a Free token (Dwarf)', () => {
|
||||||
|
expect(parseAncestryBoosts(['Constitution', 'Wisdom', 'Free'])).toEqual({ fixed: ['con', 'wis'], free: 1 });
|
||||||
|
});
|
||||||
|
it('reads "Two free ability boosts" (Human)', () => {
|
||||||
|
expect(parseAncestryBoosts(['Two free ability boosts'])).toEqual({ fixed: [], free: 2 });
|
||||||
|
});
|
||||||
|
it('tolerates junk', () => {
|
||||||
|
expect(parseAncestryBoosts(undefined)).toEqual({ fixed: [], free: 0 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseBackgroundBoosts', () => {
|
||||||
|
it('lists the choose-one options and grants one free boost (Acolyte)', () => {
|
||||||
|
expect(parseBackgroundBoosts(['Intelligence', 'Wisdom'])).toEqual({ options: ['int', 'wis'], free: 1 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseFlaw', () => {
|
||||||
|
it('reads a flaw string or array', () => {
|
||||||
|
expect(parseFlaw('Charisma')).toBe('cha');
|
||||||
|
expect(parseFlaw(['Strength'])).toBe('str');
|
||||||
|
expect(parseFlaw(undefined)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import type { AbilityKey, AbilityScores } from '../types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PF2e ability scores are built from boosts, not a 5e-style array. Every score
|
||||||
|
* starts at 10; an ancestry flaw subtracts 2; each boost adds +2 while the score
|
||||||
|
* is below 18, otherwise +1. Boosts are applied in canonical order (ancestry →
|
||||||
|
* background → class → free) so the rare >18 case resolves correctly.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const ABIL_WORD: Record<string, AbilityKey> = {
|
||||||
|
strength: 'str', dexterity: 'dex', constitution: 'con',
|
||||||
|
intelligence: 'int', wisdom: 'wis', charisma: 'cha',
|
||||||
|
};
|
||||||
|
|
||||||
|
const NUM_WORD: Record<string, number> = { one: 1, two: 2, three: 3, four: 4 };
|
||||||
|
|
||||||
|
export function abilityWord(word: string): AbilityKey | undefined {
|
||||||
|
return ABIL_WORD[word.trim().toLowerCase()];
|
||||||
|
}
|
||||||
|
|
||||||
|
function base10(): AbilityScores {
|
||||||
|
return { str: 10, dex: 10, con: 10, int: 10, wis: 10, cha: 10 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apply an ordered list of boosts (and an optional flaw) to a base-10 spread. */
|
||||||
|
export function pf2eApplyBoosts(boosts: readonly AbilityKey[], flaw?: AbilityKey | null): AbilityScores {
|
||||||
|
const s = base10();
|
||||||
|
if (flaw) s[flaw] -= 2;
|
||||||
|
for (const b of boosts) s[b] += s[b] < 18 ? 2 : 1;
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse an ancestry `attribute` array into the fixed boosts and the number of free
|
||||||
|
* boosts it grants. Handles specific abilities ("Constitution"), the "Free" token,
|
||||||
|
* and prose like "Two free ability boosts".
|
||||||
|
*/
|
||||||
|
export function parseAncestryBoosts(attribute: unknown): { fixed: AbilityKey[]; free: number } {
|
||||||
|
const arr = Array.isArray(attribute) ? attribute.map(String) : [];
|
||||||
|
const fixed: AbilityKey[] = [];
|
||||||
|
let free = 0;
|
||||||
|
for (const raw of arr) {
|
||||||
|
const k = abilityWord(raw);
|
||||||
|
if (k) { fixed.push(k); continue; }
|
||||||
|
const m = /(\bone\b|\btwo\b|\bthree\b|\bfour\b|\d+)\s+free/i.exec(raw);
|
||||||
|
if (m) {
|
||||||
|
const word = m[1]!.toLowerCase();
|
||||||
|
const n = NUM_WORD[word] ?? Number(word);
|
||||||
|
free += Number.isFinite(n) ? n : 1;
|
||||||
|
} else if (/free/i.test(raw)) free += 1;
|
||||||
|
}
|
||||||
|
return { fixed, free };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse an ancestry flaw field (legacy ancestries) into a single ability, if any. */
|
||||||
|
export function parseFlaw(flaw: unknown): AbilityKey | undefined {
|
||||||
|
if (Array.isArray(flaw)) { for (const f of flaw) { const k = abilityWord(String(f)); if (k) return k; } return undefined; }
|
||||||
|
if (typeof flaw === 'string') return abilityWord(flaw);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a background `attribute` array. A PF2e background grants one boost chosen
|
||||||
|
* from its listed abilities plus one free boost; the data lists the choose-from
|
||||||
|
* options. Returns the options and the free-boost count (1).
|
||||||
|
*/
|
||||||
|
export function parseBackgroundBoosts(attribute: unknown): { options: AbilityKey[]; free: number } {
|
||||||
|
const arr = Array.isArray(attribute) ? attribute.map(String) : [];
|
||||||
|
const options = arr.map(abilityWord).filter((k): k is AbilityKey => !!k);
|
||||||
|
return { options, free: options.length ? 1 : 0 };
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user