UX S3: SRD copy-edit pass — sanitize jargon + curated overrides

Plumbed through the open5e importer so regen stays safe:

- New cmd/open5e-import/desc_overrides.go holds two per-ID override
  maps (spellDescOverride, magicItemDescOverride) and a regex-driven
  cleanDesc sanitizer. Override wins outright; otherwise the SRD
  first-sentence runs through cleanDesc, which strips the phrases
  the S3 acceptance criteria forbid (saving throw[s], spell slot,
  within range, 5-foot, DC <n>, "(save DC X)" parentheticals,
  "constitution score is N", "out to a range of N feet"). A small
  post-pass repairs the orphan stubs the strippers leave behind
  (" and." trailers, "must make." after the saving-throw object
  is gone).

- gen.go (spells) + magicitems.go now call spellDescription /
  magicItemDescription instead of raw firstSentence; same hand-
  authored override pattern, same cleanDesc fallthrough.

- Override coverage: the 19 SRD-only spells that show up in
  defaultKnownSpells (call_lightning, charm_person, vicious_mockery,
  …) plus ~35 high-visibility magic items (Amulet of Health, every
  Belt of Giant Strength variant, Cloak of Displacement, etc.).
  Tone is outcome-first second-person with bite — these surface
  in the spellbook and the curio shop, so they get to be funny.

- tuned.go (R21) + magicitems.go strip "(...)" from emitted Names
  via stripNameParenthetical. Slug keeps the variant; only the
  display text loses the qualifier. Two bestiary entries
  (giant_rat_diseased, deep_gnome_svirfneblin) and stone_of_good_luck
  affected.

- Regenerated all three data files. Acceptance grep is clean:
  zero hits for any banned phrase in Description/Desc fields.

- New cmd/open5e-import/desc_overrides_test.go covers the
  sanitizer regressions, orphan-repair, name-strip, and the
  override-wins-but-fallthrough-still-sanitizes path.

Conflicts: none. S4 (magic-item UX) wanted this in first so the
new curio renderer consumes clean text — ready for it now.
This commit is contained in:
prosolis
2026-05-14 21:53:06 -07:00
parent c48e12a296
commit 1512f6cc50
8 changed files with 474 additions and 169 deletions

View File

