diff --git a/cmd/char-migrate/main.go b/cmd/char-migrate/main.go
new file mode 100644
index 0000000..dfc9dc4
--- /dev/null
+++ b/cmd/char-migrate/main.go
@@ -0,0 +1,90 @@
+// Command char-migrate mints a confirmed D&D character for one or more users,
+// for the "stuck adventurer" cases the boredom ticker can't reach: veteran
+// legacy players who never triggered auto-migration, and players who abandoned
+// !setup at race pick. It reuses the game's own constructors (stats, HP/AC,
+// resources, spells) via plugin.AdminBuildConfirmedCharacter.
+//
+// It opens the SAME gogobee.db the live bot uses (WAL + busy_timeout), so it is
+// safe to run alongside the running process — but snapshot the db dir first.
+//
+// Usage:
+//
+// char-migrate -data /home/reala/gogobee/data \
+// '@nonk:parodia.dev:human:rogue:4' \
+// '@knightstar:matrix.org:half_elf:warlock:1'
+//
+// Each spec is mxid:race:class:level (mxid contains colons, so we split from
+// the right for the last three fields).
+package main
+
+import (
+ "flag"
+ "fmt"
+ "log"
+ "os"
+ "strconv"
+ "strings"
+
+ "gogobee/internal/db"
+ "gogobee/internal/plugin"
+
+ "maunium.net/go/mautrix/id"
+)
+
+func main() {
+ dataDir := flag.String("data", "", "data dir containing gogobee.db (e.g. /home/reala/gogobee/data)")
+ flag.Parse()
+
+ if *dataDir == "" || flag.NArg() == 0 {
+ fmt.Fprintln(os.Stderr, "usage: char-migrate -data
':::' ...")
+ os.Exit(2)
+ }
+
+ if err := db.Init(*dataDir); err != nil {
+ log.Fatalf("db init: %v", err)
+ }
+
+ 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)
+ if err != nil {
+ log.Fatalf("build %s: %v", uid, err)
+ }
+ fmt.Printf("OK %-28s %s %s L%d HP=%d AC=%d STR%d DEX%d CON%d INT%d WIS%d CHA%d\n",
+ uid, c.Race, c.Class, c.Level, c.HPMax, c.ArmorClass,
+ c.STR, c.DEX, c.CON, c.INT, c.WIS, c.CHA)
+ }
+}
+
+// parseSpec splits mxid:race:class:level. The mxid itself contains a colon
+// (@user:server), so split the last three fields off the right.
+func parseSpec(spec string) (id.UserID, plugin.DnDRace, plugin.DnDClass, int, error) {
+ i := strings.LastIndex(spec, ":")
+ if i < 0 {
+ return "", "", "", 0, fmt.Errorf("missing :level")
+ }
+ level, err := strconv.Atoi(spec[i+1:])
+ if err != nil {
+ return "", "", "", 0, fmt.Errorf("level: %w", err)
+ }
+ rest := spec[:i]
+ j := strings.LastIndex(rest, ":")
+ if j < 0 {
+ return "", "", "", 0, fmt.Errorf("missing :class")
+ }
+ class := rest[j+1:]
+ rest = rest[:j]
+ k := strings.LastIndex(rest, ":")
+ if k < 0 {
+ return "", "", "", 0, fmt.Errorf("missing :race")
+ }
+ race := rest[k+1:]
+ mxid := rest[:k]
+ if mxid == "" || race == "" || class == "" {
+ return "", "", "", 0, fmt.Errorf("empty field")
+ }
+ return id.UserID(mxid), plugin.DnDRace(race), plugin.DnDClass(class), level, nil
+}
diff --git a/internal/plugin/admin_char_migrate.go b/internal/plugin/admin_char_migrate.go
new file mode 100644
index 0000000..6d7de0f
--- /dev/null
+++ b/internal/plugin/admin_char_migrate.go
@@ -0,0 +1,73 @@
+package plugin
+
+import (
+ "fmt"
+ "log/slog"
+ "time"
+
+ "maunium.net/go/mautrix/id"
+)
+
+// AdminBuildConfirmedCharacter mints (or overwrites) a confirmed D&D character
+// for one user with an explicitly chosen race/class/level, reusing the same
+// constructors as auto-migration and !setup confirm: class-tuned standard
+// array + racial mods, the canonical HP/AC formulas, resource pool, and — for
+// casters — the starter spell list and slot pool.
+//
+// It exists for the "stuck adventurer" cases the boredom ticker can't reach on
+// its own:
+//
+// - A veteran legacy player with an adventure_characters row but no
+// dnd_character (never triggered auto-migration because auto-migration only
+// fires on active play — an idle player never does). Pass the class/race the
+// faithful migration would infer to reproduce it exactly.
+// - A player who abandoned !setup at race pick (pending_setup=1, no class), so
+// tryBoredomStart skips them forever. This overwrites the draft with a
+// finished sheet.
+//
+// AutoMigrated is set so the player can freely rebuild via !setup (no respec
+// cooldown) when they return — the class was assigned for them, not chosen.
+//
+// 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) {
+ if level < 1 {
+ level = 1
+ }
+ scores := applyRaceMods(race, classStatPriority(class))
+ c := &DnDCharacter{
+ UserID: userID,
+ Race: race,
+ Class: class,
+ Level: level,
+ STR: scores[0], DEX: scores[1], CON: scores[2],
+ INT: scores[3], WIS: scores[4], CHA: scores[5],
+ PendingSetup: false,
+ AutoMigrated: true,
+ CreatedAt: time.Now().UTC(),
+ UpdatedAt: time.Now().UTC(),
+ }
+ conMod := abilityModifier(c.CON)
+ dexMod := abilityModifier(c.DEX)
+ c.HPMax = computeMaxHP(c.Class, conMod, c.Level)
+ c.HPCurrent = c.HPMax
+ c.ArmorClass = computeAC(c.Class, dexMod)
+ c.ShortRestCharges = c.Level
+
+ if err := ensurePlayerMetaSeed(userID); err != nil {
+ return nil, fmt.Errorf("seed player_meta: %w", err)
+ }
+ if err := SaveDnDCharacter(c); err != nil {
+ return nil, fmt.Errorf("save dnd_character: %w", err)
+ }
+ if err := initResources(userID, c.Class); err != nil {
+ return nil, fmt.Errorf("init resources: %w", err)
+ }
+ if err := ensureSpellsForCharacter(c); err != nil {
+ return nil, fmt.Errorf("seed spells: %w", err)
+ }
+ slog.Info("admin: built confirmed character",
+ "user", userID, "race", race, "class", class, "level", level,
+ "hp", c.HPMax, "ac", c.ArmorClass)
+ return c, nil
+}