// 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) } // 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) } 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) } 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 }