feat: expand gameplay and state persistence

- add character imprint, awakening, and equipment refinement

- add equipment ticket gacha and persistent progression fields

- add request-scoped account transactions with crash recovery

- persist age confirmation and isolate client plugin patches

- separate one-off save repair tools from shipped tooling
This commit is contained in:
2026-09-23 00:55:36 +08:00
parent f55a71ac52
commit 113e941bef
57 changed files with 4377 additions and 1314 deletions
+1
View File
@@ -30,6 +30,7 @@ TestResults/
.ghc.environment.*
# Python caches and temporary files
tools/python/repair/
__pycache__/
**/__pycache__/
*.py[cod]
+95 -2
View File
@@ -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)")
+21
View File
@@ -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")
}
}
+57 -29
View File
@@ -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" +
+26
View File
@@ -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)
+28
View File
@@ -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)
}
}
+10
View File
@@ -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, "", " ")
-1
View File
@@ -22,7 +22,6 @@ var emptyResponses = map[string]int{
"/AvatarShopWishListInfo": 470,
"/CafeteriaInfo": 351,
"/CashBonusInfo": 588,
"/CharAwakeInfo": 0,
"/CharPartnerInfo": 65,
"/CommunityRewardInfo": 289,
"/DailyStoryInfo": 538,
+5 -2
View File
@@ -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),
+36 -12
View File
@@ -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
}
+85
View File
@@ -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,
+7
View File
@@ -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
+397
View File
@@ -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
}
+91
View File
@@ -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)
}
}
+112 -5
View File
@@ -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)
}
+295
View File
@@ -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
}
@@ -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)
}
}
@@ -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")
}
}
+15
View File
@@ -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
+15
View File
@@ -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
+13 -3
View File
@@ -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)
+300
View File
@@ -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)
}
+140
View File
@@ -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())
}
}
+102
View File
@@ -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...)
+539 -3
View File
@@ -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 {
+172 -1
View File
@@ -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})
+11
View File
@@ -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 {
+11
View File
@@ -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
+59 -5
View File
@@ -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.
+11
View File
@@ -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 {
+71 -4
View File
@@ -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) {
+161
View File
@@ -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)
+2 -2
View File
@@ -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
}
@@ -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 {
+31 -21
View File
@@ -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))
+413
View File
@@ -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)
}
+16
View File
@@ -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()
}
+38
View File
@@ -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
}
+28
View File
@@ -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)
}
+40
View File
@@ -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
}
+7
View File
@@ -0,0 +1,7 @@
//go:build !windows && !linux && !darwin
package statetx
func snapshotBefore(source, destination string) error {
return copySynced(source, destination)
}
+131
View File
@@ -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(&sectorsPerCluster)),
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
}
+200
View File
@@ -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)
}
+252
View File
@@ -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)
}
}
}
+107
View File
@@ -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);
}
}
+91 -4
View File
@@ -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("ὮὮὫὭὢὩὭὢὦὪὠ");
+3
View File
@@ -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 {
-38
View File
@@ -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
@@ -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())
@@ -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())
-90
View File
@@ -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())
@@ -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())
@@ -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())
-273
View File
@@ -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())
-209
View File
@@ -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())
-114
View File
@@ -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())
-118
View File
@@ -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 = [