Files
bd2/go/internal/statebridge/migrate.go
T
2026-09-21 09:53:46 +08:00

183 lines
6.8 KiB
Go

package statebridge
import (
"bytes"
"context"
"crypto/sha256"
"errors"
"fmt"
"slices"
controlv1 "bd2server/gen/state/control/v1"
controlv2 "bd2server/gen/state/control/v2"
statev1 "bd2server/gen/state/v1"
statev2 "bd2server/gen/state/v2"
"google.golang.org/protobuf/proto"
)
const MigrationBridgeAPIVersion = 2
type MigrationResult struct {
SourceSHA256 [32]byte
TargetSHA256 [32]byte
Target *statev2.Snapshot
Violations []*controlv1.Violation
}
// MigrateV1ToV2 performs an in-memory, read-only migration. The Haskell tool
// receives only protobuf bytes and cannot discover or write the state path.
func MigrateV1ToV2(ctx context.Context, stateDir, toolPath string) (MigrationResult, error) {
source, sourceHash, err := LoadSnapshot(stateDir)
if err != nil {
return MigrationResult{}, err
}
request := &controlv2.MigrateV1ToV2Request{
BridgeApiVersion: MigrationBridgeAPIVersion,
SourceSha256: sourceHash[:],
Source: source,
}
payload, err := proto.MarshalOptions{Deterministic: true}.Marshal(request)
if err != nil {
return MigrationResult{}, fmt.Errorf("statebridge: encode migration request: %w", err)
}
responsePayload, err := runTool(ctx, toolPath, []string{"migrate-v1-v2"}, payload)
if err != nil {
return MigrationResult{}, err
}
var response controlv2.MigrateV1ToV2Response
if err := proto.Unmarshal(responsePayload, &response); err != nil {
return MigrationResult{}, fmt.Errorf("statebridge: decode migration response: %w", err)
}
if response.BridgeApiVersion != MigrationBridgeAPIVersion {
return MigrationResult{}, fmt.Errorf("statebridge: migrator API version %d", response.BridgeApiVersion)
}
if !bytes.Equal(response.SourceSha256, sourceHash[:]) {
return MigrationResult{}, errors.New("statebridge: migrator returned the wrong source hash")
}
if err := validateViolations(response.Violations); err != nil {
return MigrationResult{}, err
}
result := MigrationResult{SourceSHA256: sourceHash, Violations: response.Violations}
if !response.Migrated {
if response.Target != nil {
return MigrationResult{}, errors.New("statebridge: rejected migration returned a target")
}
if !hasErrorViolation(response.Violations) {
return MigrationResult{}, errors.New("statebridge: rejected migration omitted an error violation")
}
return result, nil
}
if response.Target == nil {
return MigrationResult{}, errors.New("statebridge: accepted migration omitted target")
}
if hasErrorViolation(response.Violations) {
return MigrationResult{}, errors.New("statebridge: accepted migration returned errors")
}
if err := verifyV2(source, response.Target); err != nil {
return MigrationResult{}, err
}
targetBytes, err := proto.MarshalOptions{Deterministic: true}.Marshal(response.Target)
if err != nil {
return MigrationResult{}, fmt.Errorf("statebridge: encode migrated target: %w", err)
}
result.Target = response.Target
result.TargetSHA256 = sha256.Sum256(targetBytes)
return result, nil
}
func hasErrorViolation(violations []*controlv1.Violation) bool {
for _, violation := range violations {
if violation.Severity == controlv1.Severity_SEVERITY_ERROR {
return true
}
}
return false
}
func verifyV2(source *statev1.Snapshot, target *statev2.Snapshot) error {
if target.FormatVersion != 2 || target.ClientVersion != source.ClientVersion || target.GameDataVersion != source.GameDataVersion {
return errors.New("statebridge: migrated target metadata mismatch")
}
if target.Roster == nil || target.Collection == nil || source.Characters == nil || source.Collection == nil {
return errors.New("statebridge: migrated target omitted roster or collection")
}
unchanged := [][2]proto.Message{
{source.Progress, target.Progress}, {source.Deck, target.Deck}, {source.Inventory, target.Inventory},
{source.Equipment, target.Equipment}, {source.Wallet, target.Wallet}, {source.Mail, target.Mail},
{source.Missions, target.Missions},
}
for _, pair := range unchanged {
if !proto.Equal(pair[0], pair[1]) {
return errors.New("statebridge: migration changed an unchanged V1 domain")
}
}
baseCount := len(source.Characters.Characters)
acquiredCount := len(source.Collection.Characters)
if len(target.Roster.Characters) != baseCount+acquiredCount {
return errors.New("statebridge: migration changed character count")
}
for i, record := range target.Roster.Characters {
if record == nil || record.Character == nil {
return errors.New("statebridge: migration emitted an empty character record")
}
if i < baseCount {
if record.Origin != statev2.CharacterOrigin_CHARACTER_ORIGIN_BASE || !proto.Equal(record.Character, source.Characters.Characters[i]) {
return errors.New("statebridge: migration changed a base character")
}
} else if record.Origin != statev2.CharacterOrigin_CHARACTER_ORIGIN_ACQUIRED || !proto.Equal(record.Character, source.Collection.Characters[i-baseCount]) {
return errors.New("statebridge: migration changed an acquired character")
}
}
if target.Roster.NextAcquiredCharacterIndex != source.Collection.NextCharacterIndex ||
target.Roster.NextCostumeIndex != source.Collection.NextCostumeIndex ||
len(target.Roster.Costumes) != len(source.Collection.Costumes) {
return errors.New("statebridge: migration changed roster allocation state")
}
for i := range target.Roster.Costumes {
if !proto.Equal(target.Roster.Costumes[i], source.Collection.Costumes[i]) {
return errors.New("statebridge: migration changed a costume")
}
}
if !collectionLedgerEqual(source.Collection, target.Collection) {
return errors.New("statebridge: migration changed collection ledger state")
}
return nil
}
func collectionLedgerEqual(source *statev1.Collection, target *statev2.CollectionLedger) bool {
return equalU64(source.LatestPreview, target.LatestPreview) && source.PreviewEventIndex == target.PreviewEventIndex &&
source.PreviewLocked == target.PreviewLocked && protoSlicesEqual(source.BaseCostumeLevels, target.BaseCostumeLevels) &&
protoSlicesEqual(source.GachaSelections, target.GachaSelections) && protoSlicesEqual(source.StepUpProgress, target.StepUpProgress) &&
protoSlicesEqual(source.GachaUsers, target.GachaUsers) && protoSlicesEqual(source.GachaFixed, target.GachaFixed) &&
equalStrings(source.GachaApplied, target.GachaApplied) && protoSlicesEqual(source.GachaPointExchanges, target.GachaPointExchanges) &&
source.GachaCountCorrected == target.GachaCountCorrected && protoSlicesEqual(source.Grants, target.Grants)
}
func protoSlicesEqual[T proto.Message](left, right []T) bool {
if len(left) != len(right) {
return false
}
for i := range left {
if !proto.Equal(left[i], right[i]) {
return false
}
}
return true
}
func equalU64(left, right []uint64) bool {
return slices.Equal(left, right)
}
func equalStrings(left, right []string) bool {
if len(left) != len(right) {
return false
}
for i := range left {
if left[i] != right[i] {
return false
}
}
return true
}