From 85e5ba5fce32f80cd9508c9a6e342e42e5b31af0 Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Thu, 16 Jul 2026 06:42:54 -0700 Subject: [PATCH] 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. --- cmd/char-migrate/main.go | 23 +++++++++++++++++++---- internal/plugin/admin_char_migrate.go | 16 ++++++++++++++++ 2 files changed, 35 insertions(+), 4 deletions(-) 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,