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:
@@ -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;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user