@@ -0,0 +1,152 @@
package main
import (
"regexp"
"strings"
)
// Description-cleaning hooks shared by the spell and magic-item generators.
//
// The Open5e dump descriptions are SRD prose pulled verbatim — they leak
// "within range," "WIS saving throw," "your Constitution score is 19," and
// other rules-text the game now hides. This file handles two layers:
//
// - A sanitizer (cleanDesc) strips the worst jargon clauses from the auto
// first-sentence and tidies the result. Used as the default path.
// - Per-ID override maps win outright when a description needs more than
// mechanical scrubbing (e.g. Amulet of Health's "score is 19," all of
// the Mage default-list cantrips). Hand-author here, not in the
// generated file — these survive a regen.
// spellDescOverride wins over the auto-derived first-sentence Description
// when the slug matches. Cover anything that surfaces in the player's
// spellbook listing (defaultKnownSpells) or the !spells available command.
//
// Tone target: outcome-first, second person, plain English. No DC, no slot
// jargon, no "within range." Match the voice already in dnd_spells_data.go.
var spellDescOverride = map[string]string{
// SRD-only spells that appear in defaultKnownSpells (no hand-authored
// overlay in dnd_spells_data.go). Sourced from the S3 surfaced-spells
// audit; each gives the player a one-line read of what it does, with
// just enough bite that the spellbook isn't a phone book.
"call_lightning": "Summon a sulking thundercloud, then ask it — politely — to drop a bolt on someone you dislike.",
"charm_person": "Sweet-talk a humanoid into treating you as their new favourite acquaintance. Wears off; they may notice.",
"conjure_animals": "Call up a posse of summoned beasts. They are very enthusiastic and only mostly trained.",
"eldritch_blast": "A beam of crackling otherworldly Whatever streaks at a target. Your patron's preferred greeting.",
"entangle": "The ground sprouts rude, grabby roots in a patch. Anyone caught looks undignified at best.",
"faerie_fire": "Outline targets in glittery light. They can't hide; you hit them more easily; they hate it.",
"fear": "Project a cone of pure dread. Brave folk turn and run; less brave folk were already running.",
"flaming_sphere": "A rolling ball of fire shows up and goes wherever you point. Carpet not included.",
"haste": "An ally surges with speed — extra ground, sharper defences, an extra swing. Brief, glorious, exhausting.",
"healing_word": "Bark a healing syllable across the battlefield. An ally finds themselves slightly less dead.",
"heat_metal": "Their weapon, their armour, their belt buckle — glowing red-hot. Hard to grip. Worse to wear.",
"heroism": "An ally takes courage. No fear, more grit, slightly insufferable battle cries.",
"hideous_laughter": "Target collapses in a fit of unstoppable giggling. Awkward for them, very funny for you.",
"insect_plague": "A swarm of biting locusts boils up in an area. Everyone inside has Opinions about it.",
"invisibility": "An ally fades from sight. Lasts until they attack, cast, or yell something dramatic.",
"produce_flame": "A flame springs to your palm. Use it as a torch, or sling it at someone. Multipurpose.",
"ray_of_frost": "A freezing beam catches a target and leaves them sluggish, frosted, and visibly cross.",
"vampiric_touch": "Drain life from a target. The damage you deal patches you up. Frowned upon at parties.",
"vicious_mockery": "Hurl an insult so cutting it physically hurts. Their next swing wobbles in shame.",
// Hand-authored overlay descriptions in dnd_spells_data.go are the
// canonical text and win at registry merge time, so they don't need
// entries here. If a spell ever moves out of buildSpellList(), add it
// above.
}
// magicItemDescOverride wins over the auto-derived Desc when the slug
// matches. Priority targets: items the plan called out by name (Amulet of
// Health, Belt of Giant Strength) and any item whose SRD blurb leaks rules
// math or names a spell the player can't cast.
var magicItemDescOverride = map[string]string{
"amulet_of_health": "Hums against your collarbone and quietly upgrades your constitution to 'bothersome to kill.'",
"belt_of_giant_strength_hill": "Buckle it on; suddenly you can deadlift the bartender. Hill-giant grade.",
"belt_of_giant_strength_stone": "Buckle it on; the floor creaks where you weren't standing yesterday. Stone-giant grade.",
"belt_of_giant_strength_frost": "Buckle it on; doors come off their hinges when you knock politely. Frost-giant grade.",
"belt_of_giant_strength_fire": "Buckle it on; furniture rearranges itself to be elsewhere. Fire-giant grade.",
"belt_of_giant_strength_cloud": "Buckle it on; you arm-wrestle siege engines for fun. Cloud-giant grade.",
"belt_of_giant_strength_storm": "Buckle it on; the world feels strangely flimsy. Storm-giant grade.",
"belt_of_hill_giant_strength": "Buckle it on; suddenly you can deadlift the bartender. Hill-giant grade.",
"belt_of_stone_giant_strength": "Buckle it on; the floor creaks where you weren't standing yesterday. Stone-giant grade.",
"belt_of_frost_giant_strength": "Buckle it on; doors come off their hinges when you knock politely. Frost-giant grade.",
"belt_of_fire_giant_strength": "Buckle it on; furniture rearranges itself to be elsewhere. Fire-giant grade.",
"belt_of_cloud_giant_strength": "Buckle it on; you arm-wrestle siege engines for fun. Cloud-giant grade.",
"belt_of_storm_giant_strength": "Buckle it on; the world feels strangely flimsy. Storm-giant grade.",
"belt_of_dwarvenkind": "A stout dwarven belt — beard not included, hardiness very much included.",
"boots_of_levitation": "Float upward at will. Down is a separate problem you'll figure out later.",
"boots_of_speed": "Click the heels together; the world starts moving at a polite walking pace.",
"boots_of_striding_and_springing": "Tireless stride and effortless hops. Ceilings: now optional.",
"boots_of_the_winterlands": "Snow doesn't slow you, cold doesn't bite, and your tracks tactfully erase themselves.",
"bracers_of_archery": "Steady your bow arm — your arrows hit harder and find seams in armour they shouldn't.",
"bracers_of_defense": "Light bracers that quietly turn aside blows. No armour required, no questions asked.",
"brooch_of_shielding": "Pinned to your cloak. Eats incoming bolts of force like they're hors d'oeuvres.",
"cloak_of_displacement": "Bends light around you. Attackers keep swinging at where you obviously are, and missing.",
"cloak_of_elvenkind": "Pulls shadows around you whether you asked or not. Watchful eyes slide right off.",
"cloak_of_protection": "A perfectly ordinary travelling cloak that perfectly extraordinarily refuses to let you get hit.",
"cloak_of_resistance": "Wards off one kind of harm — fire, frost, lightning, poison, or some other unpleasant Tuesday.",
"cloak_of_the_bat": "By night you glide instead of fall; on a really good night, you fly. Daytime: just a cloak.",
"cloak_of_the_manta_ray": "Underwater, you swim like a manta and breathe like you're on land. On land, you look damp.",
"gauntlets_of_ogre_power": "Heavy gauntlets that lend you an ogre's strength and an ogre's complete disregard for door frames.",
"headband_of_intellect": "Slips on, makes you think clever thoughts you have no business thinking. People notice.",
"ring_of_protection": "A plain ring. Quietly turns aside blows you'd otherwise take. Refuses to explain how.",
"ring_of_regeneration": "Wounds knit themselves back together while you wear it. Slowly. Smugly.",
"ring_of_resistance": "Wards off one kind of harm — fire, frost, lightning, poison, or another flavour of bad day.",
"ring_of_swimming": "Slip through water faster than any swimmer should. Dolphins file complaints.",
"ring_of_warmth": "Cold weather no longer applies to you. Chill damage softens; you become unbearable in winter.",
"slippers_of_spider_climbing": "Walk up walls and across ceilings as if the floor were merely a suggestion.",
}
// cleanDesc strips the worst SRD jargon from a description before it lands in
// the generated file. It is intentionally conservative: drop offending phrases,
// collapse the resulting whitespace, leave the rest of the sentence alone. Any
// description that still reads poorly after this pass should get a hand-authored
// entry in spellDescOverride / magicItemDescOverride above.
func cleanDesc(s string) string {
if s == "" {
return s
}
for _, pat := range descScrub {
s = pat.ReplaceAllString(s, "")
}
// Collapse runs of whitespace and tidy stray punctuation orphans left
// behind by deletions ("a creature and ", " , ", " .").
s = reSpaces.ReplaceAllString(s, " ")
s = strings.ReplaceAll(s, " ,", ",")
s = strings.ReplaceAll(s, " .", ".")
s = strings.ReplaceAll(s, "( )", "")
// Repair sentence stubs left by clause-strippers — "...must make." after
// the saving-throw object is gone, or trailing " and." where one half of
// a coordinated noun phrase was removed. These are conservative: they
// only fire when the orphan sits at end-of-sentence.
s = reOrphanMustMake.ReplaceAllString(s, "$1")
s = reOrphanTrailingAnd.ReplaceAllString(s, ".")
s = strings.TrimSpace(s)
return s
}
var (
reSpaces = regexp.MustCompile(`\s+`)
reOrphanMustMake = regexp.MustCompile(`(?i)\s+must\s+(?:make|succeed on|attempt)([.,;])`)
reOrphanTrailingAnd = regexp.MustCompile(`(?i),?\s+and\.`)
descScrub = []*regexp.Regexp{
// Range / distance jargon.
regexp.MustCompile(`(?i)\s*\bwithin range\b`),
regexp.MustCompile(`(?i)\s*\bwithin \d+ feet(?: of [a-z ]+?)?`),
regexp.MustCompile(`(?i)\s+\d+-foot[- ](?:radius|cone|cube|line|sphere|square)`),
regexp.MustCompile(`(?i)\s+(?:out to a range of\s*)?\d+ feet\b`),
regexp.MustCompile(`(?i)\s+out to a range of\b`),
// Save / DC / slot jargon.
regexp.MustCompile(`(?i)\s*\b(?:Strength|Dexterity|Constitution|Intelligence|Wisdom|Charisma)\s+saving throws?\b`),
regexp.MustCompile(`(?i)\s*\bsaving throws?\b`),
regexp.MustCompile(`(?i)\s*\(save(?:\s+DC\s*\d+)?\)`),
regexp.MustCompile(`(?i)\s*\bDC\s*\d+\b`),
regexp.MustCompile(`(?i)\s*\b(?:using|cast(?:ing)?(?: it)? with) a spell slot of \d+(?:st|nd|rd|th) level or higher\b`),
regexp.MustCompile(`(?i)\s*\bspell slot\b`),
// Cross-references and stat-block leakage.
regexp.MustCompile(`(?i)\s*\bsee the spell\b`),
regexp.MustCompile(`(?i)\s*\b(?:wisdom|strength|dexterity|constitution|intelligence|charisma)\s+save\b`),
regexp.MustCompile(`(?i)\s*\b(?:wisdom|strength|dexterity|constitution|intelligence|charisma)\s+score\s+is\s+\d+\b`),
regexp.MustCompile(`(?i)\s*\bconstitution score\b`),
}
)

