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 +}