pf2e character creator: brief, readable overviews matching 5e

The pf2e creator showed long, truncated run-ons where 5e shows clean, brief
overviews: classes were cut mid-word at 400 chars, backgrounds rendered the raw
Foundry "<Name> Source Core Rulebook pg. N …" citation prefix, and ancestry
summaries ended in a stray ellipsis.

- new src/features/characters/builder/overview.ts: briefOverview() strips the
  Foundry citation prefix, drops a trailing ellipsis, and keeps the first clean
  sentence (or a word-boundary trim — never mid-word). A no-op on already-short
  text, so 5e is unchanged. Applied to pf2e class/ancestry/background overviews.
- dedupeByName(): the pf2e data carries reprints under duplicate names (94
  ancestries, 612 backgrounds) which duplicated options and triggered React
  duplicate-key warnings — now de-duplicated at load.
- 6 unit tests for both helpers.

Verified live: pf2e class cards now read as clean single sentences; no citation
junk; no duplicate-key warnings. Gate: 289 unit + 35 e2e + 2 realtime, build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-09 10:51:28 +02:00
parent ba5ae37111
commit f75b50adb8
4 changed files with 80 additions and 6 deletions
@@ -14,6 +14,7 @@ import { createRng } from '@/lib/rng';
import { newId } from '@/lib/ids';
import { formatModifier } from '@/lib/format';
import { getClassTip, raceSynergyNote } from '@/lib/assistant/builder';
import { briefOverview, dedupeByName } from './overview';
import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Codex';
@@ -84,7 +85,7 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
} else {
void loadPf2e('ancestries').then((rs) => {
if (!on) return;
setOrigins(rs.map((r) => {
setOrigins(dedupeByName(rs.map((r) => {
const hp = typeof r.hp === 'number' ? r.hp : undefined;
const speed = (r.speed as { land?: number } | undefined)?.land;
const attrArr = Array.isArray(r.attribute) ? (r.attribute as string[]).filter((a) => a !== 'Free') : [];
@@ -92,14 +93,14 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
const meta = [attrSummary, hp !== undefined ? `${hp} HP` : ''].filter(Boolean).join(' · ');
return {
name: String(r.name),
desc: String((r.summary ?? r.text) ?? ''),
desc: briefOverview(String((r.summary ?? r.text) ?? '')),
...(hp !== undefined ? { hp } : {}),
...(speed ? { speed } : {}),
meta,
};
}));
})));
});
void loadPf2e('backgrounds').then((bs) => on && setBackgrounds(bs.map((b) => ({ name: String(b.name), desc: 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) ?? '')) })))));
}
return () => { on = false; };
}, [system]);
@@ -308,7 +309,7 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
<span className="rounded-full bg-elevated px-1.5 py-0.5 text-[10px] uppercase text-muted">{playstyle(c)}</span>
</div>
<div className="mt-0.5 text-[11px] text-muted">{system === 'pf2e' ? `${c.hitDie} HP/lvl` : `d${c.hitDie}`}{c.keyAbilities.length ? ` · ${c.keyAbilities.map((k) => ABILITY_ABBR[k as AbilityKey]).join('/')}` : ''}{c.caster !== 'none' ? ' · caster' : ''}</div>
{c.description && <p className="mt-1 line-clamp-2 text-xs text-muted">{c.description}</p>}
{c.description && <p className="mt-1 line-clamp-2 text-xs text-muted">{briefOverview(c.description)}</p>}
</button>
))}
{classes.length === 0 && <p className="text-sm text-muted">Loading classes</p>}
@@ -0,0 +1,38 @@
import { describe, it, expect } from 'vitest';
import { briefOverview, dedupeByName } from './overview';
describe('briefOverview', () => {
it('leaves short text untouched (5e feature names)', () => {
expect(briefOverview('Rage')).toBe('Rage');
expect(briefOverview('Fighting Style')).toBe('Fighting Style');
});
it('keeps the first clean sentence of a long run-on, no mid-word cut', () => {
const fighter = 'Fighting for honor, greed, loyalty, or simply the thrill of battle, you are an undisputed master of weaponry and combat techniques. You combine your actions through clever combinations…';
expect(briefOverview(fighter)).toBe('Fighting for honor, greed, loyalty, or simply the thrill of battle, you are an undisputed master of weaponry and combat techniques.');
});
it('strips the Foundry "Name Source … pg. N" citation prefix, then takes the first sentence', () => {
const bg = ' Acolyte Source Core Rulebook pg. 60 You spent your early days in a religious monastery or cloister. You may have traveled out into the world to spread the message of your religion or because you cast away the teachings of your faith.';
expect(briefOverview(bg)).toBe('You spent your early days in a religious monastery or cloister.');
});
it('drops a stray trailing ellipsis from a pre-truncated summary', () => {
expect(briefOverview('Dwarves are a stoic and stern people…')).toBe('Dwarves are a stoic and stern people');
});
it('falls back to a word-boundary trim when the first sentence is very long', () => {
const long = `${'word '.repeat(60)}end.`;
const out = briefOverview(long);
expect(out.endsWith('…')).toBe(true);
expect(out.length).toBeLessThanOrEqual(141);
expect(out).not.toMatch(/wor…$/); // never cuts mid-word
});
});
describe('dedupeByName', () => {
it('keeps the first of each name, case-insensitively', () => {
const out = dedupeByName([{ name: 'Human' }, { name: 'Elf' }, { name: 'human' }, { name: 'Elf' }]);
expect(out.map((x) => x.name)).toEqual(['Human', 'Elf']);
});
});
@@ -0,0 +1,35 @@
/**
* Helpers that make the pf2e creator read as cleanly as the 5e one. The pf2e
* (Foundry) data carries long, citation-prefixed descriptions and reprinted
* entries under duplicate names; these trim and de-duplicate at the edge.
*/
/**
* A short, clean overview from possibly-long text. Strips the
* "<Name> Source Core Rulebook pg. N" citation prefix Foundry entries carry,
* drops a stray trailing ellipsis from prior truncation, then keeps the first
* sentence (or a clean word-boundary trim). A no-op on already-short text.
*/
export function briefOverview(text: string): string {
let t = (text || '').replace(/\s+/g, ' ').trim();
const citation = /^.{0,80}?\bSource\b.*?\bpg\.\s*\d+\s*/i.exec(t);
if (citation) t = t.slice(citation[0].length).trim();
t = t.replace(/…\s*$/, '').trim();
if (t.length <= 140) return t;
const sentence = /^(.{40,180}?[.!?])(?:\s|$)/.exec(t);
if (sentence) return sentence[1]!;
const cut = t.slice(0, 140);
const sp = cut.lastIndexOf(' ');
return `${(sp > 40 ? cut.slice(0, sp) : cut).trim()}`;
}
/** Keep the first entry of each name (case-insensitive) — drops reprints/variants. */
export function dedupeByName<T extends { name: string }>(items: T[]): T[] {
const seen = new Set<string>();
return items.filter((it) => {
const k = it.name.trim().toLowerCase();
if (seen.has(k)) return false;
seen.add(k);
return true;
});
}
File diff suppressed because one or more lines are too long