View File

@@ -0,0 +1,122 @@
package main
import (
"strings"
"testing"
)
// TestCleanDescStripsJargon — the S3 acceptance criteria translated into
// regression cases. Any phrase listed here must not survive the sanitizer.
func TestCleanDescStripsJargon(t *testing.T) {
cases := []struct {
name string
in string
mustNot []string
}{
{
name: "saving throw + DC",
in: "Each creature in the area must make a Dexterity saving throw (DC 15) or take damage.",
mustNot: []string{
"saving throw", "DC 15", "DC15",
},
},
{
name: "saving throws plural",
in: "You have advantage on saving throws against spells.",
mustNot: []string{"saving throws"},
},
{
name: "within range",
in: "Hurl a bolt at a creature within range.",
mustNot: []string{"within range"},
},
{
name: "5-foot radius",
in: "Each creature in a 5-foot radius takes damage.",
mustNot: []string{"5-foot"},
},
{
name: "spell slot upcast clause",
in: "When you cast this spell using a spell slot of 2nd level or higher, the damage increases.",
mustNot: []string{"spell slot"},
},
{
name: "constitution score",
in: "Your Constitution score is 19 while you wear this amulet.",
mustNot: []string{"Constitution score"},
},
{
name: "(save DC X) parenthetical",
in: "You can use an action to cast the detect thoughts spell (save DC 13) from it.",
mustNot: []string{"(save", "DC 13"},
},
{
name: "wisdom save",
in: "Forces a wisdom save against the caster.",
mustNot: []string{"wisdom save"},
},
{
name: "out to a range of feet",
in: "You have darkvision out to a range of 60 feet.",
mustNot: []string{"feet", "out to a range"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := cleanDesc(tc.in)
low := strings.ToLower(got)
for _, ban := range tc.mustNot {
if strings.Contains(low, strings.ToLower(ban)) {
t.Errorf("cleanDesc kept banned phrase %q\n in: %q\n out: %q", ban, tc.in, got)
}
}
})
}
}
// TestCleanDescRepairsOrphans — the strippers leave dangling stubs ("must
// make.", " and."); the post-pass is meant to tidy them so player-facing
// prose doesn't end mid-clause.
func TestCleanDescRepairsOrphans(t *testing.T) {
in := "You gain a +1 bonus to ability checks and saving throws."
got := cleanDesc(in)
if strings.HasSuffix(got, " and.") {
t.Errorf("orphan trailing 'and.' survived: %q", got)
}
in2 := "Up to three creatures of your choice that you can see must make charisma saving throws."
got2 := cleanDesc(in2)
if strings.Contains(strings.ToLower(got2), "must make.") {
t.Errorf("orphan 'must make.' survived: %q", got2)
}
}
// TestStripNameParenthetical — bestiary R21 / magic-item alias names.
func TestStripNameParenthetical(t *testing.T) {
cases := map[string]string{
"Giant Rat (Diseased)": "Giant Rat",
"Deep Gnome (Svirfneblin)": "Deep Gnome",
"Stone of Good Luck (Luckstone)": "Stone of Good Luck",
"Ordinary Name": "Ordinary Name",
"": "",
}
for in, want := range cases {
if got := stripNameParenthetical(in); got != want {
t.Errorf("stripNameParenthetical(%q) = %q, want %q", in, got, want)
}
}
}
// TestSpellOverrideWins — the curated descriptions for the SRD-only default
// spells must beat the auto first-sentence path.
func TestSpellOverrideWins(t *testing.T) {
got := spellDescription("vicious_mockery", "anything could go here")
if got != spellDescOverride["vicious_mockery"] {
t.Errorf("override didn't win: got %q", got)
}
// Unknown ID falls through to cleanDesc.
in := "Hurl a bolt at a creature within range."
got = spellDescription("unknown_spell_xyz", in)
if strings.Contains(got, "within range") {
t.Errorf("fallthrough didn't sanitize: %q", got)
}
}

