Files
bd2/go/cmd/bd2server/state.go
T
2026-09-21 09:53:46 +08:00

152 lines
5.3 KiB
Go

package main
import (
"context"
"encoding/hex"
"errors"
"flag"
"fmt"
"path/filepath"
controlv1 "bd2server/gen/state/control/v1"
"bd2server/internal/gacha"
"bd2server/internal/gamedata"
"bd2server/internal/player"
"bd2server/internal/statebridge"
)
func stateCommand(args []string) error {
if len(args) == 0 {
return errors.New("usage: bd2server state <check|migrate-v1-v2|repair> [options]")
}
switch args[0] {
case "check":
return stateCheckCommand(args[1:])
case "migrate-v1-v2":
return stateMigrateV1ToV2Command(args[1:])
case "repair":
return stateRepairCommand(args[1:])
default:
return errors.New("usage: bd2server state <check|migrate-v1-v2|repair> [options]")
}
}
func stateCheckCommand(args []string) error {
fs := flag.NewFlagSet("state check", flag.ContinueOnError)
stateDir := fs.String("state", filepath.FromSlash("../data/state"), "directory containing the nine JSON state files")
tool := fs.String("tool", "", "optional Haskell state tool override")
if err := fs.Parse(args); err != nil {
return err
}
resolved, cleanup, err := statebridge.ResolveTool(*tool)
if err != nil {
return err
}
defer cleanup()
result, err := statebridge.Check(context.Background(), filepath.Clean(*stateDir), resolved)
if err != nil {
return err
}
fmt.Printf("state SHA-256: %s\n", hex.EncodeToString(result.SourceSHA256[:]))
var errorsFound int
for _, violation := range result.Violations {
severity := violation.Severity.String()
fmt.Printf("%s %s %s: %s", severity, violation.Code, filepath.Join(violation.Path...), violation.Message)
if len(violation.RelatedIds) != 0 {
fmt.Printf(" ids=%v", violation.RelatedIds)
}
fmt.Println()
if violation.Severity == controlv1.Severity_SEVERITY_ERROR {
errorsFound++
}
}
if errorsFound != 0 {
return fmt.Errorf("state check rejected with %d error(s)", errorsFound)
}
fmt.Printf("state check passed (%d warning(s))\n", len(result.Violations))
return nil
}
func stateMigrateV1ToV2Command(args []string) error {
fs := flag.NewFlagSet("state migrate-v1-v2", flag.ContinueOnError)
stateDir := fs.String("state", filepath.FromSlash("../data/state"), "directory containing the nine JSON state files")
tool := fs.String("tool", "", "optional Haskell state tool override")
if err := fs.Parse(args); err != nil {
return err
}
resolved, cleanup, err := statebridge.ResolveTool(*tool)
if err != nil {
return err
}
defer cleanup()
result, err := statebridge.MigrateV1ToV2(context.Background(), filepath.Clean(*stateDir), resolved)
if err != nil {
return err
}
fmt.Printf("source JSON SHA-256: %s\n", hex.EncodeToString(result.SourceSHA256[:]))
for _, violation := range result.Violations {
fmt.Printf("%s %s %s: %s\n", violation.Severity.String(), violation.Code, filepath.Join(violation.Path...), violation.Message)
}
if result.Target == nil {
return errors.New("state migration rejected; no files were changed")
}
fmt.Printf("target protobuf SHA-256: %s\n", hex.EncodeToString(result.TargetSHA256[:]))
fmt.Printf("V1 -> V2 migration passed: %d character(s), %d costume(s); no files were changed\n",
len(result.Target.Roster.Characters), len(result.Target.Roster.Costumes))
return nil
}
func stateRepairCommand(args []string) error {
fs := flag.NewFlagSet("state repair", flag.ContinueOnError)
stateDir := fs.String("state", filepath.FromSlash("../data/state"), "directory containing the nine JSON state files")
tool := fs.String("tool", "", "optional Haskell state tool override")
gameData := fs.String("game-data", "", "versioned GameData root (required)")
gameDataVersion := fs.String("game-data-version", "", "validated GameData version (required)")
playerSeed := fs.String("player-seed", `seed\v2_34_13\starter_player.json`, "versioned starter player seed")
apply := fs.Bool("apply", false, "install a changed repair result with rollback protection")
if err := fs.Parse(args); err != nil {
return err
}
if *gameData == "" || *gameDataVersion == "" {
return errors.New("state repair requires --game-data and --game-data-version")
}
resolved, cleanup, err := statebridge.ResolveTool(*tool)
if err != nil {
return err
}
defer cleanup()
catalog, err := gamedata.LoadRegularCostumeGacha(filepath.Clean(*gameData), *gameDataVersion)
if err != nil {
return err
}
starter, err := player.Load(filepath.Clean(*playerSeed))
if err != nil {
return err
}
groupID, gachaIDs := gacha.StepUpMigrationFact()
validation, err := statebridge.BuildValidationContext(*gameDataVersion, catalog, starter, groupID, gachaIDs)
if err != nil {
return err
}
result, err := statebridge.Repair(context.Background(), filepath.Clean(*stateDir), resolved, validation)
if err != nil {
return err
}
fmt.Printf("source JSON SHA-256: %s\n", hex.EncodeToString(result.SourceSHA256[:]))
if result.Target == nil {
return violationsError("state repair rejected", result.Violations)
}
fmt.Printf("target protobuf SHA-256: %s\nchanged: %t\n", hex.EncodeToString(result.TargetSHA256[:]), result.Changed)
if result.Changed && *apply {
if err := statebridge.InstallRepair(filepath.Clean(*stateDir), result); err != nil {
return err
}
fmt.Println("repair installed; rollback protection cleared after complete commit")
} else if result.Changed {
fmt.Println("dry run only; pass --apply to install")
} else {
fmt.Println("state is already current; no files changed")
}
return nil
}