adventure: validate race/class + clamp level in admin char-migrate

Code-review fixes on the stuck-adventurer minting path:
- AdminBuildConfirmedCharacter now runs race/class through parseRace/
  parseClass (same as !setup), so a typo or non-playable class errors
  instead of silently minting a 1-HP/AC-10/no-spell sheet; inputs are
  normalized too.
- Clamp level to dndMaxLevel to match the L20 cap enforced elsewhere.
- CLI parses/validates every spec before db.Init, so a malformed spec
  mid-batch no longer leaves earlier specs already committed.
This commit is contained in:
prosolis
2026-07-16 06:42:54 -07:00
parent d08a20a114
commit 85e5ba5fce
2 changed files with 35 additions and 4 deletions

View File

@@ -40,16 +40,31 @@ func main() {
os.Exit(2) os.Exit(2)
} }
if err := db.Init(*dataDir); err != nil { // Parse every spec before touching the DB, so a malformed spec in the
log.Fatalf("db init: %v", err) // middle of the batch fails fast instead of leaving the earlier specs
// already committed to gogobee.db.
type charSpec struct {
uid id.UserID
race plugin.DnDRace
class plugin.DnDClass
level int
} }
specs := make([]charSpec, 0, flag.NArg())
for _, spec := range flag.Args() { for _, spec := range flag.Args() {
uid, race, class, level, err := parseSpec(spec) uid, race, class, level, err := parseSpec(spec)
if err != nil { if err != nil {
log.Fatalf("bad spec %q: %v", spec, err) log.Fatalf("bad spec %q: %v", spec, err)
} }
c, err := plugin.AdminBuildConfirmedCharacter(uid, race, class, level) specs = append(specs, charSpec{uid, race, class, level})
}
if err := db.Init(*dataDir); err != nil {
log.Fatalf("db init: %v", err)
}
for _, s := range specs {
uid := s.uid
c, err := plugin.AdminBuildConfirmedCharacter(s.uid, s.race, s.class, s.level)
if err != nil { if err != nil {
log.Fatalf("build %s: %v", uid, err) log.Fatalf("build %s: %v", uid, err)
} }

View File

@@ -31,9 +31,25 @@ import (
// SaveDnDCharacter upserts on user_id, so an existing pending draft is replaced. // SaveDnDCharacter upserts on user_id, so an existing pending draft is replaced.
// initResources / ensureSpellsForCharacter are idempotent. // initResources / ensureSpellsForCharacter are idempotent.
func AdminBuildConfirmedCharacter(userID id.UserID, race DnDRace, class DnDClass, level int) (*DnDCharacter, error) { func AdminBuildConfirmedCharacter(userID id.UserID, race DnDRace, class DnDClass, level int) (*DnDCharacter, error) {
// Validate (and normalize) race/class through the same parsers !setup uses,
// so a typo or a non-playable class fails loudly instead of silently minting
// a broken sheet — an unknown class falls through classInfo to 1 HP / AC 10
// / no spells, which looks "built" but isn't.
r, ok := parseRace(string(race))
if !ok {
return nil, fmt.Errorf("unknown race %q", race)
}
cl, ok := parseClass(string(class))
if !ok {
return nil, fmt.Errorf("unknown or non-playable class %q", class)
}
race, class = r, cl
if level < 1 { if level < 1 {
level = 1 level = 1
} }
if level > dndMaxLevel {
level = dndMaxLevel
}
scores := applyRaceMods(race, classStatPriority(class)) scores := applyRaceMods(race, classStatPriority(class))
c := &DnDCharacter{ c := &DnDCharacter{
UserID: userID, UserID: userID,