View File

@@ -142,7 +142,7 @@ func classify(s open5eSpell, classes []string) genSpell {
School: strings.ToLower(strings.TrimSpace(s.School)),
Classes: classes,
Concentration: s.RequiresConcentration,
Description: firstSentence(desc, 200),
Description: spellDescription(strings.ReplaceAll(s.Slug, "-", "_"), desc),
Upcast: firstSentence(strings.TrimSpace(s.HigherLevel), 200),
AOE: looksAOE(low),
}
@@ -240,6 +240,16 @@ func looksAOE(low string) bool {
return false
}
// spellDescription returns the description text emitted for a spell. A
// hand-authored override in spellDescOverride wins outright; otherwise the
// SRD first-sentence is run through cleanDesc to strip jargon clauses.
func spellDescription(id, raw string) string {
if s, ok := spellDescOverride[id]; ok {
return s
}
return firstSentence(cleanDesc(raw), 200)
}
// firstSentence trims free-text fields down to a single sentence, capped at
// max runes, so generated Description/Upcast stay terminal-friendly.
func firstSentence(s string, max int) string {

View File

@@ -171,9 +171,9 @@ func genMagicItems() error {
func classifyMagicItem(m open5eMagicItem) genMagicItem {
g := genMagicItem{
ID: strings.ReplaceAll(m.Slug, "-", "_"),
Name: m.Name,
Name: stripNameParenthetical(m.Name),
Attunement: strings.TrimSpace(m.RequiresAttunement) != "",
Desc: firstSentence(strings.TrimSpace(m.Desc), 200),
Desc: magicItemDescription(strings.ReplaceAll(m.Slug, "-", "_"), strings.TrimSpace(m.Desc)),
}
rarity := strings.ToLower(strings.TrimSpace(m.Rarity))
@@ -250,6 +250,15 @@ func inferSlot(kind, name string) string {
return ""
}
// magicItemDescription returns the Desc text emitted for an item. Override
// wins; otherwise the SRD first-sentence runs through cleanDesc.
func magicItemDescription(id, raw string) string {
if s, ok := magicItemDescOverride[id]; ok {
return s
}
return firstSentence(cleanDesc(raw), 200)
}
// ── Code emission ────────────────────────────────────────────────────────────
func emitMagicItems(items []genMagicItem) []byte {

View File

@@ -96,7 +96,7 @@ func tuneMonster(b genStatBlock) genTunedMonster {
ability := abilityFromTraits(b.Traits)
return genTunedMonster{
ID: b.Slug,
Name: b.Name,
Name: stripNameParenthetical(b.Name),
CR: b.CR,
HP: b.HP,
AC: ac,
@@ -110,6 +110,18 @@ func tuneMonster(b genStatBlock) genTunedMonster {
}
}
// stripNameParenthetical drops a trailing "(…)" qualifier from a monster
// name so the player-facing display reads "Giant Rat" rather than "Giant Rat
// (Diseased)". The slug still carries the variant, so the engine keeps both
// records distinct — only the display text loses the qualifier.
func stripNameParenthetical(name string) string {
i := strings.IndexByte(name, '(')
if i <= 0 {
return name
}
return strings.TrimSpace(name[:i])
}
// traitAbilityRules maps SRD special-ability names onto the engine's
// MonsterAbility effects. Each rule's Match list holds lowercase substrings; a
// creature's trait matches a rule if any substring is contained in the