diff --git a/cmd/char-migrate/main.go b/cmd/char-migrate/main.go index dfc9dc4..d1b09bf 100644 --- a/cmd/char-migrate/main.go +++ b/cmd/char-migrate/main.go @@ -40,16 +40,31 @@ func main() { os.Exit(2) } - if err := db.Init(*dataDir); err != nil { - log.Fatalf("db init: %v", err) + // Parse every spec before touching the DB, so a malformed spec in the + // 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() { uid, race, class, level, err := parseSpec(spec) if err != nil { 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 { log.Fatalf("build %s: %v", uid, err) } diff --git a/internal/plugin/admin_char_migrate.go b/internal/plugin/admin_char_migrate.go index 6d7de0f..24f5aee 100644 --- a/internal/plugin/admin_char_migrate.go +++ b/internal/plugin/admin_char_migrate.go @@ -31,9 +31,25 @@ import ( // SaveDnDCharacter upserts on user_id, so an existing pending draft is replaced. // initResources / ensureSpellsForCharacter are idempotent. 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 { level = 1 } + if level > dndMaxLevel { + level = dndMaxLevel + } scores := applyRaceMods(race, classStatPriority(class)) c := &DnDCharacter{ UserID: userID,