diff --git a/.gitignore b/.gitignore index 3581cfa..4557ea7 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,7 @@ TestResults/ .ghc.environment.* # Python caches and temporary files +tools/python/repair/ __pycache__/ **/__pycache__/ *.py[cod] diff --git a/go/cmd/bd2server/main.go b/go/cmd/bd2server/main.go index b23f723..3bc48c5 100644 --- a/go/cmd/bd2server/main.go +++ b/go/cmd/bd2server/main.go @@ -28,6 +28,8 @@ import ( "bd2server/internal/readonly" "bd2server/internal/schedule" "bd2server/internal/session" + "bd2server/internal/statebridge" + "bd2server/internal/statetx" "bd2server/internal/transport" "bd2server/internal/world" ) @@ -57,7 +59,7 @@ func main() { } } -func serve(args []string) error { +func serve(args []string) (serveErr error) { fs := flag.NewFlagSet("serve", flag.ContinueOnError) listen := fs.String("listen", "127.0.0.1:8080", "local listen address") cdn := fs.String("cdn", "", "ServerData root (required)") @@ -128,9 +130,30 @@ func serve(args []string) error { if err != nil { return fmt.Errorf("load regular gacha GameData: %w", err) } - if err := repairStateBeforeServe(filepath.Dir(filepath.Clean(*stateFile)), *stateTool, *gameDataVersion, regularGacha, starter); err != nil { + stateDir := filepath.Dir(filepath.Clean(*stateFile)) + if err := validateAccountStatePaths(stateDir, *stateFile, *deckState); err != nil { + return err + } + stateTransaction, err := statetx.Open(stateDir, statebridge.StateFiles()) + if err != nil { + return fmt.Errorf("recover account state transaction: %w", err) + } + if err := repairStateBeforeServe(stateDir, *stateTool, *gameDataVersion, regularGacha, starter); err != nil { return fmt.Errorf("state migration/repair: %w", err) } + startupTransaction, err := stateTransaction.Begin() + if err != nil { + return fmt.Errorf("begin startup state transaction: %w", err) + } + startupCommitted := false + defer func() { + if startupCommitted { + return + } + if rollbackErr := startupTransaction.Rollback(); rollbackErr != nil { + serveErr = errors.Join(serveErr, rollbackErr) + } + }() serverConfig, err := readonly.Load(filepath.Clean(*readonlySeed)) if err != nil { return fmt.Errorf("load readonly server configuration: %w", err) @@ -170,8 +193,13 @@ func serve(args []string) error { if err != nil { return fmt.Errorf("read account seed hope powder: %w", err) } + equipMileage, equipMileageExchangeGage, err := login.SeedEquipmentMileage() + if err != nil { + return fmt.Errorf("read account seed equipment mileage: %w", err) + } wallet, err := player.OpenWallet(filepath.Join(filepath.Dir(*stateFile), "wallet.json"), player.Currency{ Gold: gold, FreeJewelry: freeJewelry, Jewelry: jewelry, Mileage: mileage, HopePowder: hopePowder, + EquipMileage: equipMileage, EquipMileageExchangeGage: equipMileageExchangeGage, }) if err != nil { return fmt.Errorf("load wallet state: %w", err) @@ -221,6 +249,13 @@ func serve(args []string) error { if err := ownedEquipment.AttachUpgrade(equipmentUpgrade, wallet, ownedItems); err != nil { return fmt.Errorf("attach equipment upgrade GameData: %w", err) } + equipmentSmelting, err := gamedata.LoadEquipmentSmeltingDesign(filepath.Clean(*gameData), *gameDataVersion) + if err != nil { + return fmt.Errorf("load equipment smelting GameData: %w", err) + } + if err := ownedEquipment.AttachSmelting(equipmentSmelting, wallet, ownedItems); err != nil { + return fmt.Errorf("attach equipment smelting GameData: %w", err) + } collection, err := player.OpenCollectionStore(filepath.Join(filepath.Dir(*stateFile), "collection.json"), starter.Costumes) if err != nil { return fmt.Errorf("load owned collection: %w", err) @@ -272,6 +307,15 @@ func serve(args []string) error { return fmt.Errorf("load pictorial GameData: %w", err) } pictorialService := &pictorial.Service{Design: pictorialDesign, Owned: worldService} + charAwakeDesign, err := gamedata.LoadCharAwakeDesign(filepath.Clean(*gameData), *gameDataVersion) + if err != nil { + return fmt.Errorf("load character awakening GameData: %w", err) + } + charAwakeService, err := player.NewCharAwakeService(charAwakeDesign, collection, worldService.CharacterService(), ownedItems, wallet) + if err != nil { + return err + } + pictorialService.AwakeContributions = charAwakeService.Contributions if err := worldService.CharacterService().AttachMaxHealth(pictorialService.MaxHealth); err != nil { return fmt.Errorf("attach pictorial character stats: %w", err) } @@ -302,6 +346,7 @@ func serve(args []string) error { deckStateStore, ownedItems, ownedEquipment, + charAwakeService, costumePotentialService, starter, mailService, @@ -315,6 +360,22 @@ func serve(args []string) error { if err != nil { return err } + if err := ensureAccountStateInitialized( + progressState, deckStateStore, ownedItems, ownedEquipment, + worldService.CharacterService(), collection, wallet, mailService, missionService, + ); err != nil { + return fmt.Errorf("initialize complete account state generation: %w", err) + } + if err := startupTransaction.Commit(); err != nil { + return fmt.Errorf("commit startup state transaction: %w", err) + } + startupCommitted = true + if err := stateTransaction.Check(); err != nil { + return fmt.Errorf("startup state transaction requires recovery restart: %w", err) + } + if err := game.AttachStateCoordinator(stateTransaction); err != nil { + return err + } dispatcher := transport.Bootstrap{Config: cfg} handler := transport.HTTP{Dispatcher: dispatcher, Raw: game, CDNDir: filepath.Clean(*cdn), GameDataDir: filepath.Clean(*gameData)}.Handler() server := &http.Server{ @@ -329,6 +390,38 @@ func serve(args []string) error { return server.ListenAndServe() } +type accountStateInitializer interface { + EnsurePersisted() error +} + +func ensureAccountStateInitialized(stores ...accountStateInitializer) error { + if len(stores) != len(statebridge.StateFiles()) { + return fmt.Errorf("account state initializer count %d does not match transaction file count %d", len(stores), len(statebridge.StateFiles())) + } + for _, store := range stores { + if store == nil { + return errors.New("nil account state initializer") + } + if err := store.EnsurePersisted(); err != nil { + return err + } + } + return nil +} + +func validateAccountStatePaths(stateDir, progressPath, deckPath string) error { + stateDir = filepath.Clean(stateDir) + wantProgress := filepath.Join(stateDir, "progress.json") + wantDeck := filepath.Join(stateDir, "deck.json") + if filepath.Clean(progressPath) != wantProgress { + return fmt.Errorf("account state must use %q; got progress path %q", wantProgress, progressPath) + } + if filepath.Clean(deckPath) != wantDeck { + return fmt.Errorf("all account files must share one transaction directory; use --deck-state %q, got %q", wantDeck, deckPath) + } + return nil +} + func patchClient(args []string) error { fs := flag.NewFlagSet("patch-client", flag.ContinueOnError) gameDir := fs.String("game-dir", "", "BrownDust II game directory (required)") diff --git a/go/cmd/bd2server/main_test.go b/go/cmd/bd2server/main_test.go new file mode 100644 index 0000000..a71cd44 --- /dev/null +++ b/go/cmd/bd2server/main_test.go @@ -0,0 +1,21 @@ +package main + +import ( + "path/filepath" + "testing" +) + +func TestValidateAccountStatePathsRequiresOneTransactionDirectory(t *testing.T) { + root := t.TempDir() + progress := filepath.Join(root, "progress.json") + deck := filepath.Join(root, "deck.json") + if err := validateAccountStatePaths(root, progress, deck); err != nil { + t.Fatal(err) + } + if err := validateAccountStatePaths(root, filepath.Join(root, "renamed.json"), deck); err == nil { + t.Fatal("renamed progress escaped transaction allow-list") + } + if err := validateAccountStatePaths(root, progress, filepath.Join(t.TempDir(), "deck.json")); err == nil { + t.Fatal("deck outside account transaction directory was accepted") + } +} diff --git a/go/gen/state/v1/state.pb.go b/go/gen/state/v1/state.pb.go index 588c513..84aa439 100644 --- a/go/gen/state/v1/state.pb.go +++ b/go/gen/state/v1/state.pb.go @@ -1041,20 +1041,21 @@ func (x *EquipmentOption) GetId() uint64 { } type Equipment struct { - state protoimpl.MessageState `protogen:"open.v1"` - InventoryIndex uint64 `protobuf:"varint,1,opt,name=inventory_index,json=inventoryIndex,proto3" json:"inventory_index,omitempty"` - Id uint64 `protobuf:"varint,2,opt,name=id,proto3" json:"id,omitempty"` - Level uint64 `protobuf:"varint,3,opt,name=level,proto3" json:"level,omitempty"` - UseChar uint64 `protobuf:"varint,4,opt,name=use_char,json=useChar,proto3" json:"use_char,omitempty"` - KeepFlag uint64 `protobuf:"varint,5,opt,name=keep_flag,json=keepFlag,proto3" json:"keep_flag,omitempty"` - LockFlag uint64 `protobuf:"varint,6,opt,name=lock_flag,json=lockFlag,proto3" json:"lock_flag,omitempty"` - SortId uint64 `protobuf:"varint,7,opt,name=sort_id,json=sortId,proto3" json:"sort_id,omitempty"` - MainOptions []*EquipmentOption `protobuf:"bytes,8,rep,name=main_options,json=mainOptions,proto3" json:"main_options,omitempty"` - SubOptions []*EquipmentOption `protobuf:"bytes,9,rep,name=sub_options,json=subOptions,proto3" json:"sub_options,omitempty"` - PrivateOption *EquipmentOption `protobuf:"bytes,10,opt,name=private_option,json=privateOption,proto3" json:"private_option,omitempty"` - Ranks []uint64 `protobuf:"varint,11,rep,packed,name=ranks,proto3" json:"ranks,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + InventoryIndex uint64 `protobuf:"varint,1,opt,name=inventory_index,json=inventoryIndex,proto3" json:"inventory_index,omitempty"` + Id uint64 `protobuf:"varint,2,opt,name=id,proto3" json:"id,omitempty"` + Level uint64 `protobuf:"varint,3,opt,name=level,proto3" json:"level,omitempty"` + UseChar uint64 `protobuf:"varint,4,opt,name=use_char,json=useChar,proto3" json:"use_char,omitempty"` + KeepFlag uint64 `protobuf:"varint,5,opt,name=keep_flag,json=keepFlag,proto3" json:"keep_flag,omitempty"` + LockFlag uint64 `protobuf:"varint,6,opt,name=lock_flag,json=lockFlag,proto3" json:"lock_flag,omitempty"` + SortId uint64 `protobuf:"varint,7,opt,name=sort_id,json=sortId,proto3" json:"sort_id,omitempty"` + MainOptions []*EquipmentOption `protobuf:"bytes,8,rep,name=main_options,json=mainOptions,proto3" json:"main_options,omitempty"` + SubOptions []*EquipmentOption `protobuf:"bytes,9,rep,name=sub_options,json=subOptions,proto3" json:"sub_options,omitempty"` + PrivateOption *EquipmentOption `protobuf:"bytes,10,opt,name=private_option,json=privateOption,proto3" json:"private_option,omitempty"` + Ranks []uint64 `protobuf:"varint,11,rep,packed,name=ranks,proto3" json:"ranks,omitempty"` + UpgradeAttempts uint64 `protobuf:"varint,12,opt,name=upgrade_attempts,json=upgradeAttempts,proto3" json:"upgrade_attempts,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *Equipment) Reset() { @@ -1164,6 +1165,13 @@ func (x *Equipment) GetRanks() []uint64 { return nil } +func (x *Equipment) GetUpgradeAttempts() uint64 { + if x != nil { + return x.UpgradeAttempts + } + return 0 +} + type NamedIndex struct { state protoimpl.MessageState `protogen:"open.v1"` Identity string `protobuf:"bytes,1,opt,name=identity,proto3" json:"identity,omitempty"` @@ -2605,17 +2613,19 @@ func (x *Collection) GetGrants() []*CollectionGrant { } type Wallet struct { - state protoimpl.MessageState `protogen:"open.v1"` - ClientVersion string `protobuf:"bytes,1,opt,name=client_version,json=clientVersion,proto3" json:"client_version,omitempty"` - Gold uint64 `protobuf:"varint,2,opt,name=gold,proto3" json:"gold,omitempty"` - FreeJewelry uint64 `protobuf:"varint,3,opt,name=free_jewelry,json=freeJewelry,proto3" json:"free_jewelry,omitempty"` - Jewelry uint64 `protobuf:"varint,4,opt,name=jewelry,proto3" json:"jewelry,omitempty"` - Mileage uint64 `protobuf:"varint,5,opt,name=mileage,proto3" json:"mileage,omitempty"` - HopePowder uint64 `protobuf:"varint,6,opt,name=hope_powder,json=hopePowder,proto3" json:"hope_powder,omitempty"` - GrantedIdentities []string `protobuf:"bytes,7,rep,name=granted_identities,json=grantedIdentities,proto3" json:"granted_identities,omitempty"` - SpentIdentities []string `protobuf:"bytes,8,rep,name=spent_identities,json=spentIdentities,proto3" json:"spent_identities,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + ClientVersion string `protobuf:"bytes,1,opt,name=client_version,json=clientVersion,proto3" json:"client_version,omitempty"` + Gold uint64 `protobuf:"varint,2,opt,name=gold,proto3" json:"gold,omitempty"` + FreeJewelry uint64 `protobuf:"varint,3,opt,name=free_jewelry,json=freeJewelry,proto3" json:"free_jewelry,omitempty"` + Jewelry uint64 `protobuf:"varint,4,opt,name=jewelry,proto3" json:"jewelry,omitempty"` + Mileage uint64 `protobuf:"varint,5,opt,name=mileage,proto3" json:"mileage,omitempty"` + HopePowder uint64 `protobuf:"varint,6,opt,name=hope_powder,json=hopePowder,proto3" json:"hope_powder,omitempty"` + GrantedIdentities []string `protobuf:"bytes,7,rep,name=granted_identities,json=grantedIdentities,proto3" json:"granted_identities,omitempty"` + SpentIdentities []string `protobuf:"bytes,8,rep,name=spent_identities,json=spentIdentities,proto3" json:"spent_identities,omitempty"` + EquipMileage uint64 `protobuf:"varint,9,opt,name=equip_mileage,json=equipMileage,proto3" json:"equip_mileage,omitempty"` + EquipMileageExchangeGage uint64 `protobuf:"varint,10,opt,name=equip_mileage_exchange_gage,json=equipMileageExchangeGage,proto3" json:"equip_mileage_exchange_gage,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *Wallet) Reset() { @@ -2704,6 +2714,20 @@ func (x *Wallet) GetSpentIdentities() []string { return nil } +func (x *Wallet) GetEquipMileage() uint64 { + if x != nil { + return x.EquipMileage + } + return 0 +} + +func (x *Wallet) GetEquipMileageExchangeGage() uint64 { + if x != nil { + return x.EquipMileageExchangeGage + } + return 0 +} + type MailState struct { state protoimpl.MessageState `protogen:"open.v1"` ClientVersion string `protobuf:"bytes,1,opt,name=client_version,json=clientVersion,proto3" json:"client_version,omitempty"` @@ -2916,7 +2940,7 @@ const file_bd2_state_v1_state_proto_rawDesc = "" + "grantItems\"<\n" + "\x0fEquipmentOption\x12\x19\n" + "\bgroup_id\x18\x01 \x01(\x04R\agroupId\x12\x0e\n" + - "\x02id\x18\x02 \x01(\x04R\x02id\"\xa6\x03\n" + + "\x02id\x18\x02 \x01(\x04R\x02id\"\xd1\x03\n" + "\tEquipment\x12'\n" + "\x0finventory_index\x18\x01 \x01(\x04R\x0einventoryIndex\x12\x0e\n" + "\x02id\x18\x02 \x01(\x04R\x02id\x12\x14\n" + @@ -2930,7 +2954,8 @@ const file_bd2_state_v1_state_proto_rawDesc = "" + "subOptions\x12D\n" + "\x0eprivate_option\x18\n" + " \x01(\v2\x1d.bd2.state.v1.EquipmentOptionR\rprivateOption\x12\x14\n" + - "\x05ranks\x18\v \x03(\x04R\x05ranks\"Q\n" + + "\x05ranks\x18\v \x03(\x04R\x05ranks\x12)\n" + + "\x10upgrade_attempts\x18\f \x01(\x04R\x0fupgradeAttempts\"Q\n" + "\n" + "NamedIndex\x12\x1a\n" + "\bidentity\x18\x01 \x01(\tR\bidentity\x12'\n" + @@ -3073,7 +3098,7 @@ const file_bd2_state_v1_state_proto_rawDesc = "" + "\rgacha_applied\x18\x0e \x03(\tR\fgachaApplied\x12Y\n" + "\x15gacha_point_exchanges\x18\x0f \x03(\v2%.bd2.state.v1.NamedGachaPointExchangeR\x13gachaPointExchanges\x122\n" + "\x15gacha_count_corrected\x18\x10 \x01(\bR\x13gachaCountCorrected\x125\n" + - "\x06grants\x18\x11 \x03(\v2\x1d.bd2.state.v1.CollectionGrantR\x06grants\"\x95\x02\n" + + "\x06grants\x18\x11 \x03(\v2\x1d.bd2.state.v1.CollectionGrantR\x06grants\"\xf9\x02\n" + "\x06Wallet\x12%\n" + "\x0eclient_version\x18\x01 \x01(\tR\rclientVersion\x12\x12\n" + "\x04gold\x18\x02 \x01(\x04R\x04gold\x12!\n" + @@ -3083,7 +3108,10 @@ const file_bd2_state_v1_state_proto_rawDesc = "" + "\vhope_powder\x18\x06 \x01(\x04R\n" + "hopePowder\x12-\n" + "\x12granted_identities\x18\a \x03(\tR\x11grantedIdentities\x12)\n" + - "\x10spent_identities\x18\b \x03(\tR\x0fspentIdentities\"Z\n" + + "\x10spent_identities\x18\b \x03(\tR\x0fspentIdentities\x12#\n" + + "\requip_mileage\x18\t \x01(\x04R\fequipMileage\x12=\n" + + "\x1bequip_mileage_exchange_gage\x18\n" + + " \x01(\x04R\x18equipMileageExchangeGage\"Z\n" + "\tMailState\x12%\n" + "\x0eclient_version\x18\x01 \x01(\tR\rclientVersion\x12&\n" + "\x0fopened_mail_ids\x18\x02 \x03(\x04R\ropenedMailIds\"\xa3\x01\n" + diff --git a/go/internal/account/login.go b/go/internal/account/login.go index 6692efe..7c4c89a 100644 --- a/go/internal/account/login.go +++ b/go/internal/account/login.go @@ -46,6 +46,10 @@ type HopePowderProvider interface { HopePowderBalance() uint64 } +type EquipmentMileageProvider interface { + EquipmentMileageBalances() (mileage, exchangeGage uint64) +} + func (s *LoginSeed) AttachCurrencies(provider CurrencyProvider) error { if provider == nil { return errors.New("account: nil currency provider") @@ -96,6 +100,19 @@ func (s *LoginSeed) SeedHopePowder() (uint64, error) { return value, nil } +func (s *LoginSeed) SeedEquipmentMileage() (mileage, exchangeGage uint64, err error) { + if err = s.Validate(); err != nil { + return 0, 0, err + } + if mileage, _, err = wire.Varint(s.UserInfo, 67); err != nil { + return 0, 0, err + } + if exchangeGage, _, err = wire.Varint(s.UserInfo, 68); err != nil { + return 0, 0, err + } + return mileage, exchangeGage, nil +} + type diskSeed struct { Version string `json:"version"` PacketCode int `json:"packet_code"` @@ -239,6 +256,15 @@ func (s *LoginSeed) Login(request, sessionKey []byte) ([]byte, error) { return nil, fmt.Errorf("account: replace hope powder: %w", err) } } + if provider, ok := s.currencies.(EquipmentMileageProvider); ok { + equipMileage, exchangeGage := provider.EquipmentMileageBalances() + if user, _, err = wire.ReplaceVarint(user, 67, equipMileage); err != nil { + return nil, fmt.Errorf("account: replace equipment mileage: %w", err) + } + if user, _, err = wire.ReplaceVarint(user, 68, exchangeGage); err != nil { + return nil, fmt.Errorf("account: replace equipment mileage exchange gauge: %w", err) + } + } } if s.firstGacha != nil { value := uint64(0) diff --git a/go/internal/account/login_test.go b/go/internal/account/login_test.go index 9f25b9d..963ca17 100644 --- a/go/internal/account/login_test.go +++ b/go/internal/account/login_test.go @@ -87,3 +87,31 @@ func TestCheckedInSeedBuildsLoginWithoutCapture(t *testing.T) { t.Fatalf("unexpected generated key: %q found=%v err=%v", key, found, err) } } + +type loginCurrencyFixture struct{} + +func (loginCurrencyFixture) Currencies() (uint64, uint64, uint64, uint64) { + return 1, 2, 3, 4 +} +func (loginCurrencyFixture) EquipmentMileageBalances() (uint64, uint64) { return 17, 845 } + +func TestLoginRestoresEquipmentMileageFromCurrencyProvider(t *testing.T) { + seed := &LoginSeed{Version: Version23413, PacketCode: 3, UserInfo: wire.AppendVarint(nil, 1, 1)} + if err := seed.AttachCurrencies(loginCurrencyFixture{}); err != nil { + t.Fatal(err) + } + response, err := seed.Login(wire.AppendVarint(nil, 1, 1), []byte("0123456789abcdef0123456789abcdef")) + if err != nil { + t.Fatal(err) + } + user, found, err := wire.Bytes(response, 1) + if err != nil || !found { + t.Fatalf("missing login user: %v", err) + } + if mileage, found, err := wire.Varint(user, 67); err != nil || !found || mileage != 17 { + t.Fatalf("equipment mileage=%d found=%v err=%v", mileage, found, err) + } + if gauge, found, err := wire.Varint(user, 68); err != nil || !found || gauge != 845 { + t.Fatalf("equipment mileage gauge=%d found=%v err=%v", gauge, found, err) + } +} diff --git a/go/internal/deck/deck.go b/go/internal/deck/deck.go index d35fea1..8f262a6 100644 --- a/go/internal/deck/deck.go +++ b/go/internal/deck/deck.go @@ -131,6 +131,16 @@ func OpenStore(path string, seed Seed) (*Store, error) { } return s, nil } +func (s *Store) EnsurePersisted() error { + s.mu.Lock() + defer s.mu.Unlock() + if _, e := os.Stat(s.path); e == nil { + return nil + } else if !errors.Is(e, os.ErrNotExist) { + return e + } + return s.commit(clone(s.state)) +} func (s *Store) commit(next state) error { if s.path != "" { b, e := json.MarshalIndent(next, "", " ") diff --git a/go/internal/feature/feature.go b/go/internal/feature/feature.go index ddd104a..012f84c 100644 --- a/go/internal/feature/feature.go +++ b/go/internal/feature/feature.go @@ -22,7 +22,6 @@ var emptyResponses = map[string]int{ "/AvatarShopWishListInfo": 470, "/CafeteriaInfo": 351, "/CashBonusInfo": 588, - "/CharAwakeInfo": 0, "/CharPartnerInfo": 65, "/CommunityRewardInfo": 289, "/DailyStoryInfo": 538, diff --git a/go/internal/feature/feature_test.go b/go/internal/feature/feature_test.go index acc0fae..63bcc2d 100644 --- a/go/internal/feature/feature_test.go +++ b/go/internal/feature/feature_test.go @@ -8,8 +8,8 @@ import ( ) func TestHandleAuditedEmptyResponses(t *testing.T) { - if got := len(EmptyPacketCodes()); got != 35 { - t.Fatalf("audited empty-response registry has %d paths, want 35", got) + if got := len(EmptyPacketCodes()); got != 34 { + t.Fatalf("audited empty-response registry has %d paths, want 34", got) } for path, wantCode := range EmptyPacketCodes() { t.Run(path, func(t *testing.T) { @@ -25,6 +25,9 @@ func TestHandleRejectsUnknownAndInvalidRequests(t *testing.T) { if code, proto, ok, err := Handle("/not-a-real-endpoint", wire.AppendVarint(nil, 1, 1)); code != 0 || proto != nil || ok || err != nil { t.Fatalf("unknown route was not fail-closed: code=%d proto=%x ok=%t err=%v", code, proto, ok, err) } + if _, _, ok, err := Handle("/CharAwakeInfo", wire.AppendVarint(nil, 1, 1)); ok || err != nil { + t.Fatalf("stateful CharAwakeInfo must not be handled by feature defaults: ok=%v err=%v", ok, err) + } for name, request := range map[string][]byte{ "empty": nil, "no-sequence": wire.AppendVarint(nil, 2, 1), diff --git a/go/internal/gacha/gacha.go b/go/internal/gacha/gacha.go index bed15cf..6fbecb1 100644 --- a/go/internal/gacha/gacha.go +++ b/go/internal/gacha/gacha.go @@ -352,24 +352,33 @@ func (s *Service) buy(request []byte, seq uint64) (int, []byte, bool, error) { return 146, response, true, nil } -// buyEquipment is intentionally limited to the current three GameData-backed -// pickup groups. Equipment lives in its own inventory, but the official -// GachaUser/GachaFixed records are shared protocol state and are persisted in -// CollectionStore under the same request identity. +// Scheduled equipment draws update GachaUser/GachaFixed accounting. Standalone +// ticket-only draws have no schedule group and persist only their idempotency +// marker plus the generated equipment instances. func (s *Service) buyEquipment(seq, buyType uint64, tickets []player.Item, design gamedata.EquipmentGacha) (int, []byte, bool, error) { if s.equipmentInventory == nil { return 146, nil, true, errors.New("gacha: equipment inventory not attached") } - if buyType != 1 || design.PriceType != 3 { + if buyType != 1 || (!design.TicketOnly && design.PriceType != 3) { return 146, nil, true, fmt.Errorf("gacha: unsupported equipment buy type %d", buyType) } + if design.TicketOnly && len(tickets) == 0 { + return 146, nil, true, errors.New("gacha: ticket-only equipment draw requires a ticket") + } for _, ticket := range tickets { - if len(design.TicketIDs) != 1 || ticket.ID != design.TicketIDs[0] { + allowed := false + for _, ticketID := range design.TicketIDs { + if ticket.ID == ticketID { + allowed = true + break + } + } + if !allowed { return 146, nil, true, fmt.Errorf("gacha: ticket %d is not valid for equipment gacha %d", ticket.ID, design.ID) } } - group, ok := s.equipmentCatalog.GroupForGacha(design.ID) - if !ok { + group, hasGroup := s.equipmentCatalog.GroupForGacha(design.ID) + if !hasGroup && !design.TicketOnly { return 146, nil, true, fmt.Errorf("gacha: equipment gacha %d has no group", design.ID) } identity := s.requestIdentity(design.ID, seq) @@ -388,7 +397,7 @@ func (s *Service) buyEquipment(seq, buyType uint64, tickets []player.Item, desig for _, ticket := range tickets { ticketCount += ticket.Count } - if ticketCount > uint64(design.Count) { + if ticketCount > uint64(design.Count) || (design.TicketOnly && ticketCount != uint64(design.Count)) { return 146, nil, true, errors.New("gacha: invalid equipment ticket count") } if ticketCount != 0 { @@ -400,16 +409,27 @@ func (s *Service) buyEquipment(seq, buyType uint64, tickets []player.Item, desig } } if remain := uint64(design.Count) - ticketCount; remain != 0 { + if design.TicketOnly { + return 146, nil, true, errors.New("gacha: ticket-only equipment draw cannot use diamonds") + } if _, err := s.wallet.SpendFreeJewelryOnce(identity, design.Price/uint64(design.Count)*remain); err != nil { return 146, nil, true, err } } fixed := s.equipmentCatalog.Fixed() - roll, state, err := design.Roll(s.collection.GachaFixedCount(fixed.ID, 2), s.collection.GachaFixedCount(fixed.ID, 3), fixed) + var priorSR, priorUR uint64 + if !design.TicketOnly { + priorSR = s.collection.GachaFixedCount(fixed.ID, 2) + priorUR = s.collection.GachaFixedCount(fixed.ID, 3) + } + roll, state, err := design.Roll(priorSR, priorUR, fixed) if err != nil { return 146, nil, true, err } - fixedStates := []player.GachaFixedState{{FixedID: fixed.ID, Type: 2, Count: state.SRCount, ApplySort: state.SRSort}, {FixedID: fixed.ID, Type: 3, Count: state.URCount, ApplySort: state.URSort}} + var fixedStates []player.GachaFixedState + if !design.TicketOnly { + fixedStates = []player.GachaFixedState{{FixedID: fixed.ID, Type: 2, Count: state.SRCount, ApplySort: state.SRSort}, {FixedID: fixed.ID, Type: 3, Count: state.URCount, ApplySort: state.URSort}} + } for sort, equipmentID := range roll { main, sub, private, err := s.equipmentCatalog.RollOptions(equipmentID) if err != nil { @@ -431,7 +451,11 @@ func (s *Service) buyEquipment(seq, buyType uint64, tickets []player.Item, desig } entries = append(entries, saved) } - grant, err = s.collection.GrantEquipmentPurchase(identity, uint64(len(entries)), player.GachaPurchase{Group: gamedata.GachaGroupDesign{ID: group.ID, PointCount: group.PointCount}, BuyType: buyType, Fixed: fixedStates}) + if design.TicketOnly { + grant, err = s.collection.GrantEquipmentDraw(identity) + } else { + grant, err = s.collection.GrantEquipmentPurchase(identity, uint64(len(entries)), player.GachaPurchase{Group: gamedata.GachaGroupDesign{ID: group.ID, PointCount: group.PointCount}, BuyType: buyType, Fixed: fixedStates}) + } if err != nil { return 146, nil, true, err } diff --git a/go/internal/gacha/gacha_test.go b/go/internal/gacha/gacha_test.go index 7bcd141..c7d2369 100644 --- a/go/internal/gacha/gacha_test.go +++ b/go/internal/gacha/gacha_test.go @@ -1,6 +1,7 @@ package gacha import ( + "os" "path/filepath" "testing" @@ -9,6 +10,90 @@ import ( "bd2server/internal/wire" ) +func TestTicketOnlyEquipmentDrawUsesGameDataAndNoScheduleAccounting(t *testing.T) { + root := os.Getenv("BD2_TEST_GAMEDATA_ROOT") + if root == "" { + t.Skip("set BD2_TEST_GAMEDATA_ROOT for installed GameData integration test") + } + const version = "20260910162539" + infinite, err := gamedata.LoadInfiniteGacha(root, version) + if err != nil { + t.Fatal(err) + } + regular, err := gamedata.LoadRegularCostumeGacha(root, version) + if err != nil { + t.Fatal(err) + } + equipmentCatalog, err := gamedata.LoadEquipmentGacha(root, version) + if err != nil { + t.Fatal(err) + } + dir := t.TempDir() + collection, err := player.OpenCollectionStore(filepath.Join(dir, "collection.json"), nil) + if err != nil { + t.Fatal(err) + } + wallet, err := player.OpenWallet(filepath.Join(dir, "wallet.json"), player.Currency{}) + if err != nil { + t.Fatal(err) + } + inventory, err := player.OpenInventory(filepath.Join(dir, "items.json"), &player.Starter{Version: "2.34.13"}) + if err != nil { + t.Fatal(err) + } + granted, err := inventory.GrantOnce("ticket", []gamedata.BattleReward{{Type: 8, ID: 1104, Count: 20}}) + if err != nil { + t.Fatal(err) + } + equipment, err := player.OpenEquipmentInventory(filepath.Join(dir, "equipment.json")) + if err != nil { + t.Fatal(err) + } + service, err := NewService(infinite, regular, collection, wallet) + if err != nil { + t.Fatal(err) + } + service.AttachInventory(inventory) + service.AttachEquipmentGacha(equipmentCatalog, equipment) + service.BeginSession("ticket-only") + request := wire.AppendVarint(wire.AppendVarint(nil, 1, 7), 2, 71200001) + request = wire.AppendVarint(request, 3, 1) + ticket := granted[0] + ticket.Count = 10 + request = wire.AppendBytes(request, 4, player.ItemWire(ticket)) + code, response, handled, err := service.Handle("/GachaBuy", request) + if err != nil || !handled || code != 146 { + t.Fatalf("ticket-only draw code=%d handled=%v err=%v", code, handled, err) + } + bundle, found, err := wire.Bytes(response, 1) + if err != nil || !found || countFields(bundle, 4) != 10 { + t.Fatalf("ticket-only reward equipment=%d found=%v err=%v", countFields(bundle, 4), found, err) + } + if len(equipment.All()) != 10 { + t.Fatalf("ticket-only persisted equipment=%d", len(equipment.All())) + } + if user := collection.GachaUser(71200001); user != (player.GachaUserState{}) { + t.Fatalf("standalone draw invented schedule accounting: %+v", user) + } + remaining := inventory.All() + foundTicket := false + for _, item := range remaining { + if item.InvenIndex == ticket.InvenIndex { + foundTicket = true + if item.Count != 10 { + t.Fatalf("ticket count=%d want=10", item.Count) + } + } + } + if !foundTicket { + t.Fatal("remaining ticket stack missing") + } + _, replay, _, err := service.Handle("/GachaBuy", request) + if err != nil || string(replay) != string(response) || len(equipment.All()) != 10 { + t.Fatalf("ticket-only replay changed result: equipment=%d err=%v", len(equipment.All()), err) + } +} + func fixtureCharacter(id, hp uint64) gamedata.CharacterDesign { return gamedata.CharacterDesign{ ID: id, HP: hp, CostumeMaxLevel: 5, diff --git a/go/internal/gamedata/attributes.go b/go/internal/gamedata/attributes.go index 90e25f3..868755b 100644 --- a/go/internal/gamedata/attributes.go +++ b/go/internal/gamedata/attributes.go @@ -13,6 +13,10 @@ const ( StatMagic StatDefencePercent StatMagicResistancePercent + StatCriticalChance + StatCriticalDamage + StatElementDamage + StatElementResistance ) // StatContribution is one already-resolved contribution from an equipment @@ -22,6 +26,9 @@ type StatContribution struct { Stat Stat Flat float64 Percent float64 + // Option preserves the exact Define_CharStatOption for systems such as + // awakening whose element-specific distinctions exceed BaseStats. + Option uint64 } // BaseStats contains the design value after the character level curve has diff --git a/go/internal/gamedata/char_awake.go b/go/internal/gamedata/char_awake.go new file mode 100644 index 0000000..01393b2 --- /dev/null +++ b/go/internal/gamedata/char_awake.go @@ -0,0 +1,397 @@ +package gamedata + +import ( + "database/sql" + "fmt" + "math" + "os" + "path/filepath" +) + +// CharAwakeCost is one exact CharAwakeGrowthTable material row. Type 4 is +// gold (and therefore has ID 0); type 8 is a mutable ResourceTable item. +type CharAwakeCost struct{ Type, ID, Count uint64 } + +// CharAwakeGrowth is one cumulative imprint level or one awakening effect. +// Imprint StatValue is the value at that target level, not a per-level delta. +type CharAwakeGrowth struct { + ID uint64 + Costs []CharAwakeCost + StatType uint64 + StatValue float64 +} + +type CharAwakeCharacter struct { + UniqueCharID uint64 + Active bool + ImprintIDs [3]uint64 + ImprintGrowth [3][]CharAwakeGrowth + AwakeGrowth []CharAwakeGrowth +} + +type CharAwakeCharacterStage struct { + UniqueCharID uint64 + Grade uint64 + GrowthGrade uint64 + MaximumLevel uint64 +} + +// CharAwakeDesign is immutable 2.34.13 design data. Account progress remains +// in CollectionStore and is indexed by UniqueCharId, as CharAwakeDBInfo is. +type CharAwakeDesign struct { + Characters map[uint64]CharAwakeCharacter + Stages map[uint64]CharAwakeCharacterStage +} + +type CharImprintTarget struct { + Slot uint64 + TargetLevel uint64 +} + +func LoadCharAwakeDesign(root, version string) (*CharAwakeDesign, error) { + plain, err := ReadQuestDatabase(root, version) + if err != nil { + return nil, err + } + dir, err := os.MkdirTemp("", "bd2-char-awake-") + if err != nil { + return nil, err + } + defer os.RemoveAll(dir) + path := filepath.Join(dir, "common.db") + if err := os.WriteFile(path, plain, 0o600); err != nil { + return nil, err + } + db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro") + if err != nil { + return nil, err + } + defer db.Close() + return loadCharAwakeDesign(db) +} + +func loadCharAwakeDesign(db *sql.DB) (*CharAwakeDesign, error) { + growth := make(map[uint64]CharAwakeGrowth) + rows, err := db.Query("SELECT id,ProtoBuf FROM CharAwakeGrowthTable ORDER BY id") + if err != nil { + return nil, err + } + for rows.Next() { + var id uint64 + var proto []byte + if err := rows.Scan(&id, &proto); err != nil { + rows.Close() + return nil, err + } + counts, _ := packedInts(proto, 1) + ids, _ := packedInts(proto, 2) + types, _ := packedInts(proto, 3) + protoID, _ := packedInts(proto, 4) + statType, _ := packedInts(proto, 5) + statValue, hasStatValue, err := fixed64Double(proto, 6) + if err != nil || len(protoID) != 1 || protoID[0] != id || len(statType) != 1 || !hasStatValue || math.IsNaN(statValue) || math.IsInf(statValue, 0) { + rows.Close() + return nil, fmt.Errorf("gamedata: invalid CharAwakeGrowthTable row %d", id) + } + if len(counts) != len(ids) || len(counts) != len(types) { + rows.Close() + return nil, fmt.Errorf("gamedata: mismatched CharAwakeGrowthTable costs %d", id) + } + entry := CharAwakeGrowth{ID: id, StatType: statType[0], StatValue: statValue} + if entry.StatType == 0 || entry.StatType > 20 { + rows.Close() + return nil, fmt.Errorf("gamedata: unsupported awakening stat type %d in row %d", entry.StatType, id) + } + for i := range counts { + if counts[i] == 0 || (types[i] != 4 && types[i] != 8) || (types[i] == 4 && ids[i] != 0) || (types[i] == 8 && ids[i] == 0) { + rows.Close() + return nil, fmt.Errorf("gamedata: invalid awakening cost in row %d", id) + } + entry.Costs = append(entry.Costs, CharAwakeCost{Type: types[i], ID: ids[i], Count: counts[i]}) + } + growth[id] = entry + } + if err := rows.Close(); err != nil { + return nil, err + } + + imprints := make(map[uint64][]CharAwakeGrowth) + rows, err = db.Query("SELECT id,ProtoBuf FROM CharImprintTable ORDER BY id") + if err != nil { + return nil, err + } + for rows.Next() { + var id uint64 + var proto []byte + if err := rows.Scan(&id, &proto); err != nil { + rows.Close() + return nil, err + } + growthIDs, _ := packedInts(proto, 1) + protoID, _ := packedInts(proto, 2) + if len(protoID) != 1 || protoID[0] != id || len(growthIDs) == 0 { + rows.Close() + return nil, fmt.Errorf("gamedata: invalid CharImprintTable row %d", id) + } + for _, growthID := range growthIDs { + entry, ok := growth[growthID] + if !ok || len(entry.Costs) == 0 { + rows.Close() + return nil, fmt.Errorf("gamedata: imprint %d has invalid growth %d", id, growthID) + } + imprints[id] = append(imprints[id], entry) + } + } + if err := rows.Close(); err != nil { + return nil, err + } + + design := &CharAwakeDesign{Characters: map[uint64]CharAwakeCharacter{}, Stages: map[uint64]CharAwakeCharacterStage{}} + rows, err = db.Query("SELECT id,ProtoBuf FROM CharAwakeTable ORDER BY id") + if err != nil { + return nil, err + } + for rows.Next() { + var rowID uint64 + var proto []byte + if err := rows.Scan(&rowID, &proto); err != nil { + rows.Close() + return nil, err + } + active, _ := packedInts(proto, 1) + awakeIDs, _ := packedInts(proto, 2) + unique, _ := packedInts(proto, 3) + slot1, _ := packedInts(proto, 4) + slot2, _ := packedInts(proto, 5) + slot3, _ := packedInts(proto, 6) + if len(active) != 1 || active[0] != 1 || len(unique) != 1 || unique[0] != rowID || len(slot1) != 1 || len(slot2) != 1 || len(slot3) != 1 || len(awakeIDs) == 0 { + rows.Close() + return nil, fmt.Errorf("gamedata: invalid CharAwakeTable row %d", rowID) + } + entry := CharAwakeCharacter{UniqueCharID: rowID, Active: true, ImprintIDs: [3]uint64{slot1[0], slot2[0], slot3[0]}} + for i, imprintID := range entry.ImprintIDs { + levels, ok := imprints[imprintID] + if !ok { + rows.Close() + return nil, fmt.Errorf("gamedata: awakening %d has unknown imprint %d", rowID, imprintID) + } + entry.ImprintGrowth[i] = append([]CharAwakeGrowth(nil), levels...) + } + for i, growthID := range awakeIDs { + awakeGrowth, ok := growth[growthID] + if !ok || (i > 0 && len(awakeGrowth.Costs) != 0) { + rows.Close() + return nil, fmt.Errorf("gamedata: awakening %d has invalid growth %d", rowID, growthID) + } + entry.AwakeGrowth = append(entry.AwakeGrowth, awakeGrowth) + } + if len(entry.AwakeGrowth[0].Costs) == 0 { + rows.Close() + return nil, fmt.Errorf("gamedata: awakening %d has no activation cost", rowID) + } + design.Characters[rowID] = entry + } + if err := rows.Close(); err != nil { + return nil, err + } + + rows, err = db.Query("SELECT id,ProtoBuf FROM CharTable ORDER BY id") + if err != nil { + return nil, err + } + for rows.Next() { + var id uint64 + var proto []byte + if err := rows.Scan(&id, &proto); err != nil { + rows.Close() + return nil, err + } + growthID, _ := packedInts(proto, 1) + grade, _ := packedInts(proto, 9) + growthGrade, _ := packedInts(proto, 10) + unique, _ := packedInts(proto, 20) + if len(unique) != 1 || design.Characters[unique[0]].UniqueCharID == 0 { + continue + } + if len(growthID) != 1 || len(grade) != 1 || len(growthGrade) != 1 { + rows.Close() + return nil, fmt.Errorf("gamedata: invalid awakening character stage %d", id) + } + var growthProto []byte + if err := db.QueryRow("SELECT ProtoBuf FROM CharGrowthTable WHERE id=?", growthID[0]).Scan(&growthProto); err != nil { + rows.Close() + return nil, fmt.Errorf("gamedata: awakening character %d growth: %w", id, err) + } + maximum, _ := packedInts(growthProto, 9) + if len(maximum) != 1 || maximum[0] == 0 { + rows.Close() + return nil, fmt.Errorf("gamedata: invalid awakening character maximum %d", id) + } + design.Stages[id] = CharAwakeCharacterStage{UniqueCharID: unique[0], Grade: grade[0], GrowthGrade: growthGrade[0], MaximumLevel: maximum[0]} + } + if err := rows.Close(); err != nil { + return nil, err + } + if len(design.Characters) == 0 || len(design.Stages) == 0 { + return nil, fmt.Errorf("gamedata: awakening design is empty") + } + return design, nil +} + +func (d *CharAwakeDesign) CharacterUniqueID(characterID uint64) (uint64, bool) { + if d == nil { + return 0, false + } + stage, ok := d.Stages[characterID] + return stage.UniqueCharID, ok +} + +// ValidateGrowthCompleted mirrors CommonPacket.IsCharGrowthLevelCompleted: +// the current CharTable stage must have Growthgrade == Grade and its own +// CharGrowthTable.MaxLevel must have been reached. +func (d *CharAwakeDesign) ValidateGrowthCompleted(characterID, level uint64) (uint64, error) { + if d == nil { + return 0, fmt.Errorf("gamedata: awakening design unavailable") + } + stage, ok := d.Stages[characterID] + if !ok || stage.UniqueCharID == 0 { + return 0, fmt.Errorf("gamedata: character %d has no awakening design", characterID) + } + if stage.Grade != stage.GrowthGrade || level < stage.MaximumLevel { + return 0, fmt.Errorf("gamedata: character %d has not completed growth", characterID) + } + return stage.UniqueCharID, nil +} + +func appendAwakeCosts(dst []CharAwakeCost, src []CharAwakeCost) ([]CharAwakeCost, error) { + for _, cost := range src { + merged := false + for i := range dst { + if dst[i].Type != cost.Type || dst[i].ID != cost.ID { + continue + } + if cost.Count > ^uint64(0)-dst[i].Count { + return nil, fmt.Errorf("gamedata: awakening cost overflow") + } + dst[i].Count += cost.Count + merged = true + break + } + if !merged { + dst = append(dst, cost) + } + } + return dst, nil +} + +func (d *CharAwakeDesign) ImprintCosts(uniqueCharID uint64, current [3]uint64, targets []CharImprintTarget) ([]CharAwakeCost, [3]uint64, error) { + entry, ok := d.Characters[uniqueCharID] + if !ok || !entry.Active || len(targets) == 0 { + return nil, current, fmt.Errorf("gamedata: invalid imprint request for character %d", uniqueCharID) + } + next := current + seen := [3]bool{} + var costs []CharAwakeCost + for _, target := range targets { + if target.Slot < 1 || target.Slot > 3 || seen[target.Slot-1] { + return nil, current, fmt.Errorf("gamedata: invalid or duplicate imprint slot %d", target.Slot) + } + seen[target.Slot-1] = true + slot := target.Slot - 1 + maximum := uint64(len(entry.ImprintGrowth[slot])) + if target.TargetLevel <= current[slot] || target.TargetLevel > maximum { + return nil, current, fmt.Errorf("gamedata: invalid imprint target %d for slot %d at %d/%d", target.TargetLevel, target.Slot, current[slot], maximum) + } + for level := current[slot] + 1; level <= target.TargetLevel; level++ { + var err error + costs, err = appendAwakeCosts(costs, entry.ImprintGrowth[slot][level-1].Costs) + if err != nil { + return nil, current, err + } + } + next[slot] = target.TargetLevel + } + return costs, next, nil +} + +func (d *CharAwakeDesign) AwakeCosts(uniqueCharID uint64, levels [3]uint64, isAwake bool) ([]CharAwakeCost, error) { + entry, ok := d.Characters[uniqueCharID] + if !ok || !entry.Active || isAwake { + return nil, fmt.Errorf("gamedata: character %d cannot awaken", uniqueCharID) + } + for i := range levels { + if levels[i] != uint64(len(entry.ImprintGrowth[i])) { + return nil, fmt.Errorf("gamedata: character %d imprint slot %d is not complete", uniqueCharID, i+1) + } + } + return append([]CharAwakeCost(nil), entry.AwakeGrowth[0].Costs...), nil +} + +func (d *CharAwakeDesign) ValidateProgress(uniqueCharID uint64, levels [3]uint64, isAwake bool) error { + entry, ok := d.Characters[uniqueCharID] + if !ok || !entry.Active { + return fmt.Errorf("gamedata: character %d has no awakening design", uniqueCharID) + } + for i, level := range levels { + maximum := uint64(len(entry.ImprintGrowth[i])) + if level > maximum || (isAwake && level != maximum) { + return fmt.Errorf("gamedata: invalid saved awakening slot %d level %d/%d", i+1, level, maximum) + } + } + return nil +} + +// CharAwakeContributions reproduces the client aggregation: each imprint slot +// contributes only its target-level cumulative row, while awakening adds all +// CharAwakeTable.GrowthId stat rows. +func (d *CharAwakeDesign) CharAwakeContributions(uniqueCharID uint64, levels [3]uint64, isAwake bool) ([]StatContribution, error) { + if err := d.ValidateProgress(uniqueCharID, levels, isAwake); err != nil { + return nil, err + } + entry := d.Characters[uniqueCharID] + var growth []CharAwakeGrowth + for i, level := range levels { + if level > uint64(len(entry.ImprintGrowth[i])) { + return nil, fmt.Errorf("gamedata: imprint slot %d level %d exceeds maximum", i+1, level) + } + if level != 0 { + growth = append(growth, entry.ImprintGrowth[i][level-1]) + } + } + if isAwake { + growth = append(growth, entry.AwakeGrowth...) + } + result := make([]StatContribution, 0, len(growth)) + for _, row := range growth { + contribution := StatContribution{Option: row.StatType} + switch row.StatType { + case 1, 2: + contribution.Stat = StatHealth + case 3, 4: + contribution.Stat = StatAttack + case 5, 6: + contribution.Stat = StatMagic + case 7: + contribution.Stat = StatDefencePercent + case 8: + contribution.Stat = StatMagicResistancePercent + case 9: + contribution.Stat = StatCriticalChance + case 10: + contribution.Stat = StatCriticalDamage + case 11, 12, 13, 14, 15, 19: + contribution.Stat = StatElementDamage + case 16, 17, 18, 20: + contribution.Stat = StatElementResistance + default: + return nil, fmt.Errorf("gamedata: unsupported awakening stat option %d", row.StatType) + } + if row.StatType == 2 || row.StatType == 4 || row.StatType == 6 { + contribution.Percent = row.StatValue + } else { + contribution.Flat = row.StatValue + } + result = append(result, contribution) + } + return result, nil +} diff --git a/go/internal/gamedata/char_awake_test.go b/go/internal/gamedata/char_awake_test.go new file mode 100644 index 0000000..962e400 --- /dev/null +++ b/go/internal/gamedata/char_awake_test.go @@ -0,0 +1,91 @@ +package gamedata + +import ( + "os" + "testing" +) + +func testCharAwakeDesign() *CharAwakeDesign { + levels := func(stat uint64, values ...float64) []CharAwakeGrowth { + result := make([]CharAwakeGrowth, len(values)) + for i, value := range values { + result[i] = CharAwakeGrowth{ID: uint64(i + 1), Costs: []CharAwakeCost{{Type: 8, ID: 701 + uint64(i), Count: uint64(i + 1)}, {Type: 4, Count: 100}}, StatType: stat, StatValue: value} + } + return result + } + character := CharAwakeCharacter{UniqueCharID: 35, Active: true} + character.ImprintGrowth[0] = levels(1, 5, 10) + character.ImprintGrowth[1] = levels(2, .01, .02) + character.ImprintGrowth[2] = levels(3, 2, 4) + character.AwakeGrowth = []CharAwakeGrowth{ + {ID: 100, Costs: []CharAwakeCost{{Type: 8, ID: 705, Count: 20}, {Type: 4, Count: 500}}, StatType: 4, StatValue: .12}, + {ID: 101, StatType: 14, StatValue: .1}, + } + return &CharAwakeDesign{ + Characters: map[uint64]CharAwakeCharacter{35: character}, + Stages: map[uint64]CharAwakeCharacterStage{354: {UniqueCharID: 35, Grade: 5, GrowthGrade: 5, MaximumLevel: 100}}, + } +} + +func TestCharAwakeImprintUsesCrossLevelCostsAndCumulativeStats(t *testing.T) { + design := testCharAwakeDesign() + costs, levels, err := design.ImprintCosts(35, [3]uint64{0, 0, 0}, []CharImprintTarget{{Slot: 1, TargetLevel: 2}, {Slot: 2, TargetLevel: 1}}) + if err != nil { + t.Fatal(err) + } + if levels != [3]uint64{2, 1, 0} || len(costs) != 3 { + t.Fatalf("levels=%v costs=%+v", levels, costs) + } + var gold uint64 + for _, cost := range costs { + if cost.Type == 4 { + gold += cost.Count + } + } + if gold != 300 { + t.Fatalf("gold=%d want=300", gold) + } + stats, err := design.CharAwakeContributions(35, levels, false) + if err != nil || len(stats) != 2 || stats[0].Flat != 10 || stats[1].Percent != .01 { + t.Fatalf("cumulative target-level stats=%+v err=%v", stats, err) + } + if _, _, err := design.ImprintCosts(35, levels, []CharImprintTarget{{Slot: 1, TargetLevel: 1}}); err == nil { + t.Fatal("imprint downgrade accepted") + } +} + +func TestCharAwakeActivationRequiresAllSlotsAndAddsEveryEffect(t *testing.T) { + design := testCharAwakeDesign() + if _, err := design.AwakeCosts(35, [3]uint64{2, 2, 1}, false); err == nil { + t.Fatal("incomplete imprint slots accepted") + } + costs, err := design.AwakeCosts(35, [3]uint64{2, 2, 2}, false) + if err != nil || len(costs) != 2 || costs[0].ID != 705 || costs[1].Type != 4 { + t.Fatalf("awakening costs=%+v err=%v", costs, err) + } + stats, err := design.CharAwakeContributions(35, [3]uint64{2, 2, 2}, true) + if err != nil || len(stats) != 5 || stats[3].Stat != StatAttack || stats[3].Percent != .12 || stats[4].Option != 14 { + t.Fatalf("awakening stats=%+v err=%v", stats, err) + } +} + +func TestCharAwakeAgainstInstalledVersion23413(t *testing.T) { + root := os.Getenv("BD2_TEST_GAMEDATA_ROOT") + if root == "" { + t.Skip("set BD2_TEST_GAMEDATA_ROOT for installed GameData integration test") + } + design, err := LoadCharAwakeDesign(root, "20260910162539") + if err != nil { + t.Fatal(err) + } + entry := design.Characters[35] + if len(design.Characters) != 86 || len(entry.ImprintGrowth[0]) != 10 || len(entry.ImprintGrowth[1]) != 10 || len(entry.ImprintGrowth[2]) != 10 || len(entry.AwakeGrowth) != 2 { + t.Fatalf("installed awakening design characters=%d char35=%+v", len(design.Characters), entry) + } + if entry.AwakeGrowth[0].Costs[0] != (CharAwakeCost{Type: 8, ID: 705, Count: 200}) || entry.AwakeGrowth[1].StatType != 14 || entry.AwakeGrowth[1].StatValue != .1 { + t.Fatalf("installed char35 awakening=%+v", entry.AwakeGrowth) + } + if unique, err := design.ValidateGrowthCompleted(354, 100); err != nil || unique != 35 { + t.Fatalf("installed final stage unique=%d err=%v", unique, err) + } +} diff --git a/go/internal/gamedata/equipment_gacha.go b/go/internal/gamedata/equipment_gacha.go index a9bd089..5e09634 100644 --- a/go/internal/gamedata/equipment_gacha.go +++ b/go/internal/gamedata/equipment_gacha.go @@ -11,10 +11,9 @@ import ( "path/filepath" ) -// EquipmentGachaCatalog is the server-owned portion of the active exclusive -// equipment draws. It deliberately models the three groups which the -// 2026-09-20 GameData actually attaches to current pickup banners; it does -// not infer a schedule or an equipment 12PICK banner from historical tables. +// EquipmentGachaCatalog is the server-owned portion of exclusive-equipment +// draws. Scheduled groups and standalone ticket-only draws are both derived +// from GameData; the server does not enumerate product IDs in request logic. type EquipmentGachaCatalog struct { Gachas map[uint64]EquipmentGacha groups map[uint64]EquipmentGachaGroup @@ -28,6 +27,7 @@ type EquipmentGacha struct { PriceType, Price uint64 TicketIDs []uint64 Pool []WeightedEquipment + TicketOnly bool } type EquipmentGachaGroup struct{ ID, FixedID, PointCount, OneTimeGachaID, TenTimeGachaID uint64 } type EquipmentFixedDesign struct { @@ -40,6 +40,7 @@ type WeightedEquipment struct { } type EquipmentDesign struct { ID uint64 + Grade uint64 Main, Sub []OptionGroup Private []OptionGroup RankGroupID uint64 @@ -109,6 +110,56 @@ func LoadEquipmentGacha(root, version string) (*EquipmentGachaCatalog, error) { } } } + // Guaranteed equipment tickets are standalone GachaTable rows: they have + // no diamond price, at least one resource-ticket id, and an equipment-only + // RewardGroup tree. Discover every such row instead of special-casing a + // product number from a log. + rows, err := db.Query("SELECT id,ProtoBuf FROM GachaTable ORDER BY id") + if err != nil { + return nil, err + } + for rows.Next() { + var id uint64 + var raw []byte + if err := rows.Scan(&id, &raw); err != nil { + rows.Close() + return nil, err + } + if _, exists := c.Gachas[id]; exists { + continue + } + count, _ := packedInts(raw, 5) + reward, _ := packedInts(raw, 7) + price, _ := packedInts(raw, 10) + kind, _ := packedInts(raw, 12) + tickets, _ := packedInts(raw, 8) + if len(count) != 1 || count[0] == 0 || len(reward) != 1 || len(tickets) == 0 || len(price) != 0 || len(kind) != 0 { + continue + } + pool, equipmentOnly, err := classifyEquipmentRewardPool(db, reward[0]) + if err != nil { + rows.Close() + return nil, fmt.Errorf("gamedata: ticket gacha %d: %w", id, err) + } + if !equipmentOnly { + continue + } + g := EquipmentGacha{ID: id, Count: int(count[0]), TicketIDs: append([]uint64(nil), tickets...), Pool: pool, TicketOnly: true} + c.Gachas[id] = g + for _, item := range pool { + if err := c.loadEquipmentTree(db, item); err != nil { + rows.Close() + return nil, err + } + } + } + if err := rows.Err(); err != nil { + rows.Close() + return nil, err + } + if err := rows.Close(); err != nil { + return nil, err + } var fixed []byte if err := db.QueryRow("SELECT ProtoBuf FROM GachaFixedTable WHERE id=1").Scan(&fixed); err != nil { return nil, err @@ -123,6 +174,43 @@ func LoadEquipmentGacha(root, version string) (*EquipmentGachaCatalog, error) { return c, nil } +func classifyEquipmentRewardPool(db *sql.DB, groupID uint64) ([]WeightedEquipment, bool, error) { + var raw []byte + if err := db.QueryRow("SELECT ProtoBuf FROM RewardGroupTable WHERE id=?", groupID).Scan(&raw); err != nil { + return nil, false, err + } + ids, _ := packedInts(raw, 5) + types, _ := packedInts(raw, 6) + weights, _ := packedInts(raw, 8) + if len(ids) == 0 || len(ids) != len(types) || len(ids) != len(weights) { + return nil, false, errors.New("malformed reward group") + } + out := make([]WeightedEquipment, 0, len(ids)) + for i, id := range ids { + entry := WeightedEquipment{Weight: weights[i]} + if entry.Weight == 0 { + return nil, false, errors.New("reward group has zero weight") + } + switch types[i] { + case 10: + entry.ID = id + case 9: + children, equipmentOnly, err := classifyEquipmentRewardPool(db, id) + if err != nil { + return nil, false, err + } + if !equipmentOnly { + return nil, false, nil + } + entry.Children = children + default: + return nil, false, nil + } + out = append(out, entry) + } + return out, true, nil +} + func loadEquipmentGacha(db *sql.DB, id uint64) (EquipmentGacha, error) { var raw []byte if err := db.QueryRow("SELECT ProtoBuf FROM GachaTable WHERE id=?", id).Scan(&raw); err != nil { @@ -214,7 +302,11 @@ func loadEquipmentDesign(db *sql.DB, id uint64) (EquipmentDesign, error) { private, _ := packedInts(raw, 17) sub, _ := packedInts(raw, 21) rank, _ := packedInts(raw, 19) - d := EquipmentDesign{ID: id} + grade, _ := packedInts(raw, 3) + if len(grade) != 1 || grade[0] == 0 { + return EquipmentDesign{}, fmt.Errorf("gamedata: equipment %d malformed grade", id) + } + d := EquipmentDesign{ID: id, Grade: grade[0]} var err error if d.Main, err = loadOptionGroups(db, main); err != nil { return d, err @@ -312,6 +404,21 @@ func (c *EquipmentGachaCatalog) RollOptions(id uint64) (main, sub []EquipmentOpt return } func (g EquipmentGacha) Roll(previousSR, previousUR uint64, fixed EquipmentFixedDesign) ([]uint64, EquipmentRoll, error) { + if g.TicketOnly { + state := EquipmentRoll{SRCount: previousSR, URCount: previousUR, SRSort: -1, URSort: -1} + if g.Count <= 0 || len(g.Pool) == 0 { + return nil, state, errors.New("gamedata: invalid ticket equipment gacha") + } + out := make([]uint64, g.Count) + for i := range out { + id, err := rollEquipmentChoiceWith(g.Pool, cryptoDraw) + if err != nil { + return nil, state, err + } + out[i] = id + } + return out, state, nil + } return g.rollWith(previousSR, previousUR, fixed, cryptoDraw) } diff --git a/go/internal/gamedata/equipment_smelting.go b/go/internal/gamedata/equipment_smelting.go new file mode 100644 index 0000000..2bdb4d9 --- /dev/null +++ b/go/internal/gamedata/equipment_smelting.go @@ -0,0 +1,295 @@ +package gamedata + +import ( + "database/sql" + "fmt" + "math" + "os" + "path/filepath" +) + +// EquipmentSmeltingDesign contains only static 2.34.13 GameData facts. It does +// not decide whether a lower-score result is applied or how mileage is paid: +// those are server transactions and require an official response capture. +type EquipmentSmeltingDesign struct { + Equipment map[uint64]EquipmentSmeltingItem + Ranks map[[2]uint64]EquipmentSmeltingRank + Grades map[uint64][]PromotionCost + Mileage EquipmentSmeltingMileage + MaxStreak uint64 +} + +type EquipmentSmeltingItem struct { + Grade, RankGroup, MaxLevel uint64 +} + +type EquipmentSmeltingRank struct { + // GrowthPoint is the per-rank battle-power contribution. Values is the + // separate 1..4 score used to compare a refinement candidate. + GrowthPoint []uint64 + Values []uint64 + Ratio []float64 +} + +type EquipmentSmeltingMileage struct { + UseType, UseID, UseCount uint64 + RewardType, RewardID, RewardCount uint64 +} + +func LoadEquipmentSmeltingDesign(root, version string) (*EquipmentSmeltingDesign, error) { + plain, err := ReadQuestDatabase(root, version) + if err != nil { + return nil, err + } + dir, err := os.MkdirTemp("", "bd2-equipment-smelting-") + if err != nil { + return nil, err + } + defer os.RemoveAll(dir) + path := filepath.Join(dir, "common.db") + if err := os.WriteFile(path, plain, 0o600); err != nil { + return nil, err + } + db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro") + if err != nil { + return nil, err + } + defer db.Close() + return loadEquipmentSmeltingDesign(db) +} + +func loadEquipmentSmeltingDesign(db *sql.DB) (*EquipmentSmeltingDesign, error) { + d := &EquipmentSmeltingDesign{ + Equipment: make(map[uint64]EquipmentSmeltingItem), + Ranks: make(map[[2]uint64]EquipmentSmeltingRank), + Grades: make(map[uint64][]PromotionCost), + } + rows, err := db.Query("SELECT id,ProtoBuf FROM EquipmentTable") + if err != nil { + return nil, err + } + for rows.Next() { + var id uint64 + var proto []byte + if err := rows.Scan(&id, &proto); err != nil { + rows.Close() + return nil, err + } + grade, _ := packedInts(proto, 3) + maximum, _ := packedInts(proto, 13) + rankGroup, _ := packedInts(proto, 19) + if len(grade) != 1 || len(maximum) != 1 || len(rankGroup) != 1 || grade[0] == 0 || maximum[0] == 0 || rankGroup[0] == 0 { + rows.Close() + return nil, fmt.Errorf("gamedata: equipment %d has invalid smelting design", id) + } + d.Equipment[id] = EquipmentSmeltingItem{Grade: grade[0], RankGroup: rankGroup[0], MaxLevel: maximum[0]} + } + if err := rows.Err(); err != nil { + rows.Close() + return nil, err + } + if err := rows.Close(); err != nil { + return nil, err + } + rows, err = db.Query("SELECT id,ProtoBuf FROM EquipmentGradeTable") + if err != nil { + return nil, err + } + for rows.Next() { + var id uint64 + var proto []byte + if err := rows.Scan(&id, &proto); err != nil { + rows.Close() + return nil, err + } + counts, _ := packedInts(proto, 4) + ids, _ := packedInts(proto, 5) + types, _ := packedInts(proto, 6) + if len(counts) == 0 || len(counts) != len(ids) || len(counts) != len(types) { + rows.Close() + return nil, fmt.Errorf("gamedata: equipment grade %d has invalid smelting costs", id) + } + for i := range counts { + if counts[i] == 0 || (types[i] != 4 && types[i] != 8) || (types[i] == 4 && ids[i] != 0) || (types[i] == 8 && ids[i] == 0) { + rows.Close() + return nil, fmt.Errorf("gamedata: equipment grade %d has invalid smelting material", id) + } + d.Grades[id] = append(d.Grades[id], PromotionCost{Type: types[i], ID: ids[i], Count: counts[i]}) + } + } + if err := rows.Err(); err != nil { + rows.Close() + return nil, err + } + if err := rows.Close(); err != nil { + return nil, err + } + rows, err = db.Query("SELECT groupId,id,ProtoBuf FROM EquipmentRankTable") + if err != nil { + return nil, err + } + for rows.Next() { + var group, slot uint64 + var proto []byte + if err := rows.Scan(&group, &slot, &proto); err != nil { + rows.Close() + return nil, err + } + ratios, err := fixed32Floats(proto, 4) + growth, growthErr := packedInts(proto, 2) + values, valueErr := packedInts(proto, 5) + if err != nil || growthErr != nil || valueErr != nil || slot < 1 || slot > 3 || len(ratios) != 4 || len(growth) != 4 || len(values) != 4 { + rows.Close() + return nil, fmt.Errorf("gamedata: equipment rank %d/%d has invalid smelting distribution", group, slot) + } + var total float64 + for i := range ratios { + if ratios[i] < 0 || ratios[i] > 1 || math.IsNaN(ratios[i]) || values[i] == 0 || growth[i] == 0 { + rows.Close() + return nil, fmt.Errorf("gamedata: equipment rank %d/%d has invalid smelting value", group, slot) + } + total += ratios[i] + if i != 0 && (values[i] <= values[i-1] || growth[i] <= growth[i-1]) { + rows.Close() + return nil, fmt.Errorf("gamedata: equipment rank %d/%d is not increasing", group, slot) + } + } + if math.Abs(total-1) > 1e-5 { + rows.Close() + return nil, fmt.Errorf("gamedata: equipment rank %d/%d ratio total %.8f", group, slot, total) + } + d.Ranks[[2]uint64{group, slot}] = EquipmentSmeltingRank{GrowthPoint: growth, Values: values, Ratio: ratios} + } + if err := rows.Err(); err != nil { + rows.Close() + return nil, err + } + if err := rows.Close(); err != nil { + return nil, err + } + for id, equipment := range d.Equipment { + if len(d.Grades[equipment.Grade]) == 0 { + return nil, fmt.Errorf("gamedata: equipment %d has no grade costs", id) + } + for slot := uint64(1); slot <= 3; slot++ { + if len(d.Ranks[[2]uint64{equipment.RankGroup, slot}].Values) != 4 { + return nil, fmt.Errorf("gamedata: equipment %d has no rank slot %d", id, slot) + } + } + } + var mileageProto []byte + if err := db.QueryRow("SELECT ProtoBuf FROM EquipmentMileageTable WHERE id=0").Scan(&mileageProto); err != nil { + return nil, fmt.Errorf("gamedata: equipment mileage: %w", err) + } + mileageCount, _ := packedInts(mileageProto, 2) + mileageID, _ := packedInts(mileageProto, 3) + mileageType, _ := packedInts(mileageProto, 4) + useCount, _ := packedInts(mileageProto, 5) + useID, _ := packedInts(mileageProto, 6) + useType, _ := packedInts(mileageProto, 7) + if len(mileageCount) != 1 || len(mileageID) > 1 || len(mileageType) != 1 || len(useCount) != 1 || len(useID) != 1 || len(useType) != 1 || mileageCount[0] == 0 || useCount[0] == 0 { + return nil, fmt.Errorf("gamedata: invalid equipment mileage definition") + } + if len(mileageID) == 1 { + d.Mileage.RewardID = mileageID[0] + } + d.Mileage.RewardCount, d.Mileage.RewardType = mileageCount[0], mileageType[0] + d.Mileage.UseCount, d.Mileage.UseID, d.Mileage.UseType = useCount[0], useID[0], useType[0] + if d.Mileage.RewardType == 0 || d.Mileage.UseType == 0 || d.Mileage.UseID == 0 { + return nil, fmt.Errorf("gamedata: invalid equipment mileage item types") + } + for grade, costs := range d.Grades { + found := false + for _, cost := range costs { + if cost.Type == d.Mileage.UseType && cost.ID == d.Mileage.UseID { + found = true + break + } + } + if !found { + return nil, fmt.Errorf("gamedata: equipment grade %d does not consume mileage material", grade) + } + } + var defaults []byte + if err := db.QueryRow("SELECT ProtoBuf FROM GameDefaultTable WHERE id=0").Scan(&defaults); err != nil { + return nil, fmt.Errorf("gamedata: equipment smelting limit: %w", err) + } + maxStreak, _ := packedInts(defaults, 5) + if len(maxStreak) != 1 || maxStreak[0] == 0 { + return nil, fmt.Errorf("gamedata: invalid maximum smelting streak") + } + d.MaxStreak = maxStreak[0] + return d, nil +} + +func (d *EquipmentSmeltingDesign) Cost(equipmentID uint64) ([]PromotionCost, error) { + if d == nil { + return nil, fmt.Errorf("gamedata: equipment smelting design unavailable") + } + equipment, ok := d.Equipment[equipmentID] + if !ok || len(d.Grades[equipment.Grade]) == 0 { + return nil, fmt.Errorf("gamedata: equipment %d has no smelting cost", equipmentID) + } + return append([]PromotionCost(nil), d.Grades[equipment.Grade]...), nil +} + +func (d *EquipmentSmeltingDesign) Score(equipmentID uint64, ranks []uint64) (uint64, error) { + if d == nil || len(ranks) != 3 { + return 0, fmt.Errorf("gamedata: invalid equipment rank score input") + } + equipment, ok := d.Equipment[equipmentID] + if !ok { + return 0, fmt.Errorf("gamedata: equipment %d has no smelting design", equipmentID) + } + var score uint64 + for i, rank := range ranks { + values := d.Ranks[[2]uint64{equipment.RankGroup, uint64(i + 1)}].Values + if rank < 1 || rank > uint64(len(values)) || values[rank-1] > ^uint64(0)-score { + return 0, fmt.Errorf("gamedata: invalid equipment rank %d in slot %d", rank, i+1) + } + score += values[rank-1] + } + return score, nil +} + +func (d *EquipmentSmeltingDesign) MaximumRanks(equipmentID uint64) ([]uint64, error) { + if d == nil { + return nil, fmt.Errorf("gamedata: equipment smelting design unavailable") + } + equipment, ok := d.Equipment[equipmentID] + if !ok { + return nil, fmt.Errorf("gamedata: equipment %d has no smelting design", equipmentID) + } + ranks := make([]uint64, 3) + for slot := uint64(1); slot <= 3; slot++ { + values := d.Ranks[[2]uint64{equipment.RankGroup, slot}].Values + if len(values) == 0 { + return nil, fmt.Errorf("gamedata: equipment %d has no rank slot %d", equipmentID, slot) + } + ranks[slot-1] = uint64(len(values)) + } + return ranks, nil +} + +// RollCandidate independently rolls all three rank slots from the official +// EquipmentRankTable distributions. Whether the candidate replaces the +// current ranks is a player-state transaction, not a GameData concern. +func (d *EquipmentSmeltingDesign) RollCandidate(equipmentID uint64) ([]uint64, error) { + if d == nil { + return nil, fmt.Errorf("gamedata: equipment smelting design unavailable") + } + equipment, ok := d.Equipment[equipmentID] + if !ok { + return nil, fmt.Errorf("gamedata: equipment %d has no smelting design", equipmentID) + } + result := make([]uint64, 3) + for slot := uint64(1); slot <= 3; slot++ { + rank := d.Ranks[[2]uint64{equipment.RankGroup, slot}] + value, err := cryptoRankRoll(rank.Ratio) + if err != nil { + return nil, fmt.Errorf("gamedata: roll equipment %d rank slot %d: %w", equipmentID, slot, err) + } + result[slot-1] = value + } + return result, nil +} diff --git a/go/internal/gamedata/equipment_smelting_test.go b/go/internal/gamedata/equipment_smelting_test.go new file mode 100644 index 0000000..ae13d40 --- /dev/null +++ b/go/internal/gamedata/equipment_smelting_test.go @@ -0,0 +1,69 @@ +package gamedata + +import ( + "os" + "testing" +) + +func TestEquipmentSmeltingAgainstInstalledVersion23413(t *testing.T) { + root := os.Getenv("BD2_TEST_GAMEDATA_ROOT") + if root == "" { + t.Skip("set BD2_TEST_GAMEDATA_ROOT for installed GameData integration test") + } + design, err := LoadEquipmentSmeltingDesign(root, "20260910162539") + if err != nil { + t.Fatal(err) + } + equipment := design.Equipment[943035] + if equipment.Grade != 4 || equipment.RankGroup != 904 || equipment.MaxLevel != 9 { + t.Fatalf("UR equipment smelting design=%+v", equipment) + } + cost, err := design.Cost(943035) + if err != nil || len(cost) != 2 || cost[0] != (PromotionCost{Type: 4, Count: 80}) || cost[1] != (PromotionCost{Type: 8, ID: 10, Count: 30}) { + t.Fatalf("UR smelting cost=%+v err=%v", cost, err) + } + if design.MaxStreak != 5000 || design.Mileage != (EquipmentSmeltingMileage{UseType: 8, UseID: 10, UseCount: 1000, RewardType: 68, RewardCount: 1}) { + t.Fatalf("smelting limit=%d mileage=%+v", design.MaxStreak, design.Mileage) + } + if got := design.Ranks[[2]uint64{904, 1}].Values; len(got) != 4 || got[0] != 1 || got[3] != 4 { + t.Fatalf("UR slot1 score values=%v", got) + } + if got := design.Ranks[[2]uint64{904, 1}].GrowthPoint; len(got) != 4 || got[0] != 34 || got[3] != 135 { + t.Fatalf("UR slot1 growth points=%v", got) + } + if score, err := design.Score(943035, []uint64{1, 2, 3}); err != nil || score != 1+4+9 { + t.Fatalf("score=%d err=%v", score, err) + } +} + +func TestEquipmentSmeltingMaximumRanksComeFromDesign(t *testing.T) { + design := &EquipmentSmeltingDesign{ + Equipment: map[uint64]EquipmentSmeltingItem{1: {RankGroup: 9}}, + Ranks: map[[2]uint64]EquipmentSmeltingRank{ + {9, 1}: {Values: []uint64{1, 2}}, + {9, 2}: {Values: []uint64{1, 2, 3}}, + {9, 3}: {Values: []uint64{1, 2, 3, 4}}, + }, + } + got, err := design.MaximumRanks(1) + if err != nil || len(got) != 3 || got[0] != 2 || got[1] != 3 || got[2] != 4 { + t.Fatalf("maximum ranks=%v err=%v", got, err) + } +} + +func TestEquipmentSmeltingRejectsInvalidRank(t *testing.T) { + design := &EquipmentSmeltingDesign{ + Equipment: map[uint64]EquipmentSmeltingItem{1: {Grade: 4, RankGroup: 9, MaxLevel: 9}}, + Ranks: map[[2]uint64]EquipmentSmeltingRank{ + {9, 1}: {Values: []uint64{1, 2, 3, 4}}, + {9, 2}: {Values: []uint64{2, 4, 6, 8}}, + {9, 3}: {Values: []uint64{3, 6, 9, 12}}, + }, + } + if _, err := design.Score(1, []uint64{1, 0, 1}); err == nil { + t.Fatal("uninitialized smelting rank accepted") + } + if score, err := design.Score(1, []uint64{4, 4, 4}); err != nil || score != 24 { + t.Fatalf("maximum score=%d err=%v", score, err) + } +} diff --git a/go/internal/gamedata/equipment_ticket_gacha_test.go b/go/internal/gamedata/equipment_ticket_gacha_test.go new file mode 100644 index 0000000..e5c779b --- /dev/null +++ b/go/internal/gamedata/equipment_ticket_gacha_test.go @@ -0,0 +1,40 @@ +package gamedata + +import ( + "os" + "testing" +) + +func TestEquipmentTicketOnlyGachaAgainstInstalledVersion23413(t *testing.T) { + root := os.Getenv("BD2_TEST_GAMEDATA_ROOT") + if root == "" { + t.Skip("set BD2_TEST_GAMEDATA_ROOT for installed GameData integration test") + } + catalog, err := LoadEquipmentGacha(root, "20260910162539") + if err != nil { + t.Fatal(err) + } + gacha, ok := catalog.Gacha(71200001) + if !ok || !gacha.TicketOnly || gacha.Count != 10 || gacha.Price != 0 || gacha.PriceType != 0 || len(gacha.TicketIDs) != 1 || gacha.TicketIDs[0] != 1104 { + t.Fatalf("ticket-only equipment gacha=%+v ok=%v", gacha, ok) + } + if len(gacha.Pool) != 3 || gacha.Pool[0].Weight != 150 || gacha.Pool[1].Weight != 350 || gacha.Pool[2].Weight != 500 { + t.Fatalf("ticket-only equipment pool=%+v", gacha.Pool) + } + if len(gacha.Pool[0].Children) != 50 || len(gacha.Pool[1].Children) != 9 || len(gacha.Pool[2].Children) != 14 { + t.Fatalf("ticket-only equipment branches=%d/%d/%d", len(gacha.Pool[0].Children), len(gacha.Pool[1].Children), len(gacha.Pool[2].Children)) + } + // These branches separate the owning character's star grade, not the + // equipment rarity. Every candidate is EquipmentTable.Grade=4 (UR). + for _, branch := range gacha.Pool { + for _, item := range branch.Children { + design, found := catalog.equipment[item.ID] + if !found || design.Grade != 4 { + t.Fatalf("UR-guaranteed candidate %d design=%+v found=%v", item.ID, design, found) + } + } + } + if _, grouped := catalog.GroupForGacha(gacha.ID); grouped { + t.Fatal("standalone ticket draw incorrectly attached to a schedule group") + } +} diff --git a/go/internal/mail/mail.go b/go/internal/mail/mail.go index e818423..4e5ea2c 100644 --- a/go/internal/mail/mail.go +++ b/go/internal/mail/mail.go @@ -228,6 +228,17 @@ func OpenService(path string, starter *Starter, inventory *player.Inventory, wal return s, nil } +func (s *Service) EnsurePersisted() error { + s.mu.Lock() + defer s.mu.Unlock() + if _, err := os.Stat(s.path); err == nil { + return nil + } else if !errors.Is(err, os.ErrNotExist) { + return err + } + return s.persist(s.state) +} + // AttachSeedPath enables development-time hot reloading of an atomically // replaced mail seed. It is deliberately a mailbox concern, not an HTTP debug // endpoint: the client continues to call only the normal /MailInfo API. @@ -436,6 +447,10 @@ func (s *Service) commit(next stateSnapshot) error { return nil } } + return s.persist(next) +} + +func (s *Service) persist(next stateSnapshot) error { b, err := json.Marshal(next) if err != nil { return err diff --git a/go/internal/missions/missions.go b/go/internal/missions/missions.go index a6abe31..21097be 100644 --- a/go/internal/missions/missions.go +++ b/go/internal/missions/missions.go @@ -75,6 +75,17 @@ func Open(path string, design *gamedata.MissionDesign, inventory *player.Invento return s, nil } +func (s *Service) EnsurePersisted() error { + s.mu.Lock() + defer s.mu.Unlock() + if _, err := os.Stat(s.path); err == nil { + return nil + } else if !errors.Is(err, os.ErrNotExist) { + return err + } + return s.persist(cloneSnapshot(s.state)) +} + // CompleteMission is the only way normal mission eligibility enters this // package. It is persistent and idempotent. Gameplay/event handlers should // call it only after they have independently verified the table condition. @@ -655,6 +666,10 @@ func (s *Service) commit(next snapshot) error { if equalStrings(next.Completed, s.state.Completed) && equalStrings(next.Claimed, s.state.Claimed) && equalProgress(next.Progress, s.state.Progress) { return nil } + return s.persist(next) +} + +func (s *Service) persist(next snapshot) error { b, err := json.Marshal(next) if err != nil { return err diff --git a/go/internal/pictorial/service.go b/go/internal/pictorial/service.go index 9f7f260..ec0bb84 100644 --- a/go/internal/pictorial/service.go +++ b/go/internal/pictorial/service.go @@ -23,9 +23,12 @@ type Owned interface { } type Service struct { - Design *gamedata.PictorialDesign - Owned Owned - baseHealth sync.Map // [2]uint64 (design character ID, level) -> design-only base HP + Design *gamedata.PictorialDesign + Owned Owned + // AwakeContributions is injected by the character-awakening domain so all + // server-side maximum-HP consumers use the same derived account state. + AwakeContributions func(player.Character) ([]gamedata.StatContribution, error) + baseHealth sync.Map // [2]uint64 (design character ID, level) -> design-only base HP } func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) { @@ -270,6 +273,13 @@ func (s *Service) MaxHealth(character player.Character) (uint64, error) { if err != nil { return 0, err } + if s.AwakeContributions != nil { + awake, err := s.AwakeContributions(character) + if err != nil { + return 0, err + } + contributions = append(contributions, awake...) + } maxHP := gamedata.AggregateStats(gamedata.BaseStats{Health: value.(float64)}, contributions).Health if maxHP < 1 || maxHP > float64(^uint64(0)) { return 0, fmt.Errorf("pictorial: invalid maximum health for character %d: %v", character.ID, maxHP) diff --git a/go/internal/player/char_awake.go b/go/internal/player/char_awake.go new file mode 100644 index 0000000..78a72e5 --- /dev/null +++ b/go/internal/player/char_awake.go @@ -0,0 +1,300 @@ +package player + +import ( + "errors" + "fmt" + "sort" + "strconv" + + "bd2server/internal/gamedata" + "bd2server/internal/wire" +) + +type CharAwakeService struct { + design *gamedata.CharAwakeDesign + collection *CollectionStore + characters *CharacterStore + inventory *Inventory + wallet *Wallet +} + +func NewCharAwakeService(design *gamedata.CharAwakeDesign, collection *CollectionStore, characters *CharacterStore, inventory *Inventory, wallet *Wallet) (*CharAwakeService, error) { + if design == nil || collection == nil || characters == nil || inventory == nil || wallet == nil { + return nil, errors.New("player: incomplete character awakening service") + } + for uniqueID, progress := range collection.CharAwakeStates() { + if err := design.ValidateProgress(uniqueID, progress.ImprintLevels, progress.IsAwake); err != nil { + return nil, fmt.Errorf("player: invalid saved character awakening progress: %w", err) + } + } + return &CharAwakeService{design: design, collection: collection, characters: characters, inventory: inventory, wallet: wallet}, nil +} + +func (s *CharAwakeService) Handle(path string, request []byte) (int, []byte, bool, error) { + switch path { + case "/CharAwakeInfo": + return s.info(request) + case "/CharImprintLevelUp": + return s.imprintLevelUp(request) + case "/CharAwakeActive": + return s.awakeActive(request) + default: + return 0, nil, false, nil + } +} + +func validAwakeSequence(request []byte, path string) error { + seq, found, err := wire.Varint(request, 1) + if err != nil || !found || seq == 0 { + return fmt.Errorf("player: %s missing sequence", path) + } + return nil +} + +func (s *CharAwakeService) info(request []byte) (int, []byte, bool, error) { + if err := validAwakeSequence(request, "CharAwakeInfo"); err != nil { + return 0, nil, true, err + } + states := s.collection.CharAwakeStates() + ids := make([]uint64, 0, len(states)) + for id := range states { + ids = append(ids, id) + } + sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) + var response []byte + for _, id := range ids { + progress := states[id] + var entry []byte + entry = wire.AppendVarint(entry, 1, id) + for i, level := range progress.ImprintLevels { + if level != 0 { + entry = wire.AppendVarint(entry, 2+i, level) + } + } + if progress.IsAwake { + entry = wire.AppendVarint(entry, 5, 1) + } + response = wire.AppendBytes(response, 1, entry) + } + return 326, response, true, nil +} + +func decodeAwakeMaterials(request []byte, fieldNumber int) ([]Item, error) { + var materials []Item + err := wire.Walk(request, func(field wire.Field) error { + if field.Number != fieldNumber { + return nil + } + if field.Type != 2 { + return errors.New("player: invalid character awakening material field") + } + var item Item + if err := decodeVarints(field.Value, map[int]*uint64{1: &item.InvenIndex, 2: &item.ID, 3: &item.Type, 4: &item.Count, 5: &item.KeepFlag, 6: &item.TimeValue, 9: &item.SortID, 10: &item.UseCount}); err != nil { + return err + } + if item.Type == 0 || item.Count == 0 || (item.Type != 4 && (item.InvenIndex == 0 || item.ID == 0)) { + return errors.New("player: incomplete character awakening material") + } + materials = append(materials, item) + return nil + }) + return materials, err +} + +func (s *CharAwakeService) imprintLevelUp(request []byte) (int, []byte, bool, error) { + if err := validAwakeSequence(request, "CharImprintLevelUp"); err != nil { + return 0, nil, true, err + } + characterIndex, found, err := wire.Varint(request, 2) + if err != nil || !found || characterIndex == 0 { + return 0, nil, true, errors.New("player: CharImprintLevelUp missing character") + } + var targets []gamedata.CharImprintTarget + err = wire.Walk(request, func(field wire.Field) error { + if field.Number != 3 { + return nil + } + if field.Type != 2 { + return errors.New("player: invalid imprint target field") + } + var target gamedata.CharImprintTarget + if err := decodeVarints(field.Value, map[int]*uint64{1: &target.Slot, 2: &target.TargetLevel}); err != nil { + return err + } + if target.Slot == 0 || target.TargetLevel == 0 { + return errors.New("player: incomplete imprint target") + } + targets = append(targets, target) + return nil + }) + if err != nil || len(targets) == 0 { + if err == nil { + err = errors.New("player: CharImprintLevelUp has no targets") + } + return 0, nil, true, err + } + materials, err := decodeAwakeMaterials(request, 4) + if err != nil || len(materials) == 0 { + if err == nil { + err = errors.New("player: CharImprintLevelUp has no materials") + } + return 0, nil, true, err + } + character, found := s.characters.Find(characterIndex) + if !found { + return 0, nil, true, fmt.Errorf("player: unknown imprint character %d", characterIndex) + } + uniqueID, err := s.design.ValidateGrowthCompleted(character.ID, character.Level) + if err != nil { + return 0, nil, true, fmt.Errorf("player: validate imprint character growth: %w", err) + } + current, _ := s.collection.CharAwakeState(uniqueID) + costs, levels, err := s.design.ImprintCosts(uniqueID, current.ImprintLevels, targets) + if err != nil { + return 0, nil, true, fmt.Errorf("player: validate imprint GameData: %w", err) + } + items, gold, err := s.validateCosts(costs, materials) + if err != nil { + return 0, nil, true, fmt.Errorf("player: validate imprint materials: %w", err) + } + if len(items) != 0 { + if err := s.inventory.CanConsume(items); err != nil { + return 0, nil, true, fmt.Errorf("player: validate imprint inventory: %w", err) + } + } + if gold != 0 && !s.wallet.CanSpendGold(gold) { + return 0, nil, true, errors.New("player: insufficient gold for character imprint") + } + identity := "char-imprint:" + strconv.FormatUint(uniqueID, 10) + for _, level := range levels { + identity += ":" + strconv.FormatUint(level, 10) + } + if gold != 0 { + if _, err := s.wallet.SpendGoldOnce(identity, gold); err != nil { + return 0, nil, true, fmt.Errorf("player: spend imprint gold: %w", err) + } + } + if len(items) != 0 { + if err := s.inventory.Consume(items); err != nil { + return 0, nil, true, fmt.Errorf("player: consume imprint materials: %w", err) + } + } + next := current + next.ImprintLevels = levels + if err := s.collection.UpdateCharAwake(uniqueID, current, next); err != nil { + return 0, nil, true, fmt.Errorf("player: persist character imprint: %w", err) + } + return 327, nil, true, nil +} + +func (s *CharAwakeService) awakeActive(request []byte) (int, []byte, bool, error) { + if err := validAwakeSequence(request, "CharAwakeActive"); err != nil { + return 0, nil, true, err + } + characterIndex, found, err := wire.Varint(request, 2) + if err != nil || !found || characterIndex == 0 { + return 0, nil, true, errors.New("player: CharAwakeActive missing character") + } + materials, err := decodeAwakeMaterials(request, 3) + if err != nil || len(materials) == 0 { + if err == nil { + err = errors.New("player: CharAwakeActive has no materials") + } + return 0, nil, true, err + } + character, found := s.characters.Find(characterIndex) + if !found { + return 0, nil, true, fmt.Errorf("player: unknown awakening character %d", characterIndex) + } + uniqueID, err := s.design.ValidateGrowthCompleted(character.ID, character.Level) + if err != nil { + return 0, nil, true, fmt.Errorf("player: validate awakening character growth: %w", err) + } + current, _ := s.collection.CharAwakeState(uniqueID) + costs, err := s.design.AwakeCosts(uniqueID, current.ImprintLevels, current.IsAwake) + if err != nil { + return 0, nil, true, fmt.Errorf("player: validate awakening GameData: %w", err) + } + items, gold, err := s.validateCosts(costs, materials) + if err != nil { + return 0, nil, true, fmt.Errorf("player: validate awakening materials: %w", err) + } + if len(items) != 0 { + if err := s.inventory.CanConsume(items); err != nil { + return 0, nil, true, fmt.Errorf("player: validate awakening inventory: %w", err) + } + } + if gold != 0 && !s.wallet.CanSpendGold(gold) { + return 0, nil, true, errors.New("player: insufficient gold for character awakening") + } + identity := "char-awake:" + strconv.FormatUint(uniqueID, 10) + if gold != 0 { + if _, err := s.wallet.SpendGoldOnce(identity, gold); err != nil { + return 0, nil, true, fmt.Errorf("player: spend awakening gold: %w", err) + } + } + if len(items) != 0 { + if err := s.inventory.Consume(items); err != nil { + return 0, nil, true, fmt.Errorf("player: consume awakening materials: %w", err) + } + } + next := current + next.IsAwake = true + if err := s.collection.UpdateCharAwake(uniqueID, current, next); err != nil { + return 0, nil, true, fmt.Errorf("player: persist character awakening: %w", err) + } + return 328, nil, true, nil +} + +func (s *CharAwakeService) validateCosts(costs []gamedata.CharAwakeCost, materials []Item) ([]Item, uint64, error) { + want := make(map[[2]uint64]uint64, len(costs)) + for _, cost := range costs { + key := [2]uint64{cost.Type, cost.ID} + if cost.Count > ^uint64(0)-want[key] { + return nil, 0, errors.New("character awakening cost overflow") + } + want[key] += cost.Count + } + got := make(map[[2]uint64]uint64, len(materials)) + var items []Item + var gold uint64 + for _, material := range materials { + key := [2]uint64{material.Type, material.ID} + switch material.Type { + case 4: + if material.InvenIndex != 0 || material.ID != 0 || gold != 0 { + return nil, 0, errors.New("invalid character awakening currency") + } + gold = material.Count + case 8: + items = append(items, material) + default: + return nil, 0, fmt.Errorf("unsupported character awakening material type %d", material.Type) + } + if material.Count > ^uint64(0)-got[key] { + return nil, 0, errors.New("submitted character awakening material overflow") + } + got[key] += material.Count + } + if len(got) != len(want) { + return nil, 0, fmt.Errorf("material kinds mismatch: request=%v GameData=%v", got, want) + } + for key, count := range want { + if got[key] != count { + return nil, 0, fmt.Errorf("material %d/%d=%d want %d", key[0], key[1], got[key], count) + } + } + return items, gold, nil +} + +// Contributions exposes server-side derived stats for CharInfo, revival and +// battle calculators. The client independently computes the same values from +// CharAwakeInfo, so neither side relies on a persisted derived number. +func (s *CharAwakeService) Contributions(character Character) ([]gamedata.StatContribution, error) { + uniqueID, ok := s.design.CharacterUniqueID(character.ID) + if !ok { + return nil, nil + } + progress, _ := s.collection.CharAwakeState(uniqueID) + return s.design.CharAwakeContributions(uniqueID, progress.ImprintLevels, progress.IsAwake) +} diff --git a/go/internal/player/char_awake_test.go b/go/internal/player/char_awake_test.go new file mode 100644 index 0000000..5726a86 --- /dev/null +++ b/go/internal/player/char_awake_test.go @@ -0,0 +1,140 @@ +package player + +import ( + "path/filepath" + "testing" + + "bd2server/internal/gamedata" + "bd2server/internal/wire" +) + +func newCharAwakeHarness(t *testing.T) (*CharAwakeService, *CollectionStore, *Inventory, *Wallet, []Item) { + t.Helper() + dir := t.TempDir() + starter := &Starter{Version: "2.34.13"} + inventory, err := OpenInventory(filepath.Join(dir, "items.json"), starter) + if err != nil { + t.Fatal(err) + } + granted, err := inventory.GrantOnce("awake-test", []gamedata.BattleReward{ + {Type: 8, ID: 701, Count: 1}, {Type: 8, ID: 702, Count: 1}, {Type: 8, ID: 703, Count: 1}, {Type: 8, ID: 705, Count: 20}, + }) + if err != nil { + t.Fatal(err) + } + wallet, err := OpenWallet(filepath.Join(dir, "wallet.json"), Currency{Gold: 1000}) + if err != nil { + t.Fatal(err) + } + collection, err := OpenCollectionStore(filepath.Join(dir, "collection.json"), nil) + if err != nil { + t.Fatal(err) + } + characters := &CharacterStore{characters: []Character{{InvenIndex: 77, ID: 354, Level: 100}}} + character := gamedata.CharAwakeCharacter{UniqueCharID: 35, Active: true} + for i := 0; i < 3; i++ { + character.ImprintGrowth[i] = []gamedata.CharAwakeGrowth{{ + ID: uint64(i + 1), StatType: uint64(i*2 + 1), StatValue: float64(i + 1), + Costs: []gamedata.CharAwakeCost{{Type: 8, ID: uint64(701 + i), Count: 1}, {Type: 4, Count: 100}}, + }} + } + character.AwakeGrowth = []gamedata.CharAwakeGrowth{ + {ID: 100, StatType: 4, StatValue: .12, Costs: []gamedata.CharAwakeCost{{Type: 8, ID: 705, Count: 20}, {Type: 4, Count: 500}}}, + {ID: 101, StatType: 14, StatValue: .1}, + } + design := &gamedata.CharAwakeDesign{ + Characters: map[uint64]gamedata.CharAwakeCharacter{35: character}, + Stages: map[uint64]gamedata.CharAwakeCharacterStage{ + 354: {UniqueCharID: 35, Grade: 5, GrowthGrade: 5, MaximumLevel: 100}, + }, + } + service, err := NewCharAwakeService(design, collection, characters, inventory, wallet) + if err != nil { + t.Fatal(err) + } + return service, collection, inventory, wallet, granted +} + +func awakeMaterial(item Item) []byte { return ItemWire(item) } + +func TestCharImprintAndAwakePersistConsumeAndRestoreInfo(t *testing.T) { + service, collection, inventory, wallet, granted := newCharAwakeHarness(t) + request := wire.AppendVarint(nil, 1, 10) + request = wire.AppendVarint(request, 2, 77) + for slot := uint64(1); slot <= 3; slot++ { + target := wire.AppendVarint(wire.AppendVarint(nil, 1, slot), 2, 1) + request = wire.AppendBytes(request, 3, target) + request = wire.AppendBytes(request, 4, awakeMaterial(granted[slot-1])) + } + request = wire.AppendBytes(request, 4, awakeMaterial(Item{Type: 4, Count: 300})) + code, proto, handled, err := service.Handle("/CharImprintLevelUp", request) + if err != nil || !handled || code != 327 || len(proto) != 0 { + t.Fatalf("imprint code=%d proto=%x handled=%v err=%v", code, proto, handled, err) + } + progress, found := collection.CharAwakeState(35) + if !found || progress.ImprintLevels != [3]uint64{1, 1, 1} || progress.IsAwake { + t.Fatalf("imprint progress=%+v found=%v", progress, found) + } + if wallet.Snapshot().Gold != 700 || len(inventory.All()) != 1 { + t.Fatalf("post-imprint wallet=%+v items=%+v", wallet.Snapshot(), inventory.All()) + } + + request = wire.AppendVarint(nil, 1, 11) + request = wire.AppendVarint(request, 2, 77) + request = wire.AppendBytes(request, 3, awakeMaterial(granted[3])) + request = wire.AppendBytes(request, 3, awakeMaterial(Item{Type: 4, Count: 500})) + code, proto, handled, err = service.Handle("/CharAwakeActive", request) + if err != nil || !handled || code != 328 || len(proto) != 0 { + t.Fatalf("awake code=%d proto=%x handled=%v err=%v", code, proto, handled, err) + } + progress, _ = collection.CharAwakeState(35) + if !progress.IsAwake || wallet.Snapshot().Gold != 200 || len(inventory.All()) != 0 { + t.Fatalf("post-awake progress=%+v wallet=%+v items=%+v", progress, wallet.Snapshot(), inventory.All()) + } + restarted, err := OpenCollectionStore(collection.path, nil) + if err != nil { + t.Fatal(err) + } + service.collection = restarted + progress, found = restarted.CharAwakeState(35) + if !found || progress.ImprintLevels != [3]uint64{1, 1, 1} || !progress.IsAwake { + t.Fatalf("restarted awakening progress=%+v found=%v", progress, found) + } + + code, proto, handled, err = service.Handle("/CharAwakeInfo", wire.AppendVarint(nil, 1, 12)) + if err != nil || !handled || code != 326 { + t.Fatalf("info code=%d handled=%v err=%v", code, handled, err) + } + var entry []byte + if err := wire.Walk(proto, func(field wire.Field) error { + if field.Number == 1 { + entry = append([]byte(nil), field.Value...) + } + return nil + }); err != nil { + t.Fatal(err) + } + for field, want := range map[int]uint64{1: 35, 2: 1, 3: 1, 4: 1, 5: 1} { + got, present, err := wire.Varint(entry, field) + if err != nil || !present || got != want { + t.Fatalf("CharAwakeDBInfo field %d=%d present=%v err=%v want=%d proto=%x", field, got, present, err, want, entry) + } + } +} + +func TestCharImprintRejectsClientCostMismatchBeforeMutation(t *testing.T) { + service, collection, inventory, wallet, granted := newCharAwakeHarness(t) + request := wire.AppendVarint(nil, 1, 20) + request = wire.AppendVarint(request, 2, 77) + request = wire.AppendBytes(request, 3, wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 1)) + bad := granted[0] + bad.Count = 2 + request = wire.AppendBytes(request, 4, awakeMaterial(bad)) + request = wire.AppendBytes(request, 4, awakeMaterial(Item{Type: 4, Count: 100})) + if _, _, handled, err := service.Handle("/CharImprintLevelUp", request); !handled || err == nil { + t.Fatalf("mismatched costs handled=%v err=%v", handled, err) + } + if _, found := collection.CharAwakeState(35); found || wallet.Snapshot().Gold != 1000 || len(inventory.All()) != 4 { + t.Fatalf("rejected request mutated state: found=%v wallet=%+v items=%+v", found, wallet.Snapshot(), inventory.All()) + } +} diff --git a/go/internal/player/collection.go b/go/internal/player/collection.go index 6cb67e3..c0e3c75 100644 --- a/go/internal/player/collection.go +++ b/go/internal/player/collection.go @@ -83,6 +83,13 @@ type GachaSelection struct { ItemID uint64 `json:"item_id"` } +// CharAwakeProgress is account-wide character state keyed by CharTable's +// UniqueCharId. Promotion changes CharTable.Id, but never this identity. +type CharAwakeProgress struct { + ImprintLevels [3]uint64 `json:"imprint_levels"` + IsAwake bool `json:"is_awake"` +} + // FirstGachaCompletedIdentity is the persisted account flag represented in // the existing collection grant ledger. It is written atomically with the // official GachaSubType=3 first-pick transaction. @@ -99,6 +106,7 @@ type collectionSnapshot struct { Costumes []Costume `json:"costumes,omitempty"` BaseCostumeLevels map[string]uint64 `json:"base_costume_levels,omitempty"` CostumePotential map[string][]uint64 `json:"costume_potential"` + CharAwake map[string]CharAwakeProgress `json:"char_awake"` GachaSelections map[string][]GachaSelection `json:"gacha_selections,omitempty"` StepUpProgress map[string]uint64 `json:"step_up_progress,omitempty"` GachaUsers map[string]GachaUserState `json:"gacha_users,omitempty"` @@ -125,6 +133,7 @@ func OpenCollectionStore(path string, base []Costume) (*CollectionStore, error) Version: "2.34.13", NextCharacterIndex: 920000001, NextCostumeIndex: 930000001, BaseCostumeLevels: map[string]uint64{}, GachaSelections: map[string][]GachaSelection{}, CostumePotential: map[string][]uint64{}, + CharAwake: map[string]CharAwakeProgress{}, StepUpProgress: map[string]uint64{}, GachaUsers: map[string]GachaUserState{}, GachaFixed: map[string]GachaFixedState{}, GachaApplied: map[string]bool{}, GachaPointExchange: map[string]GachaPointExchange{}, Grants: map[string]CollectionGrant{}, }} @@ -147,6 +156,15 @@ func OpenCollectionStore(path string, base []Costume) (*CollectionStore, error) if s.data.CostumePotential == nil { return nil, errors.New("player: collection save requires costume_potential; migrate the development save") } + if s.data.CharAwake == nil { + return nil, errors.New("player: collection save requires char_awake; migrate the development save") + } + for key, progress := range s.data.CharAwake { + uniqueID, parseErr := strconv.ParseUint(key, 10, 64) + if parseErr != nil || uniqueID == 0 || (progress.ImprintLevels == [3]uint64{} && !progress.IsAwake) { + return nil, errors.New("player: invalid char_awake ledger") + } + } if s.data.GachaSelections == nil { s.data.GachaSelections = map[string][]GachaSelection{} } @@ -174,6 +192,17 @@ func OpenCollectionStore(path string, base []Costume) (*CollectionStore, error) return s, nil } +func (s *CollectionStore) EnsurePersisted() error { + s.mu.Lock() + defer s.mu.Unlock() + if _, err := os.Stat(s.path); err == nil { + return nil + } else if !errors.Is(err, os.ErrNotExist) { + return err + } + return s.commit(cloneCollection(s.data)) +} + func emptyCollectionGrant(grant CollectionGrant) bool { return len(grant.CharacterIndices) == 0 && len(grant.CostumeIndices) == 0 && len(grant.Upgrades) == 0 && len(grant.Exchanges) == 0 && @@ -474,6 +503,26 @@ func (s *CollectionStore) GrantEquipmentPurchase(identity string, count uint64, return grant, nil } +// GrantEquipmentDraw records a standalone ticket draw without inventing a +// schedule group, points, purchase counts, or fixed-pity state. +func (s *CollectionStore) GrantEquipmentDraw(identity string) (CollectionGrant, error) { + if identity == "" { + return CollectionGrant{}, errors.New("player: invalid equipment draw identity") + } + s.mu.Lock() + defer s.mu.Unlock() + if grant, ok := s.data.Grants[identity]; ok { + return cloneGrant(grant), nil + } + next := cloneCollection(s.data) + grant := CollectionGrant{} + next.Grants[identity] = grant + if err := s.commit(next); err != nil { + return CollectionGrant{}, err + } + return grant, nil +} + func (s *CollectionStore) grantCostumes(identity string, costumeIDs []uint64, character func(uint64) (gamedata.CharacterDesign, bool), mutate func(*collectionSnapshot, *CollectionGrant) error) (CollectionGrant, error) { s.mu.Lock() defer s.mu.Unlock() @@ -804,6 +853,55 @@ func (s *CollectionStore) ActivateCostumePotential(costumeIndex uint64, nodes [] return s.commit(next) } +func (s *CollectionStore) CharAwakeState(uniqueCharID uint64) (CharAwakeProgress, bool) { + if uniqueCharID == 0 { + return CharAwakeProgress{}, false + } + s.mu.Lock() + defer s.mu.Unlock() + progress, ok := s.data.CharAwake[strconv.FormatUint(uniqueCharID, 10)] + return progress, ok +} + +func (s *CollectionStore) CharAwakeStates() map[uint64]CharAwakeProgress { + s.mu.Lock() + defer s.mu.Unlock() + result := make(map[uint64]CharAwakeProgress, len(s.data.CharAwake)) + for key, progress := range s.data.CharAwake { + uniqueID, err := strconv.ParseUint(key, 10, 64) + if err == nil && uniqueID != 0 { + result[uniqueID] = progress + } + } + return result +} + +// UpdateCharAwake atomically advances one UniqueCharId ledger entry. expected +// rejects stale concurrent requests before one response can overwrite another. +func (s *CollectionStore) UpdateCharAwake(uniqueCharID uint64, expected, nextProgress CharAwakeProgress) error { + if uniqueCharID == 0 || nextProgress == (CharAwakeProgress{}) { + return errors.New("player: invalid character awakening progress") + } + for i := range nextProgress.ImprintLevels { + if nextProgress.ImprintLevels[i] < expected.ImprintLevels[i] { + return errors.New("player: character imprint level cannot decrease") + } + } + if expected.IsAwake && !nextProgress.IsAwake { + return errors.New("player: character awakening cannot be removed") + } + s.mu.Lock() + defer s.mu.Unlock() + key := strconv.FormatUint(uniqueCharID, 10) + current := s.data.CharAwake[key] + if current != expected { + return errors.New("player: stale character awakening progress") + } + next := cloneCollection(s.data) + next.CharAwake[key] = nextProgress + return s.commit(next) +} + func (s *CollectionStore) Grant(identity string) (CollectionGrant, bool) { s.mu.Lock() defer s.mu.Unlock() @@ -883,6 +981,10 @@ func cloneCollection(in collectionSnapshot) collectionSnapshot { for k, v := range in.CostumePotential { out.CostumePotential[k] = append([]uint64(nil), v...) } + out.CharAwake = make(map[string]CharAwakeProgress, len(in.CharAwake)) + for k, v := range in.CharAwake { + out.CharAwake[k] = v + } out.GachaSelections = make(map[string][]GachaSelection, len(in.GachaSelections)) for k, v := range in.GachaSelections { out.GachaSelections[k] = append([]GachaSelection(nil), v...) diff --git a/go/internal/player/equipment.go b/go/internal/player/equipment.go index 0fc51cd..0a93e22 100644 --- a/go/internal/player/equipment.go +++ b/go/internal/player/equipment.go @@ -1,9 +1,11 @@ package player import ( + "bytes" "encoding/json" "errors" "fmt" + "math" "os" "path/filepath" "strconv" @@ -56,8 +58,16 @@ type EquipmentInventory struct { characters *CharacterStore slots map[uint64]uint64 upgrade *gamedata.EquipmentUpgradeDesign + smelting *gamedata.EquipmentSmeltingDesign wallet *Wallet inventory *Inventory + sessionID string + smeltCache map[string]smeltingReply +} + +type smeltingReply struct { + code int + body []byte } func (s *EquipmentInventory) AttachUpgrade(design *gamedata.EquipmentUpgradeDesign, wallet *Wallet, inventory *Inventory) error { @@ -70,6 +80,26 @@ func (s *EquipmentInventory) AttachUpgrade(design *gamedata.EquipmentUpgradeDesi return nil } +func (s *EquipmentInventory) AttachSmelting(design *gamedata.EquipmentSmeltingDesign, wallet *Wallet, inventory *Inventory) error { + if design == nil || wallet == nil || inventory == nil { + return errors.New("player: incomplete equipment smelting configuration") + } + s.mu.Lock() + defer s.mu.Unlock() + s.smelting, s.wallet, s.inventory = design, wallet, inventory + return nil +} + +// BeginSession scopes the in-memory request replay cache. A repeated protobuf +// sequence in one login must return the first refinement result without a +// second roll or charge. +func (s *EquipmentInventory) BeginSession(id string) { + s.mu.Lock() + defer s.mu.Unlock() + s.sessionID = id + s.smeltCache = make(map[string]smeltingReply) +} + func (s *EquipmentInventory) AttachSlots(slots map[uint64]uint64) error { if len(slots) == 0 { return errors.New("player: empty equipment slot design") @@ -97,7 +127,7 @@ func (s *EquipmentInventory) AttachCharacters(characters *CharacterStore) error } func OpenEquipmentInventory(path string) (*EquipmentInventory, error) { - s := &EquipmentInventory{path: filepath.Clean(path), owned: equipmentSnapshot{ + s := &EquipmentInventory{path: filepath.Clean(path), smeltCache: make(map[string]smeltingReply), owned: equipmentSnapshot{ Version: "2.34.13", NextIndex: 910000001, Granted: map[string]uint64{}, }} data, err := os.ReadFile(s.path) @@ -132,6 +162,17 @@ func OpenEquipmentInventory(path string) (*EquipmentInventory, error) { return s, nil } +func (s *EquipmentInventory) EnsurePersisted() error { + s.mu.Lock() + defer s.mu.Unlock() + if _, err := os.Stat(s.path); err == nil { + return nil + } else if !errors.Is(err, os.ErrNotExist) { + return err + } + return s.commitLocked(cloneEquipmentSnapshot(s.owned), "initial account generation") +} + // GrantOnce returns the same instance on a retry, allowing QuestClear response // retries without duplicating ownership. func (s *EquipmentInventory) GrantOnce(identity string, equipmentID uint64) (Equipment, error) { @@ -260,7 +301,7 @@ func equipmentOptionWire(option EquipmentOption) []byte { } func (s *EquipmentInventory) Handle(path string, request []byte) (int, []byte, bool, error) { - if path != "/EquipInfo" && path != "/EquipUse" && path != "/EquipClear" && path != "/EquipChange" && path != "/EquipUpgrade" && path != "/EquipSequenceUpgrade" && path != "/EquipMarkSet" && path != "/EquipMarkDelete" && path != "/EquipLock" { + if path != "/EquipInfo" && path != "/EquipUse" && path != "/EquipClear" && path != "/EquipChange" && path != "/EquipUpgrade" && path != "/EquipSequenceUpgrade" && path != "/EquipSmelting" && path != "/EquipSequenceSmelting" && path != "/EquipMarkSet" && path != "/EquipMarkDelete" && path != "/EquipLock" { return 0, nil, false, nil } if seq, found, err := wire.Varint(request, 1); err != nil || !found || seq == 0 { @@ -278,6 +319,12 @@ func (s *EquipmentInventory) Handle(path string, request []byte) (int, []byte, b if path == "/EquipSequenceUpgrade" { return s.upgradeSequence(request) } + if path == "/EquipSmelting" { + return s.smeltOnce(request) + } + if path == "/EquipSequenceSmelting" { + return s.smeltSequence(request) + } if path == "/EquipChange" { return s.change(request) } @@ -455,7 +502,496 @@ func (s *EquipmentInventory) upgradeSequence(request []byte) (int, []byte, bool, if usedGold != 0 { response = wire.AppendVarint(response, 7, usedGold) } - return 170, response, true, nil + return 176, response, true, nil +} + +func (s *EquipmentInventory) smeltOnce(request []byte) (int, []byte, bool, error) { + seq, _, _ := wire.Varint(request, 1) + index, found, err := wire.Varint(request, 2) + if err != nil || !found || index == 0 { + return 0, nil, true, errors.New("player: EquipSmelting missing equipment") + } + materials, err := equipmentRequestItems(request, 3, "EquipSmelting") + if err != nil { + return 0, nil, true, err + } + s.mu.Lock() + locked := true + defer func() { + if locked { + s.mu.Unlock() + } + }() + cacheKey := s.smeltingCacheKey("single", seq) + if reply, ok := s.smeltCache[cacheKey]; ok { + return reply.code, append([]byte(nil), reply.body...), true, nil + } + position, current, err := s.smeltingEquipmentLocked(index) + if err != nil { + return 0, nil, true, err + } + costs, err := s.smelting.Cost(current.ID) + if err != nil { + return 0, nil, true, err + } + gold, _, err := validateSmeltingMaterials(costs, materials) + if err != nil { + return 0, nil, true, err + } + if gold != 0 && !s.wallet.CanSpendGold(gold) { + return 0, nil, true, errors.New("player: insufficient gold for equipment smelting") + } + var itemMaterials []Item + var consumedMileageMaterial uint64 + for _, item := range materials { + if item.Type != 4 { + itemMaterials = append(itemMaterials, item) + } + if item.Type == s.smelting.Mileage.UseType && item.ID == s.smelting.Mileage.UseID { + consumedMileageMaterial += item.Count + } + } + if len(itemMaterials) != 0 { + if err := s.inventory.CanConsume(itemMaterials); err != nil { + return 0, nil, true, err + } + } + candidate, err := s.smelting.RollCandidate(current.ID) + if err != nil { + return 0, nil, true, err + } + currentScore, err := s.smelting.Score(current.ID, current.Rank) + if err != nil { + return 0, nil, true, err + } + candidateScore, err := s.smelting.Score(current.ID, candidate) + if err != nil { + return 0, nil, true, err + } + success := candidateScore > currentScore + next := cloneEquipmentSnapshot(s.owned) + if success { + next.Equipment[position].Rank = append([]uint64(nil), candidate...) + } + currency, earned, err := s.commitSmeltingLocked( + next, itemMaterials, gold, consumedMileageMaterial, + "equip-smelting:"+cacheKey, "smelting") + if err != nil { + return 0, nil, true, err + } + current = next.Equipment[position] + response := wire.AppendBytes(nil, 1, EquipmentWire(current)) + s.mu.Unlock() + locked = false + if character, ok := s.equippedCharacter(current); ok { + response = wire.AppendBytes(response, 2, CharacterWire(character)) + } + if !success { + response = wire.AppendVarint(response, 3, equipUpgradeFail) + for _, rank := range candidate { + response = wire.AppendVarint(response, 4, rank) + } + } + response = appendSmeltingMileage(response, 5, 6, currency.EquipMileageExchangeGage, s.smelting.Mileage, earned) + s.mu.Lock() + s.smeltCache[cacheKey] = smeltingReply{code: 105, body: append([]byte(nil), response...)} + s.mu.Unlock() + return 105, response, true, nil +} + +func (s *EquipmentInventory) smeltSequence(request []byte) (int, []byte, bool, error) { + seq, _, _ := wire.Varint(request, 1) + index, found, err := wire.Varint(request, 2) + if err != nil || !found || index == 0 { + return 0, nil, true, errors.New("player: EquipSequenceSmelting missing equipment") + } + count, found, err := wire.Varint(request, 3) + if err != nil || !found || count == 0 { + return 0, nil, true, errors.New("player: EquipSequenceSmelting invalid attempt count") + } + target, _, err := wire.Varint(request, 4) + if err != nil { + return 0, nil, true, errors.New("player: EquipSequenceSmelting invalid target score") + } + s.mu.Lock() + locked := true + defer func() { + if locked { + s.mu.Unlock() + } + }() + cacheKey := s.smeltingCacheKey("sequence", seq) + if reply, ok := s.smeltCache[cacheKey]; ok { + return reply.code, append([]byte(nil), reply.body...), true, nil + } + position, current, err := s.smeltingEquipmentLocked(index) + if err != nil { + return 0, nil, true, err + } + if count > s.smelting.MaxStreak { + return 0, nil, true, fmt.Errorf("player: EquipSequenceSmelting attempt count %d exceeds %d", count, s.smelting.MaxStreak) + } + maximumRanks, err := s.smelting.MaximumRanks(current.ID) + if err != nil { + return 0, nil, true, err + } + maximumScore, err := s.smelting.Score(current.ID, maximumRanks) + if err != nil { + return 0, nil, true, err + } + if target > maximumScore { + return 0, nil, true, fmt.Errorf("player: EquipSequenceSmelting target %d exceeds %d", target, maximumScore) + } + costs, err := s.smelting.Cost(current.ID) + if err != nil { + return 0, nil, true, err + } + currentScore, err := s.smelting.Score(current.ID, current.Rank) + if err != nil { + return 0, nil, true, err + } + result := uint64(equipUpgradeStopMaxTryCount) + var attempts, successes uint64 + if currentScore >= maximumScore { + result = equipUpgradeStopMaxLevel + } else if target != 0 && currentScore >= target { + result = equipUpgradeStopTargetLevel + } + for attempts < count && result == equipUpgradeStopMaxTryCount { + if _, _, selectErr := s.selectSmeltingCosts(costs, attempts+1); selectErr != nil { + result = equipUpgradeStopNotEnough + break + } + candidate, rollErr := s.smelting.RollCandidate(current.ID) + if rollErr != nil { + return 0, nil, true, rollErr + } + candidateScore, scoreErr := s.smelting.Score(current.ID, candidate) + if scoreErr != nil { + return 0, nil, true, scoreErr + } + attempts++ + if candidateScore > currentScore { + current.Rank = append([]uint64(nil), candidate...) + currentScore = candidateScore + successes++ + } + if currentScore >= maximumScore { + result = equipUpgradeStopMaxLevel + } else if target != 0 && currentScore >= target { + result = equipUpgradeStopTargetLevel + } + } + var consumed, lack []Item + var gold, mileageMaterial, earned uint64 + currency := s.wallet.Snapshot() + if attempts != 0 { + consumed, gold, err = s.selectSmeltingCosts(costs, attempts) + if err != nil { + return 0, nil, true, err + } + for _, item := range consumed { + if item.Type == s.smelting.Mileage.UseType && item.ID == s.smelting.Mileage.UseID { + mileageMaterial += item.Count + } + } + var itemMaterials []Item + for _, item := range consumed { + if item.Type != 4 { + itemMaterials = append(itemMaterials, item) + } + } + next := cloneEquipmentSnapshot(s.owned) + next.Equipment[position].Rank = append([]uint64(nil), current.Rank...) + currency, earned, err = s.commitSmeltingLocked( + next, itemMaterials, gold, mileageMaterial, + "equip-sequence-smelting:"+cacheKey, "sequence smelting") + if err != nil { + return 0, nil, true, err + } + current = next.Equipment[position] + } + // When exactly count affordable attempts were made without another stop + // condition, MaxTryCount is the normal terminal reason. NotEnough is used + // only when the next requested attempt could not be funded. + if result == equipUpgradeStopNotEnough { + lack = upgradeLackItems(costs) + } + response := wire.AppendBytes(nil, 1, EquipmentWire(current)) + s.mu.Unlock() + locked = false + if character, ok := s.equippedCharacter(current); ok { + response = wire.AppendBytes(response, 2, CharacterWire(character)) + } + response = wire.AppendVarint(response, 3, result) + response = wire.AppendVarint(response, 4, attempts) + for _, item := range consumed { + response = wire.AppendBytes(response, 5, ItemWire(item)) + } + for _, item := range lack { + response = wire.AppendBytes(response, 6, ItemWire(item)) + } + response = appendSmeltingMileage(response, 7, 8, currency.EquipMileageExchangeGage, s.smelting.Mileage, earned) + if successes != 0 { + response = wire.AppendVarint(response, 9, successes) + } + s.mu.Lock() + s.smeltCache[cacheKey] = smeltingReply{code: 177, body: append([]byte(nil), response...)} + s.mu.Unlock() + return 177, response, true, nil +} + +func (s *EquipmentInventory) smeltingCacheKey(kind string, seq uint64) string { + return kind + ":" + s.sessionID + ":seq:" + strconv.FormatUint(seq, 10) +} + +func (s *EquipmentInventory) smeltingEquipmentLocked(index uint64) (int, Equipment, error) { + if s.smelting == nil || s.wallet == nil || s.inventory == nil { + return -1, Equipment{}, errors.New("player: equipment smelting unavailable") + } + position := s.equipmentPositionLocked(index) + if position < 0 { + return -1, Equipment{}, fmt.Errorf("player: unknown equipment %d", index) + } + entry := s.owned.Equipment[position] + design, ok := s.smelting.Equipment[entry.ID] + if !ok || entry.Level != design.MaxLevel || len(entry.Rank) != 3 { + return -1, Equipment{}, fmt.Errorf("player: equipment %d is not ready for smelting", index) + } + if _, err := s.smelting.Score(entry.ID, entry.Rank); err != nil { + return -1, Equipment{}, fmt.Errorf("player: equipment %d has invalid smelting rank: %w", index, err) + } + return position, entry, nil +} + +func equipmentRequestItems(request []byte, number int, operation string) ([]Item, error) { + var result []Item + err := wire.Walk(request, func(field wire.Field) error { + if field.Number != number { + return nil + } + if field.Type != 2 { + return fmt.Errorf("player: %s invalid material", operation) + } + var item Item + if err := decodeVarints(field.Value, map[int]*uint64{1: &item.InvenIndex, 2: &item.ID, 3: &item.Type, 4: &item.Count, 5: &item.KeepFlag, 6: &item.TimeValue, 9: &item.SortID, 10: &item.UseCount}); err != nil { + return err + } + if item.Type == 0 || item.Count == 0 || (item.Type == 4 && (item.ID != 0 || item.InvenIndex != 0)) || (item.Type != 4 && (item.ID == 0 || item.InvenIndex == 0)) { + return fmt.Errorf("player: %s invalid material", operation) + } + result = append(result, item) + return nil + }) + if err != nil { + return nil, err + } + if len(result) == 0 { + return nil, fmt.Errorf("player: %s has no material", operation) + } + return result, nil +} + +func validateSmeltingMaterials(costs []gamedata.PromotionCost, materials []Item) (gold, mileageMaterial uint64, err error) { + want := make(map[[2]uint64]uint64, len(costs)) + for _, cost := range costs { + want[[2]uint64{cost.Type, cost.ID}] += cost.Count + } + got := make(map[[2]uint64]uint64, len(materials)) + for _, item := range materials { + got[[2]uint64{item.Type, item.ID}] += item.Count + if item.Type == 4 { + gold += item.Count + } else { + mileageMaterial += item.Count + } + } + if len(got) != len(want) { + return 0, 0, errors.New("player: equipment smelting material kinds mismatch") + } + for key, count := range want { + if got[key] != count { + return 0, 0, fmt.Errorf("player: equipment smelting material %d/%d=%d want=%d", key[0], key[1], got[key], count) + } + } + return gold, mileageMaterial, nil +} + +func (s *EquipmentInventory) selectSmeltingCosts(costs []gamedata.PromotionCost, attempts uint64) ([]Item, uint64, error) { + var result []Item + var gold uint64 + for _, cost := range costs { + if attempts != 0 && cost.Count > ^uint64(0)/attempts { + return nil, 0, errors.New("player: equipment smelting cost overflow") + } + count := cost.Count * attempts + switch cost.Type { + case 4: + if cost.ID != 0 || gold != 0 || !s.wallet.CanSpendGold(count) { + return nil, 0, errors.New("player: insufficient equipment smelting gold") + } + gold = count + result = append(result, Item{Type: 4, Count: count}) + case 8: + items, err := s.inventory.SelectMutable(cost.Type, cost.ID, count) + if err != nil { + return nil, 0, err + } + result = append(result, items...) + default: + return nil, 0, fmt.Errorf("player: unsupported equipment smelting cost type %d", cost.Type) + } + } + return result, gold, nil +} + +func appendSmeltingMileage(response []byte, gaugeField, rewardField int, gauge uint64, mileage gamedata.EquipmentSmeltingMileage, earned uint64) []byte { + if gauge != 0 { + response = wire.AppendVarint(response, gaugeField, gauge) + } + if earned != 0 { + reward := ItemWire(Item{ID: mileage.RewardID, Type: mileage.RewardType, Count: earned}) + bundle := wire.AppendBytes(nil, 1, reward) + response = wire.AppendBytes(response, rewardField, bundle) + } + return response +} + +// commitSmeltingLocked keeps refinement's three typed snapshots synchronized +// in memory. Caller holds equipment.mu; this method takes the remaining locks +// in wallet -> inventory order, calculates every candidate before writing, +// then publishes all three only after their files succeed. The surrounding +// request transaction supplies durable all-or-none recovery across the writes. +func (s *EquipmentInventory) commitSmeltingLocked( + nextEquipment equipmentSnapshot, + consumed []Item, + gold, mileageMaterial uint64, + identity, operation string, +) (Currency, uint64, error) { + if identity == "" || mileageMaterial == 0 || s.wallet == nil || s.inventory == nil { + return Currency{}, 0, errors.New("player: invalid transactional equipment smelting") + } + s.wallet.mu.Lock() + defer s.wallet.mu.Unlock() + s.inventory.mu.Lock() + defer s.inventory.mu.Unlock() + + nextWallet := cloneWallet(s.wallet.state) + if nextWallet.Spent[identity] { + return Currency{}, 0, errors.New("player: equipment smelting request was already committed") + } + if nextWallet.Gold < gold { + return Currency{}, 0, errors.New("player: insufficient gold for equipment smelting") + } + nextWallet.Gold -= gold + nextWallet.Spent[identity] = true + threshold, rewardCount := s.smelting.Mileage.UseCount, s.smelting.Mileage.RewardCount + if threshold == 0 || rewardCount == 0 || nextWallet.EquipMileageExchangeGage >= threshold || + math.MaxUint64-nextWallet.EquipMileageExchangeGage < mileageMaterial { + return Currency{}, 0, errors.New("player: invalid equipment smelting gauge") + } + total := nextWallet.EquipMileageExchangeGage + mileageMaterial + exchanges := total / threshold + earned := exchanges * rewardCount + if exchanges != 0 && earned/exchanges != rewardCount || math.MaxUint64-nextWallet.EquipMileage < earned { + return Currency{}, 0, errors.New("player: equipment mileage overflow") + } + nextWallet.EquipMileageExchangeGage = total % threshold + nextWallet.EquipMileage += earned + + nextItems := cloneOwnedSnapshot(s.inventory.owned) + for _, want := range consumed { + if err := consumeOwnedItem(&nextItems, want); err != nil { + return Currency{}, 0, err + } + } + + after := make(map[string][]byte, 3) + for _, entry := range []struct { + path string + current, next any + decoded any + }{ + {s.path, s.owned, nextEquipment, &equipmentSnapshot{}}, + {s.wallet.path, s.wallet.state, nextWallet, &walletSnapshot{}}, + {s.inventory.path, s.inventory.owned, nextItems, &ownedSnapshot{}}, + } { + name := filepath.Base(entry.path) + current, err := os.ReadFile(entry.path) + missing := errors.Is(err, os.ErrNotExist) + if err != nil && !missing { + return Currency{}, 0, fmt.Errorf("player: read %s transaction source: %w", name, err) + } + memoryBytes, err := json.Marshal(entry.current) + if err != nil { + return Currency{}, 0, fmt.Errorf("player: encode %s memory snapshot: %w", name, err) + } + if !missing { + if err := json.Unmarshal(current, entry.decoded); err != nil { + return Currency{}, 0, fmt.Errorf("player: decode %s transaction source: %w", name, err) + } + diskBytes, err := json.Marshal(entry.decoded) + if err != nil || !bytes.Equal(memoryBytes, diskBytes) { + return Currency{}, 0, fmt.Errorf("player: %s changed outside the account transaction", name) + } + } + encoded, err := json.Marshal(entry.next) + if err != nil { + return Currency{}, 0, fmt.Errorf("player: encode %s transaction target: %w", name, err) + } + after[name] = encoded + } + for _, name := range []string{filepath.Base(s.wallet.path), filepath.Base(s.inventory.path), filepath.Base(s.path)} { + path := filepath.Join(filepath.Dir(s.path), name) + if err := writePlayerSnapshot(path, after[name]); err != nil { + return Currency{}, 0, fmt.Errorf("player: persist equipment %s %s: %w", operation, name, err) + } + } + s.owned = nextEquipment + s.wallet.state = nextWallet + s.inventory.owned = nextItems + return nextWallet.Currency, earned, nil +} + +func writePlayerSnapshot(path string, data []byte) error { + dir := filepath.Dir(path) + file, err := os.CreateTemp(dir, ".player-state-*.tmp") + if err != nil { + return err + } + defer os.Remove(file.Name()) + if _, err = file.Write(data); err == nil { + err = file.Sync() + } + if closeErr := file.Close(); err == nil { + err = closeErr + } + if err != nil { + return err + } + return os.Rename(file.Name(), path) +} + +func consumeOwnedItem(next *ownedSnapshot, want Item) error { + if next == nil || want.InvenIndex == 0 || want.ID == 0 || want.Type == 0 || want.Count == 0 { + return errors.New("player: invalid item consumption") + } + for i, current := range next.Items { + if current.InvenIndex != want.InvenIndex { + continue + } + if current.ID != want.ID || current.Type != want.Type || current.Count < want.Count { + return fmt.Errorf("player: item %d consumption mismatch", want.InvenIndex) + } + current.Count -= want.Count + if current.Count == 0 { + next.Items = append(next.Items[:i], next.Items[i+1:]...) + } else { + next.Items[i] = current + } + return nil + } + return fmt.Errorf("player: item %d is not mutable-owned", want.InvenIndex) } func upgradeLackItems(costs []gamedata.PromotionCost) []Item { diff --git a/go/internal/player/equipment_test.go b/go/internal/player/equipment_test.go index 6f3f941..5ed47cb 100644 --- a/go/internal/player/equipment_test.go +++ b/go/internal/player/equipment_test.go @@ -57,7 +57,7 @@ func TestEquipmentUpgradeAndSequenceUseGameDataCosts(t *testing.T) { sequence = wire.AppendVarint(sequence, 3, 9) sequence = wire.AppendVarint(sequence, 6, 3) code, response, handled, err = store.Handle("/EquipSequenceUpgrade", sequence) - if err != nil || !handled || code != 170 { + if err != nil || !handled || code != 176 { t.Fatalf("sequence upgrade code=%d handled=%v err=%v", code, handled, err) } if result, _, _ := wire.Varint(response, 3); result != equipUpgradeStopMaxLevel { @@ -84,6 +84,177 @@ func TestEquipmentUpgradeAndSequenceUseGameDataCosts(t *testing.T) { } } +func TestEquipmentSmeltingImprovesByTotalScoreAndReplaysWithoutSecondCharge(t *testing.T) { + dir := t.TempDir() + wallet, err := OpenWallet(filepath.Join(dir, "wallet.json"), Currency{Gold: 1000, EquipMileageExchangeGage: 990}) + if err != nil { + t.Fatal(err) + } + inventory, err := OpenInventory(filepath.Join(dir, "items.json"), &Starter{Version: "2.34.13"}) + if err != nil { + t.Fatal(err) + } + materials, err := inventory.GrantOnce("refine-material", []gamedata.BattleReward{{Type: 8, ID: 10, Count: 100}}) + if err != nil { + t.Fatal(err) + } + store, err := OpenEquipmentInventory(filepath.Join(dir, "equipment.json")) + if err != nil { + t.Fatal(err) + } + design := smeltingTestDesign([][]float64{{1, 0, 0, 0}, {0, 0, 0, 1}, {0, 0, 0, 1}}) + if err := store.AttachSmelting(design, wallet, inventory); err != nil { + t.Fatal(err) + } + store.BeginSession("smelting-test") + entry, err := store.GrantOnce("refinable", 943035) + if err != nil { + t.Fatal(err) + } + store.owned.Equipment[0].Level = 9 + store.owned.Equipment[0].Rank = []uint64{4, 1, 1} // 6 -> candidate 1+4+4=9. + if err := store.commitLocked(cloneEquipmentSnapshot(store.owned), "test setup"); err != nil { + t.Fatal(err) + } + request := wire.AppendVarint(wire.AppendVarint(nil, 1, 77), 2, entry.InvenIndex) + request = wire.AppendBytes(request, 3, ItemWire(Item{Type: 4, Count: 80})) + material := materials[0] + material.Count = 30 + request = wire.AppendBytes(request, 3, ItemWire(material)) + code, response, handled, err := store.Handle("/EquipSmelting", request) + if err != nil || !handled || code != 105 { + t.Fatalf("smelting code=%d handled=%v err=%v", code, handled, err) + } + if result, found, _ := wire.Varint(response, 3); found || result != 0 { + t.Fatalf("successful smelting result=%d found=%v", result, found) + } + if got := store.All()[0].Rank; len(got) != 3 || got[0] != 1 || got[1] != 4 || got[2] != 4 { + t.Fatalf("smelting rank=%v", got) + } + if gauge, _, _ := wire.Varint(response, 5); gauge != 20 { + t.Fatalf("smelting gauge=%d", gauge) + } + bundle, found, err := wire.Bytes(response, 6) + if err != nil || !found { + t.Fatalf("smelting mileage bundle: %v", err) + } + reward, found, err := wire.Bytes(bundle, 1) + if err != nil || !found { + t.Fatalf("smelting mileage item: %v", err) + } + if typ, _, _ := wire.Varint(reward, 3); typ != 68 { + t.Fatalf("smelting mileage type=%d", typ) + } + if count, _, _ := wire.Varint(reward, 4); count != 1 { + t.Fatalf("smelting mileage count=%d", count) + } + if currency := wallet.Snapshot(); currency.Gold != 920 || currency.EquipMileage != 1 || currency.EquipMileageExchangeGage != 20 { + t.Fatalf("smelting wallet=%+v", currency) + } + if _, replay, _, err := store.Handle("/EquipSmelting", request); err != nil || string(replay) != string(response) { + t.Fatalf("smelting replay differs err=%v", err) + } + if currency := wallet.Snapshot(); currency.Gold != 920 || currency.EquipMileage != 1 || currency.EquipMileageExchangeGage != 20 { + t.Fatalf("smelting replay charged again: %+v", currency) + } +} + +func TestEquipmentSmeltingFailureConsumesAndReturnsCandidateGrades(t *testing.T) { + dir := t.TempDir() + wallet, _ := OpenWallet(filepath.Join(dir, "wallet.json"), Currency{Gold: 1000}) + inventory, _ := OpenInventory(filepath.Join(dir, "items.json"), &Starter{Version: "2.34.13"}) + materials, _ := inventory.GrantOnce("refine-material", []gamedata.BattleReward{{Type: 8, ID: 10, Count: 30}}) + store, _ := OpenEquipmentInventory(filepath.Join(dir, "equipment.json")) + if err := store.AttachSmelting(smeltingTestDesign([][]float64{{1, 0, 0, 0}, {1, 0, 0, 0}, {1, 0, 0, 0}}), wallet, inventory); err != nil { + t.Fatal(err) + } + entry, _ := store.GrantOnce("refinable", 943035) + store.owned.Equipment[0].Level = 9 + store.owned.Equipment[0].Rank = []uint64{4, 4, 4} + if err := store.commitLocked(cloneEquipmentSnapshot(store.owned), "test setup"); err != nil { + t.Fatal(err) + } + request := wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, entry.InvenIndex) + request = wire.AppendBytes(request, 3, ItemWire(Item{Type: 4, Count: 80})) + request = wire.AppendBytes(request, 3, ItemWire(materials[0])) + _, response, _, err := store.Handle("/EquipSmelting", request) + if err != nil { + t.Fatal(err) + } + if result, _, _ := wire.Varint(response, 3); result != equipUpgradeFail { + t.Fatalf("smelting failure result=%d", result) + } + var grades []uint64 + if err := wire.Walk(response, func(field wire.Field) error { + if field.Number == 4 { + value, _ := binary.Uvarint(field.Value) + grades = append(grades, value) + } + return nil + }); err != nil { + t.Fatal(err) + } + if len(grades) != 3 || grades[0] != 1 || grades[1] != 1 || grades[2] != 1 { + t.Fatalf("failure candidate grades=%v", grades) + } + if got := store.All()[0].Rank; got[0] != 4 || got[1] != 4 || got[2] != 4 { + t.Fatalf("failure overwrote ranks=%v", got) + } + if currency := wallet.Snapshot(); currency.Gold != 920 || currency.EquipMileageExchangeGage != 30 { + t.Fatalf("failure did not consume: %+v", currency) + } +} + +func TestEquipmentSequenceSmeltingRepeatsAndStopsAtTargetScore(t *testing.T) { + dir := t.TempDir() + wallet, _ := OpenWallet(filepath.Join(dir, "wallet.json"), Currency{Gold: 1000}) + inventory, _ := OpenInventory(filepath.Join(dir, "items.json"), &Starter{Version: "2.34.13"}) + _, _ = inventory.GrantOnce("refine-material", []gamedata.BattleReward{{Type: 8, ID: 10, Count: 300}}) + store, _ := OpenEquipmentInventory(filepath.Join(dir, "equipment.json")) + if err := store.AttachSmelting(smeltingTestDesign([][]float64{{1, 0, 0, 0}, {0, 0, 0, 1}, {0, 0, 0, 1}}), wallet, inventory); err != nil { + t.Fatal(err) + } + entry, _ := store.GrantOnce("refinable", 943035) + store.owned.Equipment[0].Level = 9 + store.owned.Equipment[0].Rank = []uint64{4, 1, 1} + if err := store.commitLocked(cloneEquipmentSnapshot(store.owned), "test setup"); err != nil { + t.Fatal(err) + } + request := wire.AppendVarint(wire.AppendVarint(nil, 1, 2), 2, entry.InvenIndex) + request = wire.AppendVarint(request, 3, 100) + request = wire.AppendVarint(request, 4, 9) + code, response, handled, err := store.Handle("/EquipSequenceSmelting", request) + if err != nil || !handled || code != 177 { + t.Fatalf("sequence smelting code=%d handled=%v err=%v", code, handled, err) + } + if result, _, _ := wire.Varint(response, 3); result != equipUpgradeStopTargetLevel { + t.Fatalf("sequence smelting result=%d", result) + } + if attempts, _, _ := wire.Varint(response, 4); attempts != 1 { + t.Fatalf("sequence smelting attempts=%d", attempts) + } + if successes, _, _ := wire.Varint(response, 9); successes != 1 { + t.Fatalf("sequence smelting successes=%d", successes) + } + if currency := wallet.Snapshot(); currency.Gold != 920 || currency.EquipMileageExchangeGage != 30 { + t.Fatalf("sequence smelting wallet=%+v", currency) + } +} + +func smeltingTestDesign(ratios [][]float64) *gamedata.EquipmentSmeltingDesign { + design := &gamedata.EquipmentSmeltingDesign{ + Equipment: map[uint64]gamedata.EquipmentSmeltingItem{943035: {Grade: 4, RankGroup: 904, MaxLevel: 9}}, + Ranks: map[[2]uint64]gamedata.EquipmentSmeltingRank{}, + Grades: map[uint64][]gamedata.PromotionCost{4: {{Type: 4, Count: 80}, {Type: 8, ID: 10, Count: 30}}}, + Mileage: gamedata.EquipmentSmeltingMileage{UseType: 8, UseID: 10, UseCount: 1000, RewardType: 68, RewardCount: 1}, + MaxStreak: 5000, + } + for i := 0; i < 3; i++ { + design.Ranks[[2]uint64{904, uint64(i + 1)}] = gamedata.EquipmentSmeltingRank{Values: []uint64{1, 2, 3, 4}, GrowthPoint: []uint64{1, 2, 3, 4}, Ratio: ratios[i]} + } + return design +} + func TestEquipmentUpgradeFailureConsumesGoldWithoutLevel(t *testing.T) { dir := t.TempDir() wallet, err := OpenWallet(filepath.Join(dir, "wallet.json"), Currency{Gold: 200}) diff --git a/go/internal/player/growth.go b/go/internal/player/growth.go index 5ca8df5..6a41aa9 100644 --- a/go/internal/player/growth.go +++ b/go/internal/player/growth.go @@ -116,6 +116,17 @@ func OpenCharacterStore(path string, seed []Character, inventory *Inventory, gam return s, nil } +func (s *CharacterStore) EnsurePersisted() error { + s.mu.Lock() + defer s.mu.Unlock() + if _, err := os.Stat(s.path); err == nil { + return nil + } else if !errors.Is(err, os.ErrNotExist) { + return err + } + return s.persist(append([]Character(nil), s.characters...)) +} + func validateCharacters(characters []Character) error { seen := make(map[uint64]bool, len(characters)) for _, character := range characters { diff --git a/go/internal/player/owned.go b/go/internal/player/owned.go index b5f67a7..bbfdb70 100644 --- a/go/internal/player/owned.go +++ b/go/internal/player/owned.go @@ -55,6 +55,17 @@ func OpenInventory(path string, starter *Starter) (*Inventory, error) { return s, nil } +func (s *Inventory) EnsurePersisted() error { + s.mu.Lock() + defer s.mu.Unlock() + if _, err := os.Stat(s.path); err == nil { + return nil + } else if !errors.Is(err, os.ErrNotExist) { + return err + } + return s.commitOwned(cloneOwnedSnapshot(s.owned)) +} + func (s *Inventory) Handle(path string, request []byte) (int, []byte, bool, error) { if path != "/ItemInfo" && path != "/UseRandomBox" { return 0, nil, false, nil diff --git a/go/internal/player/wallet.go b/go/internal/player/wallet.go index c0c353b..cd2c5e3 100644 --- a/go/internal/player/wallet.go +++ b/go/internal/player/wallet.go @@ -16,11 +16,13 @@ import ( // type 4 is gold. Paid jewelry is persisted as well, although quest rewards // in the audited tutorial range do not grant it. type Currency struct { - Gold uint64 `json:"gold"` - FreeJewelry uint64 `json:"free_jewelry"` - Jewelry uint64 `json:"jewelry"` - Mileage uint64 `json:"mileage,omitempty"` - HopePowder uint64 `json:"hope_powder,omitempty"` + Gold uint64 `json:"gold"` + FreeJewelry uint64 `json:"free_jewelry"` + Jewelry uint64 `json:"jewelry"` + Mileage uint64 `json:"mileage,omitempty"` + HopePowder uint64 `json:"hope_powder,omitempty"` + EquipMileage uint64 `json:"equip_mileage"` + EquipMileageExchangeGage uint64 `json:"equip_mileage_exchange_gage"` } type walletSnapshot struct { @@ -50,6 +52,16 @@ func OpenWallet(path string, initial Currency) (*Wallet, error) { if err != nil { return nil, fmt.Errorf("player: read wallet: %w", err) } + var shape map[string]json.RawMessage + if err := json.Unmarshal(b, &shape); err != nil { + return nil, errors.New("player: malformed wallet state") + } + if _, ok := shape["equip_mileage"]; !ok { + return nil, errors.New("player: wallet save requires equip_mileage; migrate the development save") + } + if _, ok := shape["equip_mileage_exchange_gage"]; !ok { + return nil, errors.New("player: wallet save requires equip_mileage_exchange_gage; migrate the development save") + } if err := json.Unmarshal(b, &s.state); err != nil || s.state.Version != "2.34.13" || s.state.Granted == nil { return nil, errors.New("player: malformed wallet state") } @@ -59,6 +71,17 @@ func OpenWallet(path string, initial Currency) (*Wallet, error) { return s, nil } +func (s *Wallet) EnsurePersisted() error { + s.mu.Lock() + defer s.mu.Unlock() + if _, err := os.Stat(s.path); err == nil { + return nil + } else if !errors.Is(err, os.ErrNotExist) { + return err + } + return s.commit(cloneWallet(s.state)) +} + func (s *Wallet) Snapshot() Currency { s.mu.Lock() defer s.mu.Unlock() @@ -157,6 +180,37 @@ func (s *Wallet) Currencies() (gold, freeJewelry, jewelry, mileage uint64) { func (s *Wallet) HopePowderBalance() uint64 { return s.Snapshot().HopePowder } +func (s *Wallet) EquipmentMileageBalances() (mileage, exchangeGage uint64) { + c := s.Snapshot() + return c.EquipMileage, c.EquipMileageExchangeGage +} + +// RecordEquipmentSmelting converts the actually consumed refinement material +// into the official residual gauge and type-68 mileage currency. +func (s *Wallet) RecordEquipmentSmelting(materialCount, threshold, rewardCount uint64) (Currency, uint64, error) { + if materialCount == 0 || threshold == 0 || rewardCount == 0 { + return Currency{}, 0, errors.New("player: invalid equipment smelting mileage") + } + s.mu.Lock() + defer s.mu.Unlock() + if s.state.EquipMileageExchangeGage >= threshold || math.MaxUint64-s.state.EquipMileageExchangeGage < materialCount { + return Currency{}, 0, errors.New("player: invalid equipment smelting gauge") + } + next := cloneWallet(s.state) + total := next.EquipMileageExchangeGage + materialCount + exchanges := total / threshold + earned := exchanges * rewardCount + if exchanges != 0 && earned/exchanges != rewardCount || math.MaxUint64-next.EquipMileage < earned { + return Currency{}, 0, errors.New("player: equipment mileage overflow") + } + next.EquipMileageExchangeGage = total % threshold + next.EquipMileage += earned + if err := s.commit(next); err != nil { + return Currency{}, 0, err + } + return next.Currency, earned, nil +} + // GrantMileageOnce persists the type-20 currency produced when a duplicate // costume is drawn after +5. The gacha grant identity makes recovery after a // collection/wallet split commit safe and idempotent. diff --git a/go/internal/progress/progress.go b/go/internal/progress/progress.go index 89423c3..afbac95 100644 --- a/go/internal/progress/progress.go +++ b/go/internal/progress/progress.go @@ -159,6 +159,17 @@ func OpenStore(path string) (*Store, error) { return s, nil } +func (s *Store) EnsurePersisted() error { + s.mu.Lock() + defer s.mu.Unlock() + if _, err := os.Stat(s.path); err == nil { + return nil + } else if !errors.Is(err, os.ErrNotExist) { + return err + } + return s.commit(s.position, s.tutorials, s.quests, s.cleared) +} + // commit writes a complete snapshot before exposing the new state. Caller // holds mu; failure leaves the in-memory player state unchanged. func (s *Store) commit(position SavedPosition, tutorials map[int]struct{}, quests map[string]QuestProgress, cleared map[string]struct{}) error { diff --git a/go/internal/session/server.go b/go/internal/session/server.go index 60ec65f..22b365a 100644 --- a/go/internal/session/server.go +++ b/go/internal/session/server.go @@ -15,6 +15,7 @@ import ( "bd2server/internal/cryptox" "bd2server/internal/progress" "bd2server/internal/protocol" + "bd2server/internal/statetx" "bd2server/internal/transport" "bd2server/internal/wire" ) @@ -35,6 +36,11 @@ type SessionAware interface { BeginSession(id string) } +type StateCoordinator interface { + Check() error + BeginOperation() (statetx.RequestOperation, error) +} + type Server struct { mu sync.Mutex key []byte @@ -43,6 +49,20 @@ type Server struct { login LoginService handlers []Handler progress *progress.Store + stateTx StateCoordinator +} + +// AttachStateCoordinator wraps each authenticated request (the complete batch +// for BatchRequest) in the account write-ahead transaction. An uncertain +// operation fail-stops the dispatcher, including subsequent login attempts. +func (s *Server) AttachStateCoordinator(coordinator StateCoordinator) error { + if coordinator == nil { + return errors.New("session state coordinator is nil") + } + s.mu.Lock() + defer s.mu.Unlock() + s.stateTx = coordinator + return nil } func NewServer(login LoginService, handlers ...Handler) (*Server, error) { @@ -75,6 +95,11 @@ func NewServerWithProgress(login LoginService, player *progress.Store, handlers func (s *Server) DispatchRaw(path string, body []byte, cookie string) (transport.RawReply, error) { s.mu.Lock() defer s.mu.Unlock() + if s.stateTx != nil { + if err := s.stateTx.Check(); err != nil { + return transport.RawReply{}, fmt.Errorf("account state unavailable: %w", err) + } + } if path == "/LoginUser" { request, err := cryptox.DecryptBase64Payload(string(body), cryptox.Key()) if err != nil { @@ -102,18 +127,60 @@ func (s *Server) DispatchRaw(path string, body []byte, cookie string) (transport return transport.RawReply{}, err } if path == "/BatchRequest" { - return s.handleBatch(body) + return s.withStateTransaction(func() (transport.RawReply, error) { + return s.handleBatch(body) + }) } request, err := cryptox.DecryptBase64Payload(string(body), s.key) if err != nil { return transport.RawReply{}, fmt.Errorf("%s decrypt: %w", path, err) } - code, response, err := s.dispatch(path, request) + return s.withStateTransaction(func() (transport.RawReply, error) { + code, response, err := s.dispatch(path, request) + if err != nil { + return transport.RawReply{}, err + } + encoded, err := protocol.Encode(code, response, s.key, time.Now().UnixMilli()) + return transport.RawReply{Body: encoded}, err + }) +} + +func (s *Server) withStateTransaction(run func() (transport.RawReply, error)) (reply transport.RawReply, err error) { + if s.stateTx == nil { + return run() + } + operation, err := s.stateTx.BeginOperation() if err != nil { + return transport.RawReply{}, fmt.Errorf("begin account transaction: %w", err) + } + finished := false + defer func() { + if finished { + return + } + rollbackErr := operation.Rollback() + if recovered := recover(); recovered != nil { + panic(recovered) + } + if rollbackErr != nil { + err = errors.Join(err, rollbackErr) + } + }() + reply, err = run() + if err != nil { + rollbackErr := operation.Rollback() + finished = true + if rollbackErr != nil { + return transport.RawReply{}, errors.Join(err, rollbackErr) + } return transport.RawReply{}, err } - encoded, err := protocol.Encode(code, response, s.key, time.Now().UnixMilli()) - return transport.RawReply{Body: encoded}, err + if err := operation.Commit(); err != nil { + finished = true + return transport.RawReply{}, fmt.Errorf("commit account transaction: %w", err) + } + finished = true + return reply, nil } func (s *Server) handleBatch(body []byte) (transport.RawReply, error) { diff --git a/go/internal/session/server_test.go b/go/internal/session/server_test.go index 7fe405d..dcb5182 100644 --- a/go/internal/session/server_test.go +++ b/go/internal/session/server_test.go @@ -4,10 +4,13 @@ import ( "encoding/base64" "encoding/json" "errors" + "os" + "path/filepath" "testing" "bd2server/internal/cryptox" "bd2server/internal/protocol" + "bd2server/internal/statetx" "bd2server/internal/transport" "bd2server/internal/wire" ) @@ -24,6 +27,100 @@ func (fakeLogin) Login(request, key []byte) ([]byte, error) { type fakeDomain struct{} +type fakeStateGate struct{ err error } + +func (g *fakeStateGate) Check() error { return g.err } +func (g *fakeStateGate) BeginOperation() (statetx.RequestOperation, error) { + if g.err != nil { + return nil, g.err + } + return fakeOperation{}, nil +} + +type fakeOperation struct{} + +func (fakeOperation) Commit() error { return nil } +func (fakeOperation) Rollback() error { return nil } + +type mutatingDomain struct { + root string + fail bool +} + +func (d *mutatingDomain) Handle(path string, request []byte) (int, []byte, bool, error) { + if path == "/InjectedFailure" { + return 0, nil, true, errors.New("injected domain failure") + } + if path != "/MutateTwoFiles" { + return 0, nil, false, nil + } + if err := atomicSessionTestReplace(filepath.Join(d.root, "wallet.json"), []byte("new-wallet")); err != nil { + return 0, nil, true, err + } + if err := atomicSessionTestReplace(filepath.Join(d.root, "items.json"), []byte("new-items")); err != nil { + return 0, nil, true, err + } + if d.fail { + return 0, nil, true, errors.New("injected domain failure") + } + return 77, nil, true, nil +} + +func atomicSessionTestReplace(path string, data []byte) error { + temp, err := os.CreateTemp(filepath.Dir(path), ".session-test-*.tmp") + if err != nil { + return err + } + name := temp.Name() + defer os.Remove(name) + if _, err = temp.Write(data); err == nil { + err = temp.Sync() + } + if closeErr := temp.Close(); err == nil { + err = closeErr + } + if err != nil { + return err + } + if err := os.Remove(path); err != nil { + return err + } + return os.Rename(name, path) +} + +func TestBatchUsesOneAccountTransaction(t *testing.T) { + root := t.TempDir() + for name, content := range map[string]string{"wallet.json": "old-wallet", "items.json": "old-items"} { + if err := os.WriteFile(filepath.Join(root, name), []byte(content), 0o600); err != nil { + t.Fatal(err) + } + } + coordinator, err := statetx.Open(root, []string{"wallet.json", "items.json"}) + if err != nil { + t.Fatal(err) + } + server, _ := NewServer(fakeLogin{}, &mutatingDomain{root: root}) + if err := server.AttachStateCoordinator(coordinator); err != nil { + t.Fatal(err) + } + reply := login(t, server) + requests := []protocol.BatchRequest{ + {Path: "/MutateTwoFiles", RequestData: base64.StdEncoding.EncodeToString(wire.AppendVarint(nil, 1, 2))}, + {Path: "/InjectedFailure", RequestData: base64.StdEncoding.EncodeToString(wire.AppendVarint(nil, 1, 3))}, + } + plain, _ := json.Marshal(requests) + body, _ := cryptox.EncryptBase64(plain, server.KeyForTest()) + if _, err := server.DispatchRaw("/BatchRequest", []byte(body), "s="+reply.Cookie); err == nil { + t.Fatal("partially failing batch was accepted") + } + for name, want := range map[string]string{"wallet.json": "old-wallet", "items.json": "old-items"} { + got, err := os.ReadFile(filepath.Join(root, name)) + if err != nil || string(got) != want { + t.Fatalf("batch rollback %s=%q err=%v", name, got, err) + } + } +} + func (fakeDomain) Handle(path string, request []byte) (int, []byte, bool, error) { if path == "/EmptyInfo" { return 77, nil, true, nil @@ -85,6 +182,70 @@ func TestSessionRejectsMissingCookieAndUnknownPath(t *testing.T) { } } +func TestStateGateStopsEveryRequestAfterPersistenceFailure(t *testing.T) { + server, _ := NewServer(fakeLogin{}, fakeDomain{}) + gate := &fakeStateGate{} + if err := server.AttachStateCoordinator(gate); err != nil { + t.Fatal(err) + } + reply := login(t, server) + gate.err = errors.New("uncertain transaction") + request := wire.AppendVarint(nil, 1, 2) + body, _ := cryptox.EncryptBase64Payload(request, server.KeyForTest()) + if _, err := server.DispatchRaw("/EmptyInfo", []byte(body), "s="+reply.Cookie); err == nil { + t.Fatal("request passed a failed account state gate") + } + if _, err := server.DispatchRaw("/LoginUser", []byte(body), ""); err == nil { + t.Fatal("login passed a failed account state gate") + } +} + +func TestAuthenticatedRequestTransactionCommitsOrRollsBackAllFiles(t *testing.T) { + for _, test := range []struct { + name string + fail bool + want string + }{ + {"commit", false, "new-"}, + {"rollback", true, "old-"}, + } { + t.Run(test.name, func(t *testing.T) { + root := t.TempDir() + for name, content := range map[string]string{"wallet.json": "old-wallet", "items.json": "old-items"} { + if err := os.WriteFile(filepath.Join(root, name), []byte(content), 0o600); err != nil { + t.Fatal(err) + } + } + coordinator, err := statetx.Open(root, []string{"wallet.json", "items.json"}) + if err != nil { + t.Fatal(err) + } + server, _ := NewServer(fakeLogin{}, &mutatingDomain{root: root, fail: test.fail}) + if err := server.AttachStateCoordinator(coordinator); err != nil { + t.Fatal(err) + } + reply := login(t, server) + request := wire.AppendVarint(nil, 1, 2) + body, _ := cryptox.EncryptBase64Payload(request, server.KeyForTest()) + _, requestErr := server.DispatchRaw("/MutateTwoFiles", []byte(body), "s="+reply.Cookie) + if test.fail && requestErr == nil || !test.fail && requestErr != nil { + t.Fatalf("request err=%v", requestErr) + } + for _, name := range []string{"wallet", "items"} { + got, err := os.ReadFile(filepath.Join(root, name+".json")) + if err != nil || string(got) != test.want+name { + t.Fatalf("%s=%q err=%v", name, got, err) + } + } + if test.fail { + if _, err := server.DispatchRaw("/MutateTwoFiles", []byte(body), "s="+reply.Cookie); err == nil { + t.Fatal("server continued after rolling disk back behind published domain memory") + } + } + }) + } +} + func TestNativeProgress(t *testing.T) { server, _ := NewServer(fakeLogin{}) reply := login(t, server) diff --git a/go/internal/statebridge/install.go b/go/internal/statebridge/install.go index 00298e7..31e694b 100644 --- a/go/internal/statebridge/install.go +++ b/go/internal/statebridge/install.go @@ -325,7 +325,7 @@ func equipmentDiskFromProto(in *statev1.EquipmentInventory) equipmentDisk { } func equipmentFromProto(in *statev1.Equipment) equipment { - out := equipment{InvenIndex: in.InventoryIndex, ID: in.Id, Level: in.Level, UseChar: in.UseChar, KeepFlag: in.KeepFlag, LockFlag: in.LockFlag, SortID: in.SortId, Rank: append([]uint64(nil), in.Ranks...)} + out := equipment{InvenIndex: in.InventoryIndex, ID: in.Id, Level: in.Level, UseChar: in.UseChar, KeepFlag: in.KeepFlag, LockFlag: in.LockFlag, SortID: in.SortId, Rank: append([]uint64(nil), in.Ranks...), UpgradeAttempts: in.UpgradeAttempts} for _, option := range in.MainOptions { out.MainOption = append(out.MainOption, equipmentOption{GroupID: option.GroupId, ID: option.Id}) } @@ -424,7 +424,7 @@ func collectionGrantFromProto(in *statev1.CollectionGrant) collectionGrant { } func walletDiskFromProto(in *statev1.Wallet) walletDisk { - out := walletDisk{Version: in.ClientVersion, Gold: in.Gold, FreeJewelry: in.FreeJewelry, Jewelry: in.Jewelry, Mileage: in.Mileage, HopePowder: in.HopePowder, Granted: map[string]bool{}, Spent: map[string]bool{}} + out := walletDisk{Version: in.ClientVersion, Gold: in.Gold, FreeJewelry: in.FreeJewelry, Jewelry: in.Jewelry, Mileage: in.Mileage, HopePowder: in.HopePowder, EquipMileage: in.EquipMileage, EquipMileageExchangeGage: in.EquipMileageExchangeGage, Granted: map[string]bool{}, Spent: map[string]bool{}} for _, value := range in.GrantedIdentities { out.Granted[value] = true } diff --git a/go/internal/statebridge/integration_test.go b/go/internal/statebridge/integration_test.go index c314008..99695f4 100644 --- a/go/internal/statebridge/integration_test.go +++ b/go/internal/statebridge/integration_test.go @@ -190,6 +190,29 @@ func TestHaskellMigrationPreservesSourceAndRejectsDuplicateCharacter(t *testing. } } +func TestRuntimeEquipmentAndMileageFieldsRoundTripThroughCurrentSchema(t *testing.T) { + equipment := equipmentDisk{ + Version: "2.34.13", NextIndex: 910000002, + Equipment: []equipment{{InvenIndex: 910000001, ID: 943035, Rank: []uint64{1, 2, 3}, UpgradeAttempts: 27}}, + Granted: map[string]uint64{"test": 910000001}, + } + wallet := walletDisk{ + Version: "2.34.13", EquipMileage: 19, EquipMileageExchangeGage: 845, + Granted: map[string]bool{}, Spent: map[string]bool{}, + } + equipmentProto := equipmentProto(equipment) + walletProto := walletProto(wallet) + if len(equipmentProto.Equipment) != 1 || equipmentProto.Equipment[0].UpgradeAttempts != 27 || walletProto.EquipMileage != 19 || walletProto.EquipMileageExchangeGage != 845 { + t.Fatalf("runtime fields missing from protobuf: equipment=%+v wallet=%+v", equipmentProto, walletProto) + } + if got := equipmentDiskFromProto(equipmentProto); len(got.Equipment) != 1 || got.Equipment[0].UpgradeAttempts != 27 { + t.Fatalf("equipment runtime field lost: %+v", got) + } + if got := walletDiskFromProto(walletProto); got.EquipMileage != 19 || got.EquipMileageExchangeGage != 845 { + t.Fatalf("wallet runtime fields lost: %+v", got) + } +} + func hasViolation(violations []*controlv1.Violation, code string) bool { for _, violation := range violations { if violation.Code == code && violation.Severity == controlv1.Severity_SEVERITY_ERROR { diff --git a/go/internal/statebridge/snapshot.go b/go/internal/statebridge/snapshot.go index 72de6ea..b25d98f 100644 --- a/go/internal/statebridge/snapshot.go +++ b/go/internal/statebridge/snapshot.go @@ -30,6 +30,13 @@ var stateFiles = []string{ "items.json", "mail.json", "missions.json", "progress.json", "wallet.json", } +// StateFiles returns the complete account-state filename set. The runtime +// transaction coordinator uses the same allow-list as validation/repair, so +// a newly added domain file cannot silently sit outside crash recovery. +func StateFiles() []string { + return append([]string(nil), stateFiles...) +} + func CompleteStateAvailable(dir string) (bool, error) { dir = filepath.Clean(dir) found := 0 @@ -154,17 +161,18 @@ type equipmentOption struct { ID uint64 `json:"id"` } type equipment struct { - InvenIndex uint64 `json:"inven_index"` - ID uint64 `json:"id"` - Level uint64 `json:"level"` - UseChar uint64 `json:"use_char"` - KeepFlag uint64 `json:"keep_flag"` - LockFlag uint64 `json:"lock_flag"` - SortID uint64 `json:"sort_id"` - MainOption []equipmentOption `json:"main_option"` - SubOption []equipmentOption `json:"sub_option"` - PrivateOption *equipmentOption `json:"private_option"` - Rank []uint64 `json:"rank"` + InvenIndex uint64 `json:"inven_index"` + ID uint64 `json:"id"` + Level uint64 `json:"level"` + UseChar uint64 `json:"use_char"` + KeepFlag uint64 `json:"keep_flag"` + LockFlag uint64 `json:"lock_flag"` + SortID uint64 `json:"sort_id"` + MainOption []equipmentOption `json:"main_option"` + SubOption []equipmentOption `json:"sub_option"` + PrivateOption *equipmentOption `json:"private_option"` + Rank []uint64 `json:"rank"` + UpgradeAttempts uint64 `json:"upgrade_attempts"` } type equipmentDisk struct { Version string `json:"version"` @@ -252,14 +260,16 @@ type collectionDisk struct { } type walletDisk struct { - Version string `json:"version"` - Gold uint64 `json:"gold"` - FreeJewelry uint64 `json:"free_jewelry"` - Jewelry uint64 `json:"jewelry"` - Mileage uint64 `json:"mileage"` - HopePowder uint64 `json:"hope_powder"` - Granted map[string]bool `json:"granted"` - Spent map[string]bool `json:"spent"` + Version string `json:"version"` + Gold uint64 `json:"gold"` + FreeJewelry uint64 `json:"free_jewelry"` + Jewelry uint64 `json:"jewelry"` + Mileage uint64 `json:"mileage"` + HopePowder uint64 `json:"hope_powder"` + EquipMileage uint64 `json:"equip_mileage"` + EquipMileageExchangeGage uint64 `json:"equip_mileage_exchange_gage"` + Granted map[string]bool `json:"granted"` + Spent map[string]bool `json:"spent"` } type mailDisk struct { Version string `json:"version"` @@ -441,7 +451,7 @@ func equipmentProto(in equipmentDisk) *statev1.EquipmentInventory { return out } func equipmentItemProto(v equipment) *statev1.Equipment { - out := &statev1.Equipment{InventoryIndex: v.InvenIndex, Id: v.ID, Level: v.Level, UseChar: v.UseChar, KeepFlag: v.KeepFlag, LockFlag: v.LockFlag, SortId: v.SortID, Ranks: append([]uint64(nil), v.Rank...)} + out := &statev1.Equipment{InventoryIndex: v.InvenIndex, Id: v.ID, Level: v.Level, UseChar: v.UseChar, KeepFlag: v.KeepFlag, LockFlag: v.LockFlag, SortId: v.SortID, Ranks: append([]uint64(nil), v.Rank...), UpgradeAttempts: v.UpgradeAttempts} for _, o := range v.MainOption { out.MainOptions = append(out.MainOptions, &statev1.EquipmentOption{GroupId: o.GroupID, Id: o.ID}) } @@ -517,7 +527,7 @@ func fixedProto(v gachaFixed) *statev1.GachaFixed { return &statev1.GachaFixed{FixedId: v.FixedID, Type: v.Type, Count: v.Count, ApplySortId: v.ApplySort} } func walletProto(in walletDisk) *statev1.Wallet { - return &statev1.Wallet{ClientVersion: in.Version, Gold: in.Gold, FreeJewelry: in.FreeJewelry, Jewelry: in.Jewelry, Mileage: in.Mileage, HopePowder: in.HopePowder, GrantedIdentities: sortedTrueKeys(in.Granted), SpentIdentities: sortedTrueKeys(in.Spent)} + return &statev1.Wallet{ClientVersion: in.Version, Gold: in.Gold, FreeJewelry: in.FreeJewelry, Jewelry: in.Jewelry, Mileage: in.Mileage, HopePowder: in.HopePowder, GrantedIdentities: sortedTrueKeys(in.Granted), SpentIdentities: sortedTrueKeys(in.Spent), EquipMileage: in.EquipMileage, EquipMileageExchangeGage: in.EquipMileageExchangeGage} } func namedCounts(in map[string]uint64) []*statev1.NamedCount { out := make([]*statev1.NamedCount, 0, len(in)) diff --git a/go/internal/statetx/operation.go b/go/internal/statetx/operation.go new file mode 100644 index 0000000..068df66 --- /dev/null +++ b/go/internal/statetx/operation.go @@ -0,0 +1,413 @@ +package statetx + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sort" +) + +const requestFormat = 3 + +type undoRecord struct { + Name string `json:"name"` + Exists bool `json:"exists"` + SHA256 string `json:"sha256,omitempty"` +} + +type undoManifest struct { + Format int `json:"format"` + Records []undoRecord `json:"records"` +} + +type finalRecord struct { + Name string `json:"name"` + Exists bool `json:"exists"` + SHA256 string `json:"sha256,omitempty"` +} + +type finalManifest struct { + Format int `json:"format"` + Records []finalRecord `json:"records"` +} + +// Operation is a write-ahead transaction around one authenticated request. +// Begin keeps the coordinator locked until Commit or Rollback. Domain stores +// remain responsible for typed validation and their normal single-file +// atomic writes; the durable undo log makes the complete request all-or-none +// across the account's state files. +type Operation struct { + coordinator *Coordinator + journal string + undo undoManifest + done bool +} + +type RequestOperation interface { + Commit() error + Rollback() error +} + +func (s *Coordinator) BeginOperation() (RequestOperation, error) { + return s.Begin() +} + +// Begin persists a complete undo generation before domain code is allowed to +// mutate state. Missing state files are represented explicitly so first-run +// accounts can be rolled back without inventing an empty JSON document. +func (s *Coordinator) Begin() (*Operation, error) { + s.mu.Lock() + unlock := true + defer func() { + if unlock { + s.mu.Unlock() + } + }() + if s.failed { + return nil, errors.New("statetx: previous transaction failed; restart required") + } + journal := filepath.Join(s.root, journalName) + preparing := filepath.Join(s.root, journalName+".preparing") + if _, err := os.Lstat(journal); err == nil { + s.failed = true + return nil, errors.New("statetx: existing journal; restart required") + } else if !errors.Is(err, os.ErrNotExist) { + return nil, err + } + if err := os.MkdirAll(s.root, 0o700); err != nil { + return nil, err + } + if info, err := os.Lstat(preparing); err == nil { + if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return nil, errors.New("statetx: preparing path is not a directory") + } + if err := os.RemoveAll(preparing); err != nil { + return nil, err + } + } else if !errors.Is(err, os.ErrNotExist) { + return nil, err + } + if err := os.Mkdir(preparing, 0o700); err != nil { + return nil, err + } + prepared := false + defer func() { + if !prepared { + _ = os.RemoveAll(preparing) + } + }() + if err := os.Mkdir(filepath.Join(preparing, "before"), 0o700); err != nil { + return nil, err + } + names := make([]string, 0, len(s.allowed)) + for name := range s.allowed { + names = append(names, name) + } + sort.Strings(names) + undo := undoManifest{Format: requestFormat, Records: make([]undoRecord, 0, len(names))} + for _, name := range names { + info, exists, err := s.statOptionalTarget(name) + if err != nil { + return nil, err + } + record := undoRecord{Name: name, Exists: exists} + if exists { + if cached, ok := s.cache[name]; ok && sameFileGeneration(cached.info, info) { + record.SHA256 = cached.digest + } else { + data, err := os.ReadFile(filepath.Join(s.root, name)) + if err != nil { + return nil, err + } + record.SHA256 = digest(data) + } + s.cache[name] = fileDigest{info: info, digest: record.SHA256} + if err := snapshotBefore(filepath.Join(s.root, name), filepath.Join(preparing, "before", name)); err != nil { + return nil, err + } + } else { + delete(s.cache, name) + } + undo.Records = append(undo.Records, record) + } + encoded, err := json.Marshal(undo) + if err != nil { + return nil, err + } + if err := syncDir(filepath.Join(preparing, "before")); err != nil { + return nil, err + } + if err := writeAtomicSynced(filepath.Join(preparing, "prepared.json"), encoded); err != nil { + return nil, err + } + if err := syncDir(preparing); err != nil { + return nil, err + } + if err := replaceFile(preparing, journal); err != nil { + return nil, err + } + if err := syncDir(s.root); err != nil { + return nil, err + } + prepared = true + unlock = false + return &Operation{coordinator: s, journal: journal, undo: undo}, nil +} + +// Commit records and validates the exact final generation before making it +// authoritative. Response encoding must be complete before this is called. +func (o *Operation) Commit() error { + if o == nil || o.coordinator == nil { + return errors.New("statetx: nil request transaction") + } + if o.done { + return errors.New("statetx: request transaction already finished") + } + s := o.coordinator + defer s.mu.Unlock() + o.done = true + final := finalManifest{Format: requestFormat, Records: make([]finalRecord, 0, len(o.undo.Records))} + finalCache := make(map[string]fileDigest, len(o.undo.Records)) + for _, entry := range o.undo.Records { + info, exists, err := s.statOptionalTarget(entry.Name) + if err != nil { + return o.failAndRollback(fmt.Errorf("statetx: read final %s: %w", entry.Name, err)) + } + record := finalRecord{Name: entry.Name, Exists: exists} + if exists { + if cached, ok := s.cache[entry.Name]; ok && sameFileGeneration(cached.info, info) { + record.SHA256 = cached.digest + } else { + data, err := os.ReadFile(filepath.Join(s.root, entry.Name)) + if err != nil { + return o.failAndRollback(fmt.Errorf("statetx: read final %s: %w", entry.Name, err)) + } + record.SHA256 = digest(data) + } + finalCache[entry.Name] = fileDigest{info: info, digest: record.SHA256} + } + final.Records = append(final.Records, record) + } + encoded, err := json.Marshal(final) + if err != nil { + return o.failAndRollback(err) + } + if err := writeAtomicSynced(filepath.Join(o.journal, "committed.json"), encoded); err != nil { + return o.failAndRollback(fmt.Errorf("statetx: write commit marker: %w", err)) + } + if err := os.RemoveAll(o.journal); err != nil { + // committed is authoritative. Keep serving the successful response but + // reject every later request until startup validates and cleans it. + s.failed = true + return nil + } + s.cache = finalCache + return nil +} + +// Rollback is used when domain execution or response encoding fails. If no +// file changed, the request can safely fail without poisoning the process. If +// typed stores already published any change in memory, disk is restored but +// the coordinator enters fail-stop until restart reloads all stores. +func (o *Operation) Rollback() error { + if o == nil || o.coordinator == nil { + return errors.New("statetx: nil request transaction") + } + if o.done { + return nil + } + s := o.coordinator + defer s.mu.Unlock() + o.done = true + changed, err := s.undoChanged(o.undo) + if err != nil { + s.failed = true + return err + } + if changed { + if err := s.restoreUndo(o.journal, o.undo); err != nil { + s.failed = true + return err + } + s.failed = true + return errors.New("statetx: request rolled back after state mutation; restart required") + } + if err := os.RemoveAll(o.journal); err != nil { + s.failed = true + return err + } + return nil +} + +// Abort closes an operation for tests that simulate a process crash. It does +// not touch the journal or live files; the next Open performs recovery. +func (o *Operation) Abort() { + if o == nil || o.coordinator == nil || o.done { + return + } + o.done = true + o.coordinator.mu.Unlock() +} + +func (o *Operation) failAndRollback(cause error) error { + s := o.coordinator + changed, inspectErr := s.undoChanged(o.undo) + if inspectErr != nil { + s.failed = true + return fmt.Errorf("%w; inspect rollback: %v", cause, inspectErr) + } + if changed { + if rollbackErr := s.restoreUndo(o.journal, o.undo); rollbackErr != nil { + s.failed = true + return fmt.Errorf("%w; rollback failed: %v", cause, rollbackErr) + } + s.failed = true + return fmt.Errorf("%w; state restored but restart required", cause) + } + if cleanupErr := os.RemoveAll(o.journal); cleanupErr != nil { + s.failed = true + return fmt.Errorf("%w; cleanup failed: %v", cause, cleanupErr) + } + return cause +} + +func (s *Coordinator) readOptionalTarget(name string) ([]byte, bool, error) { + info, exists, err := s.statOptionalTarget(name) + if err != nil || !exists { + return nil, exists, err + } + data, err := os.ReadFile(filepath.Join(s.root, name)) + if err != nil { + return nil, false, err + } + _ = info + return data, true, nil +} + +func (s *Coordinator) statOptionalTarget(name string) (os.FileInfo, bool, error) { + if !s.allowed[name] { + return nil, false, fmt.Errorf("statetx: unregistered state file %q", name) + } + path := filepath.Join(s.root, name) + info, err := os.Lstat(path) + if errors.Is(err, os.ErrNotExist) { + return nil, false, nil + } + if err != nil { + return nil, false, err + } + if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { + return nil, false, fmt.Errorf("statetx: %s is not a regular state file", name) + } + return info, true, nil +} + +func sameFileGeneration(left, right os.FileInfo) bool { + return left != nil && right != nil && os.SameFile(left, right) && + left.Size() == right.Size() && left.ModTime() == right.ModTime() +} + +func (s *Coordinator) undoChanged(undo undoManifest) (bool, error) { + for _, entry := range undo.Records { + info, exists, err := s.statOptionalTarget(entry.Name) + if err != nil { + return false, err + } + if exists != entry.Exists { + return true, nil + } + if exists { + cached, ok := s.cache[entry.Name] + if !ok || cached.digest != entry.SHA256 || !sameFileGeneration(cached.info, info) { + return true, nil + } + } + } + return false, nil +} + +func (s *Coordinator) restoreUndo(journal string, undo undoManifest) error { + for _, entry := range undo.Records { + target := filepath.Join(s.root, entry.Name) + if entry.Exists { + image := filepath.Join(journal, "before", entry.Name) + data, err := os.ReadFile(image) + if err != nil || digest(data) != entry.SHA256 { + return fmt.Errorf("statetx: invalid undo image for %s", entry.Name) + } + if err := replaceFromImage(image, target); err != nil { + return fmt.Errorf("statetx: restore %s: %w", entry.Name, err) + } + continue + } + info, err := os.Lstat(target) + if errors.Is(err, os.ErrNotExist) { + continue + } + if err != nil { + return err + } + if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { + return fmt.Errorf("statetx: refuse to remove non-regular %s", entry.Name) + } + if err := os.Remove(target); err != nil { + return fmt.Errorf("statetx: remove first-run %s: %w", entry.Name, err) + } + } + if err := os.RemoveAll(journal); err != nil { + return fmt.Errorf("statetx: remove undo journal: %w", err) + } + return nil +} + +func (s *Coordinator) recoverRequestLocked(journal string) error { + encoded, err := os.ReadFile(filepath.Join(journal, "prepared.json")) + if errors.Is(err, os.ErrNotExist) { + return os.RemoveAll(journal) + } + if err != nil { + return err + } + var undo undoManifest + if err := json.Unmarshal(encoded, &undo); err != nil || undo.Format != requestFormat || len(undo.Records) != len(s.allowed) { + return errors.New("statetx: malformed request undo manifest") + } + seen := make(map[string]bool, len(undo.Records)) + for _, entry := range undo.Records { + if !s.allowed[entry.Name] || seen[entry.Name] || entry.Exists && entry.SHA256 == "" || !entry.Exists && entry.SHA256 != "" { + return errors.New("statetx: invalid request undo target") + } + seen[entry.Name] = true + if entry.Exists { + data, err := os.ReadFile(filepath.Join(journal, "before", entry.Name)) + if err != nil || digest(data) != entry.SHA256 { + return fmt.Errorf("statetx: invalid request undo image for %s", entry.Name) + } + } + } + commitBytes, markerErr := os.ReadFile(filepath.Join(journal, "committed.json")) + if markerErr != nil && !errors.Is(markerErr, os.ErrNotExist) { + return markerErr + } + if errors.Is(markerErr, os.ErrNotExist) { + return s.restoreUndo(journal, undo) + } + var final finalManifest + if err := json.Unmarshal(commitBytes, &final); err != nil || final.Format != requestFormat || len(final.Records) != len(undo.Records) { + return errors.New("statetx: malformed request commit manifest") + } + seen = make(map[string]bool, len(final.Records)) + for _, entry := range final.Records { + if !s.allowed[entry.Name] || seen[entry.Name] || entry.Exists && entry.SHA256 == "" || !entry.Exists && entry.SHA256 != "" { + return errors.New("statetx: invalid request final target") + } + seen[entry.Name] = true + current, exists, err := s.readOptionalTarget(entry.Name) + if err != nil || exists != entry.Exists || exists && digest(current) != entry.SHA256 { + return fmt.Errorf("statetx: committed request file %s is not the final image", entry.Name) + } + } + return os.RemoveAll(journal) +} diff --git a/go/internal/statetx/replace_other.go b/go/internal/statetx/replace_other.go new file mode 100644 index 0000000..dde4a30 --- /dev/null +++ b/go/internal/statetx/replace_other.go @@ -0,0 +1,16 @@ +//go:build !windows + +package statetx + +import "os" + +func replaceFile(source, destination string) error { return os.Rename(source, destination) } + +func syncDir(path string) error { + dir, err := os.Open(path) + if err != nil { + return err + } + defer dir.Close() + return dir.Sync() +} diff --git a/go/internal/statetx/replace_windows.go b/go/internal/statetx/replace_windows.go new file mode 100644 index 0000000..1aa3cea --- /dev/null +++ b/go/internal/statetx/replace_windows.go @@ -0,0 +1,38 @@ +//go:build windows + +package statetx + +import ( + "os" + "syscall" + "unsafe" + +) + +var moveFileEx = syscall.NewLazyDLL("kernel32.dll").NewProc("MoveFileExW") + +func replaceFile(source, destination string) error { + from, err := syscall.UTF16PtrFromString(source) + if err != nil { + return err + } + to, err := syscall.UTF16PtrFromString(destination) + if err != nil { + return err + } + result, _, callErr := moveFileEx.Call(uintptr(unsafe.Pointer(from)), uintptr(unsafe.Pointer(to)), 0x1|0x8) + if result == 0 { + if callErr != syscall.Errno(0) { + return callErr + } + return os.ErrInvalid + } + return nil +} + +func syncDir(path string) error { + // Windows does not support FlushFileBuffers on a directory handle. The + // phase file itself is flushed, and replaceFile uses MoveFileExW with + // MOVEFILE_WRITE_THROUGH for publishing its directory entry. + return nil +} diff --git a/go/internal/statetx/snapshot_darwin.go b/go/internal/statetx/snapshot_darwin.go new file mode 100644 index 0000000..977950d --- /dev/null +++ b/go/internal/statetx/snapshot_darwin.go @@ -0,0 +1,28 @@ +//go:build darwin + +package statetx + +import ( + "os" + + "golang.org/x/sys/unix" +) + +// APFS clonefile creates an independent copy-on-write snapshot. HFS+ and +// other filesystems safely fall back to a synchronized byte copy. +func snapshotBefore(source, destination string) error { + if err := unix.Clonefile(source, destination, 0); err == nil { + file, openErr := os.OpenFile(destination, os.O_WRONLY, 0) + if openErr == nil { + openErr = file.Sync() + if closeErr := file.Close(); openErr == nil { + openErr = closeErr + } + } + if openErr == nil { + return nil + } + } + _ = os.Remove(destination) + return copySynced(source, destination) +} diff --git a/go/internal/statetx/snapshot_linux.go b/go/internal/statetx/snapshot_linux.go new file mode 100644 index 0000000..85d78c9 --- /dev/null +++ b/go/internal/statetx/snapshot_linux.go @@ -0,0 +1,40 @@ +//go:build linux + +package statetx + +import ( + "os" + + "golang.org/x/sys/unix" +) + +// Linux first asks the filesystem for an FICLONE copy-on-write snapshot +// (btrfs, XFS and supporting filesystems). Byte copying remains the safe +// fallback; hard links are deliberately avoided because in-place writes by a +// future Store implementation would also mutate the undo image. +func snapshotBefore(source, destination string) error { + if err := reflinkLinux(source, destination); err == nil { + return nil + } + _ = os.Remove(destination) + return copySynced(source, destination) +} + +func reflinkLinux(source, destination string) error { + src, err := os.Open(source) + if err != nil { + return err + } + defer src.Close() + dst, err := os.OpenFile(destination, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) + if err != nil { + return err + } + if err = unix.IoctlFileClone(int(dst.Fd()), int(src.Fd())); err == nil { + err = dst.Sync() + } + if closeErr := dst.Close(); err == nil { + err = closeErr + } + return err +} diff --git a/go/internal/statetx/snapshot_other.go b/go/internal/statetx/snapshot_other.go new file mode 100644 index 0000000..bef3e9f --- /dev/null +++ b/go/internal/statetx/snapshot_other.go @@ -0,0 +1,7 @@ +//go:build !windows && !linux && !darwin + +package statetx + +func snapshotBefore(source, destination string) error { + return copySynced(source, destination) +} diff --git a/go/internal/statetx/snapshot_windows.go b/go/internal/statetx/snapshot_windows.go new file mode 100644 index 0000000..3182662 --- /dev/null +++ b/go/internal/statetx/snapshot_windows.go @@ -0,0 +1,131 @@ +//go:build windows + +package statetx + +import ( + "os" + "sync" + "unsafe" + + "golang.org/x/sys/windows" +) + +var getDiskFreeSpaceW = windows.NewLazySystemDLL("kernel32.dll").NewProc("GetDiskFreeSpaceW") + +const fileSupportsBlockRefcounting = 0x08000000 + +type windowsVolumeSnapshotInfo struct { + clusterSize int64 + blockClone bool +} + +var windowsVolumeCache sync.Map + +type duplicateExtentsData struct { + SourceHandle windows.Handle + SourceOffset int64 + TargetOffset int64 + ByteCount int64 +} + +// ReFS block cloning creates an independent copy-on-write file. NTFS does not +// expose file-level CoW cloning, so it uses a hard link to the immutable old +// inode; every account Store is required to publish through temp+replace. +// Unsupported Windows filesystems fall back to a synchronized byte copy. +func snapshotBefore(source, destination string) error { + if volume, err := volumeSnapshotInfo(source); err == nil && volume.blockClone { + if err := blockCloneWindows(source, destination, volume.clusterSize); err == nil { + return nil + } + _ = os.Remove(destination) + } + if err := os.Link(source, destination); err == nil { + return nil + } + return copySynced(source, destination) +} + +func blockCloneWindows(source, destination string, clusterSize int64) error { + info, err := os.Stat(source) + if err != nil { + return err + } + size := info.Size() + cloneBytes := size / clusterSize * clusterSize + if cloneBytes == 0 { + return windows.ERROR_NOT_SUPPORTED + } + src, err := os.Open(source) + if err != nil { + return err + } + defer src.Close() + dst, err := os.OpenFile(destination, os.O_CREATE|os.O_EXCL|os.O_RDWR, 0o600) + if err != nil { + return err + } + if err = dst.Truncate(size); err == nil && size != 0 { + input := duplicateExtentsData{ + SourceHandle: windows.Handle(src.Fd()), + ByteCount: cloneBytes, + } + var returned uint32 + err = windows.DeviceIoControl( + windows.Handle(dst.Fd()), + windows.FSCTL_DUPLICATE_EXTENTS_TO_FILE, + (*byte)(unsafe.Pointer(&input)), uint32(unsafe.Sizeof(input)), + nil, 0, &returned, nil, + ) + } + if err == nil && cloneBytes < size { + tail := make([]byte, size-cloneBytes) + if _, readErr := src.ReadAt(tail, cloneBytes); readErr != nil { + err = readErr + } else { + _, err = dst.WriteAt(tail, cloneBytes) + } + } + if err == nil { + err = dst.Sync() + } + if closeErr := dst.Close(); err == nil { + err = closeErr + } + return err +} + +func volumeSnapshotInfo(path string) (windowsVolumeSnapshotInfo, error) { + pathUTF16, err := windows.UTF16PtrFromString(path) + if err != nil { + return windowsVolumeSnapshotInfo{}, err + } + volume := make([]uint16, windows.MAX_PATH+1) + if err := windows.GetVolumePathName(pathUTF16, &volume[0], uint32(len(volume))); err != nil { + return windowsVolumeSnapshotInfo{}, err + } + volumeName := windows.UTF16ToString(volume) + if cached, ok := windowsVolumeCache.Load(volumeName); ok { + return cached.(windowsVolumeSnapshotInfo), nil + } + var flags uint32 + if err := windows.GetVolumeInformation(&volume[0], nil, 0, nil, nil, &flags, nil, 0); err != nil { + return windowsVolumeSnapshotInfo{}, err + } + var sectorsPerCluster, bytesPerSector uint32 + result, _, callErr := getDiskFreeSpaceW.Call( + uintptr(unsafe.Pointer(&volume[0])), + uintptr(unsafe.Pointer(§orsPerCluster)), + uintptr(unsafe.Pointer(&bytesPerSector)), + 0, 0, + ) + if result == 0 { + return windowsVolumeSnapshotInfo{}, callErr + } + cluster := uint64(sectorsPerCluster) * uint64(bytesPerSector) + if cluster == 0 || cluster > uint64(^uint32(0)) { + return windowsVolumeSnapshotInfo{}, windows.ERROR_INVALID_DATA + } + resultInfo := windowsVolumeSnapshotInfo{clusterSize: int64(cluster), blockClone: flags&fileSupportsBlockRefcounting != 0} + windowsVolumeCache.Store(volumeName, resultInfo) + return resultInfo, nil +} diff --git a/go/internal/statetx/transaction.go b/go/internal/statetx/transaction.go new file mode 100644 index 0000000..820738c --- /dev/null +++ b/go/internal/statetx/transaction.go @@ -0,0 +1,200 @@ +// Package statetx provides a crash-safe write-ahead transaction around one +// account request. It knows filenames and bytes only; domain stores retain all +// JSON and gameplay semantics. +package statetx + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "sync" +) + +const journalName = ".account-transaction" + +type Coordinator struct { + mu sync.Mutex + root string + allowed map[string]bool + failed bool + cache map[string]fileDigest +} + +type fileDigest struct { + info os.FileInfo + digest string +} + +// Open validates the account boundary and recovers an interrupted request +// before any domain store reads its state. +func Open(root string, allowed []string) (*Coordinator, error) { + if len(allowed) == 0 { + return nil, errors.New("statetx: no state filenames") + } + s := &Coordinator{root: filepath.Clean(root), allowed: make(map[string]bool, len(allowed)), cache: make(map[string]fileDigest, len(allowed))} + for _, name := range allowed { + if err := validName(name); err != nil || s.allowed[name] { + return nil, fmt.Errorf("statetx: invalid or duplicate allowed filename %q", name) + } + s.allowed[name] = true + } + if err := s.recoverLocked(); err != nil { + return nil, err + } + if err := s.refreshCacheLocked(); err != nil { + return nil, err + } + return s, nil +} + +func (s *Coordinator) refreshCacheLocked() error { + next := make(map[string]fileDigest, len(s.allowed)) + for name := range s.allowed { + path := filepath.Join(s.root, name) + info, err := os.Lstat(path) + if errors.Is(err, os.ErrNotExist) { + continue + } + if err != nil { + return err + } + if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { + return fmt.Errorf("statetx: %s is not a regular state file", name) + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + next[name] = fileDigest{info: info, digest: digest(data)} + } + s.cache = next + return nil +} + +// Check is the request dispatcher fail-stop gate. Once memory could differ +// from a disk generation restored after an error, only restart may continue. +func (s *Coordinator) Check() error { + s.mu.Lock() + defer s.mu.Unlock() + if s.failed { + return errors.New("statetx: account persistence requires restart recovery") + } + return nil +} + +func validName(name string) error { + if name == "" || name == "." || name == ".." || filepath.Base(name) != name || strings.ContainsAny(name, `/\:`) || name == journalName { + return errors.New("filename is not a direct child") + } + return nil +} + +func digest(data []byte) string { + sum := sha256.Sum256(data) + return hex.EncodeToString(sum[:]) +} + +func (s *Coordinator) recoverLocked() error { + preparing := filepath.Join(s.root, journalName+".preparing") + if info, err := os.Lstat(preparing); err == nil { + if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return errors.New("statetx: preparing path is not a directory") + } + if err := os.RemoveAll(preparing); err != nil { + return err + } + } else if !errors.Is(err, os.ErrNotExist) { + return err + } + journal := filepath.Join(s.root, journalName) + info, err := os.Lstat(journal) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return err + } + if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return errors.New("statetx: journal is not a directory") + } + if _, err := os.Lstat(filepath.Join(journal, "prepared.json")); err == nil { + return s.recoverRequestLocked(journal) + } else if !errors.Is(err, os.ErrNotExist) { + return err + } + // Begin writes undo.json and then prepared before gameplay may execute. + // Therefore a journal without undo.json cannot have touched live state. + return os.RemoveAll(journal) +} + +func writeAtomicSynced(path string, data []byte) error { + dir := filepath.Dir(path) + file, err := os.CreateTemp(dir, ".statetx-phase-*.tmp") + if err != nil { + return err + } + name := file.Name() + defer os.Remove(name) + if _, err = file.Write(data); err == nil { + err = file.Sync() + } + if closeErr := file.Close(); err == nil { + err = closeErr + } + if err != nil { + return err + } + if err := replaceFile(name, path); err != nil { + return err + } + return syncDir(dir) +} + +func writeSynced(path string, data []byte) error { + f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) + if err != nil { + return err + } + _, err = f.Write(data) + if err == nil { + err = f.Sync() + } + if closeErr := f.Close(); err == nil { + err = closeErr + } + return err +} + +func copySynced(source, destination string) error { + data, err := os.ReadFile(source) + if err != nil { + return err + } + return writeSynced(destination, data) +} + +func replaceFromImage(image, target string) error { + data, err := os.ReadFile(image) + if err != nil { + return err + } + file, err := os.CreateTemp(filepath.Dir(target), ".account-*.tmp") + if err != nil { + return err + } + defer os.Remove(file.Name()) + if _, err = file.Write(data); err == nil { + err = file.Sync() + } + if closeErr := file.Close(); err == nil { + err = closeErr + } + if err != nil { + return err + } + return replaceFile(file.Name(), target) +} diff --git a/go/internal/statetx/transaction_test.go b/go/internal/statetx/transaction_test.go new file mode 100644 index 0000000..a5d58d7 --- /dev/null +++ b/go/internal/statetx/transaction_test.go @@ -0,0 +1,252 @@ +package statetx + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "testing" +) + +func openFixture(t *testing.T) (*Coordinator, string) { + t.Helper() + root := t.TempDir() + for name, content := range map[string]string{"wallet.json": "old-wallet", "items.json": "old-items"} { + if err := atomicTestReplace(filepath.Join(root, name), []byte(content)); err != nil { + t.Fatal(err) + } + } + s, err := Open(root, []string{"wallet.json", "items.json"}) + if err != nil { + t.Fatal(err) + } + return s, root +} + +func TestOpenRejectsUnsafeOrDuplicateTargets(t *testing.T) { + root := t.TempDir() + for _, names := range [][]string{{"../wallet.json"}, {"wallet.json", "wallet.json"}, {journalName}} { + if _, err := Open(root, names); err == nil { + t.Fatalf("unsafe allow-list accepted: %v", names) + } + } +} + +func TestRequestOperationCommitsWholeGeneration(t *testing.T) { + s, root := openFixture(t) + op, err := s.Begin() + if err != nil { + t.Fatal(err) + } + for name, content := range map[string]string{"items.json": "new-items", "wallet.json": "new-wallet"} { + if err := os.WriteFile(filepath.Join(root, name), []byte(content), 0o600); err != nil { + t.Fatal(err) + } + } + if err := op.Commit(); err != nil { + t.Fatal(err) + } + assertFiles(t, root, map[string]string{"items.json": "new-items", "wallet.json": "new-wallet"}) +} + +func TestRequestCrashRollsBackEveryFileOnOpen(t *testing.T) { + s, root := openFixture(t) + op, err := s.Begin() + if err != nil { + t.Fatal(err) + } + if err := atomicTestReplace(filepath.Join(root, "items.json"), []byte("new-items")); err != nil { + t.Fatal(err) + } + op.Abort() + if _, err := Open(root, []string{"wallet.json", "items.json"}); err != nil { + t.Fatal(err) + } + assertFiles(t, root, map[string]string{"items.json": "old-items", "wallet.json": "old-wallet"}) +} + +func TestRequestRollbackWithoutMutationKeepsCoordinatorUsable(t *testing.T) { + s, _ := openFixture(t) + op, err := s.Begin() + if err != nil { + t.Fatal(err) + } + if err := op.Rollback(); err != nil { + t.Fatal(err) + } + second, err := s.Begin() + if err != nil { + t.Fatalf("clean rejection poisoned coordinator: %v", err) + } + if err := second.Commit(); err != nil { + t.Fatal(err) + } +} + +func TestRequestRollbackAfterMutationRestoresAndFailsStop(t *testing.T) { + s, root := openFixture(t) + op, err := s.Begin() + if err != nil { + t.Fatal(err) + } + if err := atomicTestReplace(filepath.Join(root, "wallet.json"), []byte("partial-wallet")); err != nil { + t.Fatal(err) + } + if err := op.Rollback(); err == nil { + t.Fatal("mutating rollback did not require restart") + } + assertFiles(t, root, map[string]string{"wallet.json": "old-wallet"}) + if _, err := s.Begin(); err == nil { + t.Fatal("coordinator accepted request after memory could diverge") + } + if _, err := Open(root, []string{"wallet.json", "items.json"}); err != nil { + t.Fatalf("clean restart could not reopen state: %v", err) + } +} + +func TestFirstRunMissingFileIsRemovedByCrashRecovery(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "wallet.json"), []byte("old-wallet"), 0o600); err != nil { + t.Fatal(err) + } + s, err := Open(root, []string{"wallet.json", "items.json"}) + if err != nil { + t.Fatal(err) + } + op, err := s.Begin() + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "items.json"), []byte("created-items"), 0o600); err != nil { + t.Fatal(err) + } + op.Abort() + if _, err := Open(root, []string{"wallet.json", "items.json"}); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(root, "items.json")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("first-run file survived rollback: %v", err) + } +} + +func TestRecoveryRejectsTamperedUndoWithoutChangingState(t *testing.T) { + s, root := openFixture(t) + op, err := s.Begin() + if err != nil { + t.Fatal(err) + } + op.Abort() + journal := filepath.Join(root, journalName) + if err := atomicTestReplace(filepath.Join(journal, "before", "wallet.json"), []byte("CORRUPT")); err != nil { + t.Fatal(err) + } + if _, err := Open(root, []string{"wallet.json", "items.json"}); err == nil { + t.Fatal("tampered undo was accepted") + } + assertFiles(t, root, map[string]string{"wallet.json": "old-wallet", "items.json": "old-items"}) +} + +func TestCommittedRecoveryValidatesFinalGeneration(t *testing.T) { + s, root := openFixture(t) + op, err := s.Begin() + if err != nil { + t.Fatal(err) + } + if err := atomicTestReplace(filepath.Join(root, "wallet.json"), []byte("new-wallet")); err != nil { + t.Fatal(err) + } + final := finalManifest{Format: requestFormat, Records: []finalRecord{ + {Name: "items.json", Exists: true, SHA256: digest([]byte("old-items"))}, + {Name: "wallet.json", Exists: true, SHA256: digest([]byte("new-wallet"))}, + }} + encoded, _ := json.Marshal(final) + if err := writeAtomicSynced(filepath.Join(op.journal, "committed.json"), encoded); err != nil { + t.Fatal(err) + } + op.Abort() + if _, err := Open(root, []string{"wallet.json", "items.json"}); err != nil { + t.Fatal(err) + } + assertFiles(t, root, map[string]string{"wallet.json": "new-wallet", "items.json": "old-items"}) +} + +func TestSnapshotBeforeSurvivesAtomicLiveReplacement(t *testing.T) { + root := t.TempDir() + source := filepath.Join(root, "live.json") + snapshot := filepath.Join(root, "snapshot.json") + old := []byte("old generation that must remain immutable") + if err := os.WriteFile(source, old, 0o600); err != nil { + t.Fatal(err) + } + if err := snapshotBefore(source, snapshot); err != nil { + t.Fatal(err) + } + if err := atomicTestReplace(source, []byte("new generation")); err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(snapshot) + if err != nil || string(got) != string(old) { + t.Fatalf("snapshot changed after live replace: %q err=%v", got, err) + } +} + +func BenchmarkRequestTransactionRealState(b *testing.B) { + source := filepath.Clean(filepath.Join("..", "..", "..", "data", "state")) + if _, err := os.Stat(source); err != nil { + b.Skip("development state fixture is unavailable") + } + root := b.TempDir() + names := []string{"characters.json", "collection.json", "deck.json", "equipment.json", "items.json", "mail.json", "missions.json", "progress.json", "wallet.json"} + for _, name := range names { + data, err := os.ReadFile(filepath.Join(source, name)) + if err != nil { + b.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, name), data, 0o600); err != nil { + b.Fatal(err) + } + } + coordinator, err := Open(root, names) + if err != nil { + b.Fatal(err) + } + b.ResetTimer() + for i := 0; i < b.N; i++ { + op, err := coordinator.Begin() + if err != nil { + b.Fatal(err) + } + if err := op.Commit(); err != nil { + b.Fatal(err) + } + } +} + +func atomicTestReplace(path string, data []byte) error { + temp, err := os.CreateTemp(filepath.Dir(path), ".statetx-test-*.tmp") + if err != nil { + return err + } + name := temp.Name() + defer os.Remove(name) + if _, err = temp.Write(data); err == nil { + err = temp.Sync() + } + if closeErr := temp.Close(); err == nil { + err = closeErr + } + if err != nil { + return err + } + return replaceFile(name, path) +} + +func assertFiles(t *testing.T, root string, want map[string]string) { + t.Helper() + for name, expected := range want { + got, err := os.ReadFile(filepath.Join(root, name)) + if err != nil || string(got) != expected { + t.Fatalf("%s=%q want=%q err=%v", name, got, expected, err) + } + } +} diff --git a/plugins/LocalIdentity/AgeGateState.cs b/plugins/LocalIdentity/AgeGateState.cs new file mode 100644 index 0000000..006f8d1 --- /dev/null +++ b/plugins/LocalIdentity/AgeGateState.cs @@ -0,0 +1,107 @@ +using System; +using System.IO; +using System.Text; +using BepInEx; + +namespace Bd2LocalIdentity; + +// The server's UpdateAgeGate response is intentionally empty. Once that +// response succeeds, the only client-side fact needed on the next login is +// that this local installation has completed the age-gate flow. Do not keep +// the date of birth: it is not consumed by the client after the request and +// retaining it would add unnecessary personal data to a local plugin file. +internal static class AgeGateState +{ + private const string FileName = "bd2.localidentity.age-gate.state"; + private const string Content = "format=1\nconfirmed=true\n"; + private static readonly object Gate = new object(); + private static bool? confirmed; + + internal static bool IsConfirmed() + { + lock (Gate) + { + if (confirmed.HasValue) + { + return confirmed.Value; + } + + try + { + string path = StatePath(); + confirmed = File.Exists(path) && + string.Equals(File.ReadAllText(path, Encoding.UTF8), Content, StringComparison.Ordinal); + } + catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException) + { + confirmed = false; + Plugin.LogWarning("Could not read local age-gate state: " + ex.Message); + } + + return confirmed.Value; + } + } + + internal static void MarkConfirmed() + { + lock (Gate) + { + if (confirmed == true) + { + return; + } + + string path = StatePath(); + string directory = Path.GetDirectoryName(path); + if (string.IsNullOrEmpty(directory)) + { + throw new IOException("BepInEx configuration directory is unavailable."); + } + + Directory.CreateDirectory(directory); + string temporaryPath = path + "." + Guid.NewGuid().ToString("N") + ".tmp"; + try + { + WriteThrough(temporaryPath, Content); + if (File.Exists(path)) + { + // Both files are in BepInEx/config, so replacement is an + // atomic same-volume operation on the supported Windows client. + File.Replace(temporaryPath, path, null); + } + else + { + File.Move(temporaryPath, path); + } + + confirmed = true; + } + finally + { + if (File.Exists(temporaryPath)) + { + File.Delete(temporaryPath); + } + } + } + } + + private static string StatePath() + { + return Path.Combine(Paths.ConfigPath, FileName); + } + + private static void WriteThrough(string path, string content) + { + byte[] bytes = new UTF8Encoding(false).GetBytes(content); + using FileStream stream = new FileStream( + path, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + 4096, + FileOptions.WriteThrough); + stream.Write(bytes, 0, bytes.Length); + stream.Flush(true); + } +} diff --git a/plugins/LocalIdentity/Plugin.cs b/plugins/LocalIdentity/Plugin.cs index a7ed0d6..b603754 100644 --- a/plugins/LocalIdentity/Plugin.cs +++ b/plugins/LocalIdentity/Plugin.cs @@ -15,10 +15,15 @@ public sealed class Plugin : BaseUnityPlugin { public const string Guid = "bd2.localidentity"; public const string Name = "BD2 Local Identity"; - public const string Version = "0.4.0"; + public const string Version = "0.5.1"; private const string LocalServerURL = "http://127.0.0.1:8080/game/"; private static ManualLogSource Log; + internal static void LogWarning(string message) + { + Log?.LogWarning(message); + } + private void Awake() { try @@ -83,9 +88,12 @@ public sealed class Plugin : BaseUnityPlugin falseTimeoutTelemetry, prefix: new HarmonyMethod(typeof(Plugin), nameof(SkipLocalTimeoutTelemetry))); - InstallLocalPurchaseBypass(harmony); - - InstallDatabaseDiagnostics(harmony); + // Optional patches must not suppress one another on a client + // version mismatch. In particular, a missing age-gate method + // must not disable the local purchase bypass or DB diagnostics. + TryInstall("age-gate persistence", () => InstallAgeGatePersistence(harmony)); + TryInstall("local purchase bypass", () => InstallLocalPurchaseBypass(harmony)); + TryInstall("database diagnostics", () => InstallDatabaseDiagnostics(harmony)); Logger.LogInfo("Local identity active: AppManager.UseSdk => false"); } catch (Exception ex) @@ -94,6 +102,18 @@ public sealed class Plugin : BaseUnityPlugin } } + private void TryInstall(string name, Action install) + { + try + { + install(); + } + catch (Exception ex) + { + Logger.LogError("Local identity " + name + " patch failed: " + ex); + } + } + private static bool UseSdkPrefix(ref bool __result) { __result = false; @@ -137,6 +157,73 @@ public sealed class Plugin : BaseUnityPlugin return false; } + private static void InstallAgeGatePersistence(Harmony harmony) + { + // LoginUserResponse field 13 is the client's sole gate for opening + // AgeGatePopupUI. Retain the original first-run UI and request; only + // change a later LoginUser parse after its successful local state has + // been read from disk. + Type commonPacket = FindType("ὨὬὣὫὩὯὩὩὣὠὧ"); + MethodInfo updateAgeGate = commonPacket?.GetMethod( + "ὧὮὦὠὬὥὮὢὦὦὥ", + BindingFlags.Static | BindingFlags.Public, + null, + new[] { typeof(bool), typeof(int), typeof(int), typeof(int), typeof(Action) }, + null); + if (updateAgeGate == null) + { + throw new MissingMethodException("CommonPacket.SendUpdateAgeGateRequest(bool, int, int, int, Action) was not found"); + } + Type loginUserResponse = FindType("Proto.Net.LoginUserResponse"); + MethodInfo needsAgeVerificationSetter = loginUserResponse?.GetProperty( + "NeedsAgeVerification", + BindingFlags.Instance | BindingFlags.Public)?.GetSetMethod(); + if (needsAgeVerificationSetter == null) + { + throw new MissingMethodException("LoginUserResponse.NeedsAgeVerification setter was not found"); + } + harmony.Patch( + updateAgeGate, + prefix: new HarmonyMethod(typeof(Plugin), nameof(UpdateAgeGateRequestPrefix))); + harmony.Patch( + needsAgeVerificationSetter, + prefix: new HarmonyMethod(typeof(Plugin), nameof(NeedsAgeVerificationSetterPrefix))); + + Log?.LogInfo("Local age-gate confirmation persistence active (confirmed=" + AgeGateState.IsConfirmed() + ")"); + } + + private static void NeedsAgeVerificationSetterPrefix(ref bool value) + { + if (value && AgeGateState.IsConfirmed()) + { + value = false; + Log?.LogInfo("Used persisted local age-gate confirmation for LoginUser"); + } + } + + private static void UpdateAgeGateRequestPrefix(ref Action __4) + { + // CommonPacket invokes this callback only after it has parsed the + // empty UpdateAgeGateResponse and accepted errorType == 0. Wrapping + // it therefore never records failed/cancelled submissions. + Action continuation = __4; + __4 = delegate + { + try + { + AgeGateState.MarkConfirmed(); + Log?.LogInfo("Stored successful local age-gate confirmation"); + } + catch (Exception ex) + { + // Preserve the original continuation: inability to persist + // should not break a successfully completed first login. + Log?.LogWarning("Could not persist local age-gate confirmation: " + ex.Message); + } + continuation?.Invoke(); + }; + } + private static void InstallLocalPurchaseBypass(Harmony harmony) { Type platformRuler = FindType("ὮὮὫὭὢὩὭὢὦὪὠ"); diff --git a/proto/bd2/state/v1/state.proto b/proto/bd2/state/v1/state.proto index c4a63c1..1cf347c 100644 --- a/proto/bd2/state/v1/state.proto +++ b/proto/bd2/state/v1/state.proto @@ -125,6 +125,7 @@ message Equipment { repeated EquipmentOption sub_options = 9; EquipmentOption private_option = 10; repeated uint64 ranks = 11; + uint64 upgrade_attempts = 12; } message NamedIndex { @@ -289,6 +290,8 @@ message Wallet { uint64 hope_powder = 6; repeated string granted_identities = 7; repeated string spent_identities = 8; + uint64 equip_mileage = 9; + uint64 equip_mileage_exchange_gage = 10; } message MailState { diff --git a/tools/python/README.md b/tools/python/README.md index 589621d..91eb5b3 100644 --- a/tools/python/README.md +++ b/tools/python/README.md @@ -91,30 +91,6 @@ python .\tools\python\import_seed.py mail .\decoded\MailInfo.pb ` } ``` -## 存档检查点 - -检查点包含九份账号状态和 SHA-256 清单: - -```powershell -python .\tools\python\save_checkpoint.py create --label before-test -python .\tools\python\save_checkpoint.py verify .\data\state\checkpoints\某检查点 - -# 第一次只演练;不会写入 -python .\tools\python\save_checkpoint.py restore .\data\state\checkpoints\某检查点 - -# 确认客户端和服务端均停止后才真正恢复 -python .\tools\python\save_checkpoint.py restore .\data\state\checkpoints\某检查点 --apply -``` - -开发期存档格式发生变化时,不在运行时加入旧格式猜测或兼容分支。应编写一次性 Python 编辑器,先演练,再在客户端和服务端停止后创建完整检查点并原子改档。例如首次抽卡完成状态: - -```powershell -python .\tools\python\set_first_gacha_state.py --state .\data\state --completed -python .\tools\python\set_first_gacha_state.py --state .\data\state --completed --apply -``` - -正式运行时代码只接受编辑后的最终语义;开发工具不进入发布包。 - ## 临时邮件物品发放 `dev_mail_grant.py` 是独立的、仅监听环回地址的开发期浏览器工具。它只读取指定版本的 GameData,列出已验证、可走 `ItemDBInfo` 领取路径的有名道具:`FoodTable`(类型 5)、`CookingTable`(7)、`ResourceTable`(8)、`QuestItemTable`(13)、`UseItemTable`(14)、`CollectionTable`(17)、`MyRoomItemTable`(27)和 `InstantUseItemTable`(29)。`ResourceTable.Type=2` 的场景/展示哨兵和无可用名称行不提供;固定内容随机箱只用于反查内容物的真实 ID,所有 type 9 随机箱均不作为邮件选项。另提供单一金币货币条目 `type4/id0`,填写的数量在领取后直接叠加至钱包,不再发送“金币随机箱”。工具本身既不属于 `bd2server.exe`,也不修改 `data/state` 的九份账号状态。 @@ -137,24 +113,10 @@ python .\tools\python\dev_mail_grant.py serve ` 客户端 `MailDBInfo.ItemType`、`ItemId` 和 `ItemCount` 均为 `int32`,所以该工具把单附件数量限制为 `1..2147483647`;每封工具邮件固定只有一个附件。当前官方样本中单封最多观察到 5 个附件,但没有证据证明这是协议上限,因此工具不据此宣称或实施“5 件”上限。客户端邮箱 UI 按一次请求加载最多 100 封普通邮件,现有本地服务目前回传全部未开封邮件,故大量历史未领取邮件的实际 UI 表现尚待验证。现有本地 `/MailOpen` 对同一邮件 ID 的领取由 `data/state/mail.json` 的 `opened` 集合持久化,重试不会重复发奖;工具会在完整种子中分配唯一递增邮件 ID。 -曾从开发工具发放的 `ResourceTable 90045`(金币遗失物品)会在客户端邮件详情打开时因缺少 `CostumeTable id405` 崩溃。修复工具 `repair_invalid_dev_mail_resources.py` 默认只演练;确需移除已误领的 `90045/90046` 实例时,先停止客户端与服务端,再用 `--apply` 自动创建、校验九文件备份并只修 `items.json`,保留邮件已领取与发放台账。 - 这不是“所有 GameData 表都可发放”的虚假承诺:角色(元素类型 6)、装备(10)、服装(11)和我的房间奖杯(28)在客户端 `RewardDBInfoBundle` 中分别必须使用 `CharDBInfo`、`EquipDBInfo`、`CostumeDBInfo`、`MyRoomTrophyDBInfo`,而当前本地邮件服务尚未连接相应领域存档,工具不会提供它们;直接伪装成 `ItemDBInfo` 会造成客户端状态错误。付费/普通货币之外的特殊货币亦不在当前本地钱包实现范围内。客户端 `DataManager.GetItemDTO` 对 `ContentTicket`(19)和 `LobbySettingItem`(25)没有可用于 `ItemDBInfo` 领取的 DTO 分支,故也没有提供;`GetItemInfo` 的显示分支不足以证明可安全存储。若要补齐这些类型,需要先实现对应的服务端存储、去重及正确 reward-bundle 字段,不需要客户端 patch。 热载只接受经过 `mail.Starter.Validate` 校验的完整 JSON 种子:文件未变化时不会重新读取;被检测到的坏替换会保留上一次已验证邮箱,并使该次 `/MailInfo` 请求失败而不会部分加载。工具本身总是完整写临时文件、`fsync` 后原子替换,正常发放不会让服务器看到半文件。 -## 任务 38 定向回档 - -该工具只撤销 pack21 quest38 的进度、四项物品和 1500 金币,保留其他账号资产;兼容旧存档和 v2 `pack:quest` 键: - -```powershell -# 只读预览 -python .\tools\python\rollback_quest38.py .\data\state .\data\state\checkpoints\参考检查点 - -# 停止客户端和服务端后应用;工具会先备份九份状态并校验哈希 -python .\tools\python\rollback_quest38.py .\data\state .\data\state\checkpoints\参考检查点 --apply -``` - ## 工具测试 ```powershell diff --git a/tools/python/migrate_costume_potential_state.py b/tools/python/migrate_costume_potential_state.py deleted file mode 100644 index 9a1662d..0000000 --- a/tools/python/migrate_costume_potential_state.py +++ /dev/null @@ -1,83 +0,0 @@ -#!/usr/bin/env python3 -"""Add the final costume-potential ledger to the pre-release collection save. - -Dry-run by default. Applying requires stopped client/server, creates and -verifies a complete nine-file checkpoint, and atomically changes only -collection.json. No runtime compatibility for the unpublished old format is -kept in the Go server. -""" - -from __future__ import annotations - -import argparse -from datetime import datetime -import json -from pathlib import Path - -import save_checkpoint -from set_first_gacha_state import atomic_write_json - - -DEFAULT_STATE = Path(__file__).resolve().parents[2] / "data" / "state" - - -def migrate(value: dict) -> tuple[dict, bool]: - if value.get("version") != "2.34.13" or not isinstance(value.get("costumes", []), list): - raise ValueError("collection.json is not the expected 2.34.13 save") - if "costume_potential" in value: - ledger = value["costume_potential"] - if not isinstance(ledger, dict) or any( - not isinstance(key, str) or not isinstance(nodes, list) or - any(not isinstance(node, int) or isinstance(node, bool) or node <= 0 for node in nodes) - for key, nodes in ledger.items() - ): - raise ValueError("existing costume_potential ledger is malformed") - return value, False - for costume in value.get("costumes", []): - if "potential_id" in costume or "potential_ids" in costume: - raise ValueError("unexpected legacy potential data requires explicit review") - result = dict(value) - result["costume_potential"] = {} - return result, True - - -def run(state: Path, apply: bool) -> Path | None: - target = state / "collection.json" - with target.open(encoding="utf-8") as stream: - original = json.load(stream) - migrated, changed = migrate(original) - print(json.dumps({"changed": changed, "costume_count": len(original.get("costumes", [])), "active_costume_count": len(migrated.get("costume_potential", {}))}, indent=2)) - if not changed or not apply: - return None - running = save_checkpoint.running_processes() - if running: - raise RuntimeError("client/server is still running:\n" + "\n".join(running)) - before = save_checkpoint.inspect_files(state) - backup = state / "checkpoints" / f"{datetime.now():%Y%m%d-%H%M%S}-before-costume-potential-state" - save_checkpoint.create(state, backup, "before adding final costume potential ledger") - atomic_write_json(target, migrated) - with target.open(encoding="utf-8") as stream: - written = json.load(stream) - repeated, changed_again = migrate(written) - after = save_checkpoint.inspect_files(state) - if changed_again or repeated != migrated or any(before[name] != after[name] for name in save_checkpoint.STATE_FILES if name != "collection.json"): - raise OSError(f"costume potential migration failed verification; backup: {backup}") - save_checkpoint.verify(backup) - return backup - - -def main() -> int: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--state", type=Path, default=DEFAULT_STATE) - parser.add_argument("--apply", action="store_true") - args = parser.parse_args() - try: - backup = run(args.state.resolve(), args.apply) - print(f"Migrated with complete verified backup: {backup}" if backup else "Dry-run/no change; use --apply with stopped processes when changed=true") - except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc: - parser.exit(1, f"migrate_costume_potential_state: {exc}\n") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tools/python/migrate_equipment_upgrade_attempts.py b/tools/python/migrate_equipment_upgrade_attempts.py deleted file mode 100644 index e7d3d0e..0000000 --- a/tools/python/migrate_equipment_upgrade_attempts.py +++ /dev/null @@ -1,76 +0,0 @@ -#!/usr/bin/env python3 -"""Add the final per-instance equipment upgrade-attempt counter. - -Dry-run by default. Applying requires stopped client/server, creates a verified -nine-file checkpoint, and atomically changes only equipment.json. Existing -equipment has never been successfully upgraded, so every instance starts at 0. -""" - -from __future__ import annotations - -import argparse -from datetime import datetime -import json -from pathlib import Path - -import save_checkpoint -from set_first_gacha_state import atomic_write_json - -DEFAULT_STATE = Path(__file__).resolve().parents[2] / "data" / "state" - -def migrate(value: dict) -> tuple[dict, list[int]]: - if value.get("version") != "2.34.13" or not isinstance(value.get("equipment"), list): - raise ValueError("equipment.json is not the expected 2.34.13 save") - result = dict(value) - result["equipment"] = [] - changed = [] - for original in value["equipment"]: - if not isinstance(original, dict) or not isinstance(original.get("inven_index"), int): - raise ValueError("invalid equipment instance") - item = dict(original) - if "upgrade_attempts" not in item: - item["upgrade_attempts"] = 0 - changed.append(item["inven_index"]) - elif not isinstance(item["upgrade_attempts"], int) or isinstance(item["upgrade_attempts"], bool) or item["upgrade_attempts"] < 0: - raise ValueError(f"equipment {item['inven_index']} has invalid upgrade_attempts") - result["equipment"].append(item) - return result, changed - -def run(state: Path, apply: bool) -> Path | None: - target = state / "equipment.json" - with target.open(encoding="utf-8") as stream: - original = json.load(stream) - migrated, changed = migrate(original) - print(json.dumps({"equipment_count": len(migrated["equipment"]), "changed_count": len(changed), "changed_indices": changed}, indent=2)) - if not changed or not apply: - return None - running = save_checkpoint.running_processes() - if running: - raise RuntimeError("client/server is still running:\n" + "\n".join(running)) - before = save_checkpoint.inspect_files(state) - backup = state / "checkpoints" / f"{datetime.now():%Y%m%d-%H%M%S}-before-equipment-upgrade-attempts" - save_checkpoint.create(state, backup, "before adding equipment upgrade attempt counters") - atomic_write_json(target, migrated) - with target.open(encoding="utf-8") as stream: - written = json.load(stream) - repeated, remaining = migrate(written) - after = save_checkpoint.inspect_files(state) - if remaining or repeated != migrated or any(before[name] != after[name] for name in save_checkpoint.STATE_FILES if name != "equipment.json"): - raise OSError(f"equipment upgrade migration failed verification; backup: {backup}") - save_checkpoint.verify(backup) - return backup - -def main() -> int: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--state", type=Path, default=DEFAULT_STATE) - parser.add_argument("--apply", action="store_true") - args = parser.parse_args() - try: - backup = run(args.state.resolve(), args.apply) - print(f"Migrated with complete verified backup: {backup}" if backup else "Dry-run/no change; use --apply with stopped processes when changed_count>0") - except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc: - parser.exit(1, f"migrate_equipment_upgrade_attempts: {exc}\n") - return 0 - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tools/python/repair_equipment_ranks.py b/tools/python/repair_equipment_ranks.py deleted file mode 100644 index 06f813a..0000000 --- a/tools/python/repair_equipment_ranks.py +++ /dev/null @@ -1,90 +0,0 @@ -#!/usr/bin/env python3 -"""Repair pre-release equipment saves with missing three-slot rank arrays. - -The 2.34.13 client indexes EquipBaseInfo.Rank[0..2] even before enhancement. -Dry-run by default. Applying requires stopped client/server processes, verifies -and backs up all nine state files, and atomically updates equipment.json only. -""" - -from __future__ import annotations - -import argparse -from datetime import datetime -import json -from pathlib import Path - -import save_checkpoint -from set_first_gacha_state import atomic_write_json - - -DEFAULT_STATE = Path(__file__).resolve().parents[2] / "data" / "state" - - -def repair(value: dict) -> tuple[dict, list[int]]: - if value.get("version") != "2.34.13": - raise ValueError("equipment.json must be a 2.34.13 save") - entries = value.get("equipment") - if not isinstance(entries, list): - raise ValueError("equipment must be an array") - result = dict(value) - result["equipment"] = [] - updated = [] - for item in entries: - if not isinstance(item, dict) or not isinstance(item.get("inven_index"), int): - raise ValueError("invalid equipment instance") - copy = dict(item) - if "rank" not in copy: - copy["rank"] = [0, 0, 0] - updated.append(copy["inven_index"]) - elif not isinstance(copy["rank"], list) or len(copy["rank"]) != 3 or any( - not isinstance(rank, int) or isinstance(rank, bool) or rank < 0 or rank > 4 - for rank in copy["rank"] - ): - raise ValueError(f"equipment {copy['inven_index']} has an invalid rank array") - result["equipment"].append(copy) - return result, updated - - -def apply_state(state: Path, apply: bool) -> Path | None: - target = state / "equipment.json" - with target.open("r", encoding="utf-8") as stream: - original = json.load(stream) - repaired, updated = repair(original) - print(json.dumps({"equipment_count": len(repaired["equipment"]), "missing_rank_count": len(updated), "updated_indices": updated}, indent=2)) - if not updated or not apply: - return None - running = save_checkpoint.running_processes() - if running: - raise RuntimeError("client/server is still running:\n" + "\n".join(running)) - before = save_checkpoint.inspect_files(state) - stamp = datetime.now().strftime("%Y%m%d-%H%M%S") - backup = state / "checkpoints" / f"{stamp}-before-equipment-ranks" - save_checkpoint.create(state, backup, "before three-slot equipment rank repair") - atomic_write_json(target, repaired) - with target.open("r", encoding="utf-8") as stream: - written = json.load(stream) - _, remaining = repair(written) - after = save_checkpoint.inspect_files(state) - if remaining or any(before[name] != after[name] for name in save_checkpoint.STATE_FILES if name != "equipment.json"): - raise OSError(f"equipment repair failed verification; backup: {backup}") - return backup - - -def main() -> int: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--state", type=Path, default=DEFAULT_STATE) - parser.add_argument("--apply", action="store_true") - args = parser.parse_args() - try: - backup = apply_state(args.state.resolve(), args.apply) - if backup: - print(f"updated equipment.json; verified nine-file backup: {backup}") - elif not args.apply: - print("dry run only; pass --apply after stopping game and server") - except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc: - parser.exit(1, f"repair_equipment_ranks: {exc}\n") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tools/python/repair_failed_collection_growth.py b/tools/python/repair_failed_collection_growth.py deleted file mode 100644 index c266b23..0000000 --- a/tools/python/repair_failed_collection_growth.py +++ /dev/null @@ -1,127 +0,0 @@ -#!/usr/bin/env python3 -"""Undo the six rejected 2026-09-21 20:19–20:20 collection promotions. - -The six /CharGrowth requests for collection character 920000054 (6510/1) -charged inventory and one gold payment before failing to persist a changed -character ID. This one-time repair only accepts the exact observed save and -six matching server errors, and removes the refund items those failed requests -created. Dry-run by default; applying requires stopped client/server, backs up -and verifies all nine account files, then writes only items.json/wallet.json. -""" - -from __future__ import annotations - -import argparse -from datetime import datetime -import json -from pathlib import Path - -import save_checkpoint -from set_first_gacha_state import atomic_write_json - - -DEFAULT_STATE = Path(__file__).resolve().parents[2] / "data" / "state" -DEFAULT_LOG = Path(__file__).resolve().parents[2] / "logs" / "server-20260921-201837.err.log" -ERROR = "persist promoted collection character: player: collection character 920000054 not found" -IDENTITY = "char-promote:920000054:6510" -CONSUMED = { - 900000038: (11, 93, 1), - 900000041: (12, 86, 2), - 900000042: (9, 94738, 753), - 900000045: (14, 99973, 4), - 900000046: (13, 99980, 3), -} -REFUNDS = {index: (8 if index % 2 else 7, 1 if index % 2 else 3) for index in range(900000049, 900000061)} - - -def repair(items: dict, wallet: dict, collection: dict, log: str) -> tuple[dict, dict]: - errors = [line for line in log.splitlines() if ERROR in line] - if len(errors) != 6 or any("path=/CharGrowth" not in line for line in errors): - raise ValueError(f"expected exactly six rejected promotion requests, got {len(errors)}") - if any(value.get("version") != "2.34.13" for value in (items, wallet, collection)): - raise ValueError("unexpected save version") - owner = [char for char in collection.get("characters", []) if char.get("inven_index") == 920000054] - if len(owner) != 1 or owner[0].get("id") != 6510 or owner[0].get("level") != 1: - raise ValueError("collection character no longer matches the rejected request") - if wallet.get("gold") != 56050 or wallet.get("spent", {}).get(IDENTITY) is not True: - raise ValueError("wallet differs from the observed failed payment") - inventory = {item.get("inven_index"): item for item in items.get("items", [])} - for index, (item_id, remaining, _) in CONSUMED.items(): - item = inventory.get(index) - if not item or item.get("id") != item_id or item.get("type") != 8 or item.get("count") != remaining: - raise ValueError(f"item stack {index} differs from the observed failed requests") - for index, (item_id, count) in REFUNDS.items(): - item = inventory.get(index) - if not item or item.get("id") != item_id or item.get("type") != 8 or item.get("count") != count: - raise ValueError(f"failed-request refund {index} was changed or used") - for indices in items.get("grant_items", {}).values(): - if any(index in REFUNDS for index in indices): - raise ValueError("a failed-request refund has a separate grant reference") - - corrected_items = dict(items) - corrected_items["items"] = [] - for original in items["items"]: - index = original["inven_index"] - if index in REFUNDS: - continue - item = dict(original) - if index in CONSUMED: - item["count"] += 6 * CONSUMED[index][2] - corrected_items["items"].append(item) - corrected_wallet = dict(wallet) - corrected_wallet["gold"] = wallet["gold"] + 10000 - corrected_wallet["spent"] = dict(wallet["spent"]) - del corrected_wallet["spent"][IDENTITY] - return corrected_items, corrected_wallet - - -def run(state: Path, log_path: Path, apply: bool) -> Path | None: - before = save_checkpoint.inspect_files(state) - values = {} - for name in ("items", "wallet", "collection"): - with (state / f"{name}.json").open(encoding="utf-8") as stream: - values[name] = json.load(stream) - repaired_items, repaired_wallet = repair(values["items"], values["wallet"], values["collection"], log_path.read_text(encoding="utf-8")) - print(json.dumps({ - "failed_requests": 6, - "character": "920000054 / 6510 / level 1 (unchanged)", - "restored_gold": 10000, - "restored_item_counts": {str(index): 6 * value[2] for index, value in CONSUMED.items()}, - "removed_unearned_refund_indices": sorted(REFUNDS), - }, indent=2)) - if not apply: - return None - running = save_checkpoint.running_processes() - if running: - raise RuntimeError("client/server is still running:\n" + "\n".join(running)) - backup = state / "checkpoints" / f"{datetime.now():%Y%m%d-%H%M%S}-before-failed-collection-growth-repair" - save_checkpoint.create(state, backup, "before undoing six rejected collection CharGrowth requests") - atomic_write_json(state / "items.json", repaired_items) - atomic_write_json(state / "wallet.json", repaired_wallet) - after = save_checkpoint.inspect_files(state) - if any(before[name] != after[name] for name in save_checkpoint.STATE_FILES if name not in {"items.json", "wallet.json"}): - raise OSError(f"unrelated account state changed; backup: {backup}") - for name, expected in (("items.json", repaired_items), ("wallet.json", repaired_wallet)): - with (state / name).open(encoding="utf-8") as stream: - if json.load(stream) != expected: - raise OSError(f"failed verification of {name}; backup: {backup}") - save_checkpoint.verify(backup) - return backup - - -def main() -> int: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--state", type=Path, default=DEFAULT_STATE) - parser.add_argument("--log", type=Path, default=DEFAULT_LOG) - parser.add_argument("--apply", action="store_true") - args = parser.parse_args() - try: - backup = run(args.state.resolve(), args.log.resolve(), args.apply) - print(f"Repaired with complete verified backup: {backup}" if backup else "Dry-run only; pass --apply after stopping client/server") - except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc: - parser.exit(1, f"repair_failed_collection_growth: {exc}\n") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tools/python/repair_invalid_dev_mail_resources.py b/tools/python/repair_invalid_dev_mail_resources.py deleted file mode 100644 index 39b014f..0000000 --- a/tools/python/repair_invalid_dev_mail_resources.py +++ /dev/null @@ -1,92 +0,0 @@ -#!/usr/bin/env python3 -"""Remove internal ResourceTable sentinels accidentally granted by dev mail. - -Dry-run by default. Applying requires stopped client/server, creates a verified -nine-file checkpoint, and atomically changes only items.json. Historical mail -grant ledgers remain so the invalid attachment cannot be replayed. -""" - -from __future__ import annotations - -import argparse -from datetime import datetime -import json -from pathlib import Path - -import save_checkpoint -from set_first_gacha_state import atomic_write_json - - -DEFAULT_STATE = Path(__file__).resolve().parents[2] / "data" / "state" -INVALID_RESOURCE_IDS = {90045, 90046} - - -def repair(value: dict) -> tuple[dict, list[int]]: - if value.get("version") != "2.34.13" or not isinstance(value.get("items"), list): - raise ValueError("items.json is not the expected 2.34.13 save") - grant_items = value.get("grant_items") - granted = value.get("granted") - if not isinstance(grant_items, dict) or not isinstance(granted, dict): - raise ValueError("items.json grant ledgers are missing") - bad_indices: list[int] = [] - for item in value["items"]: - if not isinstance(item, dict): - raise ValueError("invalid item instance") - if item.get("type") == 8 and item.get("id") in INVALID_RESOURCE_IDS: - index = item.get("inven_index") - if not isinstance(index, int) or index <= 0: - raise ValueError("invalid internal-resource inventory index") - owners = [ - identity for identity, indices in grant_items.items() - if isinstance(indices, list) and index in indices - ] - if len(owners) != 1 or not owners[0].startswith("mail:") or not granted.get(owners[0]): - raise ValueError(f"internal resource {index} is not an acknowledged mail grant") - bad_indices.append(index) - result = dict(value) - result["items"] = [item for item in value["items"] if item.get("inven_index") not in set(bad_indices)] - return result, bad_indices - - -def run(state: Path, apply: bool) -> Path | None: - target = state / "items.json" - with target.open(encoding="utf-8") as stream: - original = json.load(stream) - repaired, removed = repair(original) - print(json.dumps({"removed_count": len(removed), "removed_indices": removed}, indent=2)) - if not removed or not apply: - return None - running = save_checkpoint.running_processes() - if running: - raise RuntimeError("client/server is still running:\n" + "\n".join(running)) - before = save_checkpoint.inspect_files(state) - backup = state / "checkpoints" / f"{datetime.now():%Y%m%d-%H%M%S}-before-invalid-dev-mail-resource-repair" - save_checkpoint.create(state, backup, "before removing invalid developer-mail resource sentinels") - atomic_write_json(target, repaired) - with target.open(encoding="utf-8") as stream: - written = json.load(stream) - repeated, remaining = repair(written) - after = save_checkpoint.inspect_files(state) - if remaining or repeated != repaired or any( - before[name] != after[name] for name in save_checkpoint.STATE_FILES if name != "items.json" - ): - raise OSError(f"invalid developer-mail resource repair failed verification; backup: {backup}") - save_checkpoint.verify(backup) - return backup - - -def main() -> int: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--state", type=Path, default=DEFAULT_STATE) - parser.add_argument("--apply", action="store_true") - args = parser.parse_args() - try: - backup = run(args.state.resolve(), args.apply) - print(f"Repaired with complete verified backup: {backup}" if backup else "Dry-run/no change; use --apply after stopping the client/server") - except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc: - parser.exit(1, f"repair_invalid_dev_mail_resources: {exc}\n") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tools/python/rollback_quest38.py b/tools/python/rollback_quest38.py deleted file mode 100644 index b1723c5..0000000 --- a/tools/python/rollback_quest38.py +++ /dev/null @@ -1,273 +0,0 @@ -#!/usr/bin/env python3 -"""Narrowly roll the local account back to the pack21 quest-38 boundary. - -This is a one-off development/recovery tool, not server code. It understands -both legacy progress saves and the v2 ``pack:quest`` schema. By default it only -prints the planned changes; pass --apply after stopping the client and server. -Before applying it copies all nine account files to a timestamped checkpoint -and verifies every SHA-256 hash. -""" - -from __future__ import annotations - -import argparse -import copy -from datetime import datetime -import hashlib -import json -import os -from pathlib import Path -import shutil -import subprocess -import sys - - -STATE_FILES = ( - "characters.json", - "collection.json", - "deck.json", - "equipment.json", - "items.json", - "mail.json", - "missions.json", - "progress.json", - "wallet.json", -) -QUEST_ITEMS = { - 900000033: (122, 19, 1), - 900000034: (11, 19, 1), - 900000035: (501, 27, 6), - 900000036: (101, 27, 3), -} -ITEM_GRANT = "pack21:quest38:items" -WALLET_GRANT = "pack21:quest38" -WORKSPACE_ROOT = Path(__file__).resolve().parents[2] -DEFAULT_STATE = WORKSPACE_ROOT / "data" / "state" - - -def load_json(path: Path): - with path.open("r", encoding="utf-8") as stream: - return json.load(stream) - - -def sha256(path: Path) -> str: - digest = hashlib.sha256() - with path.open("rb") as stream: - for block in iter(lambda: stream.read(1024 * 1024), b""): - digest.update(block) - return digest.hexdigest().upper() - - -def quest_key(progress: dict, pack_id: int, quest_id: int) -> str: - version = int(progress.get("version", 0)) - return f"{pack_id}:{quest_id}" if version >= 2 else str(quest_id) - - -def quest_cleared(progress: dict, pack_id: int, quest_id: int) -> bool: - value = progress.get("cleared_quests", {}).get(quest_key(progress, pack_id, quest_id)) - if int(progress.get("version", 0)) >= 2: - return value is True - return value == pack_id - - -def running_game_processes() -> list[str]: - if os.name != "nt": - return [] - result = subprocess.run( - ["tasklist", "/FO", "CSV", "/NH"], - check=False, - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - ) - names = [] - for line in result.stdout.splitlines(): - lowered = line.lower() - if "browndust2" in lowered or "brown dust ii" in lowered or "bd2server.exe" in lowered: - names.append(line) - return names - - -def build_changes(state: Path, reference: Path) -> dict[str, dict]: - progress = load_json(state / "progress.json") - prior_progress = load_json(reference / "progress.json") - if not quest_cleared(progress, 21, 38): - raise ValueError("current save has not cleared pack21 quest38") - if not quest_cleared(prior_progress, 21, 37) or quest_cleared(prior_progress, 21, 38): - raise ValueError("reference is not at the quest37-complete/quest38-pending boundary") - - progress = copy.deepcopy(progress) - progress.get("cleared_quests", {}).pop(quest_key(progress, 21, 38), None) - progress.get("quests", {}).pop(quest_key(progress, 21, 38), None) - progress["position"] = copy.deepcopy(prior_progress["position"]) - - items = load_json(state / "items.json") - indices = items.get("grant_items", {}).get(ITEM_GRANT) - if ( - items.get("granted", {}).get(ITEM_GRANT) is not True - or not isinstance(indices, list) - or set(map(int, indices)) != set(QUEST_ITEMS) - ): - raise ValueError("quest38 item grant does not match the expected four instances") - remaining = [] - removed: set[int] = set() - for item in items.get("items", []): - index = int(item.get("inven_index", 0)) - if index not in QUEST_ITEMS: - remaining.append(item) - continue - expected = QUEST_ITEMS[index] - actual = (int(item.get("id", 0)), int(item.get("type", 0)), int(item.get("count", 0))) - if actual != expected: - raise ValueError(f"unexpected quest38 reward item {item!r}; expected {expected}") - removed.add(index) - if removed != set(QUEST_ITEMS): - raise ValueError(f"quest38 reward instances are incomplete: found {sorted(removed)}") - items = copy.deepcopy(items) - items["items"] = remaining - items["granted"].pop(ITEM_GRANT, None) - items["grant_items"].pop(ITEM_GRANT, None) - items["next_index"] = max( - (int(item.get("inven_index", 0)) + 1 for item in remaining), default=1 - ) - - wallet = load_json(state / "wallet.json") - if wallet.get("granted", {}).get(WALLET_GRANT) is not True or int(wallet.get("gold", 0)) < 1500: - raise ValueError("quest38 wallet grant is absent or gold is below 1500") - wallet = copy.deepcopy(wallet) - wallet["gold"] = int(wallet["gold"]) - 1500 - wallet["granted"].pop(WALLET_GRANT, None) - - deck = load_json(state / "deck.json") - prior_deck = load_json(reference / "deck.json") - if "field_char_control_deck_type" not in prior_deck: - raise ValueError("reference deck has no field_char_control_deck_type") - deck = copy.deepcopy(deck) - deck["field_char_control_deck_type"] = prior_deck["field_char_control_deck_type"] - - return { - "progress.json": progress, - "items.json": items, - "wallet.json": wallet, - "deck.json": deck, - } - - -def checkpoint_all(state: Path, target: Path) -> None: - if target.exists(): - raise FileExistsError(f"checkpoint already exists: {target}") - target.mkdir(parents=True) - try: - for name in STATE_FILES: - source = state / name - if not source.is_file(): - raise FileNotFoundError(f"missing state file: {source}") - shutil.copy2(source, target / name) - mismatches = [ - name for name in STATE_FILES if sha256(state / name) != sha256(target / name) - ] - if mismatches: - raise OSError(f"checkpoint SHA-256 mismatch: {mismatches}") - except Exception: - shutil.rmtree(target, ignore_errors=True) - raise - - -def atomic_write_json(path: Path, value: dict) -> None: - temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp") - try: - with temporary.open("w", encoding="utf-8", newline="\n") as stream: - json.dump(value, stream, ensure_ascii=False, indent=2) - stream.write("\n") - stream.flush() - os.fsync(stream.fileno()) - os.replace(temporary, path) - finally: - temporary.unlink(missing_ok=True) - - -def describe(changes: dict[str, dict]) -> dict: - progress = changes["progress.json"] - wallet = changes["wallet.json"] - items = changes["items.json"] - deck = changes["deck.json"] - return { - "quest37_cleared": quest_cleared(progress, 21, 37), - "quest38_cleared": quest_cleared(progress, 21, 38), - "position": progress.get("position"), - "gold": wallet.get("gold"), - "next_item_index": items.get("next_index"), - "field_char_control_deck_type": deck.get("field_char_control_deck_type"), - "files_to_modify": sorted(changes), - } - - -def verify_written_state(state: Path) -> dict: - progress = load_json(state / "progress.json") - items = load_json(state / "items.json") - wallet = load_json(state / "wallet.json") - deck = load_json(state / "deck.json") - if not quest_cleared(progress, 21, 37) or quest_cleared(progress, 21, 38): - raise ValueError("written progress is not at the quest38 boundary") - if ITEM_GRANT in items.get("granted", {}) or ITEM_GRANT in items.get("grant_items", {}): - raise ValueError("written inventory still contains the quest38 grant") - if any(int(item.get("inven_index", 0)) in QUEST_ITEMS for item in items.get("items", [])): - raise ValueError("written inventory still contains a quest38 reward instance") - if WALLET_GRANT in wallet.get("granted", {}): - raise ValueError("written wallet still contains the quest38 grant") - return describe( - { - "progress.json": progress, - "items.json": items, - "wallet.json": wallet, - "deck.json": deck, - } - ) - - -def main() -> int: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("state", nargs="?", default=DEFAULT_STATE, type=Path) - parser.add_argument( - "reference", - nargs="?", - default=DEFAULT_STATE / "checkpoints" / "20260920-1801-before-regular-gacha", - type=Path, - ) - parser.add_argument("--apply", action="store_true", help="perform the rollback") - parser.add_argument( - "--backup", - type=Path, - help="checkpoint destination; defaults below state/checkpoints", - ) - args = parser.parse_args() - state = args.state.resolve() - reference = args.reference.resolve() - try: - changes = build_changes(state, reference) - print(json.dumps(describe(changes), ensure_ascii=False, indent=2)) - if not args.apply: - print("dry run only; stop client/server and pass --apply to write", file=sys.stderr) - return 0 - running = running_game_processes() - if running: - raise RuntimeError("client/server is still running:\n" + "\n".join(running)) - backup = args.backup - if backup is None: - stamp = datetime.now().strftime("%Y%m%d-%H%M%S") - backup = state / "checkpoints" / f"{stamp}-before-quest38-rollback" - backup = backup.resolve() - checkpoint_all(state, backup) - for name, value in changes.items(): - atomic_write_json(state / name, value) - verified = verify_written_state(state) - print(f"rollback applied; verified checkpoint: {backup}") - print(json.dumps(verified, ensure_ascii=False, indent=2)) - except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc: - parser.exit(1, f"rollback_quest38: {exc}\n") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tools/python/save_checkpoint.py b/tools/python/save_checkpoint.py deleted file mode 100644 index e87afa9..0000000 --- a/tools/python/save_checkpoint.py +++ /dev/null @@ -1,209 +0,0 @@ -#!/usr/bin/env python3 -"""Create, verify, and restore complete local-account checkpoints. - -Unlike the old tutorial-specific Go command this tool does not synthesize game -state. It copies the nine authoritative JSON files, records SHA-256 hashes, and -requires --apply plus stopped client/server processes before restore. -""" - -from __future__ import annotations - -import argparse -from datetime import datetime -import hashlib -import json -import os -from pathlib import Path -import shutil -import subprocess -import sys - - -STATE_FILES = ( - "characters.json", - "collection.json", - "deck.json", - "equipment.json", - "items.json", - "mail.json", - "missions.json", - "progress.json", - "wallet.json", -) -MANIFEST = "checkpoint.json" -WORKSPACE_ROOT = Path(__file__).resolve().parents[2] -DEFAULT_STATE = WORKSPACE_ROOT / "data" / "state" - - -def digest(path: Path) -> str: - result = hashlib.sha256() - with path.open("rb") as stream: - for block in iter(lambda: stream.read(1024 * 1024), b""): - result.update(block) - return result.hexdigest().upper() - - -def inspect_files(directory: Path) -> dict[str, dict[str, object]]: - result = {} - for name in STATE_FILES: - path = directory / name - if not path.is_file(): - raise FileNotFoundError(f"missing account state file: {path}") - with path.open("r", encoding="utf-8") as stream: - json.load(stream) - result[name] = {"size": path.stat().st_size, "sha256": digest(path)} - return result - - -def running_processes() -> list[str]: - if os.name != "nt": - return [] - result = subprocess.run( - ["tasklist", "/FO", "CSV", "/NH"], - check=False, - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - ) - if result.returncode != 0: - detail = (result.stderr or result.stdout).strip() - raise RuntimeError( - "cannot verify that the client/server are stopped; " - f"tasklist failed with exit code {result.returncode}: {detail}" - ) - return [ - line - for line in result.stdout.splitlines() - if "browndust2" in line.lower() - or "brown dust ii" in line.lower() - or "bd2server.exe" in line.lower() - ] - - -def write_manifest(target: Path, files: dict[str, dict[str, object]], label: str) -> None: - value = { - "format": 1, - "created_at": datetime.now().astimezone().isoformat(), - "label": label, - "files": files, - } - path = target / MANIFEST - temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp") - try: - with temporary.open("w", encoding="utf-8", newline="\n") as stream: - json.dump(value, stream, ensure_ascii=False, indent=2) - stream.write("\n") - stream.flush() - os.fsync(stream.fileno()) - os.replace(temporary, path) - finally: - temporary.unlink(missing_ok=True) - - -def create(state: Path, target: Path, label: str) -> dict[str, dict[str, object]]: - source_info = inspect_files(state) - if target.exists(): - raise FileExistsError(f"checkpoint already exists: {target}") - target.mkdir(parents=True) - try: - for name in STATE_FILES: - shutil.copy2(state / name, target / name) - copied_info = inspect_files(target) - if copied_info != source_info: - raise OSError("checkpoint differs from source after SHA-256 verification") - write_manifest(target, copied_info, label) - return copied_info - except Exception: - shutil.rmtree(target, ignore_errors=True) - raise - - -def verify(checkpoint: Path) -> dict[str, dict[str, object]]: - actual = inspect_files(checkpoint) - manifest_path = checkpoint / MANIFEST - if manifest_path.is_file(): - with manifest_path.open("r", encoding="utf-8") as stream: - manifest = json.load(stream) - if manifest.get("format") != 1 or manifest.get("files") != actual: - raise ValueError("checkpoint manifest does not match its files") - return actual - - -def atomic_copy(source: Path, target: Path) -> None: - temporary = target.with_name(f".{target.name}.{os.getpid()}.tmp") - try: - with source.open("rb") as reader, temporary.open("wb") as writer: - shutil.copyfileobj(reader, writer, length=1024 * 1024) - writer.flush() - os.fsync(writer.fileno()) - os.replace(temporary, target) - finally: - temporary.unlink(missing_ok=True) - - -def restore(state: Path, checkpoint: Path, apply: bool) -> Path | None: - checkpoint_info = verify(checkpoint) - print(json.dumps(checkpoint_info, ensure_ascii=False, indent=2)) - if not apply: - print("dry run only; stop client/server and pass --apply to restore", file=sys.stderr) - return None - running = running_processes() - if running: - raise RuntimeError("client/server is still running:\n" + "\n".join(running)) - stamp = datetime.now().strftime("%Y%m%d-%H%M%S") - backup = state / "checkpoints" / f"{stamp}-before-restore" - create(state, backup, "automatic backup before restore") - for name in STATE_FILES: - atomic_copy(checkpoint / name, state / name) - if inspect_files(state) != checkpoint_info: - raise OSError(f"restore verification failed; previous state is at {backup}") - return backup - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description=__doc__) - sub = parser.add_subparsers(dest="command", required=True) - command = sub.add_parser("create") - command.add_argument("--state", type=Path, default=DEFAULT_STATE) - command.add_argument("--label", default="manual") - command.add_argument("--output", type=Path) - - command = sub.add_parser("verify") - command.add_argument("checkpoint", type=Path) - - command = sub.add_parser("restore") - command.add_argument("checkpoint", type=Path) - command.add_argument("--state", type=Path, default=DEFAULT_STATE) - command.add_argument("--apply", action="store_true") - return parser - - -def main() -> int: - parser = build_parser() - args = parser.parse_args() - try: - if args.command == "create": - state = args.state.resolve() - output = args.output - if output is None: - stamp = datetime.now().strftime("%Y%m%d-%H%M%S") - safe_label = "-".join(args.label.strip().split()) or "manual" - output = state / "checkpoints" / f"{stamp}-{safe_label}" - output = output.resolve() - info = create(state, output, args.label) - print(f"created {output}") - print(json.dumps(info, ensure_ascii=False, indent=2)) - elif args.command == "verify": - print(json.dumps(verify(args.checkpoint.resolve()), ensure_ascii=False, indent=2)) - else: - backup = restore(args.state.resolve(), args.checkpoint.resolve(), args.apply) - if backup is not None: - print(f"restored {args.checkpoint.resolve()}; previous state: {backup}") - except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc: - parser.exit(1, f"save_checkpoint: {exc}\n") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tools/python/set_first_gacha_state.py b/tools/python/set_first_gacha_state.py deleted file mode 100644 index e281f01..0000000 --- a/tools/python/set_first_gacha_state.py +++ /dev/null @@ -1,114 +0,0 @@ -#!/usr/bin/env python3 -"""Set the explicit first-gacha completion marker in a development save. - -This is a pre-release schema edit, not a compatibility migration. By default -it prints the proposed collection.json change. Applying requires stopped game -and server processes, creates a verified nine-file checkpoint, and atomically -replaces only collection.json. -""" - -from __future__ import annotations - -import argparse -from datetime import datetime -import json -import os -from pathlib import Path -import sys - -import save_checkpoint - - -IDENTITY = "account:first-gacha-completed" -WORKSPACE_ROOT = Path(__file__).resolve().parents[2] -DEFAULT_STATE = WORKSPACE_ROOT / "data" / "state" - - -def updated_collection(value: dict, completed: bool) -> tuple[dict, bool]: - if value.get("version") != "2.34.13": - raise ValueError("collection.json is not a 2.34.13 save") - grants = value.get("grants") - if not isinstance(grants, dict): - raise ValueError("collection.json grants must be an object") - result = dict(value) - result["grants"] = dict(grants) - before = IDENTITY in grants - if completed: - existing = grants.get(IDENTITY) - if before and existing != {}: - raise ValueError("first-gacha marker exists with unexpected payload") - result["grants"][IDENTITY] = {} - else: - result["grants"].pop(IDENTITY, None) - return result, before != completed - - -def atomic_write_json(path: Path, value: dict) -> None: - temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp") - try: - with temporary.open("w", encoding="utf-8", newline="\n") as stream: - json.dump(value, stream, ensure_ascii=False, separators=(",", ":")) - stream.write("\n") - stream.flush() - os.fsync(stream.fileno()) - os.replace(temporary, path) - finally: - temporary.unlink(missing_ok=True) - - -def apply_state(state: Path, completed: bool, apply: bool) -> Path | None: - collection_path = state / "collection.json" - with collection_path.open("r", encoding="utf-8") as stream: - current = json.load(stream) - updated, changed = updated_collection(current, completed) - print(json.dumps({ - "identity": IDENTITY, - "before": IDENTITY in current["grants"], - "after": completed, - "changed": changed, - }, ensure_ascii=False, indent=2)) - if not changed: - return None - if not apply: - print("dry run only; stop client/server and pass --apply", file=sys.stderr) - return None - running = save_checkpoint.running_processes() - if running: - raise RuntimeError("client/server is still running:\n" + "\n".join(running)) - save_checkpoint.inspect_files(state) - stamp = datetime.now().strftime("%Y%m%d-%H%M%S") - backup = state / "checkpoints" / f"{stamp}-before-first-gacha-state" - save_checkpoint.create(state, backup, "before explicit first-gacha state edit") - atomic_write_json(collection_path, updated) - with collection_path.open("r", encoding="utf-8") as stream: - written = json.load(stream) - if (IDENTITY in written.get("grants", {})) != completed: - raise OSError(f"written first-gacha state failed verification; backup: {backup}") - save_checkpoint.inspect_files(state) - return backup - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--state", type=Path, default=DEFAULT_STATE) - selection = parser.add_mutually_exclusive_group(required=True) - selection.add_argument("--completed", action="store_true") - selection.add_argument("--not-completed", action="store_true") - parser.add_argument("--apply", action="store_true") - return parser - - -def main() -> int: - parser = build_parser() - args = parser.parse_args() - try: - backup = apply_state(args.state.resolve(), args.completed, args.apply) - if backup is not None: - print(f"updated {args.state.resolve() / 'collection.json'}; backup: {backup}") - except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc: - parser.exit(1, f"set_first_gacha_state: {exc}\n") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tools/python/tests/test_offline_tools.py b/tools/python/tests/test_offline_tools.py index 96da396..9226675 100644 --- a/tools/python/tests/test_offline_tools.py +++ b/tools/python/tests/test_offline_tools.py @@ -18,16 +18,9 @@ sys.path.insert(0, str(TOOLS)) import gamedata_db import import_seed -import save_checkpoint import deobfuscate_client_source import extract_client_proto -import set_first_gacha_state -import repair_equipment_ranks -import repair_failed_collection_growth -import migrate_costume_potential_state -import migrate_equipment_upgrade_attempts import dev_mail_grant -import repair_invalid_dev_mail_resources class Arguments: @@ -110,117 +103,6 @@ class ImportToolTests(unittest.TestCase): self.assertEqual(mailbox["mails"][0]["reward_counts"], [3]) -class CheckpointToolTests(unittest.TestCase): - def test_invalid_dev_mail_resource_repair_preserves_grant_ledger(self): - identity = "mail:13043056739:items" - original = { - "version": "2.34.13", - "items": [ - {"inven_index": 10, "id": 90045, "type": 8, "count": 1}, - {"inven_index": 11, "id": 127, "type": 8, "count": 99}, - ], - "granted": {identity: True}, - "grant_items": {identity: [10]}, - } - repaired, removed = repair_invalid_dev_mail_resources.repair(original) - self.assertEqual(removed, [10]) - self.assertEqual([item["inven_index"] for item in repaired["items"]], [11]) - self.assertEqual(repaired["granted"], original["granted"]) - self.assertEqual(repaired["grant_items"], original["grant_items"]) - repeated, removed = repair_invalid_dev_mail_resources.repair(repaired) - self.assertEqual(removed, []) - self.assertEqual(repeated, repaired) - - def test_process_check_fails_closed_when_tasklist_is_unavailable(self): - failed = mock.Mock(returncode=1, stdout="", stderr="ERROR: Access denied") - with mock.patch.object(save_checkpoint.os, "name", "nt"), mock.patch.object(save_checkpoint.subprocess, "run", return_value=failed): - with self.assertRaisesRegex(RuntimeError, "cannot verify"): - save_checkpoint.running_processes() - - def test_equipment_upgrade_attempt_migration_is_explicit_and_idempotent(self): - original = {"version": "2.34.13", "equipment": [{"inven_index": 1, "id": 10010}]} - migrated, changed = migrate_equipment_upgrade_attempts.migrate(original) - self.assertEqual(changed, [1]) - self.assertEqual(migrated["equipment"][0]["upgrade_attempts"], 0) - self.assertNotIn("upgrade_attempts", original["equipment"][0]) - repeated, changed = migrate_equipment_upgrade_attempts.migrate(migrated) - self.assertEqual(changed, []) - self.assertEqual(repeated, migrated) - - def test_costume_potential_state_migration_is_final_and_idempotent(self): - original = {"version": "2.34.13", "costumes": [{"inven_index": 1, "id": 1001}]} - migrated, changed = migrate_costume_potential_state.migrate(original) - self.assertTrue(changed) - self.assertEqual(migrated["costume_potential"], {}) - self.assertNotIn("costume_potential", original) - repeated, changed = migrate_costume_potential_state.migrate(migrated) - self.assertFalse(changed) - self.assertEqual(repeated, migrated) - with self.assertRaises(ValueError): - migrate_costume_potential_state.migrate({"version": "2.34.13", "costumes": [{"potential_id": 1}]}) - - def test_failed_collection_growth_repair_requires_exact_six_unchanged_requests(self): - module = repair_failed_collection_growth - items = {"version": "2.34.13", "grant_items": {}, "next_index": 900000061, "items": [ - {"inven_index": index, "id": value[0], "type": 8, "count": value[1]} - for index, value in module.CONSUMED.items() - ] + [ - {"inven_index": index, "id": value[0], "type": 8, "count": value[1]} - for index, value in module.REFUNDS.items() - ]} - wallet = {"version": "2.34.13", "gold": 56050, "spent": {module.IDENTITY: True}} - collection = {"version": "2.34.13", "characters": [ - {"inven_index": 920000054, "id": 6510, "level": 1}, - ]} - line = f"WARN session packet rejected path=/CharGrowth error={module.ERROR}\n" - corrected_items, corrected_wallet = module.repair(items, wallet, collection, line * 6) - self.assertEqual(corrected_wallet["gold"], 66050) - self.assertNotIn(module.IDENTITY, corrected_wallet["spent"]) - self.assertEqual(len(corrected_items["items"]), len(module.CONSUMED)) - self.assertEqual(next(item["count"] for item in corrected_items["items"] if item["inven_index"] == 900000042), 99256) - self.assertEqual(items["items"][2]["count"], 94738) - with self.assertRaises(ValueError): - module.repair(items, wallet, collection, line * 5) - - def test_equipment_rank_repair_only_fills_missing_arrays(self): - original = {"version": "2.34.13", "equipment": [ - {"inven_index": 1, "id": 10010}, - {"inven_index": 2, "id": 943619, "rank": [0, 2, 0]}, - ]} - repaired, indices = repair_equipment_ranks.repair(original) - self.assertEqual(indices, [1]) - self.assertEqual(repaired["equipment"][0]["rank"], [0, 0, 0]) - self.assertEqual(repaired["equipment"][1]["rank"], [0, 2, 0]) - self.assertNotIn("rank", original["equipment"][0]) - repeated, indices = repair_equipment_ranks.repair(repaired) - self.assertEqual(indices, []) - self.assertEqual(repeated, repaired) - with self.assertRaises(ValueError): - repair_equipment_ranks.repair({"version": "2.34.13", "equipment": [{"inven_index": 3, "rank": [1, 2]}]}) - - def test_checkpoint_hash_manifest(self): - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - state = root / "state" - target = root / "checkpoint" - state.mkdir() - for name in save_checkpoint.STATE_FILES: - (state / name).write_text(json.dumps({"name": name}), encoding="utf-8") - created = save_checkpoint.create(state, target, "test") - self.assertEqual(save_checkpoint.verify(target), created) - - def test_first_gacha_state_edit_is_explicit_and_idempotent(self): - original = {"version": "2.34.13", "grants": {"draw": {}}} - completed, changed = set_first_gacha_state.updated_collection(original, True) - self.assertTrue(changed) - self.assertEqual(completed["grants"][set_first_gacha_state.IDENTITY], {}) - repeated, changed = set_first_gacha_state.updated_collection(completed, True) - self.assertFalse(changed) - pending, changed = set_first_gacha_state.updated_collection(repeated, False) - self.assertTrue(changed) - self.assertNotIn(set_first_gacha_state.IDENTITY, pending["grants"]) - - class DevelopmentMailGrantToolTests(unittest.TestCase): def test_deterministic_random_box_is_replaced_by_direct_material(self): items = [