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:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user