mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-16 17:02:42 +00:00
adventure: mint a confirmed sheet for a stuck adventurer
The boredom ticker needs a confirmed dnd_character, so two populations never leave on their own: veteran legacy players with an adventure_characters row but no dnd_character (auto-migration only fires on active play, which an idle player never does), and players who abandoned !setup at race pick (pending_setup=1). Both are correct guards in tryBoredomStart, not bugs — the fix is to hand them a finished sheet. AdminBuildConfirmedCharacter forces a race/class/level and reuses the same constructors as auto-migration and !setup confirm (class-tuned standard array, racial mods, HP/AC, resource pool, caster spells+slots), with auto_migrated=1 so the player can freely !setup-rebuild the class that was chosen for them. cmd/char-migrate is the one-off that drives it against a live gogobee.db.
This commit is contained in:
90
cmd/char-migrate/main.go
Normal file
90
cmd/char-migrate/main.go
Normal file
@@ -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 <dir> '<mxid>:<race>:<class>:<level>' ...")
|
||||
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
|
||||
}
|
||||
73
internal/plugin/admin_char_migrate.go
Normal file
73
internal/plugin/admin_char_migrate.go
Normal file
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user