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:
@@ -30,6 +30,7 @@ TestResults/
|
||||
.ghc.environment.*
|
||||
|
||||
# Python caches and temporary files
|
||||
tools/python/repair/
|
||||
__pycache__/
|
||||
**/__pycache__/
|
||||
*.py[cod]
|
||||
|
||||
@@ -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)")
|
||||
|
||||
@@ -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
@@ -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" +
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, "", " ")
|
||||
|
||||
@@ -22,7 +22,6 @@ var emptyResponses = map[string]int{
|
||||
"/AvatarShopWishListInfo": 470,
|
||||
"/CafeteriaInfo": 351,
|
||||
"/CashBonusInfo": 588,
|
||||
"/CharAwakeInfo": 0,
|
||||
"/CharPartnerInfo": 65,
|
||||
"/CommunityRewardInfo": 289,
|
||||
"/DailyStoryInfo": 538,
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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...)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
//go:build !windows && !linux && !darwin
|
||||
|
||||
package statetx
|
||||
|
||||
func snapshotBefore(source, destination string) error {
|
||||
return copySynced(source, destination)
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
//go:build windows
|
||||
|
||||
package statetx
|
||||
|
||||
import (
|
||||
"os"
|
||||
"sync"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
var getDiskFreeSpaceW = windows.NewLazySystemDLL("kernel32.dll").NewProc("GetDiskFreeSpaceW")
|
||||
|
||||
const fileSupportsBlockRefcounting = 0x08000000
|
||||
|
||||
type windowsVolumeSnapshotInfo struct {
|
||||
clusterSize int64
|
||||
blockClone bool
|
||||
}
|
||||
|
||||
var windowsVolumeCache sync.Map
|
||||
|
||||
type duplicateExtentsData struct {
|
||||
SourceHandle windows.Handle
|
||||
SourceOffset int64
|
||||
TargetOffset int64
|
||||
ByteCount int64
|
||||
}
|
||||
|
||||
// ReFS block cloning creates an independent copy-on-write file. NTFS does not
|
||||
// expose file-level CoW cloning, so it uses a hard link to the immutable old
|
||||
// inode; every account Store is required to publish through temp+replace.
|
||||
// Unsupported Windows filesystems fall back to a synchronized byte copy.
|
||||
func snapshotBefore(source, destination string) error {
|
||||
if volume, err := volumeSnapshotInfo(source); err == nil && volume.blockClone {
|
||||
if err := blockCloneWindows(source, destination, volume.clusterSize); err == nil {
|
||||
return nil
|
||||
}
|
||||
_ = os.Remove(destination)
|
||||
}
|
||||
if err := os.Link(source, destination); err == nil {
|
||||
return nil
|
||||
}
|
||||
return copySynced(source, destination)
|
||||
}
|
||||
|
||||
func blockCloneWindows(source, destination string, clusterSize int64) error {
|
||||
info, err := os.Stat(source)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
size := info.Size()
|
||||
cloneBytes := size / clusterSize * clusterSize
|
||||
if cloneBytes == 0 {
|
||||
return windows.ERROR_NOT_SUPPORTED
|
||||
}
|
||||
src, err := os.Open(source)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer src.Close()
|
||||
dst, err := os.OpenFile(destination, os.O_CREATE|os.O_EXCL|os.O_RDWR, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = dst.Truncate(size); err == nil && size != 0 {
|
||||
input := duplicateExtentsData{
|
||||
SourceHandle: windows.Handle(src.Fd()),
|
||||
ByteCount: cloneBytes,
|
||||
}
|
||||
var returned uint32
|
||||
err = windows.DeviceIoControl(
|
||||
windows.Handle(dst.Fd()),
|
||||
windows.FSCTL_DUPLICATE_EXTENTS_TO_FILE,
|
||||
(*byte)(unsafe.Pointer(&input)), uint32(unsafe.Sizeof(input)),
|
||||
nil, 0, &returned, nil,
|
||||
)
|
||||
}
|
||||
if err == nil && cloneBytes < size {
|
||||
tail := make([]byte, size-cloneBytes)
|
||||
if _, readErr := src.ReadAt(tail, cloneBytes); readErr != nil {
|
||||
err = readErr
|
||||
} else {
|
||||
_, err = dst.WriteAt(tail, cloneBytes)
|
||||
}
|
||||
}
|
||||
if err == nil {
|
||||
err = dst.Sync()
|
||||
}
|
||||
if closeErr := dst.Close(); err == nil {
|
||||
err = closeErr
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func volumeSnapshotInfo(path string) (windowsVolumeSnapshotInfo, error) {
|
||||
pathUTF16, err := windows.UTF16PtrFromString(path)
|
||||
if err != nil {
|
||||
return windowsVolumeSnapshotInfo{}, err
|
||||
}
|
||||
volume := make([]uint16, windows.MAX_PATH+1)
|
||||
if err := windows.GetVolumePathName(pathUTF16, &volume[0], uint32(len(volume))); err != nil {
|
||||
return windowsVolumeSnapshotInfo{}, err
|
||||
}
|
||||
volumeName := windows.UTF16ToString(volume)
|
||||
if cached, ok := windowsVolumeCache.Load(volumeName); ok {
|
||||
return cached.(windowsVolumeSnapshotInfo), nil
|
||||
}
|
||||
var flags uint32
|
||||
if err := windows.GetVolumeInformation(&volume[0], nil, 0, nil, nil, &flags, nil, 0); err != nil {
|
||||
return windowsVolumeSnapshotInfo{}, err
|
||||
}
|
||||
var sectorsPerCluster, bytesPerSector uint32
|
||||
result, _, callErr := getDiskFreeSpaceW.Call(
|
||||
uintptr(unsafe.Pointer(&volume[0])),
|
||||
uintptr(unsafe.Pointer(§orsPerCluster)),
|
||||
uintptr(unsafe.Pointer(&bytesPerSector)),
|
||||
0, 0,
|
||||
)
|
||||
if result == 0 {
|
||||
return windowsVolumeSnapshotInfo{}, callErr
|
||||
}
|
||||
cluster := uint64(sectorsPerCluster) * uint64(bytesPerSector)
|
||||
if cluster == 0 || cluster > uint64(^uint32(0)) {
|
||||
return windowsVolumeSnapshotInfo{}, windows.ERROR_INVALID_DATA
|
||||
}
|
||||
resultInfo := windowsVolumeSnapshotInfo{clusterSize: int64(cluster), blockClone: flags&fileSupportsBlockRefcounting != 0}
|
||||
windowsVolumeCache.Store(volumeName, resultInfo)
|
||||
return resultInfo, nil
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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("ὮὮὫὭὢὩὭὢὦὪὠ");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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 = [
|
||||
|
||||
Reference in New Issue
Block a user