mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
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:
152
cmd/open5e-import/desc_overrides.go
Normal file
152
cmd/open5e-import/desc_overrides.go
Normal 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`),
|
||||
}
|
||||
)
|
||||
122
cmd/open5e-import/desc_overrides_test.go
Normal file
122
cmd/open5e-import/desc_overrides_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user