feat: expand gameplay and state persistence

- add character imprint, awakening, and equipment refinement

- add equipment ticket gacha and persistent progression fields

- add request-scoped account transactions with crash recovery

- persist age confirmation and isolate client plugin patches

- separate one-off save repair tools from shipped tooling
This commit is contained in:
2026-09-23 00:55:36 +08:00
parent f55a71ac52
commit 113e941bef
57 changed files with 4377 additions and 1314 deletions
+95 -2
View File
@@ -28,6 +28,8 @@ import (
"bd2server/internal/readonly"
"bd2server/internal/schedule"
"bd2server/internal/session"
"bd2server/internal/statebridge"
"bd2server/internal/statetx"
"bd2server/internal/transport"
"bd2server/internal/world"
)
@@ -57,7 +59,7 @@ func main() {
}
}
func serve(args []string) error {
func serve(args []string) (serveErr error) {
fs := flag.NewFlagSet("serve", flag.ContinueOnError)
listen := fs.String("listen", "127.0.0.1:8080", "local listen address")
cdn := fs.String("cdn", "", "ServerData root (required)")
@@ -128,9 +130,30 @@ func serve(args []string) error {
if err != nil {
return fmt.Errorf("load regular gacha GameData: %w", err)
}
if err := repairStateBeforeServe(filepath.Dir(filepath.Clean(*stateFile)), *stateTool, *gameDataVersion, regularGacha, starter); err != nil {
stateDir := filepath.Dir(filepath.Clean(*stateFile))
if err := validateAccountStatePaths(stateDir, *stateFile, *deckState); err != nil {
return err
}
stateTransaction, err := statetx.Open(stateDir, statebridge.StateFiles())
if err != nil {
return fmt.Errorf("recover account state transaction: %w", err)
}
if err := repairStateBeforeServe(stateDir, *stateTool, *gameDataVersion, regularGacha, starter); err != nil {
return fmt.Errorf("state migration/repair: %w", err)
}
startupTransaction, err := stateTransaction.Begin()
if err != nil {
return fmt.Errorf("begin startup state transaction: %w", err)
}
startupCommitted := false
defer func() {
if startupCommitted {
return
}
if rollbackErr := startupTransaction.Rollback(); rollbackErr != nil {
serveErr = errors.Join(serveErr, rollbackErr)
}
}()
serverConfig, err := readonly.Load(filepath.Clean(*readonlySeed))
if err != nil {
return fmt.Errorf("load readonly server configuration: %w", err)
@@ -170,8 +193,13 @@ func serve(args []string) error {
if err != nil {
return fmt.Errorf("read account seed hope powder: %w", err)
}
equipMileage, equipMileageExchangeGage, err := login.SeedEquipmentMileage()
if err != nil {
return fmt.Errorf("read account seed equipment mileage: %w", err)
}
wallet, err := player.OpenWallet(filepath.Join(filepath.Dir(*stateFile), "wallet.json"), player.Currency{
Gold: gold, FreeJewelry: freeJewelry, Jewelry: jewelry, Mileage: mileage, HopePowder: hopePowder,
EquipMileage: equipMileage, EquipMileageExchangeGage: equipMileageExchangeGage,
})
if err != nil {
return fmt.Errorf("load wallet state: %w", err)
@@ -221,6 +249,13 @@ func serve(args []string) error {
if err := ownedEquipment.AttachUpgrade(equipmentUpgrade, wallet, ownedItems); err != nil {
return fmt.Errorf("attach equipment upgrade GameData: %w", err)
}
equipmentSmelting, err := gamedata.LoadEquipmentSmeltingDesign(filepath.Clean(*gameData), *gameDataVersion)
if err != nil {
return fmt.Errorf("load equipment smelting GameData: %w", err)
}
if err := ownedEquipment.AttachSmelting(equipmentSmelting, wallet, ownedItems); err != nil {
return fmt.Errorf("attach equipment smelting GameData: %w", err)
}
collection, err := player.OpenCollectionStore(filepath.Join(filepath.Dir(*stateFile), "collection.json"), starter.Costumes)
if err != nil {
return fmt.Errorf("load owned collection: %w", err)
@@ -272,6 +307,15 @@ func serve(args []string) error {
return fmt.Errorf("load pictorial GameData: %w", err)
}
pictorialService := &pictorial.Service{Design: pictorialDesign, Owned: worldService}
charAwakeDesign, err := gamedata.LoadCharAwakeDesign(filepath.Clean(*gameData), *gameDataVersion)
if err != nil {
return fmt.Errorf("load character awakening GameData: %w", err)
}
charAwakeService, err := player.NewCharAwakeService(charAwakeDesign, collection, worldService.CharacterService(), ownedItems, wallet)
if err != nil {
return err
}
pictorialService.AwakeContributions = charAwakeService.Contributions
if err := worldService.CharacterService().AttachMaxHealth(pictorialService.MaxHealth); err != nil {
return fmt.Errorf("attach pictorial character stats: %w", err)
}
@@ -302,6 +346,7 @@ func serve(args []string) error {
deckStateStore,
ownedItems,
ownedEquipment,
charAwakeService,
costumePotentialService,
starter,
mailService,
@@ -315,6 +360,22 @@ func serve(args []string) error {
if err != nil {
return err
}
if err := ensureAccountStateInitialized(
progressState, deckStateStore, ownedItems, ownedEquipment,
worldService.CharacterService(), collection, wallet, mailService, missionService,
); err != nil {
return fmt.Errorf("initialize complete account state generation: %w", err)
}
if err := startupTransaction.Commit(); err != nil {
return fmt.Errorf("commit startup state transaction: %w", err)
}
startupCommitted = true
if err := stateTransaction.Check(); err != nil {
return fmt.Errorf("startup state transaction requires recovery restart: %w", err)
}
if err := game.AttachStateCoordinator(stateTransaction); err != nil {
return err
}
dispatcher := transport.Bootstrap{Config: cfg}
handler := transport.HTTP{Dispatcher: dispatcher, Raw: game, CDNDir: filepath.Clean(*cdn), GameDataDir: filepath.Clean(*gameData)}.Handler()
server := &http.Server{
@@ -329,6 +390,38 @@ func serve(args []string) error {
return server.ListenAndServe()
}
type accountStateInitializer interface {
EnsurePersisted() error
}
func ensureAccountStateInitialized(stores ...accountStateInitializer) error {
if len(stores) != len(statebridge.StateFiles()) {
return fmt.Errorf("account state initializer count %d does not match transaction file count %d", len(stores), len(statebridge.StateFiles()))
}
for _, store := range stores {
if store == nil {
return errors.New("nil account state initializer")
}
if err := store.EnsurePersisted(); err != nil {
return err
}
}
return nil
}
func validateAccountStatePaths(stateDir, progressPath, deckPath string) error {
stateDir = filepath.Clean(stateDir)
wantProgress := filepath.Join(stateDir, "progress.json")
wantDeck := filepath.Join(stateDir, "deck.json")
if filepath.Clean(progressPath) != wantProgress {
return fmt.Errorf("account state must use %q; got progress path %q", wantProgress, progressPath)
}
if filepath.Clean(deckPath) != wantDeck {
return fmt.Errorf("all account files must share one transaction directory; use --deck-state %q, got %q", wantDeck, deckPath)
}
return nil
}
func patchClient(args []string) error {
fs := flag.NewFlagSet("patch-client", flag.ContinueOnError)
gameDir := fs.String("game-dir", "", "BrownDust II game directory (required)")
+21
View File
@@ -0,0 +1,21 @@
package main
import (
"path/filepath"
"testing"
)
func TestValidateAccountStatePathsRequiresOneTransactionDirectory(t *testing.T) {
root := t.TempDir()
progress := filepath.Join(root, "progress.json")
deck := filepath.Join(root, "deck.json")
if err := validateAccountStatePaths(root, progress, deck); err != nil {
t.Fatal(err)
}
if err := validateAccountStatePaths(root, filepath.Join(root, "renamed.json"), deck); err == nil {
t.Fatal("renamed progress escaped transaction allow-list")
}
if err := validateAccountStatePaths(root, progress, filepath.Join(t.TempDir(), "deck.json")); err == nil {
t.Fatal("deck outside account transaction directory was accepted")
}
}
+57 -29
View File
@@ -1041,20 +1041,21 @@ func (x *EquipmentOption) GetId() uint64 {
}
type Equipment struct {
state protoimpl.MessageState `protogen:"open.v1"`
InventoryIndex uint64 `protobuf:"varint,1,opt,name=inventory_index,json=inventoryIndex,proto3" json:"inventory_index,omitempty"`
Id uint64 `protobuf:"varint,2,opt,name=id,proto3" json:"id,omitempty"`
Level uint64 `protobuf:"varint,3,opt,name=level,proto3" json:"level,omitempty"`
UseChar uint64 `protobuf:"varint,4,opt,name=use_char,json=useChar,proto3" json:"use_char,omitempty"`
KeepFlag uint64 `protobuf:"varint,5,opt,name=keep_flag,json=keepFlag,proto3" json:"keep_flag,omitempty"`
LockFlag uint64 `protobuf:"varint,6,opt,name=lock_flag,json=lockFlag,proto3" json:"lock_flag,omitempty"`
SortId uint64 `protobuf:"varint,7,opt,name=sort_id,json=sortId,proto3" json:"sort_id,omitempty"`
MainOptions []*EquipmentOption `protobuf:"bytes,8,rep,name=main_options,json=mainOptions,proto3" json:"main_options,omitempty"`
SubOptions []*EquipmentOption `protobuf:"bytes,9,rep,name=sub_options,json=subOptions,proto3" json:"sub_options,omitempty"`
PrivateOption *EquipmentOption `protobuf:"bytes,10,opt,name=private_option,json=privateOption,proto3" json:"private_option,omitempty"`
Ranks []uint64 `protobuf:"varint,11,rep,packed,name=ranks,proto3" json:"ranks,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
state protoimpl.MessageState `protogen:"open.v1"`
InventoryIndex uint64 `protobuf:"varint,1,opt,name=inventory_index,json=inventoryIndex,proto3" json:"inventory_index,omitempty"`
Id uint64 `protobuf:"varint,2,opt,name=id,proto3" json:"id,omitempty"`
Level uint64 `protobuf:"varint,3,opt,name=level,proto3" json:"level,omitempty"`
UseChar uint64 `protobuf:"varint,4,opt,name=use_char,json=useChar,proto3" json:"use_char,omitempty"`
KeepFlag uint64 `protobuf:"varint,5,opt,name=keep_flag,json=keepFlag,proto3" json:"keep_flag,omitempty"`
LockFlag uint64 `protobuf:"varint,6,opt,name=lock_flag,json=lockFlag,proto3" json:"lock_flag,omitempty"`
SortId uint64 `protobuf:"varint,7,opt,name=sort_id,json=sortId,proto3" json:"sort_id,omitempty"`
MainOptions []*EquipmentOption `protobuf:"bytes,8,rep,name=main_options,json=mainOptions,proto3" json:"main_options,omitempty"`
SubOptions []*EquipmentOption `protobuf:"bytes,9,rep,name=sub_options,json=subOptions,proto3" json:"sub_options,omitempty"`
PrivateOption *EquipmentOption `protobuf:"bytes,10,opt,name=private_option,json=privateOption,proto3" json:"private_option,omitempty"`
Ranks []uint64 `protobuf:"varint,11,rep,packed,name=ranks,proto3" json:"ranks,omitempty"`
UpgradeAttempts uint64 `protobuf:"varint,12,opt,name=upgrade_attempts,json=upgradeAttempts,proto3" json:"upgrade_attempts,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *Equipment) Reset() {
@@ -1164,6 +1165,13 @@ func (x *Equipment) GetRanks() []uint64 {
return nil
}
func (x *Equipment) GetUpgradeAttempts() uint64 {
if x != nil {
return x.UpgradeAttempts
}
return 0
}
type NamedIndex struct {
state protoimpl.MessageState `protogen:"open.v1"`
Identity string `protobuf:"bytes,1,opt,name=identity,proto3" json:"identity,omitempty"`
@@ -2605,17 +2613,19 @@ func (x *Collection) GetGrants() []*CollectionGrant {
}
type Wallet struct {
state protoimpl.MessageState `protogen:"open.v1"`
ClientVersion string `protobuf:"bytes,1,opt,name=client_version,json=clientVersion,proto3" json:"client_version,omitempty"`
Gold uint64 `protobuf:"varint,2,opt,name=gold,proto3" json:"gold,omitempty"`
FreeJewelry uint64 `protobuf:"varint,3,opt,name=free_jewelry,json=freeJewelry,proto3" json:"free_jewelry,omitempty"`
Jewelry uint64 `protobuf:"varint,4,opt,name=jewelry,proto3" json:"jewelry,omitempty"`
Mileage uint64 `protobuf:"varint,5,opt,name=mileage,proto3" json:"mileage,omitempty"`
HopePowder uint64 `protobuf:"varint,6,opt,name=hope_powder,json=hopePowder,proto3" json:"hope_powder,omitempty"`
GrantedIdentities []string `protobuf:"bytes,7,rep,name=granted_identities,json=grantedIdentities,proto3" json:"granted_identities,omitempty"`
SpentIdentities []string `protobuf:"bytes,8,rep,name=spent_identities,json=spentIdentities,proto3" json:"spent_identities,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
state protoimpl.MessageState `protogen:"open.v1"`
ClientVersion string `protobuf:"bytes,1,opt,name=client_version,json=clientVersion,proto3" json:"client_version,omitempty"`
Gold uint64 `protobuf:"varint,2,opt,name=gold,proto3" json:"gold,omitempty"`
FreeJewelry uint64 `protobuf:"varint,3,opt,name=free_jewelry,json=freeJewelry,proto3" json:"free_jewelry,omitempty"`
Jewelry uint64 `protobuf:"varint,4,opt,name=jewelry,proto3" json:"jewelry,omitempty"`
Mileage uint64 `protobuf:"varint,5,opt,name=mileage,proto3" json:"mileage,omitempty"`
HopePowder uint64 `protobuf:"varint,6,opt,name=hope_powder,json=hopePowder,proto3" json:"hope_powder,omitempty"`
GrantedIdentities []string `protobuf:"bytes,7,rep,name=granted_identities,json=grantedIdentities,proto3" json:"granted_identities,omitempty"`
SpentIdentities []string `protobuf:"bytes,8,rep,name=spent_identities,json=spentIdentities,proto3" json:"spent_identities,omitempty"`
EquipMileage uint64 `protobuf:"varint,9,opt,name=equip_mileage,json=equipMileage,proto3" json:"equip_mileage,omitempty"`
EquipMileageExchangeGage uint64 `protobuf:"varint,10,opt,name=equip_mileage_exchange_gage,json=equipMileageExchangeGage,proto3" json:"equip_mileage_exchange_gage,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *Wallet) Reset() {
@@ -2704,6 +2714,20 @@ func (x *Wallet) GetSpentIdentities() []string {
return nil
}
func (x *Wallet) GetEquipMileage() uint64 {
if x != nil {
return x.EquipMileage
}
return 0
}
func (x *Wallet) GetEquipMileageExchangeGage() uint64 {
if x != nil {
return x.EquipMileageExchangeGage
}
return 0
}
type MailState struct {
state protoimpl.MessageState `protogen:"open.v1"`
ClientVersion string `protobuf:"bytes,1,opt,name=client_version,json=clientVersion,proto3" json:"client_version,omitempty"`
@@ -2916,7 +2940,7 @@ const file_bd2_state_v1_state_proto_rawDesc = "" +
"grantItems\"<\n" +
"\x0fEquipmentOption\x12\x19\n" +
"\bgroup_id\x18\x01 \x01(\x04R\agroupId\x12\x0e\n" +
"\x02id\x18\x02 \x01(\x04R\x02id\"\xa6\x03\n" +
"\x02id\x18\x02 \x01(\x04R\x02id\"\xd1\x03\n" +
"\tEquipment\x12'\n" +
"\x0finventory_index\x18\x01 \x01(\x04R\x0einventoryIndex\x12\x0e\n" +
"\x02id\x18\x02 \x01(\x04R\x02id\x12\x14\n" +
@@ -2930,7 +2954,8 @@ const file_bd2_state_v1_state_proto_rawDesc = "" +
"subOptions\x12D\n" +
"\x0eprivate_option\x18\n" +
" \x01(\v2\x1d.bd2.state.v1.EquipmentOptionR\rprivateOption\x12\x14\n" +
"\x05ranks\x18\v \x03(\x04R\x05ranks\"Q\n" +
"\x05ranks\x18\v \x03(\x04R\x05ranks\x12)\n" +
"\x10upgrade_attempts\x18\f \x01(\x04R\x0fupgradeAttempts\"Q\n" +
"\n" +
"NamedIndex\x12\x1a\n" +
"\bidentity\x18\x01 \x01(\tR\bidentity\x12'\n" +
@@ -3073,7 +3098,7 @@ const file_bd2_state_v1_state_proto_rawDesc = "" +
"\rgacha_applied\x18\x0e \x03(\tR\fgachaApplied\x12Y\n" +
"\x15gacha_point_exchanges\x18\x0f \x03(\v2%.bd2.state.v1.NamedGachaPointExchangeR\x13gachaPointExchanges\x122\n" +
"\x15gacha_count_corrected\x18\x10 \x01(\bR\x13gachaCountCorrected\x125\n" +
"\x06grants\x18\x11 \x03(\v2\x1d.bd2.state.v1.CollectionGrantR\x06grants\"\x95\x02\n" +
"\x06grants\x18\x11 \x03(\v2\x1d.bd2.state.v1.CollectionGrantR\x06grants\"\xf9\x02\n" +
"\x06Wallet\x12%\n" +
"\x0eclient_version\x18\x01 \x01(\tR\rclientVersion\x12\x12\n" +
"\x04gold\x18\x02 \x01(\x04R\x04gold\x12!\n" +
@@ -3083,7 +3108,10 @@ const file_bd2_state_v1_state_proto_rawDesc = "" +
"\vhope_powder\x18\x06 \x01(\x04R\n" +
"hopePowder\x12-\n" +
"\x12granted_identities\x18\a \x03(\tR\x11grantedIdentities\x12)\n" +
"\x10spent_identities\x18\b \x03(\tR\x0fspentIdentities\"Z\n" +
"\x10spent_identities\x18\b \x03(\tR\x0fspentIdentities\x12#\n" +
"\requip_mileage\x18\t \x01(\x04R\fequipMileage\x12=\n" +
"\x1bequip_mileage_exchange_gage\x18\n" +
" \x01(\x04R\x18equipMileageExchangeGage\"Z\n" +
"\tMailState\x12%\n" +
"\x0eclient_version\x18\x01 \x01(\tR\rclientVersion\x12&\n" +
"\x0fopened_mail_ids\x18\x02 \x03(\x04R\ropenedMailIds\"\xa3\x01\n" +
+26
View File
@@ -46,6 +46,10 @@ type HopePowderProvider interface {
HopePowderBalance() uint64
}
type EquipmentMileageProvider interface {
EquipmentMileageBalances() (mileage, exchangeGage uint64)
}
func (s *LoginSeed) AttachCurrencies(provider CurrencyProvider) error {
if provider == nil {
return errors.New("account: nil currency provider")
@@ -96,6 +100,19 @@ func (s *LoginSeed) SeedHopePowder() (uint64, error) {
return value, nil
}
func (s *LoginSeed) SeedEquipmentMileage() (mileage, exchangeGage uint64, err error) {
if err = s.Validate(); err != nil {
return 0, 0, err
}
if mileage, _, err = wire.Varint(s.UserInfo, 67); err != nil {
return 0, 0, err
}
if exchangeGage, _, err = wire.Varint(s.UserInfo, 68); err != nil {
return 0, 0, err
}
return mileage, exchangeGage, nil
}
type diskSeed struct {
Version string `json:"version"`
PacketCode int `json:"packet_code"`
@@ -239,6 +256,15 @@ func (s *LoginSeed) Login(request, sessionKey []byte) ([]byte, error) {
return nil, fmt.Errorf("account: replace hope powder: %w", err)
}
}
if provider, ok := s.currencies.(EquipmentMileageProvider); ok {
equipMileage, exchangeGage := provider.EquipmentMileageBalances()
if user, _, err = wire.ReplaceVarint(user, 67, equipMileage); err != nil {
return nil, fmt.Errorf("account: replace equipment mileage: %w", err)
}
if user, _, err = wire.ReplaceVarint(user, 68, exchangeGage); err != nil {
return nil, fmt.Errorf("account: replace equipment mileage exchange gauge: %w", err)
}
}
}
if s.firstGacha != nil {
value := uint64(0)
+28
View File
@@ -87,3 +87,31 @@ func TestCheckedInSeedBuildsLoginWithoutCapture(t *testing.T) {
t.Fatalf("unexpected generated key: %q found=%v err=%v", key, found, err)
}
}
type loginCurrencyFixture struct{}
func (loginCurrencyFixture) Currencies() (uint64, uint64, uint64, uint64) {
return 1, 2, 3, 4
}
func (loginCurrencyFixture) EquipmentMileageBalances() (uint64, uint64) { return 17, 845 }
func TestLoginRestoresEquipmentMileageFromCurrencyProvider(t *testing.T) {
seed := &LoginSeed{Version: Version23413, PacketCode: 3, UserInfo: wire.AppendVarint(nil, 1, 1)}
if err := seed.AttachCurrencies(loginCurrencyFixture{}); err != nil {
t.Fatal(err)
}
response, err := seed.Login(wire.AppendVarint(nil, 1, 1), []byte("0123456789abcdef0123456789abcdef"))
if err != nil {
t.Fatal(err)
}
user, found, err := wire.Bytes(response, 1)
if err != nil || !found {
t.Fatalf("missing login user: %v", err)
}
if mileage, found, err := wire.Varint(user, 67); err != nil || !found || mileage != 17 {
t.Fatalf("equipment mileage=%d found=%v err=%v", mileage, found, err)
}
if gauge, found, err := wire.Varint(user, 68); err != nil || !found || gauge != 845 {
t.Fatalf("equipment mileage gauge=%d found=%v err=%v", gauge, found, err)
}
}
+10
View File
@@ -131,6 +131,16 @@ func OpenStore(path string, seed Seed) (*Store, error) {
}
return s, nil
}
func (s *Store) EnsurePersisted() error {
s.mu.Lock()
defer s.mu.Unlock()
if _, e := os.Stat(s.path); e == nil {
return nil
} else if !errors.Is(e, os.ErrNotExist) {
return e
}
return s.commit(clone(s.state))
}
func (s *Store) commit(next state) error {
if s.path != "" {
b, e := json.MarshalIndent(next, "", " ")
-1
View File
@@ -22,7 +22,6 @@ var emptyResponses = map[string]int{
"/AvatarShopWishListInfo": 470,
"/CafeteriaInfo": 351,
"/CashBonusInfo": 588,
"/CharAwakeInfo": 0,
"/CharPartnerInfo": 65,
"/CommunityRewardInfo": 289,
"/DailyStoryInfo": 538,
+5 -2
View File
@@ -8,8 +8,8 @@ import (
)
func TestHandleAuditedEmptyResponses(t *testing.T) {
if got := len(EmptyPacketCodes()); got != 35 {
t.Fatalf("audited empty-response registry has %d paths, want 35", got)
if got := len(EmptyPacketCodes()); got != 34 {
t.Fatalf("audited empty-response registry has %d paths, want 34", got)
}
for path, wantCode := range EmptyPacketCodes() {
t.Run(path, func(t *testing.T) {
@@ -25,6 +25,9 @@ func TestHandleRejectsUnknownAndInvalidRequests(t *testing.T) {
if code, proto, ok, err := Handle("/not-a-real-endpoint", wire.AppendVarint(nil, 1, 1)); code != 0 || proto != nil || ok || err != nil {
t.Fatalf("unknown route was not fail-closed: code=%d proto=%x ok=%t err=%v", code, proto, ok, err)
}
if _, _, ok, err := Handle("/CharAwakeInfo", wire.AppendVarint(nil, 1, 1)); ok || err != nil {
t.Fatalf("stateful CharAwakeInfo must not be handled by feature defaults: ok=%v err=%v", ok, err)
}
for name, request := range map[string][]byte{
"empty": nil,
"no-sequence": wire.AppendVarint(nil, 2, 1),
+36 -12
View File
@@ -352,24 +352,33 @@ func (s *Service) buy(request []byte, seq uint64) (int, []byte, bool, error) {
return 146, response, true, nil
}
// buyEquipment is intentionally limited to the current three GameData-backed
// pickup groups. Equipment lives in its own inventory, but the official
// GachaUser/GachaFixed records are shared protocol state and are persisted in
// CollectionStore under the same request identity.
// Scheduled equipment draws update GachaUser/GachaFixed accounting. Standalone
// ticket-only draws have no schedule group and persist only their idempotency
// marker plus the generated equipment instances.
func (s *Service) buyEquipment(seq, buyType uint64, tickets []player.Item, design gamedata.EquipmentGacha) (int, []byte, bool, error) {
if s.equipmentInventory == nil {
return 146, nil, true, errors.New("gacha: equipment inventory not attached")
}
if buyType != 1 || design.PriceType != 3 {
if buyType != 1 || (!design.TicketOnly && design.PriceType != 3) {
return 146, nil, true, fmt.Errorf("gacha: unsupported equipment buy type %d", buyType)
}
if design.TicketOnly && len(tickets) == 0 {
return 146, nil, true, errors.New("gacha: ticket-only equipment draw requires a ticket")
}
for _, ticket := range tickets {
if len(design.TicketIDs) != 1 || ticket.ID != design.TicketIDs[0] {
allowed := false
for _, ticketID := range design.TicketIDs {
if ticket.ID == ticketID {
allowed = true
break
}
}
if !allowed {
return 146, nil, true, fmt.Errorf("gacha: ticket %d is not valid for equipment gacha %d", ticket.ID, design.ID)
}
}
group, ok := s.equipmentCatalog.GroupForGacha(design.ID)
if !ok {
group, hasGroup := s.equipmentCatalog.GroupForGacha(design.ID)
if !hasGroup && !design.TicketOnly {
return 146, nil, true, fmt.Errorf("gacha: equipment gacha %d has no group", design.ID)
}
identity := s.requestIdentity(design.ID, seq)
@@ -388,7 +397,7 @@ func (s *Service) buyEquipment(seq, buyType uint64, tickets []player.Item, desig
for _, ticket := range tickets {
ticketCount += ticket.Count
}
if ticketCount > uint64(design.Count) {
if ticketCount > uint64(design.Count) || (design.TicketOnly && ticketCount != uint64(design.Count)) {
return 146, nil, true, errors.New("gacha: invalid equipment ticket count")
}
if ticketCount != 0 {
@@ -400,16 +409,27 @@ func (s *Service) buyEquipment(seq, buyType uint64, tickets []player.Item, desig
}
}
if remain := uint64(design.Count) - ticketCount; remain != 0 {
if design.TicketOnly {
return 146, nil, true, errors.New("gacha: ticket-only equipment draw cannot use diamonds")
}
if _, err := s.wallet.SpendFreeJewelryOnce(identity, design.Price/uint64(design.Count)*remain); err != nil {
return 146, nil, true, err
}
}
fixed := s.equipmentCatalog.Fixed()
roll, state, err := design.Roll(s.collection.GachaFixedCount(fixed.ID, 2), s.collection.GachaFixedCount(fixed.ID, 3), fixed)
var priorSR, priorUR uint64
if !design.TicketOnly {
priorSR = s.collection.GachaFixedCount(fixed.ID, 2)
priorUR = s.collection.GachaFixedCount(fixed.ID, 3)
}
roll, state, err := design.Roll(priorSR, priorUR, fixed)
if err != nil {
return 146, nil, true, err
}
fixedStates := []player.GachaFixedState{{FixedID: fixed.ID, Type: 2, Count: state.SRCount, ApplySort: state.SRSort}, {FixedID: fixed.ID, Type: 3, Count: state.URCount, ApplySort: state.URSort}}
var fixedStates []player.GachaFixedState
if !design.TicketOnly {
fixedStates = []player.GachaFixedState{{FixedID: fixed.ID, Type: 2, Count: state.SRCount, ApplySort: state.SRSort}, {FixedID: fixed.ID, Type: 3, Count: state.URCount, ApplySort: state.URSort}}
}
for sort, equipmentID := range roll {
main, sub, private, err := s.equipmentCatalog.RollOptions(equipmentID)
if err != nil {
@@ -431,7 +451,11 @@ func (s *Service) buyEquipment(seq, buyType uint64, tickets []player.Item, desig
}
entries = append(entries, saved)
}
grant, err = s.collection.GrantEquipmentPurchase(identity, uint64(len(entries)), player.GachaPurchase{Group: gamedata.GachaGroupDesign{ID: group.ID, PointCount: group.PointCount}, BuyType: buyType, Fixed: fixedStates})
if design.TicketOnly {
grant, err = s.collection.GrantEquipmentDraw(identity)
} else {
grant, err = s.collection.GrantEquipmentPurchase(identity, uint64(len(entries)), player.GachaPurchase{Group: gamedata.GachaGroupDesign{ID: group.ID, PointCount: group.PointCount}, BuyType: buyType, Fixed: fixedStates})
}
if err != nil {
return 146, nil, true, err
}
+85
View File
@@ -1,6 +1,7 @@
package gacha
import (
"os"
"path/filepath"
"testing"
@@ -9,6 +10,90 @@ import (
"bd2server/internal/wire"
)
func TestTicketOnlyEquipmentDrawUsesGameDataAndNoScheduleAccounting(t *testing.T) {
root := os.Getenv("BD2_TEST_GAMEDATA_ROOT")
if root == "" {
t.Skip("set BD2_TEST_GAMEDATA_ROOT for installed GameData integration test")
}
const version = "20260910162539"
infinite, err := gamedata.LoadInfiniteGacha(root, version)
if err != nil {
t.Fatal(err)
}
regular, err := gamedata.LoadRegularCostumeGacha(root, version)
if err != nil {
t.Fatal(err)
}
equipmentCatalog, err := gamedata.LoadEquipmentGacha(root, version)
if err != nil {
t.Fatal(err)
}
dir := t.TempDir()
collection, err := player.OpenCollectionStore(filepath.Join(dir, "collection.json"), nil)
if err != nil {
t.Fatal(err)
}
wallet, err := player.OpenWallet(filepath.Join(dir, "wallet.json"), player.Currency{})
if err != nil {
t.Fatal(err)
}
inventory, err := player.OpenInventory(filepath.Join(dir, "items.json"), &player.Starter{Version: "2.34.13"})
if err != nil {
t.Fatal(err)
}
granted, err := inventory.GrantOnce("ticket", []gamedata.BattleReward{{Type: 8, ID: 1104, Count: 20}})
if err != nil {
t.Fatal(err)
}
equipment, err := player.OpenEquipmentInventory(filepath.Join(dir, "equipment.json"))
if err != nil {
t.Fatal(err)
}
service, err := NewService(infinite, regular, collection, wallet)
if err != nil {
t.Fatal(err)
}
service.AttachInventory(inventory)
service.AttachEquipmentGacha(equipmentCatalog, equipment)
service.BeginSession("ticket-only")
request := wire.AppendVarint(wire.AppendVarint(nil, 1, 7), 2, 71200001)
request = wire.AppendVarint(request, 3, 1)
ticket := granted[0]
ticket.Count = 10
request = wire.AppendBytes(request, 4, player.ItemWire(ticket))
code, response, handled, err := service.Handle("/GachaBuy", request)
if err != nil || !handled || code != 146 {
t.Fatalf("ticket-only draw code=%d handled=%v err=%v", code, handled, err)
}
bundle, found, err := wire.Bytes(response, 1)
if err != nil || !found || countFields(bundle, 4) != 10 {
t.Fatalf("ticket-only reward equipment=%d found=%v err=%v", countFields(bundle, 4), found, err)
}
if len(equipment.All()) != 10 {
t.Fatalf("ticket-only persisted equipment=%d", len(equipment.All()))
}
if user := collection.GachaUser(71200001); user != (player.GachaUserState{}) {
t.Fatalf("standalone draw invented schedule accounting: %+v", user)
}
remaining := inventory.All()
foundTicket := false
for _, item := range remaining {
if item.InvenIndex == ticket.InvenIndex {
foundTicket = true
if item.Count != 10 {
t.Fatalf("ticket count=%d want=10", item.Count)
}
}
}
if !foundTicket {
t.Fatal("remaining ticket stack missing")
}
_, replay, _, err := service.Handle("/GachaBuy", request)
if err != nil || string(replay) != string(response) || len(equipment.All()) != 10 {
t.Fatalf("ticket-only replay changed result: equipment=%d err=%v", len(equipment.All()), err)
}
}
func fixtureCharacter(id, hp uint64) gamedata.CharacterDesign {
return gamedata.CharacterDesign{
ID: id, HP: hp, CostumeMaxLevel: 5,
+7
View File
@@ -13,6 +13,10 @@ const (
StatMagic
StatDefencePercent
StatMagicResistancePercent
StatCriticalChance
StatCriticalDamage
StatElementDamage
StatElementResistance
)
// StatContribution is one already-resolved contribution from an equipment
@@ -22,6 +26,9 @@ type StatContribution struct {
Stat Stat
Flat float64
Percent float64
// Option preserves the exact Define_CharStatOption for systems such as
// awakening whose element-specific distinctions exceed BaseStats.
Option uint64
}
// BaseStats contains the design value after the character level curve has
+397
View File
@@ -0,0 +1,397 @@
package gamedata
import (
"database/sql"
"fmt"
"math"
"os"
"path/filepath"
)
// CharAwakeCost is one exact CharAwakeGrowthTable material row. Type 4 is
// gold (and therefore has ID 0); type 8 is a mutable ResourceTable item.
type CharAwakeCost struct{ Type, ID, Count uint64 }
// CharAwakeGrowth is one cumulative imprint level or one awakening effect.
// Imprint StatValue is the value at that target level, not a per-level delta.
type CharAwakeGrowth struct {
ID uint64
Costs []CharAwakeCost
StatType uint64
StatValue float64
}
type CharAwakeCharacter struct {
UniqueCharID uint64
Active bool
ImprintIDs [3]uint64
ImprintGrowth [3][]CharAwakeGrowth
AwakeGrowth []CharAwakeGrowth
}
type CharAwakeCharacterStage struct {
UniqueCharID uint64
Grade uint64
GrowthGrade uint64
MaximumLevel uint64
}
// CharAwakeDesign is immutable 2.34.13 design data. Account progress remains
// in CollectionStore and is indexed by UniqueCharId, as CharAwakeDBInfo is.
type CharAwakeDesign struct {
Characters map[uint64]CharAwakeCharacter
Stages map[uint64]CharAwakeCharacterStage
}
type CharImprintTarget struct {
Slot uint64
TargetLevel uint64
}
func LoadCharAwakeDesign(root, version string) (*CharAwakeDesign, error) {
plain, err := ReadQuestDatabase(root, version)
if err != nil {
return nil, err
}
dir, err := os.MkdirTemp("", "bd2-char-awake-")
if err != nil {
return nil, err
}
defer os.RemoveAll(dir)
path := filepath.Join(dir, "common.db")
if err := os.WriteFile(path, plain, 0o600); err != nil {
return nil, err
}
db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro")
if err != nil {
return nil, err
}
defer db.Close()
return loadCharAwakeDesign(db)
}
func loadCharAwakeDesign(db *sql.DB) (*CharAwakeDesign, error) {
growth := make(map[uint64]CharAwakeGrowth)
rows, err := db.Query("SELECT id,ProtoBuf FROM CharAwakeGrowthTable ORDER BY id")
if err != nil {
return nil, err
}
for rows.Next() {
var id uint64
var proto []byte
if err := rows.Scan(&id, &proto); err != nil {
rows.Close()
return nil, err
}
counts, _ := packedInts(proto, 1)
ids, _ := packedInts(proto, 2)
types, _ := packedInts(proto, 3)
protoID, _ := packedInts(proto, 4)
statType, _ := packedInts(proto, 5)
statValue, hasStatValue, err := fixed64Double(proto, 6)
if err != nil || len(protoID) != 1 || protoID[0] != id || len(statType) != 1 || !hasStatValue || math.IsNaN(statValue) || math.IsInf(statValue, 0) {
rows.Close()
return nil, fmt.Errorf("gamedata: invalid CharAwakeGrowthTable row %d", id)
}
if len(counts) != len(ids) || len(counts) != len(types) {
rows.Close()
return nil, fmt.Errorf("gamedata: mismatched CharAwakeGrowthTable costs %d", id)
}
entry := CharAwakeGrowth{ID: id, StatType: statType[0], StatValue: statValue}
if entry.StatType == 0 || entry.StatType > 20 {
rows.Close()
return nil, fmt.Errorf("gamedata: unsupported awakening stat type %d in row %d", entry.StatType, id)
}
for i := range counts {
if counts[i] == 0 || (types[i] != 4 && types[i] != 8) || (types[i] == 4 && ids[i] != 0) || (types[i] == 8 && ids[i] == 0) {
rows.Close()
return nil, fmt.Errorf("gamedata: invalid awakening cost in row %d", id)
}
entry.Costs = append(entry.Costs, CharAwakeCost{Type: types[i], ID: ids[i], Count: counts[i]})
}
growth[id] = entry
}
if err := rows.Close(); err != nil {
return nil, err
}
imprints := make(map[uint64][]CharAwakeGrowth)
rows, err = db.Query("SELECT id,ProtoBuf FROM CharImprintTable ORDER BY id")
if err != nil {
return nil, err
}
for rows.Next() {
var id uint64
var proto []byte
if err := rows.Scan(&id, &proto); err != nil {
rows.Close()
return nil, err
}
growthIDs, _ := packedInts(proto, 1)
protoID, _ := packedInts(proto, 2)
if len(protoID) != 1 || protoID[0] != id || len(growthIDs) == 0 {
rows.Close()
return nil, fmt.Errorf("gamedata: invalid CharImprintTable row %d", id)
}
for _, growthID := range growthIDs {
entry, ok := growth[growthID]
if !ok || len(entry.Costs) == 0 {
rows.Close()
return nil, fmt.Errorf("gamedata: imprint %d has invalid growth %d", id, growthID)
}
imprints[id] = append(imprints[id], entry)
}
}
if err := rows.Close(); err != nil {
return nil, err
}
design := &CharAwakeDesign{Characters: map[uint64]CharAwakeCharacter{}, Stages: map[uint64]CharAwakeCharacterStage{}}
rows, err = db.Query("SELECT id,ProtoBuf FROM CharAwakeTable ORDER BY id")
if err != nil {
return nil, err
}
for rows.Next() {
var rowID uint64
var proto []byte
if err := rows.Scan(&rowID, &proto); err != nil {
rows.Close()
return nil, err
}
active, _ := packedInts(proto, 1)
awakeIDs, _ := packedInts(proto, 2)
unique, _ := packedInts(proto, 3)
slot1, _ := packedInts(proto, 4)
slot2, _ := packedInts(proto, 5)
slot3, _ := packedInts(proto, 6)
if len(active) != 1 || active[0] != 1 || len(unique) != 1 || unique[0] != rowID || len(slot1) != 1 || len(slot2) != 1 || len(slot3) != 1 || len(awakeIDs) == 0 {
rows.Close()
return nil, fmt.Errorf("gamedata: invalid CharAwakeTable row %d", rowID)
}
entry := CharAwakeCharacter{UniqueCharID: rowID, Active: true, ImprintIDs: [3]uint64{slot1[0], slot2[0], slot3[0]}}
for i, imprintID := range entry.ImprintIDs {
levels, ok := imprints[imprintID]
if !ok {
rows.Close()
return nil, fmt.Errorf("gamedata: awakening %d has unknown imprint %d", rowID, imprintID)
}
entry.ImprintGrowth[i] = append([]CharAwakeGrowth(nil), levels...)
}
for i, growthID := range awakeIDs {
awakeGrowth, ok := growth[growthID]
if !ok || (i > 0 && len(awakeGrowth.Costs) != 0) {
rows.Close()
return nil, fmt.Errorf("gamedata: awakening %d has invalid growth %d", rowID, growthID)
}
entry.AwakeGrowth = append(entry.AwakeGrowth, awakeGrowth)
}
if len(entry.AwakeGrowth[0].Costs) == 0 {
rows.Close()
return nil, fmt.Errorf("gamedata: awakening %d has no activation cost", rowID)
}
design.Characters[rowID] = entry
}
if err := rows.Close(); err != nil {
return nil, err
}
rows, err = db.Query("SELECT id,ProtoBuf FROM CharTable ORDER BY id")
if err != nil {
return nil, err
}
for rows.Next() {
var id uint64
var proto []byte
if err := rows.Scan(&id, &proto); err != nil {
rows.Close()
return nil, err
}
growthID, _ := packedInts(proto, 1)
grade, _ := packedInts(proto, 9)
growthGrade, _ := packedInts(proto, 10)
unique, _ := packedInts(proto, 20)
if len(unique) != 1 || design.Characters[unique[0]].UniqueCharID == 0 {
continue
}
if len(growthID) != 1 || len(grade) != 1 || len(growthGrade) != 1 {
rows.Close()
return nil, fmt.Errorf("gamedata: invalid awakening character stage %d", id)
}
var growthProto []byte
if err := db.QueryRow("SELECT ProtoBuf FROM CharGrowthTable WHERE id=?", growthID[0]).Scan(&growthProto); err != nil {
rows.Close()
return nil, fmt.Errorf("gamedata: awakening character %d growth: %w", id, err)
}
maximum, _ := packedInts(growthProto, 9)
if len(maximum) != 1 || maximum[0] == 0 {
rows.Close()
return nil, fmt.Errorf("gamedata: invalid awakening character maximum %d", id)
}
design.Stages[id] = CharAwakeCharacterStage{UniqueCharID: unique[0], Grade: grade[0], GrowthGrade: growthGrade[0], MaximumLevel: maximum[0]}
}
if err := rows.Close(); err != nil {
return nil, err
}
if len(design.Characters) == 0 || len(design.Stages) == 0 {
return nil, fmt.Errorf("gamedata: awakening design is empty")
}
return design, nil
}
func (d *CharAwakeDesign) CharacterUniqueID(characterID uint64) (uint64, bool) {
if d == nil {
return 0, false
}
stage, ok := d.Stages[characterID]
return stage.UniqueCharID, ok
}
// ValidateGrowthCompleted mirrors CommonPacket.IsCharGrowthLevelCompleted:
// the current CharTable stage must have Growthgrade == Grade and its own
// CharGrowthTable.MaxLevel must have been reached.
func (d *CharAwakeDesign) ValidateGrowthCompleted(characterID, level uint64) (uint64, error) {
if d == nil {
return 0, fmt.Errorf("gamedata: awakening design unavailable")
}
stage, ok := d.Stages[characterID]
if !ok || stage.UniqueCharID == 0 {
return 0, fmt.Errorf("gamedata: character %d has no awakening design", characterID)
}
if stage.Grade != stage.GrowthGrade || level < stage.MaximumLevel {
return 0, fmt.Errorf("gamedata: character %d has not completed growth", characterID)
}
return stage.UniqueCharID, nil
}
func appendAwakeCosts(dst []CharAwakeCost, src []CharAwakeCost) ([]CharAwakeCost, error) {
for _, cost := range src {
merged := false
for i := range dst {
if dst[i].Type != cost.Type || dst[i].ID != cost.ID {
continue
}
if cost.Count > ^uint64(0)-dst[i].Count {
return nil, fmt.Errorf("gamedata: awakening cost overflow")
}
dst[i].Count += cost.Count
merged = true
break
}
if !merged {
dst = append(dst, cost)
}
}
return dst, nil
}
func (d *CharAwakeDesign) ImprintCosts(uniqueCharID uint64, current [3]uint64, targets []CharImprintTarget) ([]CharAwakeCost, [3]uint64, error) {
entry, ok := d.Characters[uniqueCharID]
if !ok || !entry.Active || len(targets) == 0 {
return nil, current, fmt.Errorf("gamedata: invalid imprint request for character %d", uniqueCharID)
}
next := current
seen := [3]bool{}
var costs []CharAwakeCost
for _, target := range targets {
if target.Slot < 1 || target.Slot > 3 || seen[target.Slot-1] {
return nil, current, fmt.Errorf("gamedata: invalid or duplicate imprint slot %d", target.Slot)
}
seen[target.Slot-1] = true
slot := target.Slot - 1
maximum := uint64(len(entry.ImprintGrowth[slot]))
if target.TargetLevel <= current[slot] || target.TargetLevel > maximum {
return nil, current, fmt.Errorf("gamedata: invalid imprint target %d for slot %d at %d/%d", target.TargetLevel, target.Slot, current[slot], maximum)
}
for level := current[slot] + 1; level <= target.TargetLevel; level++ {
var err error
costs, err = appendAwakeCosts(costs, entry.ImprintGrowth[slot][level-1].Costs)
if err != nil {
return nil, current, err
}
}
next[slot] = target.TargetLevel
}
return costs, next, nil
}
func (d *CharAwakeDesign) AwakeCosts(uniqueCharID uint64, levels [3]uint64, isAwake bool) ([]CharAwakeCost, error) {
entry, ok := d.Characters[uniqueCharID]
if !ok || !entry.Active || isAwake {
return nil, fmt.Errorf("gamedata: character %d cannot awaken", uniqueCharID)
}
for i := range levels {
if levels[i] != uint64(len(entry.ImprintGrowth[i])) {
return nil, fmt.Errorf("gamedata: character %d imprint slot %d is not complete", uniqueCharID, i+1)
}
}
return append([]CharAwakeCost(nil), entry.AwakeGrowth[0].Costs...), nil
}
func (d *CharAwakeDesign) ValidateProgress(uniqueCharID uint64, levels [3]uint64, isAwake bool) error {
entry, ok := d.Characters[uniqueCharID]
if !ok || !entry.Active {
return fmt.Errorf("gamedata: character %d has no awakening design", uniqueCharID)
}
for i, level := range levels {
maximum := uint64(len(entry.ImprintGrowth[i]))
if level > maximum || (isAwake && level != maximum) {
return fmt.Errorf("gamedata: invalid saved awakening slot %d level %d/%d", i+1, level, maximum)
}
}
return nil
}
// CharAwakeContributions reproduces the client aggregation: each imprint slot
// contributes only its target-level cumulative row, while awakening adds all
// CharAwakeTable.GrowthId stat rows.
func (d *CharAwakeDesign) CharAwakeContributions(uniqueCharID uint64, levels [3]uint64, isAwake bool) ([]StatContribution, error) {
if err := d.ValidateProgress(uniqueCharID, levels, isAwake); err != nil {
return nil, err
}
entry := d.Characters[uniqueCharID]
var growth []CharAwakeGrowth
for i, level := range levels {
if level > uint64(len(entry.ImprintGrowth[i])) {
return nil, fmt.Errorf("gamedata: imprint slot %d level %d exceeds maximum", i+1, level)
}
if level != 0 {
growth = append(growth, entry.ImprintGrowth[i][level-1])
}
}
if isAwake {
growth = append(growth, entry.AwakeGrowth...)
}
result := make([]StatContribution, 0, len(growth))
for _, row := range growth {
contribution := StatContribution{Option: row.StatType}
switch row.StatType {
case 1, 2:
contribution.Stat = StatHealth
case 3, 4:
contribution.Stat = StatAttack
case 5, 6:
contribution.Stat = StatMagic
case 7:
contribution.Stat = StatDefencePercent
case 8:
contribution.Stat = StatMagicResistancePercent
case 9:
contribution.Stat = StatCriticalChance
case 10:
contribution.Stat = StatCriticalDamage
case 11, 12, 13, 14, 15, 19:
contribution.Stat = StatElementDamage
case 16, 17, 18, 20:
contribution.Stat = StatElementResistance
default:
return nil, fmt.Errorf("gamedata: unsupported awakening stat option %d", row.StatType)
}
if row.StatType == 2 || row.StatType == 4 || row.StatType == 6 {
contribution.Percent = row.StatValue
} else {
contribution.Flat = row.StatValue
}
result = append(result, contribution)
}
return result, nil
}
+91
View File
@@ -0,0 +1,91 @@
package gamedata
import (
"os"
"testing"
)
func testCharAwakeDesign() *CharAwakeDesign {
levels := func(stat uint64, values ...float64) []CharAwakeGrowth {
result := make([]CharAwakeGrowth, len(values))
for i, value := range values {
result[i] = CharAwakeGrowth{ID: uint64(i + 1), Costs: []CharAwakeCost{{Type: 8, ID: 701 + uint64(i), Count: uint64(i + 1)}, {Type: 4, Count: 100}}, StatType: stat, StatValue: value}
}
return result
}
character := CharAwakeCharacter{UniqueCharID: 35, Active: true}
character.ImprintGrowth[0] = levels(1, 5, 10)
character.ImprintGrowth[1] = levels(2, .01, .02)
character.ImprintGrowth[2] = levels(3, 2, 4)
character.AwakeGrowth = []CharAwakeGrowth{
{ID: 100, Costs: []CharAwakeCost{{Type: 8, ID: 705, Count: 20}, {Type: 4, Count: 500}}, StatType: 4, StatValue: .12},
{ID: 101, StatType: 14, StatValue: .1},
}
return &CharAwakeDesign{
Characters: map[uint64]CharAwakeCharacter{35: character},
Stages: map[uint64]CharAwakeCharacterStage{354: {UniqueCharID: 35, Grade: 5, GrowthGrade: 5, MaximumLevel: 100}},
}
}
func TestCharAwakeImprintUsesCrossLevelCostsAndCumulativeStats(t *testing.T) {
design := testCharAwakeDesign()
costs, levels, err := design.ImprintCosts(35, [3]uint64{0, 0, 0}, []CharImprintTarget{{Slot: 1, TargetLevel: 2}, {Slot: 2, TargetLevel: 1}})
if err != nil {
t.Fatal(err)
}
if levels != [3]uint64{2, 1, 0} || len(costs) != 3 {
t.Fatalf("levels=%v costs=%+v", levels, costs)
}
var gold uint64
for _, cost := range costs {
if cost.Type == 4 {
gold += cost.Count
}
}
if gold != 300 {
t.Fatalf("gold=%d want=300", gold)
}
stats, err := design.CharAwakeContributions(35, levels, false)
if err != nil || len(stats) != 2 || stats[0].Flat != 10 || stats[1].Percent != .01 {
t.Fatalf("cumulative target-level stats=%+v err=%v", stats, err)
}
if _, _, err := design.ImprintCosts(35, levels, []CharImprintTarget{{Slot: 1, TargetLevel: 1}}); err == nil {
t.Fatal("imprint downgrade accepted")
}
}
func TestCharAwakeActivationRequiresAllSlotsAndAddsEveryEffect(t *testing.T) {
design := testCharAwakeDesign()
if _, err := design.AwakeCosts(35, [3]uint64{2, 2, 1}, false); err == nil {
t.Fatal("incomplete imprint slots accepted")
}
costs, err := design.AwakeCosts(35, [3]uint64{2, 2, 2}, false)
if err != nil || len(costs) != 2 || costs[0].ID != 705 || costs[1].Type != 4 {
t.Fatalf("awakening costs=%+v err=%v", costs, err)
}
stats, err := design.CharAwakeContributions(35, [3]uint64{2, 2, 2}, true)
if err != nil || len(stats) != 5 || stats[3].Stat != StatAttack || stats[3].Percent != .12 || stats[4].Option != 14 {
t.Fatalf("awakening stats=%+v err=%v", stats, err)
}
}
func TestCharAwakeAgainstInstalledVersion23413(t *testing.T) {
root := os.Getenv("BD2_TEST_GAMEDATA_ROOT")
if root == "" {
t.Skip("set BD2_TEST_GAMEDATA_ROOT for installed GameData integration test")
}
design, err := LoadCharAwakeDesign(root, "20260910162539")
if err != nil {
t.Fatal(err)
}
entry := design.Characters[35]
if len(design.Characters) != 86 || len(entry.ImprintGrowth[0]) != 10 || len(entry.ImprintGrowth[1]) != 10 || len(entry.ImprintGrowth[2]) != 10 || len(entry.AwakeGrowth) != 2 {
t.Fatalf("installed awakening design characters=%d char35=%+v", len(design.Characters), entry)
}
if entry.AwakeGrowth[0].Costs[0] != (CharAwakeCost{Type: 8, ID: 705, Count: 200}) || entry.AwakeGrowth[1].StatType != 14 || entry.AwakeGrowth[1].StatValue != .1 {
t.Fatalf("installed char35 awakening=%+v", entry.AwakeGrowth)
}
if unique, err := design.ValidateGrowthCompleted(354, 100); err != nil || unique != 35 {
t.Fatalf("installed final stage unique=%d err=%v", unique, err)
}
}
+112 -5
View File
@@ -11,10 +11,9 @@ import (
"path/filepath"
)
// EquipmentGachaCatalog is the server-owned portion of the active exclusive
// equipment draws. It deliberately models the three groups which the
// 2026-09-20 GameData actually attaches to current pickup banners; it does
// not infer a schedule or an equipment 12PICK banner from historical tables.
// EquipmentGachaCatalog is the server-owned portion of exclusive-equipment
// draws. Scheduled groups and standalone ticket-only draws are both derived
// from GameData; the server does not enumerate product IDs in request logic.
type EquipmentGachaCatalog struct {
Gachas map[uint64]EquipmentGacha
groups map[uint64]EquipmentGachaGroup
@@ -28,6 +27,7 @@ type EquipmentGacha struct {
PriceType, Price uint64
TicketIDs []uint64
Pool []WeightedEquipment
TicketOnly bool
}
type EquipmentGachaGroup struct{ ID, FixedID, PointCount, OneTimeGachaID, TenTimeGachaID uint64 }
type EquipmentFixedDesign struct {
@@ -40,6 +40,7 @@ type WeightedEquipment struct {
}
type EquipmentDesign struct {
ID uint64
Grade uint64
Main, Sub []OptionGroup
Private []OptionGroup
RankGroupID uint64
@@ -109,6 +110,56 @@ func LoadEquipmentGacha(root, version string) (*EquipmentGachaCatalog, error) {
}
}
}
// Guaranteed equipment tickets are standalone GachaTable rows: they have
// no diamond price, at least one resource-ticket id, and an equipment-only
// RewardGroup tree. Discover every such row instead of special-casing a
// product number from a log.
rows, err := db.Query("SELECT id,ProtoBuf FROM GachaTable ORDER BY id")
if err != nil {
return nil, err
}
for rows.Next() {
var id uint64
var raw []byte
if err := rows.Scan(&id, &raw); err != nil {
rows.Close()
return nil, err
}
if _, exists := c.Gachas[id]; exists {
continue
}
count, _ := packedInts(raw, 5)
reward, _ := packedInts(raw, 7)
price, _ := packedInts(raw, 10)
kind, _ := packedInts(raw, 12)
tickets, _ := packedInts(raw, 8)
if len(count) != 1 || count[0] == 0 || len(reward) != 1 || len(tickets) == 0 || len(price) != 0 || len(kind) != 0 {
continue
}
pool, equipmentOnly, err := classifyEquipmentRewardPool(db, reward[0])
if err != nil {
rows.Close()
return nil, fmt.Errorf("gamedata: ticket gacha %d: %w", id, err)
}
if !equipmentOnly {
continue
}
g := EquipmentGacha{ID: id, Count: int(count[0]), TicketIDs: append([]uint64(nil), tickets...), Pool: pool, TicketOnly: true}
c.Gachas[id] = g
for _, item := range pool {
if err := c.loadEquipmentTree(db, item); err != nil {
rows.Close()
return nil, err
}
}
}
if err := rows.Err(); err != nil {
rows.Close()
return nil, err
}
if err := rows.Close(); err != nil {
return nil, err
}
var fixed []byte
if err := db.QueryRow("SELECT ProtoBuf FROM GachaFixedTable WHERE id=1").Scan(&fixed); err != nil {
return nil, err
@@ -123,6 +174,43 @@ func LoadEquipmentGacha(root, version string) (*EquipmentGachaCatalog, error) {
return c, nil
}
func classifyEquipmentRewardPool(db *sql.DB, groupID uint64) ([]WeightedEquipment, bool, error) {
var raw []byte
if err := db.QueryRow("SELECT ProtoBuf FROM RewardGroupTable WHERE id=?", groupID).Scan(&raw); err != nil {
return nil, false, err
}
ids, _ := packedInts(raw, 5)
types, _ := packedInts(raw, 6)
weights, _ := packedInts(raw, 8)
if len(ids) == 0 || len(ids) != len(types) || len(ids) != len(weights) {
return nil, false, errors.New("malformed reward group")
}
out := make([]WeightedEquipment, 0, len(ids))
for i, id := range ids {
entry := WeightedEquipment{Weight: weights[i]}
if entry.Weight == 0 {
return nil, false, errors.New("reward group has zero weight")
}
switch types[i] {
case 10:
entry.ID = id
case 9:
children, equipmentOnly, err := classifyEquipmentRewardPool(db, id)
if err != nil {
return nil, false, err
}
if !equipmentOnly {
return nil, false, nil
}
entry.Children = children
default:
return nil, false, nil
}
out = append(out, entry)
}
return out, true, nil
}
func loadEquipmentGacha(db *sql.DB, id uint64) (EquipmentGacha, error) {
var raw []byte
if err := db.QueryRow("SELECT ProtoBuf FROM GachaTable WHERE id=?", id).Scan(&raw); err != nil {
@@ -214,7 +302,11 @@ func loadEquipmentDesign(db *sql.DB, id uint64) (EquipmentDesign, error) {
private, _ := packedInts(raw, 17)
sub, _ := packedInts(raw, 21)
rank, _ := packedInts(raw, 19)
d := EquipmentDesign{ID: id}
grade, _ := packedInts(raw, 3)
if len(grade) != 1 || grade[0] == 0 {
return EquipmentDesign{}, fmt.Errorf("gamedata: equipment %d malformed grade", id)
}
d := EquipmentDesign{ID: id, Grade: grade[0]}
var err error
if d.Main, err = loadOptionGroups(db, main); err != nil {
return d, err
@@ -312,6 +404,21 @@ func (c *EquipmentGachaCatalog) RollOptions(id uint64) (main, sub []EquipmentOpt
return
}
func (g EquipmentGacha) Roll(previousSR, previousUR uint64, fixed EquipmentFixedDesign) ([]uint64, EquipmentRoll, error) {
if g.TicketOnly {
state := EquipmentRoll{SRCount: previousSR, URCount: previousUR, SRSort: -1, URSort: -1}
if g.Count <= 0 || len(g.Pool) == 0 {
return nil, state, errors.New("gamedata: invalid ticket equipment gacha")
}
out := make([]uint64, g.Count)
for i := range out {
id, err := rollEquipmentChoiceWith(g.Pool, cryptoDraw)
if err != nil {
return nil, state, err
}
out[i] = id
}
return out, state, nil
}
return g.rollWith(previousSR, previousUR, fixed, cryptoDraw)
}
+295
View File
@@ -0,0 +1,295 @@
package gamedata
import (
"database/sql"
"fmt"
"math"
"os"
"path/filepath"
)
// EquipmentSmeltingDesign contains only static 2.34.13 GameData facts. It does
// not decide whether a lower-score result is applied or how mileage is paid:
// those are server transactions and require an official response capture.
type EquipmentSmeltingDesign struct {
Equipment map[uint64]EquipmentSmeltingItem
Ranks map[[2]uint64]EquipmentSmeltingRank
Grades map[uint64][]PromotionCost
Mileage EquipmentSmeltingMileage
MaxStreak uint64
}
type EquipmentSmeltingItem struct {
Grade, RankGroup, MaxLevel uint64
}
type EquipmentSmeltingRank struct {
// GrowthPoint is the per-rank battle-power contribution. Values is the
// separate 1..4 score used to compare a refinement candidate.
GrowthPoint []uint64
Values []uint64
Ratio []float64
}
type EquipmentSmeltingMileage struct {
UseType, UseID, UseCount uint64
RewardType, RewardID, RewardCount uint64
}
func LoadEquipmentSmeltingDesign(root, version string) (*EquipmentSmeltingDesign, error) {
plain, err := ReadQuestDatabase(root, version)
if err != nil {
return nil, err
}
dir, err := os.MkdirTemp("", "bd2-equipment-smelting-")
if err != nil {
return nil, err
}
defer os.RemoveAll(dir)
path := filepath.Join(dir, "common.db")
if err := os.WriteFile(path, plain, 0o600); err != nil {
return nil, err
}
db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro")
if err != nil {
return nil, err
}
defer db.Close()
return loadEquipmentSmeltingDesign(db)
}
func loadEquipmentSmeltingDesign(db *sql.DB) (*EquipmentSmeltingDesign, error) {
d := &EquipmentSmeltingDesign{
Equipment: make(map[uint64]EquipmentSmeltingItem),
Ranks: make(map[[2]uint64]EquipmentSmeltingRank),
Grades: make(map[uint64][]PromotionCost),
}
rows, err := db.Query("SELECT id,ProtoBuf FROM EquipmentTable")
if err != nil {
return nil, err
}
for rows.Next() {
var id uint64
var proto []byte
if err := rows.Scan(&id, &proto); err != nil {
rows.Close()
return nil, err
}
grade, _ := packedInts(proto, 3)
maximum, _ := packedInts(proto, 13)
rankGroup, _ := packedInts(proto, 19)
if len(grade) != 1 || len(maximum) != 1 || len(rankGroup) != 1 || grade[0] == 0 || maximum[0] == 0 || rankGroup[0] == 0 {
rows.Close()
return nil, fmt.Errorf("gamedata: equipment %d has invalid smelting design", id)
}
d.Equipment[id] = EquipmentSmeltingItem{Grade: grade[0], RankGroup: rankGroup[0], MaxLevel: maximum[0]}
}
if err := rows.Err(); err != nil {
rows.Close()
return nil, err
}
if err := rows.Close(); err != nil {
return nil, err
}
rows, err = db.Query("SELECT id,ProtoBuf FROM EquipmentGradeTable")
if err != nil {
return nil, err
}
for rows.Next() {
var id uint64
var proto []byte
if err := rows.Scan(&id, &proto); err != nil {
rows.Close()
return nil, err
}
counts, _ := packedInts(proto, 4)
ids, _ := packedInts(proto, 5)
types, _ := packedInts(proto, 6)
if len(counts) == 0 || len(counts) != len(ids) || len(counts) != len(types) {
rows.Close()
return nil, fmt.Errorf("gamedata: equipment grade %d has invalid smelting costs", id)
}
for i := range counts {
if counts[i] == 0 || (types[i] != 4 && types[i] != 8) || (types[i] == 4 && ids[i] != 0) || (types[i] == 8 && ids[i] == 0) {
rows.Close()
return nil, fmt.Errorf("gamedata: equipment grade %d has invalid smelting material", id)
}
d.Grades[id] = append(d.Grades[id], PromotionCost{Type: types[i], ID: ids[i], Count: counts[i]})
}
}
if err := rows.Err(); err != nil {
rows.Close()
return nil, err
}
if err := rows.Close(); err != nil {
return nil, err
}
rows, err = db.Query("SELECT groupId,id,ProtoBuf FROM EquipmentRankTable")
if err != nil {
return nil, err
}
for rows.Next() {
var group, slot uint64
var proto []byte
if err := rows.Scan(&group, &slot, &proto); err != nil {
rows.Close()
return nil, err
}
ratios, err := fixed32Floats(proto, 4)
growth, growthErr := packedInts(proto, 2)
values, valueErr := packedInts(proto, 5)
if err != nil || growthErr != nil || valueErr != nil || slot < 1 || slot > 3 || len(ratios) != 4 || len(growth) != 4 || len(values) != 4 {
rows.Close()
return nil, fmt.Errorf("gamedata: equipment rank %d/%d has invalid smelting distribution", group, slot)
}
var total float64
for i := range ratios {
if ratios[i] < 0 || ratios[i] > 1 || math.IsNaN(ratios[i]) || values[i] == 0 || growth[i] == 0 {
rows.Close()
return nil, fmt.Errorf("gamedata: equipment rank %d/%d has invalid smelting value", group, slot)
}
total += ratios[i]
if i != 0 && (values[i] <= values[i-1] || growth[i] <= growth[i-1]) {
rows.Close()
return nil, fmt.Errorf("gamedata: equipment rank %d/%d is not increasing", group, slot)
}
}
if math.Abs(total-1) > 1e-5 {
rows.Close()
return nil, fmt.Errorf("gamedata: equipment rank %d/%d ratio total %.8f", group, slot, total)
}
d.Ranks[[2]uint64{group, slot}] = EquipmentSmeltingRank{GrowthPoint: growth, Values: values, Ratio: ratios}
}
if err := rows.Err(); err != nil {
rows.Close()
return nil, err
}
if err := rows.Close(); err != nil {
return nil, err
}
for id, equipment := range d.Equipment {
if len(d.Grades[equipment.Grade]) == 0 {
return nil, fmt.Errorf("gamedata: equipment %d has no grade costs", id)
}
for slot := uint64(1); slot <= 3; slot++ {
if len(d.Ranks[[2]uint64{equipment.RankGroup, slot}].Values) != 4 {
return nil, fmt.Errorf("gamedata: equipment %d has no rank slot %d", id, slot)
}
}
}
var mileageProto []byte
if err := db.QueryRow("SELECT ProtoBuf FROM EquipmentMileageTable WHERE id=0").Scan(&mileageProto); err != nil {
return nil, fmt.Errorf("gamedata: equipment mileage: %w", err)
}
mileageCount, _ := packedInts(mileageProto, 2)
mileageID, _ := packedInts(mileageProto, 3)
mileageType, _ := packedInts(mileageProto, 4)
useCount, _ := packedInts(mileageProto, 5)
useID, _ := packedInts(mileageProto, 6)
useType, _ := packedInts(mileageProto, 7)
if len(mileageCount) != 1 || len(mileageID) > 1 || len(mileageType) != 1 || len(useCount) != 1 || len(useID) != 1 || len(useType) != 1 || mileageCount[0] == 0 || useCount[0] == 0 {
return nil, fmt.Errorf("gamedata: invalid equipment mileage definition")
}
if len(mileageID) == 1 {
d.Mileage.RewardID = mileageID[0]
}
d.Mileage.RewardCount, d.Mileage.RewardType = mileageCount[0], mileageType[0]
d.Mileage.UseCount, d.Mileage.UseID, d.Mileage.UseType = useCount[0], useID[0], useType[0]
if d.Mileage.RewardType == 0 || d.Mileage.UseType == 0 || d.Mileage.UseID == 0 {
return nil, fmt.Errorf("gamedata: invalid equipment mileage item types")
}
for grade, costs := range d.Grades {
found := false
for _, cost := range costs {
if cost.Type == d.Mileage.UseType && cost.ID == d.Mileage.UseID {
found = true
break
}
}
if !found {
return nil, fmt.Errorf("gamedata: equipment grade %d does not consume mileage material", grade)
}
}
var defaults []byte
if err := db.QueryRow("SELECT ProtoBuf FROM GameDefaultTable WHERE id=0").Scan(&defaults); err != nil {
return nil, fmt.Errorf("gamedata: equipment smelting limit: %w", err)
}
maxStreak, _ := packedInts(defaults, 5)
if len(maxStreak) != 1 || maxStreak[0] == 0 {
return nil, fmt.Errorf("gamedata: invalid maximum smelting streak")
}
d.MaxStreak = maxStreak[0]
return d, nil
}
func (d *EquipmentSmeltingDesign) Cost(equipmentID uint64) ([]PromotionCost, error) {
if d == nil {
return nil, fmt.Errorf("gamedata: equipment smelting design unavailable")
}
equipment, ok := d.Equipment[equipmentID]
if !ok || len(d.Grades[equipment.Grade]) == 0 {
return nil, fmt.Errorf("gamedata: equipment %d has no smelting cost", equipmentID)
}
return append([]PromotionCost(nil), d.Grades[equipment.Grade]...), nil
}
func (d *EquipmentSmeltingDesign) Score(equipmentID uint64, ranks []uint64) (uint64, error) {
if d == nil || len(ranks) != 3 {
return 0, fmt.Errorf("gamedata: invalid equipment rank score input")
}
equipment, ok := d.Equipment[equipmentID]
if !ok {
return 0, fmt.Errorf("gamedata: equipment %d has no smelting design", equipmentID)
}
var score uint64
for i, rank := range ranks {
values := d.Ranks[[2]uint64{equipment.RankGroup, uint64(i + 1)}].Values
if rank < 1 || rank > uint64(len(values)) || values[rank-1] > ^uint64(0)-score {
return 0, fmt.Errorf("gamedata: invalid equipment rank %d in slot %d", rank, i+1)
}
score += values[rank-1]
}
return score, nil
}
func (d *EquipmentSmeltingDesign) MaximumRanks(equipmentID uint64) ([]uint64, error) {
if d == nil {
return nil, fmt.Errorf("gamedata: equipment smelting design unavailable")
}
equipment, ok := d.Equipment[equipmentID]
if !ok {
return nil, fmt.Errorf("gamedata: equipment %d has no smelting design", equipmentID)
}
ranks := make([]uint64, 3)
for slot := uint64(1); slot <= 3; slot++ {
values := d.Ranks[[2]uint64{equipment.RankGroup, slot}].Values
if len(values) == 0 {
return nil, fmt.Errorf("gamedata: equipment %d has no rank slot %d", equipmentID, slot)
}
ranks[slot-1] = uint64(len(values))
}
return ranks, nil
}
// RollCandidate independently rolls all three rank slots from the official
// EquipmentRankTable distributions. Whether the candidate replaces the
// current ranks is a player-state transaction, not a GameData concern.
func (d *EquipmentSmeltingDesign) RollCandidate(equipmentID uint64) ([]uint64, error) {
if d == nil {
return nil, fmt.Errorf("gamedata: equipment smelting design unavailable")
}
equipment, ok := d.Equipment[equipmentID]
if !ok {
return nil, fmt.Errorf("gamedata: equipment %d has no smelting design", equipmentID)
}
result := make([]uint64, 3)
for slot := uint64(1); slot <= 3; slot++ {
rank := d.Ranks[[2]uint64{equipment.RankGroup, slot}]
value, err := cryptoRankRoll(rank.Ratio)
if err != nil {
return nil, fmt.Errorf("gamedata: roll equipment %d rank slot %d: %w", equipmentID, slot, err)
}
result[slot-1] = value
}
return result, nil
}
@@ -0,0 +1,69 @@
package gamedata
import (
"os"
"testing"
)
func TestEquipmentSmeltingAgainstInstalledVersion23413(t *testing.T) {
root := os.Getenv("BD2_TEST_GAMEDATA_ROOT")
if root == "" {
t.Skip("set BD2_TEST_GAMEDATA_ROOT for installed GameData integration test")
}
design, err := LoadEquipmentSmeltingDesign(root, "20260910162539")
if err != nil {
t.Fatal(err)
}
equipment := design.Equipment[943035]
if equipment.Grade != 4 || equipment.RankGroup != 904 || equipment.MaxLevel != 9 {
t.Fatalf("UR equipment smelting design=%+v", equipment)
}
cost, err := design.Cost(943035)
if err != nil || len(cost) != 2 || cost[0] != (PromotionCost{Type: 4, Count: 80}) || cost[1] != (PromotionCost{Type: 8, ID: 10, Count: 30}) {
t.Fatalf("UR smelting cost=%+v err=%v", cost, err)
}
if design.MaxStreak != 5000 || design.Mileage != (EquipmentSmeltingMileage{UseType: 8, UseID: 10, UseCount: 1000, RewardType: 68, RewardCount: 1}) {
t.Fatalf("smelting limit=%d mileage=%+v", design.MaxStreak, design.Mileage)
}
if got := design.Ranks[[2]uint64{904, 1}].Values; len(got) != 4 || got[0] != 1 || got[3] != 4 {
t.Fatalf("UR slot1 score values=%v", got)
}
if got := design.Ranks[[2]uint64{904, 1}].GrowthPoint; len(got) != 4 || got[0] != 34 || got[3] != 135 {
t.Fatalf("UR slot1 growth points=%v", got)
}
if score, err := design.Score(943035, []uint64{1, 2, 3}); err != nil || score != 1+4+9 {
t.Fatalf("score=%d err=%v", score, err)
}
}
func TestEquipmentSmeltingMaximumRanksComeFromDesign(t *testing.T) {
design := &EquipmentSmeltingDesign{
Equipment: map[uint64]EquipmentSmeltingItem{1: {RankGroup: 9}},
Ranks: map[[2]uint64]EquipmentSmeltingRank{
{9, 1}: {Values: []uint64{1, 2}},
{9, 2}: {Values: []uint64{1, 2, 3}},
{9, 3}: {Values: []uint64{1, 2, 3, 4}},
},
}
got, err := design.MaximumRanks(1)
if err != nil || len(got) != 3 || got[0] != 2 || got[1] != 3 || got[2] != 4 {
t.Fatalf("maximum ranks=%v err=%v", got, err)
}
}
func TestEquipmentSmeltingRejectsInvalidRank(t *testing.T) {
design := &EquipmentSmeltingDesign{
Equipment: map[uint64]EquipmentSmeltingItem{1: {Grade: 4, RankGroup: 9, MaxLevel: 9}},
Ranks: map[[2]uint64]EquipmentSmeltingRank{
{9, 1}: {Values: []uint64{1, 2, 3, 4}},
{9, 2}: {Values: []uint64{2, 4, 6, 8}},
{9, 3}: {Values: []uint64{3, 6, 9, 12}},
},
}
if _, err := design.Score(1, []uint64{1, 0, 1}); err == nil {
t.Fatal("uninitialized smelting rank accepted")
}
if score, err := design.Score(1, []uint64{4, 4, 4}); err != nil || score != 24 {
t.Fatalf("maximum score=%d err=%v", score, err)
}
}
@@ -0,0 +1,40 @@
package gamedata
import (
"os"
"testing"
)
func TestEquipmentTicketOnlyGachaAgainstInstalledVersion23413(t *testing.T) {
root := os.Getenv("BD2_TEST_GAMEDATA_ROOT")
if root == "" {
t.Skip("set BD2_TEST_GAMEDATA_ROOT for installed GameData integration test")
}
catalog, err := LoadEquipmentGacha(root, "20260910162539")
if err != nil {
t.Fatal(err)
}
gacha, ok := catalog.Gacha(71200001)
if !ok || !gacha.TicketOnly || gacha.Count != 10 || gacha.Price != 0 || gacha.PriceType != 0 || len(gacha.TicketIDs) != 1 || gacha.TicketIDs[0] != 1104 {
t.Fatalf("ticket-only equipment gacha=%+v ok=%v", gacha, ok)
}
if len(gacha.Pool) != 3 || gacha.Pool[0].Weight != 150 || gacha.Pool[1].Weight != 350 || gacha.Pool[2].Weight != 500 {
t.Fatalf("ticket-only equipment pool=%+v", gacha.Pool)
}
if len(gacha.Pool[0].Children) != 50 || len(gacha.Pool[1].Children) != 9 || len(gacha.Pool[2].Children) != 14 {
t.Fatalf("ticket-only equipment branches=%d/%d/%d", len(gacha.Pool[0].Children), len(gacha.Pool[1].Children), len(gacha.Pool[2].Children))
}
// These branches separate the owning character's star grade, not the
// equipment rarity. Every candidate is EquipmentTable.Grade=4 (UR).
for _, branch := range gacha.Pool {
for _, item := range branch.Children {
design, found := catalog.equipment[item.ID]
if !found || design.Grade != 4 {
t.Fatalf("UR-guaranteed candidate %d design=%+v found=%v", item.ID, design, found)
}
}
}
if _, grouped := catalog.GroupForGacha(gacha.ID); grouped {
t.Fatal("standalone ticket draw incorrectly attached to a schedule group")
}
}
+15
View File
@@ -228,6 +228,17 @@ func OpenService(path string, starter *Starter, inventory *player.Inventory, wal
return s, nil
}
func (s *Service) EnsurePersisted() error {
s.mu.Lock()
defer s.mu.Unlock()
if _, err := os.Stat(s.path); err == nil {
return nil
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
return s.persist(s.state)
}
// AttachSeedPath enables development-time hot reloading of an atomically
// replaced mail seed. It is deliberately a mailbox concern, not an HTTP debug
// endpoint: the client continues to call only the normal /MailInfo API.
@@ -436,6 +447,10 @@ func (s *Service) commit(next stateSnapshot) error {
return nil
}
}
return s.persist(next)
}
func (s *Service) persist(next stateSnapshot) error {
b, err := json.Marshal(next)
if err != nil {
return err
+15
View File
@@ -75,6 +75,17 @@ func Open(path string, design *gamedata.MissionDesign, inventory *player.Invento
return s, nil
}
func (s *Service) EnsurePersisted() error {
s.mu.Lock()
defer s.mu.Unlock()
if _, err := os.Stat(s.path); err == nil {
return nil
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
return s.persist(cloneSnapshot(s.state))
}
// CompleteMission is the only way normal mission eligibility enters this
// package. It is persistent and idempotent. Gameplay/event handlers should
// call it only after they have independently verified the table condition.
@@ -655,6 +666,10 @@ func (s *Service) commit(next snapshot) error {
if equalStrings(next.Completed, s.state.Completed) && equalStrings(next.Claimed, s.state.Claimed) && equalProgress(next.Progress, s.state.Progress) {
return nil
}
return s.persist(next)
}
func (s *Service) persist(next snapshot) error {
b, err := json.Marshal(next)
if err != nil {
return err
+13 -3
View File
@@ -23,9 +23,12 @@ type Owned interface {
}
type Service struct {
Design *gamedata.PictorialDesign
Owned Owned
baseHealth sync.Map // [2]uint64 (design character ID, level) -> design-only base HP
Design *gamedata.PictorialDesign
Owned Owned
// AwakeContributions is injected by the character-awakening domain so all
// server-side maximum-HP consumers use the same derived account state.
AwakeContributions func(player.Character) ([]gamedata.StatContribution, error)
baseHealth sync.Map // [2]uint64 (design character ID, level) -> design-only base HP
}
func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) {
@@ -270,6 +273,13 @@ func (s *Service) MaxHealth(character player.Character) (uint64, error) {
if err != nil {
return 0, err
}
if s.AwakeContributions != nil {
awake, err := s.AwakeContributions(character)
if err != nil {
return 0, err
}
contributions = append(contributions, awake...)
}
maxHP := gamedata.AggregateStats(gamedata.BaseStats{Health: value.(float64)}, contributions).Health
if maxHP < 1 || maxHP > float64(^uint64(0)) {
return 0, fmt.Errorf("pictorial: invalid maximum health for character %d: %v", character.ID, maxHP)
+300
View File
@@ -0,0 +1,300 @@
package player
import (
"errors"
"fmt"
"sort"
"strconv"
"bd2server/internal/gamedata"
"bd2server/internal/wire"
)
type CharAwakeService struct {
design *gamedata.CharAwakeDesign
collection *CollectionStore
characters *CharacterStore
inventory *Inventory
wallet *Wallet
}
func NewCharAwakeService(design *gamedata.CharAwakeDesign, collection *CollectionStore, characters *CharacterStore, inventory *Inventory, wallet *Wallet) (*CharAwakeService, error) {
if design == nil || collection == nil || characters == nil || inventory == nil || wallet == nil {
return nil, errors.New("player: incomplete character awakening service")
}
for uniqueID, progress := range collection.CharAwakeStates() {
if err := design.ValidateProgress(uniqueID, progress.ImprintLevels, progress.IsAwake); err != nil {
return nil, fmt.Errorf("player: invalid saved character awakening progress: %w", err)
}
}
return &CharAwakeService{design: design, collection: collection, characters: characters, inventory: inventory, wallet: wallet}, nil
}
func (s *CharAwakeService) Handle(path string, request []byte) (int, []byte, bool, error) {
switch path {
case "/CharAwakeInfo":
return s.info(request)
case "/CharImprintLevelUp":
return s.imprintLevelUp(request)
case "/CharAwakeActive":
return s.awakeActive(request)
default:
return 0, nil, false, nil
}
}
func validAwakeSequence(request []byte, path string) error {
seq, found, err := wire.Varint(request, 1)
if err != nil || !found || seq == 0 {
return fmt.Errorf("player: %s missing sequence", path)
}
return nil
}
func (s *CharAwakeService) info(request []byte) (int, []byte, bool, error) {
if err := validAwakeSequence(request, "CharAwakeInfo"); err != nil {
return 0, nil, true, err
}
states := s.collection.CharAwakeStates()
ids := make([]uint64, 0, len(states))
for id := range states {
ids = append(ids, id)
}
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
var response []byte
for _, id := range ids {
progress := states[id]
var entry []byte
entry = wire.AppendVarint(entry, 1, id)
for i, level := range progress.ImprintLevels {
if level != 0 {
entry = wire.AppendVarint(entry, 2+i, level)
}
}
if progress.IsAwake {
entry = wire.AppendVarint(entry, 5, 1)
}
response = wire.AppendBytes(response, 1, entry)
}
return 326, response, true, nil
}
func decodeAwakeMaterials(request []byte, fieldNumber int) ([]Item, error) {
var materials []Item
err := wire.Walk(request, func(field wire.Field) error {
if field.Number != fieldNumber {
return nil
}
if field.Type != 2 {
return errors.New("player: invalid character awakening material field")
}
var item Item
if err := decodeVarints(field.Value, map[int]*uint64{1: &item.InvenIndex, 2: &item.ID, 3: &item.Type, 4: &item.Count, 5: &item.KeepFlag, 6: &item.TimeValue, 9: &item.SortID, 10: &item.UseCount}); err != nil {
return err
}
if item.Type == 0 || item.Count == 0 || (item.Type != 4 && (item.InvenIndex == 0 || item.ID == 0)) {
return errors.New("player: incomplete character awakening material")
}
materials = append(materials, item)
return nil
})
return materials, err
}
func (s *CharAwakeService) imprintLevelUp(request []byte) (int, []byte, bool, error) {
if err := validAwakeSequence(request, "CharImprintLevelUp"); err != nil {
return 0, nil, true, err
}
characterIndex, found, err := wire.Varint(request, 2)
if err != nil || !found || characterIndex == 0 {
return 0, nil, true, errors.New("player: CharImprintLevelUp missing character")
}
var targets []gamedata.CharImprintTarget
err = wire.Walk(request, func(field wire.Field) error {
if field.Number != 3 {
return nil
}
if field.Type != 2 {
return errors.New("player: invalid imprint target field")
}
var target gamedata.CharImprintTarget
if err := decodeVarints(field.Value, map[int]*uint64{1: &target.Slot, 2: &target.TargetLevel}); err != nil {
return err
}
if target.Slot == 0 || target.TargetLevel == 0 {
return errors.New("player: incomplete imprint target")
}
targets = append(targets, target)
return nil
})
if err != nil || len(targets) == 0 {
if err == nil {
err = errors.New("player: CharImprintLevelUp has no targets")
}
return 0, nil, true, err
}
materials, err := decodeAwakeMaterials(request, 4)
if err != nil || len(materials) == 0 {
if err == nil {
err = errors.New("player: CharImprintLevelUp has no materials")
}
return 0, nil, true, err
}
character, found := s.characters.Find(characterIndex)
if !found {
return 0, nil, true, fmt.Errorf("player: unknown imprint character %d", characterIndex)
}
uniqueID, err := s.design.ValidateGrowthCompleted(character.ID, character.Level)
if err != nil {
return 0, nil, true, fmt.Errorf("player: validate imprint character growth: %w", err)
}
current, _ := s.collection.CharAwakeState(uniqueID)
costs, levels, err := s.design.ImprintCosts(uniqueID, current.ImprintLevels, targets)
if err != nil {
return 0, nil, true, fmt.Errorf("player: validate imprint GameData: %w", err)
}
items, gold, err := s.validateCosts(costs, materials)
if err != nil {
return 0, nil, true, fmt.Errorf("player: validate imprint materials: %w", err)
}
if len(items) != 0 {
if err := s.inventory.CanConsume(items); err != nil {
return 0, nil, true, fmt.Errorf("player: validate imprint inventory: %w", err)
}
}
if gold != 0 && !s.wallet.CanSpendGold(gold) {
return 0, nil, true, errors.New("player: insufficient gold for character imprint")
}
identity := "char-imprint:" + strconv.FormatUint(uniqueID, 10)
for _, level := range levels {
identity += ":" + strconv.FormatUint(level, 10)
}
if gold != 0 {
if _, err := s.wallet.SpendGoldOnce(identity, gold); err != nil {
return 0, nil, true, fmt.Errorf("player: spend imprint gold: %w", err)
}
}
if len(items) != 0 {
if err := s.inventory.Consume(items); err != nil {
return 0, nil, true, fmt.Errorf("player: consume imprint materials: %w", err)
}
}
next := current
next.ImprintLevels = levels
if err := s.collection.UpdateCharAwake(uniqueID, current, next); err != nil {
return 0, nil, true, fmt.Errorf("player: persist character imprint: %w", err)
}
return 327, nil, true, nil
}
func (s *CharAwakeService) awakeActive(request []byte) (int, []byte, bool, error) {
if err := validAwakeSequence(request, "CharAwakeActive"); err != nil {
return 0, nil, true, err
}
characterIndex, found, err := wire.Varint(request, 2)
if err != nil || !found || characterIndex == 0 {
return 0, nil, true, errors.New("player: CharAwakeActive missing character")
}
materials, err := decodeAwakeMaterials(request, 3)
if err != nil || len(materials) == 0 {
if err == nil {
err = errors.New("player: CharAwakeActive has no materials")
}
return 0, nil, true, err
}
character, found := s.characters.Find(characterIndex)
if !found {
return 0, nil, true, fmt.Errorf("player: unknown awakening character %d", characterIndex)
}
uniqueID, err := s.design.ValidateGrowthCompleted(character.ID, character.Level)
if err != nil {
return 0, nil, true, fmt.Errorf("player: validate awakening character growth: %w", err)
}
current, _ := s.collection.CharAwakeState(uniqueID)
costs, err := s.design.AwakeCosts(uniqueID, current.ImprintLevels, current.IsAwake)
if err != nil {
return 0, nil, true, fmt.Errorf("player: validate awakening GameData: %w", err)
}
items, gold, err := s.validateCosts(costs, materials)
if err != nil {
return 0, nil, true, fmt.Errorf("player: validate awakening materials: %w", err)
}
if len(items) != 0 {
if err := s.inventory.CanConsume(items); err != nil {
return 0, nil, true, fmt.Errorf("player: validate awakening inventory: %w", err)
}
}
if gold != 0 && !s.wallet.CanSpendGold(gold) {
return 0, nil, true, errors.New("player: insufficient gold for character awakening")
}
identity := "char-awake:" + strconv.FormatUint(uniqueID, 10)
if gold != 0 {
if _, err := s.wallet.SpendGoldOnce(identity, gold); err != nil {
return 0, nil, true, fmt.Errorf("player: spend awakening gold: %w", err)
}
}
if len(items) != 0 {
if err := s.inventory.Consume(items); err != nil {
return 0, nil, true, fmt.Errorf("player: consume awakening materials: %w", err)
}
}
next := current
next.IsAwake = true
if err := s.collection.UpdateCharAwake(uniqueID, current, next); err != nil {
return 0, nil, true, fmt.Errorf("player: persist character awakening: %w", err)
}
return 328, nil, true, nil
}
func (s *CharAwakeService) validateCosts(costs []gamedata.CharAwakeCost, materials []Item) ([]Item, uint64, error) {
want := make(map[[2]uint64]uint64, len(costs))
for _, cost := range costs {
key := [2]uint64{cost.Type, cost.ID}
if cost.Count > ^uint64(0)-want[key] {
return nil, 0, errors.New("character awakening cost overflow")
}
want[key] += cost.Count
}
got := make(map[[2]uint64]uint64, len(materials))
var items []Item
var gold uint64
for _, material := range materials {
key := [2]uint64{material.Type, material.ID}
switch material.Type {
case 4:
if material.InvenIndex != 0 || material.ID != 0 || gold != 0 {
return nil, 0, errors.New("invalid character awakening currency")
}
gold = material.Count
case 8:
items = append(items, material)
default:
return nil, 0, fmt.Errorf("unsupported character awakening material type %d", material.Type)
}
if material.Count > ^uint64(0)-got[key] {
return nil, 0, errors.New("submitted character awakening material overflow")
}
got[key] += material.Count
}
if len(got) != len(want) {
return nil, 0, fmt.Errorf("material kinds mismatch: request=%v GameData=%v", got, want)
}
for key, count := range want {
if got[key] != count {
return nil, 0, fmt.Errorf("material %d/%d=%d want %d", key[0], key[1], got[key], count)
}
}
return items, gold, nil
}
// Contributions exposes server-side derived stats for CharInfo, revival and
// battle calculators. The client independently computes the same values from
// CharAwakeInfo, so neither side relies on a persisted derived number.
func (s *CharAwakeService) Contributions(character Character) ([]gamedata.StatContribution, error) {
uniqueID, ok := s.design.CharacterUniqueID(character.ID)
if !ok {
return nil, nil
}
progress, _ := s.collection.CharAwakeState(uniqueID)
return s.design.CharAwakeContributions(uniqueID, progress.ImprintLevels, progress.IsAwake)
}
+140
View File
@@ -0,0 +1,140 @@
package player
import (
"path/filepath"
"testing"
"bd2server/internal/gamedata"
"bd2server/internal/wire"
)
func newCharAwakeHarness(t *testing.T) (*CharAwakeService, *CollectionStore, *Inventory, *Wallet, []Item) {
t.Helper()
dir := t.TempDir()
starter := &Starter{Version: "2.34.13"}
inventory, err := OpenInventory(filepath.Join(dir, "items.json"), starter)
if err != nil {
t.Fatal(err)
}
granted, err := inventory.GrantOnce("awake-test", []gamedata.BattleReward{
{Type: 8, ID: 701, Count: 1}, {Type: 8, ID: 702, Count: 1}, {Type: 8, ID: 703, Count: 1}, {Type: 8, ID: 705, Count: 20},
})
if err != nil {
t.Fatal(err)
}
wallet, err := OpenWallet(filepath.Join(dir, "wallet.json"), Currency{Gold: 1000})
if err != nil {
t.Fatal(err)
}
collection, err := OpenCollectionStore(filepath.Join(dir, "collection.json"), nil)
if err != nil {
t.Fatal(err)
}
characters := &CharacterStore{characters: []Character{{InvenIndex: 77, ID: 354, Level: 100}}}
character := gamedata.CharAwakeCharacter{UniqueCharID: 35, Active: true}
for i := 0; i < 3; i++ {
character.ImprintGrowth[i] = []gamedata.CharAwakeGrowth{{
ID: uint64(i + 1), StatType: uint64(i*2 + 1), StatValue: float64(i + 1),
Costs: []gamedata.CharAwakeCost{{Type: 8, ID: uint64(701 + i), Count: 1}, {Type: 4, Count: 100}},
}}
}
character.AwakeGrowth = []gamedata.CharAwakeGrowth{
{ID: 100, StatType: 4, StatValue: .12, Costs: []gamedata.CharAwakeCost{{Type: 8, ID: 705, Count: 20}, {Type: 4, Count: 500}}},
{ID: 101, StatType: 14, StatValue: .1},
}
design := &gamedata.CharAwakeDesign{
Characters: map[uint64]gamedata.CharAwakeCharacter{35: character},
Stages: map[uint64]gamedata.CharAwakeCharacterStage{
354: {UniqueCharID: 35, Grade: 5, GrowthGrade: 5, MaximumLevel: 100},
},
}
service, err := NewCharAwakeService(design, collection, characters, inventory, wallet)
if err != nil {
t.Fatal(err)
}
return service, collection, inventory, wallet, granted
}
func awakeMaterial(item Item) []byte { return ItemWire(item) }
func TestCharImprintAndAwakePersistConsumeAndRestoreInfo(t *testing.T) {
service, collection, inventory, wallet, granted := newCharAwakeHarness(t)
request := wire.AppendVarint(nil, 1, 10)
request = wire.AppendVarint(request, 2, 77)
for slot := uint64(1); slot <= 3; slot++ {
target := wire.AppendVarint(wire.AppendVarint(nil, 1, slot), 2, 1)
request = wire.AppendBytes(request, 3, target)
request = wire.AppendBytes(request, 4, awakeMaterial(granted[slot-1]))
}
request = wire.AppendBytes(request, 4, awakeMaterial(Item{Type: 4, Count: 300}))
code, proto, handled, err := service.Handle("/CharImprintLevelUp", request)
if err != nil || !handled || code != 327 || len(proto) != 0 {
t.Fatalf("imprint code=%d proto=%x handled=%v err=%v", code, proto, handled, err)
}
progress, found := collection.CharAwakeState(35)
if !found || progress.ImprintLevels != [3]uint64{1, 1, 1} || progress.IsAwake {
t.Fatalf("imprint progress=%+v found=%v", progress, found)
}
if wallet.Snapshot().Gold != 700 || len(inventory.All()) != 1 {
t.Fatalf("post-imprint wallet=%+v items=%+v", wallet.Snapshot(), inventory.All())
}
request = wire.AppendVarint(nil, 1, 11)
request = wire.AppendVarint(request, 2, 77)
request = wire.AppendBytes(request, 3, awakeMaterial(granted[3]))
request = wire.AppendBytes(request, 3, awakeMaterial(Item{Type: 4, Count: 500}))
code, proto, handled, err = service.Handle("/CharAwakeActive", request)
if err != nil || !handled || code != 328 || len(proto) != 0 {
t.Fatalf("awake code=%d proto=%x handled=%v err=%v", code, proto, handled, err)
}
progress, _ = collection.CharAwakeState(35)
if !progress.IsAwake || wallet.Snapshot().Gold != 200 || len(inventory.All()) != 0 {
t.Fatalf("post-awake progress=%+v wallet=%+v items=%+v", progress, wallet.Snapshot(), inventory.All())
}
restarted, err := OpenCollectionStore(collection.path, nil)
if err != nil {
t.Fatal(err)
}
service.collection = restarted
progress, found = restarted.CharAwakeState(35)
if !found || progress.ImprintLevels != [3]uint64{1, 1, 1} || !progress.IsAwake {
t.Fatalf("restarted awakening progress=%+v found=%v", progress, found)
}
code, proto, handled, err = service.Handle("/CharAwakeInfo", wire.AppendVarint(nil, 1, 12))
if err != nil || !handled || code != 326 {
t.Fatalf("info code=%d handled=%v err=%v", code, handled, err)
}
var entry []byte
if err := wire.Walk(proto, func(field wire.Field) error {
if field.Number == 1 {
entry = append([]byte(nil), field.Value...)
}
return nil
}); err != nil {
t.Fatal(err)
}
for field, want := range map[int]uint64{1: 35, 2: 1, 3: 1, 4: 1, 5: 1} {
got, present, err := wire.Varint(entry, field)
if err != nil || !present || got != want {
t.Fatalf("CharAwakeDBInfo field %d=%d present=%v err=%v want=%d proto=%x", field, got, present, err, want, entry)
}
}
}
func TestCharImprintRejectsClientCostMismatchBeforeMutation(t *testing.T) {
service, collection, inventory, wallet, granted := newCharAwakeHarness(t)
request := wire.AppendVarint(nil, 1, 20)
request = wire.AppendVarint(request, 2, 77)
request = wire.AppendBytes(request, 3, wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 1))
bad := granted[0]
bad.Count = 2
request = wire.AppendBytes(request, 4, awakeMaterial(bad))
request = wire.AppendBytes(request, 4, awakeMaterial(Item{Type: 4, Count: 100}))
if _, _, handled, err := service.Handle("/CharImprintLevelUp", request); !handled || err == nil {
t.Fatalf("mismatched costs handled=%v err=%v", handled, err)
}
if _, found := collection.CharAwakeState(35); found || wallet.Snapshot().Gold != 1000 || len(inventory.All()) != 4 {
t.Fatalf("rejected request mutated state: found=%v wallet=%+v items=%+v", found, wallet.Snapshot(), inventory.All())
}
}
+102
View File
@@ -83,6 +83,13 @@ type GachaSelection struct {
ItemID uint64 `json:"item_id"`
}
// CharAwakeProgress is account-wide character state keyed by CharTable's
// UniqueCharId. Promotion changes CharTable.Id, but never this identity.
type CharAwakeProgress struct {
ImprintLevels [3]uint64 `json:"imprint_levels"`
IsAwake bool `json:"is_awake"`
}
// FirstGachaCompletedIdentity is the persisted account flag represented in
// the existing collection grant ledger. It is written atomically with the
// official GachaSubType=3 first-pick transaction.
@@ -99,6 +106,7 @@ type collectionSnapshot struct {
Costumes []Costume `json:"costumes,omitempty"`
BaseCostumeLevels map[string]uint64 `json:"base_costume_levels,omitempty"`
CostumePotential map[string][]uint64 `json:"costume_potential"`
CharAwake map[string]CharAwakeProgress `json:"char_awake"`
GachaSelections map[string][]GachaSelection `json:"gacha_selections,omitempty"`
StepUpProgress map[string]uint64 `json:"step_up_progress,omitempty"`
GachaUsers map[string]GachaUserState `json:"gacha_users,omitempty"`
@@ -125,6 +133,7 @@ func OpenCollectionStore(path string, base []Costume) (*CollectionStore, error)
Version: "2.34.13", NextCharacterIndex: 920000001, NextCostumeIndex: 930000001,
BaseCostumeLevels: map[string]uint64{}, GachaSelections: map[string][]GachaSelection{},
CostumePotential: map[string][]uint64{},
CharAwake: map[string]CharAwakeProgress{},
StepUpProgress: map[string]uint64{}, GachaUsers: map[string]GachaUserState{}, GachaFixed: map[string]GachaFixedState{},
GachaApplied: map[string]bool{}, GachaPointExchange: map[string]GachaPointExchange{}, Grants: map[string]CollectionGrant{},
}}
@@ -147,6 +156,15 @@ func OpenCollectionStore(path string, base []Costume) (*CollectionStore, error)
if s.data.CostumePotential == nil {
return nil, errors.New("player: collection save requires costume_potential; migrate the development save")
}
if s.data.CharAwake == nil {
return nil, errors.New("player: collection save requires char_awake; migrate the development save")
}
for key, progress := range s.data.CharAwake {
uniqueID, parseErr := strconv.ParseUint(key, 10, 64)
if parseErr != nil || uniqueID == 0 || (progress.ImprintLevels == [3]uint64{} && !progress.IsAwake) {
return nil, errors.New("player: invalid char_awake ledger")
}
}
if s.data.GachaSelections == nil {
s.data.GachaSelections = map[string][]GachaSelection{}
}
@@ -174,6 +192,17 @@ func OpenCollectionStore(path string, base []Costume) (*CollectionStore, error)
return s, nil
}
func (s *CollectionStore) EnsurePersisted() error {
s.mu.Lock()
defer s.mu.Unlock()
if _, err := os.Stat(s.path); err == nil {
return nil
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
return s.commit(cloneCollection(s.data))
}
func emptyCollectionGrant(grant CollectionGrant) bool {
return len(grant.CharacterIndices) == 0 && len(grant.CostumeIndices) == 0 &&
len(grant.Upgrades) == 0 && len(grant.Exchanges) == 0 &&
@@ -474,6 +503,26 @@ func (s *CollectionStore) GrantEquipmentPurchase(identity string, count uint64,
return grant, nil
}
// GrantEquipmentDraw records a standalone ticket draw without inventing a
// schedule group, points, purchase counts, or fixed-pity state.
func (s *CollectionStore) GrantEquipmentDraw(identity string) (CollectionGrant, error) {
if identity == "" {
return CollectionGrant{}, errors.New("player: invalid equipment draw identity")
}
s.mu.Lock()
defer s.mu.Unlock()
if grant, ok := s.data.Grants[identity]; ok {
return cloneGrant(grant), nil
}
next := cloneCollection(s.data)
grant := CollectionGrant{}
next.Grants[identity] = grant
if err := s.commit(next); err != nil {
return CollectionGrant{}, err
}
return grant, nil
}
func (s *CollectionStore) grantCostumes(identity string, costumeIDs []uint64, character func(uint64) (gamedata.CharacterDesign, bool), mutate func(*collectionSnapshot, *CollectionGrant) error) (CollectionGrant, error) {
s.mu.Lock()
defer s.mu.Unlock()
@@ -804,6 +853,55 @@ func (s *CollectionStore) ActivateCostumePotential(costumeIndex uint64, nodes []
return s.commit(next)
}
func (s *CollectionStore) CharAwakeState(uniqueCharID uint64) (CharAwakeProgress, bool) {
if uniqueCharID == 0 {
return CharAwakeProgress{}, false
}
s.mu.Lock()
defer s.mu.Unlock()
progress, ok := s.data.CharAwake[strconv.FormatUint(uniqueCharID, 10)]
return progress, ok
}
func (s *CollectionStore) CharAwakeStates() map[uint64]CharAwakeProgress {
s.mu.Lock()
defer s.mu.Unlock()
result := make(map[uint64]CharAwakeProgress, len(s.data.CharAwake))
for key, progress := range s.data.CharAwake {
uniqueID, err := strconv.ParseUint(key, 10, 64)
if err == nil && uniqueID != 0 {
result[uniqueID] = progress
}
}
return result
}
// UpdateCharAwake atomically advances one UniqueCharId ledger entry. expected
// rejects stale concurrent requests before one response can overwrite another.
func (s *CollectionStore) UpdateCharAwake(uniqueCharID uint64, expected, nextProgress CharAwakeProgress) error {
if uniqueCharID == 0 || nextProgress == (CharAwakeProgress{}) {
return errors.New("player: invalid character awakening progress")
}
for i := range nextProgress.ImprintLevels {
if nextProgress.ImprintLevels[i] < expected.ImprintLevels[i] {
return errors.New("player: character imprint level cannot decrease")
}
}
if expected.IsAwake && !nextProgress.IsAwake {
return errors.New("player: character awakening cannot be removed")
}
s.mu.Lock()
defer s.mu.Unlock()
key := strconv.FormatUint(uniqueCharID, 10)
current := s.data.CharAwake[key]
if current != expected {
return errors.New("player: stale character awakening progress")
}
next := cloneCollection(s.data)
next.CharAwake[key] = nextProgress
return s.commit(next)
}
func (s *CollectionStore) Grant(identity string) (CollectionGrant, bool) {
s.mu.Lock()
defer s.mu.Unlock()
@@ -883,6 +981,10 @@ func cloneCollection(in collectionSnapshot) collectionSnapshot {
for k, v := range in.CostumePotential {
out.CostumePotential[k] = append([]uint64(nil), v...)
}
out.CharAwake = make(map[string]CharAwakeProgress, len(in.CharAwake))
for k, v := range in.CharAwake {
out.CharAwake[k] = v
}
out.GachaSelections = make(map[string][]GachaSelection, len(in.GachaSelections))
for k, v := range in.GachaSelections {
out.GachaSelections[k] = append([]GachaSelection(nil), v...)
+539 -3
View File
@@ -1,9 +1,11 @@
package player
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"math"
"os"
"path/filepath"
"strconv"
@@ -56,8 +58,16 @@ type EquipmentInventory struct {
characters *CharacterStore
slots map[uint64]uint64
upgrade *gamedata.EquipmentUpgradeDesign
smelting *gamedata.EquipmentSmeltingDesign
wallet *Wallet
inventory *Inventory
sessionID string
smeltCache map[string]smeltingReply
}
type smeltingReply struct {
code int
body []byte
}
func (s *EquipmentInventory) AttachUpgrade(design *gamedata.EquipmentUpgradeDesign, wallet *Wallet, inventory *Inventory) error {
@@ -70,6 +80,26 @@ func (s *EquipmentInventory) AttachUpgrade(design *gamedata.EquipmentUpgradeDesi
return nil
}
func (s *EquipmentInventory) AttachSmelting(design *gamedata.EquipmentSmeltingDesign, wallet *Wallet, inventory *Inventory) error {
if design == nil || wallet == nil || inventory == nil {
return errors.New("player: incomplete equipment smelting configuration")
}
s.mu.Lock()
defer s.mu.Unlock()
s.smelting, s.wallet, s.inventory = design, wallet, inventory
return nil
}
// BeginSession scopes the in-memory request replay cache. A repeated protobuf
// sequence in one login must return the first refinement result without a
// second roll or charge.
func (s *EquipmentInventory) BeginSession(id string) {
s.mu.Lock()
defer s.mu.Unlock()
s.sessionID = id
s.smeltCache = make(map[string]smeltingReply)
}
func (s *EquipmentInventory) AttachSlots(slots map[uint64]uint64) error {
if len(slots) == 0 {
return errors.New("player: empty equipment slot design")
@@ -97,7 +127,7 @@ func (s *EquipmentInventory) AttachCharacters(characters *CharacterStore) error
}
func OpenEquipmentInventory(path string) (*EquipmentInventory, error) {
s := &EquipmentInventory{path: filepath.Clean(path), owned: equipmentSnapshot{
s := &EquipmentInventory{path: filepath.Clean(path), smeltCache: make(map[string]smeltingReply), owned: equipmentSnapshot{
Version: "2.34.13", NextIndex: 910000001, Granted: map[string]uint64{},
}}
data, err := os.ReadFile(s.path)
@@ -132,6 +162,17 @@ func OpenEquipmentInventory(path string) (*EquipmentInventory, error) {
return s, nil
}
func (s *EquipmentInventory) EnsurePersisted() error {
s.mu.Lock()
defer s.mu.Unlock()
if _, err := os.Stat(s.path); err == nil {
return nil
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
return s.commitLocked(cloneEquipmentSnapshot(s.owned), "initial account generation")
}
// GrantOnce returns the same instance on a retry, allowing QuestClear response
// retries without duplicating ownership.
func (s *EquipmentInventory) GrantOnce(identity string, equipmentID uint64) (Equipment, error) {
@@ -260,7 +301,7 @@ func equipmentOptionWire(option EquipmentOption) []byte {
}
func (s *EquipmentInventory) Handle(path string, request []byte) (int, []byte, bool, error) {
if path != "/EquipInfo" && path != "/EquipUse" && path != "/EquipClear" && path != "/EquipChange" && path != "/EquipUpgrade" && path != "/EquipSequenceUpgrade" && path != "/EquipMarkSet" && path != "/EquipMarkDelete" && path != "/EquipLock" {
if path != "/EquipInfo" && path != "/EquipUse" && path != "/EquipClear" && path != "/EquipChange" && path != "/EquipUpgrade" && path != "/EquipSequenceUpgrade" && path != "/EquipSmelting" && path != "/EquipSequenceSmelting" && path != "/EquipMarkSet" && path != "/EquipMarkDelete" && path != "/EquipLock" {
return 0, nil, false, nil
}
if seq, found, err := wire.Varint(request, 1); err != nil || !found || seq == 0 {
@@ -278,6 +319,12 @@ func (s *EquipmentInventory) Handle(path string, request []byte) (int, []byte, b
if path == "/EquipSequenceUpgrade" {
return s.upgradeSequence(request)
}
if path == "/EquipSmelting" {
return s.smeltOnce(request)
}
if path == "/EquipSequenceSmelting" {
return s.smeltSequence(request)
}
if path == "/EquipChange" {
return s.change(request)
}
@@ -455,7 +502,496 @@ func (s *EquipmentInventory) upgradeSequence(request []byte) (int, []byte, bool,
if usedGold != 0 {
response = wire.AppendVarint(response, 7, usedGold)
}
return 170, response, true, nil
return 176, response, true, nil
}
func (s *EquipmentInventory) smeltOnce(request []byte) (int, []byte, bool, error) {
seq, _, _ := wire.Varint(request, 1)
index, found, err := wire.Varint(request, 2)
if err != nil || !found || index == 0 {
return 0, nil, true, errors.New("player: EquipSmelting missing equipment")
}
materials, err := equipmentRequestItems(request, 3, "EquipSmelting")
if err != nil {
return 0, nil, true, err
}
s.mu.Lock()
locked := true
defer func() {
if locked {
s.mu.Unlock()
}
}()
cacheKey := s.smeltingCacheKey("single", seq)
if reply, ok := s.smeltCache[cacheKey]; ok {
return reply.code, append([]byte(nil), reply.body...), true, nil
}
position, current, err := s.smeltingEquipmentLocked(index)
if err != nil {
return 0, nil, true, err
}
costs, err := s.smelting.Cost(current.ID)
if err != nil {
return 0, nil, true, err
}
gold, _, err := validateSmeltingMaterials(costs, materials)
if err != nil {
return 0, nil, true, err
}
if gold != 0 && !s.wallet.CanSpendGold(gold) {
return 0, nil, true, errors.New("player: insufficient gold for equipment smelting")
}
var itemMaterials []Item
var consumedMileageMaterial uint64
for _, item := range materials {
if item.Type != 4 {
itemMaterials = append(itemMaterials, item)
}
if item.Type == s.smelting.Mileage.UseType && item.ID == s.smelting.Mileage.UseID {
consumedMileageMaterial += item.Count
}
}
if len(itemMaterials) != 0 {
if err := s.inventory.CanConsume(itemMaterials); err != nil {
return 0, nil, true, err
}
}
candidate, err := s.smelting.RollCandidate(current.ID)
if err != nil {
return 0, nil, true, err
}
currentScore, err := s.smelting.Score(current.ID, current.Rank)
if err != nil {
return 0, nil, true, err
}
candidateScore, err := s.smelting.Score(current.ID, candidate)
if err != nil {
return 0, nil, true, err
}
success := candidateScore > currentScore
next := cloneEquipmentSnapshot(s.owned)
if success {
next.Equipment[position].Rank = append([]uint64(nil), candidate...)
}
currency, earned, err := s.commitSmeltingLocked(
next, itemMaterials, gold, consumedMileageMaterial,
"equip-smelting:"+cacheKey, "smelting")
if err != nil {
return 0, nil, true, err
}
current = next.Equipment[position]
response := wire.AppendBytes(nil, 1, EquipmentWire(current))
s.mu.Unlock()
locked = false
if character, ok := s.equippedCharacter(current); ok {
response = wire.AppendBytes(response, 2, CharacterWire(character))
}
if !success {
response = wire.AppendVarint(response, 3, equipUpgradeFail)
for _, rank := range candidate {
response = wire.AppendVarint(response, 4, rank)
}
}
response = appendSmeltingMileage(response, 5, 6, currency.EquipMileageExchangeGage, s.smelting.Mileage, earned)
s.mu.Lock()
s.smeltCache[cacheKey] = smeltingReply{code: 105, body: append([]byte(nil), response...)}
s.mu.Unlock()
return 105, response, true, nil
}
func (s *EquipmentInventory) smeltSequence(request []byte) (int, []byte, bool, error) {
seq, _, _ := wire.Varint(request, 1)
index, found, err := wire.Varint(request, 2)
if err != nil || !found || index == 0 {
return 0, nil, true, errors.New("player: EquipSequenceSmelting missing equipment")
}
count, found, err := wire.Varint(request, 3)
if err != nil || !found || count == 0 {
return 0, nil, true, errors.New("player: EquipSequenceSmelting invalid attempt count")
}
target, _, err := wire.Varint(request, 4)
if err != nil {
return 0, nil, true, errors.New("player: EquipSequenceSmelting invalid target score")
}
s.mu.Lock()
locked := true
defer func() {
if locked {
s.mu.Unlock()
}
}()
cacheKey := s.smeltingCacheKey("sequence", seq)
if reply, ok := s.smeltCache[cacheKey]; ok {
return reply.code, append([]byte(nil), reply.body...), true, nil
}
position, current, err := s.smeltingEquipmentLocked(index)
if err != nil {
return 0, nil, true, err
}
if count > s.smelting.MaxStreak {
return 0, nil, true, fmt.Errorf("player: EquipSequenceSmelting attempt count %d exceeds %d", count, s.smelting.MaxStreak)
}
maximumRanks, err := s.smelting.MaximumRanks(current.ID)
if err != nil {
return 0, nil, true, err
}
maximumScore, err := s.smelting.Score(current.ID, maximumRanks)
if err != nil {
return 0, nil, true, err
}
if target > maximumScore {
return 0, nil, true, fmt.Errorf("player: EquipSequenceSmelting target %d exceeds %d", target, maximumScore)
}
costs, err := s.smelting.Cost(current.ID)
if err != nil {
return 0, nil, true, err
}
currentScore, err := s.smelting.Score(current.ID, current.Rank)
if err != nil {
return 0, nil, true, err
}
result := uint64(equipUpgradeStopMaxTryCount)
var attempts, successes uint64
if currentScore >= maximumScore {
result = equipUpgradeStopMaxLevel
} else if target != 0 && currentScore >= target {
result = equipUpgradeStopTargetLevel
}
for attempts < count && result == equipUpgradeStopMaxTryCount {
if _, _, selectErr := s.selectSmeltingCosts(costs, attempts+1); selectErr != nil {
result = equipUpgradeStopNotEnough
break
}
candidate, rollErr := s.smelting.RollCandidate(current.ID)
if rollErr != nil {
return 0, nil, true, rollErr
}
candidateScore, scoreErr := s.smelting.Score(current.ID, candidate)
if scoreErr != nil {
return 0, nil, true, scoreErr
}
attempts++
if candidateScore > currentScore {
current.Rank = append([]uint64(nil), candidate...)
currentScore = candidateScore
successes++
}
if currentScore >= maximumScore {
result = equipUpgradeStopMaxLevel
} else if target != 0 && currentScore >= target {
result = equipUpgradeStopTargetLevel
}
}
var consumed, lack []Item
var gold, mileageMaterial, earned uint64
currency := s.wallet.Snapshot()
if attempts != 0 {
consumed, gold, err = s.selectSmeltingCosts(costs, attempts)
if err != nil {
return 0, nil, true, err
}
for _, item := range consumed {
if item.Type == s.smelting.Mileage.UseType && item.ID == s.smelting.Mileage.UseID {
mileageMaterial += item.Count
}
}
var itemMaterials []Item
for _, item := range consumed {
if item.Type != 4 {
itemMaterials = append(itemMaterials, item)
}
}
next := cloneEquipmentSnapshot(s.owned)
next.Equipment[position].Rank = append([]uint64(nil), current.Rank...)
currency, earned, err = s.commitSmeltingLocked(
next, itemMaterials, gold, mileageMaterial,
"equip-sequence-smelting:"+cacheKey, "sequence smelting")
if err != nil {
return 0, nil, true, err
}
current = next.Equipment[position]
}
// When exactly count affordable attempts were made without another stop
// condition, MaxTryCount is the normal terminal reason. NotEnough is used
// only when the next requested attempt could not be funded.
if result == equipUpgradeStopNotEnough {
lack = upgradeLackItems(costs)
}
response := wire.AppendBytes(nil, 1, EquipmentWire(current))
s.mu.Unlock()
locked = false
if character, ok := s.equippedCharacter(current); ok {
response = wire.AppendBytes(response, 2, CharacterWire(character))
}
response = wire.AppendVarint(response, 3, result)
response = wire.AppendVarint(response, 4, attempts)
for _, item := range consumed {
response = wire.AppendBytes(response, 5, ItemWire(item))
}
for _, item := range lack {
response = wire.AppendBytes(response, 6, ItemWire(item))
}
response = appendSmeltingMileage(response, 7, 8, currency.EquipMileageExchangeGage, s.smelting.Mileage, earned)
if successes != 0 {
response = wire.AppendVarint(response, 9, successes)
}
s.mu.Lock()
s.smeltCache[cacheKey] = smeltingReply{code: 177, body: append([]byte(nil), response...)}
s.mu.Unlock()
return 177, response, true, nil
}
func (s *EquipmentInventory) smeltingCacheKey(kind string, seq uint64) string {
return kind + ":" + s.sessionID + ":seq:" + strconv.FormatUint(seq, 10)
}
func (s *EquipmentInventory) smeltingEquipmentLocked(index uint64) (int, Equipment, error) {
if s.smelting == nil || s.wallet == nil || s.inventory == nil {
return -1, Equipment{}, errors.New("player: equipment smelting unavailable")
}
position := s.equipmentPositionLocked(index)
if position < 0 {
return -1, Equipment{}, fmt.Errorf("player: unknown equipment %d", index)
}
entry := s.owned.Equipment[position]
design, ok := s.smelting.Equipment[entry.ID]
if !ok || entry.Level != design.MaxLevel || len(entry.Rank) != 3 {
return -1, Equipment{}, fmt.Errorf("player: equipment %d is not ready for smelting", index)
}
if _, err := s.smelting.Score(entry.ID, entry.Rank); err != nil {
return -1, Equipment{}, fmt.Errorf("player: equipment %d has invalid smelting rank: %w", index, err)
}
return position, entry, nil
}
func equipmentRequestItems(request []byte, number int, operation string) ([]Item, error) {
var result []Item
err := wire.Walk(request, func(field wire.Field) error {
if field.Number != number {
return nil
}
if field.Type != 2 {
return fmt.Errorf("player: %s invalid material", operation)
}
var item Item
if err := decodeVarints(field.Value, map[int]*uint64{1: &item.InvenIndex, 2: &item.ID, 3: &item.Type, 4: &item.Count, 5: &item.KeepFlag, 6: &item.TimeValue, 9: &item.SortID, 10: &item.UseCount}); err != nil {
return err
}
if item.Type == 0 || item.Count == 0 || (item.Type == 4 && (item.ID != 0 || item.InvenIndex != 0)) || (item.Type != 4 && (item.ID == 0 || item.InvenIndex == 0)) {
return fmt.Errorf("player: %s invalid material", operation)
}
result = append(result, item)
return nil
})
if err != nil {
return nil, err
}
if len(result) == 0 {
return nil, fmt.Errorf("player: %s has no material", operation)
}
return result, nil
}
func validateSmeltingMaterials(costs []gamedata.PromotionCost, materials []Item) (gold, mileageMaterial uint64, err error) {
want := make(map[[2]uint64]uint64, len(costs))
for _, cost := range costs {
want[[2]uint64{cost.Type, cost.ID}] += cost.Count
}
got := make(map[[2]uint64]uint64, len(materials))
for _, item := range materials {
got[[2]uint64{item.Type, item.ID}] += item.Count
if item.Type == 4 {
gold += item.Count
} else {
mileageMaterial += item.Count
}
}
if len(got) != len(want) {
return 0, 0, errors.New("player: equipment smelting material kinds mismatch")
}
for key, count := range want {
if got[key] != count {
return 0, 0, fmt.Errorf("player: equipment smelting material %d/%d=%d want=%d", key[0], key[1], got[key], count)
}
}
return gold, mileageMaterial, nil
}
func (s *EquipmentInventory) selectSmeltingCosts(costs []gamedata.PromotionCost, attempts uint64) ([]Item, uint64, error) {
var result []Item
var gold uint64
for _, cost := range costs {
if attempts != 0 && cost.Count > ^uint64(0)/attempts {
return nil, 0, errors.New("player: equipment smelting cost overflow")
}
count := cost.Count * attempts
switch cost.Type {
case 4:
if cost.ID != 0 || gold != 0 || !s.wallet.CanSpendGold(count) {
return nil, 0, errors.New("player: insufficient equipment smelting gold")
}
gold = count
result = append(result, Item{Type: 4, Count: count})
case 8:
items, err := s.inventory.SelectMutable(cost.Type, cost.ID, count)
if err != nil {
return nil, 0, err
}
result = append(result, items...)
default:
return nil, 0, fmt.Errorf("player: unsupported equipment smelting cost type %d", cost.Type)
}
}
return result, gold, nil
}
func appendSmeltingMileage(response []byte, gaugeField, rewardField int, gauge uint64, mileage gamedata.EquipmentSmeltingMileage, earned uint64) []byte {
if gauge != 0 {
response = wire.AppendVarint(response, gaugeField, gauge)
}
if earned != 0 {
reward := ItemWire(Item{ID: mileage.RewardID, Type: mileage.RewardType, Count: earned})
bundle := wire.AppendBytes(nil, 1, reward)
response = wire.AppendBytes(response, rewardField, bundle)
}
return response
}
// commitSmeltingLocked keeps refinement's three typed snapshots synchronized
// in memory. Caller holds equipment.mu; this method takes the remaining locks
// in wallet -> inventory order, calculates every candidate before writing,
// then publishes all three only after their files succeed. The surrounding
// request transaction supplies durable all-or-none recovery across the writes.
func (s *EquipmentInventory) commitSmeltingLocked(
nextEquipment equipmentSnapshot,
consumed []Item,
gold, mileageMaterial uint64,
identity, operation string,
) (Currency, uint64, error) {
if identity == "" || mileageMaterial == 0 || s.wallet == nil || s.inventory == nil {
return Currency{}, 0, errors.New("player: invalid transactional equipment smelting")
}
s.wallet.mu.Lock()
defer s.wallet.mu.Unlock()
s.inventory.mu.Lock()
defer s.inventory.mu.Unlock()
nextWallet := cloneWallet(s.wallet.state)
if nextWallet.Spent[identity] {
return Currency{}, 0, errors.New("player: equipment smelting request was already committed")
}
if nextWallet.Gold < gold {
return Currency{}, 0, errors.New("player: insufficient gold for equipment smelting")
}
nextWallet.Gold -= gold
nextWallet.Spent[identity] = true
threshold, rewardCount := s.smelting.Mileage.UseCount, s.smelting.Mileage.RewardCount
if threshold == 0 || rewardCount == 0 || nextWallet.EquipMileageExchangeGage >= threshold ||
math.MaxUint64-nextWallet.EquipMileageExchangeGage < mileageMaterial {
return Currency{}, 0, errors.New("player: invalid equipment smelting gauge")
}
total := nextWallet.EquipMileageExchangeGage + mileageMaterial
exchanges := total / threshold
earned := exchanges * rewardCount
if exchanges != 0 && earned/exchanges != rewardCount || math.MaxUint64-nextWallet.EquipMileage < earned {
return Currency{}, 0, errors.New("player: equipment mileage overflow")
}
nextWallet.EquipMileageExchangeGage = total % threshold
nextWallet.EquipMileage += earned
nextItems := cloneOwnedSnapshot(s.inventory.owned)
for _, want := range consumed {
if err := consumeOwnedItem(&nextItems, want); err != nil {
return Currency{}, 0, err
}
}
after := make(map[string][]byte, 3)
for _, entry := range []struct {
path string
current, next any
decoded any
}{
{s.path, s.owned, nextEquipment, &equipmentSnapshot{}},
{s.wallet.path, s.wallet.state, nextWallet, &walletSnapshot{}},
{s.inventory.path, s.inventory.owned, nextItems, &ownedSnapshot{}},
} {
name := filepath.Base(entry.path)
current, err := os.ReadFile(entry.path)
missing := errors.Is(err, os.ErrNotExist)
if err != nil && !missing {
return Currency{}, 0, fmt.Errorf("player: read %s transaction source: %w", name, err)
}
memoryBytes, err := json.Marshal(entry.current)
if err != nil {
return Currency{}, 0, fmt.Errorf("player: encode %s memory snapshot: %w", name, err)
}
if !missing {
if err := json.Unmarshal(current, entry.decoded); err != nil {
return Currency{}, 0, fmt.Errorf("player: decode %s transaction source: %w", name, err)
}
diskBytes, err := json.Marshal(entry.decoded)
if err != nil || !bytes.Equal(memoryBytes, diskBytes) {
return Currency{}, 0, fmt.Errorf("player: %s changed outside the account transaction", name)
}
}
encoded, err := json.Marshal(entry.next)
if err != nil {
return Currency{}, 0, fmt.Errorf("player: encode %s transaction target: %w", name, err)
}
after[name] = encoded
}
for _, name := range []string{filepath.Base(s.wallet.path), filepath.Base(s.inventory.path), filepath.Base(s.path)} {
path := filepath.Join(filepath.Dir(s.path), name)
if err := writePlayerSnapshot(path, after[name]); err != nil {
return Currency{}, 0, fmt.Errorf("player: persist equipment %s %s: %w", operation, name, err)
}
}
s.owned = nextEquipment
s.wallet.state = nextWallet
s.inventory.owned = nextItems
return nextWallet.Currency, earned, nil
}
func writePlayerSnapshot(path string, data []byte) error {
dir := filepath.Dir(path)
file, err := os.CreateTemp(dir, ".player-state-*.tmp")
if err != nil {
return err
}
defer os.Remove(file.Name())
if _, err = file.Write(data); err == nil {
err = file.Sync()
}
if closeErr := file.Close(); err == nil {
err = closeErr
}
if err != nil {
return err
}
return os.Rename(file.Name(), path)
}
func consumeOwnedItem(next *ownedSnapshot, want Item) error {
if next == nil || want.InvenIndex == 0 || want.ID == 0 || want.Type == 0 || want.Count == 0 {
return errors.New("player: invalid item consumption")
}
for i, current := range next.Items {
if current.InvenIndex != want.InvenIndex {
continue
}
if current.ID != want.ID || current.Type != want.Type || current.Count < want.Count {
return fmt.Errorf("player: item %d consumption mismatch", want.InvenIndex)
}
current.Count -= want.Count
if current.Count == 0 {
next.Items = append(next.Items[:i], next.Items[i+1:]...)
} else {
next.Items[i] = current
}
return nil
}
return fmt.Errorf("player: item %d is not mutable-owned", want.InvenIndex)
}
func upgradeLackItems(costs []gamedata.PromotionCost) []Item {
+172 -1
View File
@@ -57,7 +57,7 @@ func TestEquipmentUpgradeAndSequenceUseGameDataCosts(t *testing.T) {
sequence = wire.AppendVarint(sequence, 3, 9)
sequence = wire.AppendVarint(sequence, 6, 3)
code, response, handled, err = store.Handle("/EquipSequenceUpgrade", sequence)
if err != nil || !handled || code != 170 {
if err != nil || !handled || code != 176 {
t.Fatalf("sequence upgrade code=%d handled=%v err=%v", code, handled, err)
}
if result, _, _ := wire.Varint(response, 3); result != equipUpgradeStopMaxLevel {
@@ -84,6 +84,177 @@ func TestEquipmentUpgradeAndSequenceUseGameDataCosts(t *testing.T) {
}
}
func TestEquipmentSmeltingImprovesByTotalScoreAndReplaysWithoutSecondCharge(t *testing.T) {
dir := t.TempDir()
wallet, err := OpenWallet(filepath.Join(dir, "wallet.json"), Currency{Gold: 1000, EquipMileageExchangeGage: 990})
if err != nil {
t.Fatal(err)
}
inventory, err := OpenInventory(filepath.Join(dir, "items.json"), &Starter{Version: "2.34.13"})
if err != nil {
t.Fatal(err)
}
materials, err := inventory.GrantOnce("refine-material", []gamedata.BattleReward{{Type: 8, ID: 10, Count: 100}})
if err != nil {
t.Fatal(err)
}
store, err := OpenEquipmentInventory(filepath.Join(dir, "equipment.json"))
if err != nil {
t.Fatal(err)
}
design := smeltingTestDesign([][]float64{{1, 0, 0, 0}, {0, 0, 0, 1}, {0, 0, 0, 1}})
if err := store.AttachSmelting(design, wallet, inventory); err != nil {
t.Fatal(err)
}
store.BeginSession("smelting-test")
entry, err := store.GrantOnce("refinable", 943035)
if err != nil {
t.Fatal(err)
}
store.owned.Equipment[0].Level = 9
store.owned.Equipment[0].Rank = []uint64{4, 1, 1} // 6 -> candidate 1+4+4=9.
if err := store.commitLocked(cloneEquipmentSnapshot(store.owned), "test setup"); err != nil {
t.Fatal(err)
}
request := wire.AppendVarint(wire.AppendVarint(nil, 1, 77), 2, entry.InvenIndex)
request = wire.AppendBytes(request, 3, ItemWire(Item{Type: 4, Count: 80}))
material := materials[0]
material.Count = 30
request = wire.AppendBytes(request, 3, ItemWire(material))
code, response, handled, err := store.Handle("/EquipSmelting", request)
if err != nil || !handled || code != 105 {
t.Fatalf("smelting code=%d handled=%v err=%v", code, handled, err)
}
if result, found, _ := wire.Varint(response, 3); found || result != 0 {
t.Fatalf("successful smelting result=%d found=%v", result, found)
}
if got := store.All()[0].Rank; len(got) != 3 || got[0] != 1 || got[1] != 4 || got[2] != 4 {
t.Fatalf("smelting rank=%v", got)
}
if gauge, _, _ := wire.Varint(response, 5); gauge != 20 {
t.Fatalf("smelting gauge=%d", gauge)
}
bundle, found, err := wire.Bytes(response, 6)
if err != nil || !found {
t.Fatalf("smelting mileage bundle: %v", err)
}
reward, found, err := wire.Bytes(bundle, 1)
if err != nil || !found {
t.Fatalf("smelting mileage item: %v", err)
}
if typ, _, _ := wire.Varint(reward, 3); typ != 68 {
t.Fatalf("smelting mileage type=%d", typ)
}
if count, _, _ := wire.Varint(reward, 4); count != 1 {
t.Fatalf("smelting mileage count=%d", count)
}
if currency := wallet.Snapshot(); currency.Gold != 920 || currency.EquipMileage != 1 || currency.EquipMileageExchangeGage != 20 {
t.Fatalf("smelting wallet=%+v", currency)
}
if _, replay, _, err := store.Handle("/EquipSmelting", request); err != nil || string(replay) != string(response) {
t.Fatalf("smelting replay differs err=%v", err)
}
if currency := wallet.Snapshot(); currency.Gold != 920 || currency.EquipMileage != 1 || currency.EquipMileageExchangeGage != 20 {
t.Fatalf("smelting replay charged again: %+v", currency)
}
}
func TestEquipmentSmeltingFailureConsumesAndReturnsCandidateGrades(t *testing.T) {
dir := t.TempDir()
wallet, _ := OpenWallet(filepath.Join(dir, "wallet.json"), Currency{Gold: 1000})
inventory, _ := OpenInventory(filepath.Join(dir, "items.json"), &Starter{Version: "2.34.13"})
materials, _ := inventory.GrantOnce("refine-material", []gamedata.BattleReward{{Type: 8, ID: 10, Count: 30}})
store, _ := OpenEquipmentInventory(filepath.Join(dir, "equipment.json"))
if err := store.AttachSmelting(smeltingTestDesign([][]float64{{1, 0, 0, 0}, {1, 0, 0, 0}, {1, 0, 0, 0}}), wallet, inventory); err != nil {
t.Fatal(err)
}
entry, _ := store.GrantOnce("refinable", 943035)
store.owned.Equipment[0].Level = 9
store.owned.Equipment[0].Rank = []uint64{4, 4, 4}
if err := store.commitLocked(cloneEquipmentSnapshot(store.owned), "test setup"); err != nil {
t.Fatal(err)
}
request := wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, entry.InvenIndex)
request = wire.AppendBytes(request, 3, ItemWire(Item{Type: 4, Count: 80}))
request = wire.AppendBytes(request, 3, ItemWire(materials[0]))
_, response, _, err := store.Handle("/EquipSmelting", request)
if err != nil {
t.Fatal(err)
}
if result, _, _ := wire.Varint(response, 3); result != equipUpgradeFail {
t.Fatalf("smelting failure result=%d", result)
}
var grades []uint64
if err := wire.Walk(response, func(field wire.Field) error {
if field.Number == 4 {
value, _ := binary.Uvarint(field.Value)
grades = append(grades, value)
}
return nil
}); err != nil {
t.Fatal(err)
}
if len(grades) != 3 || grades[0] != 1 || grades[1] != 1 || grades[2] != 1 {
t.Fatalf("failure candidate grades=%v", grades)
}
if got := store.All()[0].Rank; got[0] != 4 || got[1] != 4 || got[2] != 4 {
t.Fatalf("failure overwrote ranks=%v", got)
}
if currency := wallet.Snapshot(); currency.Gold != 920 || currency.EquipMileageExchangeGage != 30 {
t.Fatalf("failure did not consume: %+v", currency)
}
}
func TestEquipmentSequenceSmeltingRepeatsAndStopsAtTargetScore(t *testing.T) {
dir := t.TempDir()
wallet, _ := OpenWallet(filepath.Join(dir, "wallet.json"), Currency{Gold: 1000})
inventory, _ := OpenInventory(filepath.Join(dir, "items.json"), &Starter{Version: "2.34.13"})
_, _ = inventory.GrantOnce("refine-material", []gamedata.BattleReward{{Type: 8, ID: 10, Count: 300}})
store, _ := OpenEquipmentInventory(filepath.Join(dir, "equipment.json"))
if err := store.AttachSmelting(smeltingTestDesign([][]float64{{1, 0, 0, 0}, {0, 0, 0, 1}, {0, 0, 0, 1}}), wallet, inventory); err != nil {
t.Fatal(err)
}
entry, _ := store.GrantOnce("refinable", 943035)
store.owned.Equipment[0].Level = 9
store.owned.Equipment[0].Rank = []uint64{4, 1, 1}
if err := store.commitLocked(cloneEquipmentSnapshot(store.owned), "test setup"); err != nil {
t.Fatal(err)
}
request := wire.AppendVarint(wire.AppendVarint(nil, 1, 2), 2, entry.InvenIndex)
request = wire.AppendVarint(request, 3, 100)
request = wire.AppendVarint(request, 4, 9)
code, response, handled, err := store.Handle("/EquipSequenceSmelting", request)
if err != nil || !handled || code != 177 {
t.Fatalf("sequence smelting code=%d handled=%v err=%v", code, handled, err)
}
if result, _, _ := wire.Varint(response, 3); result != equipUpgradeStopTargetLevel {
t.Fatalf("sequence smelting result=%d", result)
}
if attempts, _, _ := wire.Varint(response, 4); attempts != 1 {
t.Fatalf("sequence smelting attempts=%d", attempts)
}
if successes, _, _ := wire.Varint(response, 9); successes != 1 {
t.Fatalf("sequence smelting successes=%d", successes)
}
if currency := wallet.Snapshot(); currency.Gold != 920 || currency.EquipMileageExchangeGage != 30 {
t.Fatalf("sequence smelting wallet=%+v", currency)
}
}
func smeltingTestDesign(ratios [][]float64) *gamedata.EquipmentSmeltingDesign {
design := &gamedata.EquipmentSmeltingDesign{
Equipment: map[uint64]gamedata.EquipmentSmeltingItem{943035: {Grade: 4, RankGroup: 904, MaxLevel: 9}},
Ranks: map[[2]uint64]gamedata.EquipmentSmeltingRank{},
Grades: map[uint64][]gamedata.PromotionCost{4: {{Type: 4, Count: 80}, {Type: 8, ID: 10, Count: 30}}},
Mileage: gamedata.EquipmentSmeltingMileage{UseType: 8, UseID: 10, UseCount: 1000, RewardType: 68, RewardCount: 1},
MaxStreak: 5000,
}
for i := 0; i < 3; i++ {
design.Ranks[[2]uint64{904, uint64(i + 1)}] = gamedata.EquipmentSmeltingRank{Values: []uint64{1, 2, 3, 4}, GrowthPoint: []uint64{1, 2, 3, 4}, Ratio: ratios[i]}
}
return design
}
func TestEquipmentUpgradeFailureConsumesGoldWithoutLevel(t *testing.T) {
dir := t.TempDir()
wallet, err := OpenWallet(filepath.Join(dir, "wallet.json"), Currency{Gold: 200})
+11
View File
@@ -116,6 +116,17 @@ func OpenCharacterStore(path string, seed []Character, inventory *Inventory, gam
return s, nil
}
func (s *CharacterStore) EnsurePersisted() error {
s.mu.Lock()
defer s.mu.Unlock()
if _, err := os.Stat(s.path); err == nil {
return nil
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
return s.persist(append([]Character(nil), s.characters...))
}
func validateCharacters(characters []Character) error {
seen := make(map[uint64]bool, len(characters))
for _, character := range characters {
+11
View File
@@ -55,6 +55,17 @@ func OpenInventory(path string, starter *Starter) (*Inventory, error) {
return s, nil
}
func (s *Inventory) EnsurePersisted() error {
s.mu.Lock()
defer s.mu.Unlock()
if _, err := os.Stat(s.path); err == nil {
return nil
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
return s.commitOwned(cloneOwnedSnapshot(s.owned))
}
func (s *Inventory) Handle(path string, request []byte) (int, []byte, bool, error) {
if path != "/ItemInfo" && path != "/UseRandomBox" {
return 0, nil, false, nil
+59 -5
View File
@@ -16,11 +16,13 @@ import (
// type 4 is gold. Paid jewelry is persisted as well, although quest rewards
// in the audited tutorial range do not grant it.
type Currency struct {
Gold uint64 `json:"gold"`
FreeJewelry uint64 `json:"free_jewelry"`
Jewelry uint64 `json:"jewelry"`
Mileage uint64 `json:"mileage,omitempty"`
HopePowder uint64 `json:"hope_powder,omitempty"`
Gold uint64 `json:"gold"`
FreeJewelry uint64 `json:"free_jewelry"`
Jewelry uint64 `json:"jewelry"`
Mileage uint64 `json:"mileage,omitempty"`
HopePowder uint64 `json:"hope_powder,omitempty"`
EquipMileage uint64 `json:"equip_mileage"`
EquipMileageExchangeGage uint64 `json:"equip_mileage_exchange_gage"`
}
type walletSnapshot struct {
@@ -50,6 +52,16 @@ func OpenWallet(path string, initial Currency) (*Wallet, error) {
if err != nil {
return nil, fmt.Errorf("player: read wallet: %w", err)
}
var shape map[string]json.RawMessage
if err := json.Unmarshal(b, &shape); err != nil {
return nil, errors.New("player: malformed wallet state")
}
if _, ok := shape["equip_mileage"]; !ok {
return nil, errors.New("player: wallet save requires equip_mileage; migrate the development save")
}
if _, ok := shape["equip_mileage_exchange_gage"]; !ok {
return nil, errors.New("player: wallet save requires equip_mileage_exchange_gage; migrate the development save")
}
if err := json.Unmarshal(b, &s.state); err != nil || s.state.Version != "2.34.13" || s.state.Granted == nil {
return nil, errors.New("player: malformed wallet state")
}
@@ -59,6 +71,17 @@ func OpenWallet(path string, initial Currency) (*Wallet, error) {
return s, nil
}
func (s *Wallet) EnsurePersisted() error {
s.mu.Lock()
defer s.mu.Unlock()
if _, err := os.Stat(s.path); err == nil {
return nil
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
return s.commit(cloneWallet(s.state))
}
func (s *Wallet) Snapshot() Currency {
s.mu.Lock()
defer s.mu.Unlock()
@@ -157,6 +180,37 @@ func (s *Wallet) Currencies() (gold, freeJewelry, jewelry, mileage uint64) {
func (s *Wallet) HopePowderBalance() uint64 { return s.Snapshot().HopePowder }
func (s *Wallet) EquipmentMileageBalances() (mileage, exchangeGage uint64) {
c := s.Snapshot()
return c.EquipMileage, c.EquipMileageExchangeGage
}
// RecordEquipmentSmelting converts the actually consumed refinement material
// into the official residual gauge and type-68 mileage currency.
func (s *Wallet) RecordEquipmentSmelting(materialCount, threshold, rewardCount uint64) (Currency, uint64, error) {
if materialCount == 0 || threshold == 0 || rewardCount == 0 {
return Currency{}, 0, errors.New("player: invalid equipment smelting mileage")
}
s.mu.Lock()
defer s.mu.Unlock()
if s.state.EquipMileageExchangeGage >= threshold || math.MaxUint64-s.state.EquipMileageExchangeGage < materialCount {
return Currency{}, 0, errors.New("player: invalid equipment smelting gauge")
}
next := cloneWallet(s.state)
total := next.EquipMileageExchangeGage + materialCount
exchanges := total / threshold
earned := exchanges * rewardCount
if exchanges != 0 && earned/exchanges != rewardCount || math.MaxUint64-next.EquipMileage < earned {
return Currency{}, 0, errors.New("player: equipment mileage overflow")
}
next.EquipMileageExchangeGage = total % threshold
next.EquipMileage += earned
if err := s.commit(next); err != nil {
return Currency{}, 0, err
}
return next.Currency, earned, nil
}
// GrantMileageOnce persists the type-20 currency produced when a duplicate
// costume is drawn after +5. The gacha grant identity makes recovery after a
// collection/wallet split commit safe and idempotent.
+11
View File
@@ -159,6 +159,17 @@ func OpenStore(path string) (*Store, error) {
return s, nil
}
func (s *Store) EnsurePersisted() error {
s.mu.Lock()
defer s.mu.Unlock()
if _, err := os.Stat(s.path); err == nil {
return nil
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
return s.commit(s.position, s.tutorials, s.quests, s.cleared)
}
// commit writes a complete snapshot before exposing the new state. Caller
// holds mu; failure leaves the in-memory player state unchanged.
func (s *Store) commit(position SavedPosition, tutorials map[int]struct{}, quests map[string]QuestProgress, cleared map[string]struct{}) error {
+71 -4
View File
@@ -15,6 +15,7 @@ import (
"bd2server/internal/cryptox"
"bd2server/internal/progress"
"bd2server/internal/protocol"
"bd2server/internal/statetx"
"bd2server/internal/transport"
"bd2server/internal/wire"
)
@@ -35,6 +36,11 @@ type SessionAware interface {
BeginSession(id string)
}
type StateCoordinator interface {
Check() error
BeginOperation() (statetx.RequestOperation, error)
}
type Server struct {
mu sync.Mutex
key []byte
@@ -43,6 +49,20 @@ type Server struct {
login LoginService
handlers []Handler
progress *progress.Store
stateTx StateCoordinator
}
// AttachStateCoordinator wraps each authenticated request (the complete batch
// for BatchRequest) in the account write-ahead transaction. An uncertain
// operation fail-stops the dispatcher, including subsequent login attempts.
func (s *Server) AttachStateCoordinator(coordinator StateCoordinator) error {
if coordinator == nil {
return errors.New("session state coordinator is nil")
}
s.mu.Lock()
defer s.mu.Unlock()
s.stateTx = coordinator
return nil
}
func NewServer(login LoginService, handlers ...Handler) (*Server, error) {
@@ -75,6 +95,11 @@ func NewServerWithProgress(login LoginService, player *progress.Store, handlers
func (s *Server) DispatchRaw(path string, body []byte, cookie string) (transport.RawReply, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.stateTx != nil {
if err := s.stateTx.Check(); err != nil {
return transport.RawReply{}, fmt.Errorf("account state unavailable: %w", err)
}
}
if path == "/LoginUser" {
request, err := cryptox.DecryptBase64Payload(string(body), cryptox.Key())
if err != nil {
@@ -102,18 +127,60 @@ func (s *Server) DispatchRaw(path string, body []byte, cookie string) (transport
return transport.RawReply{}, err
}
if path == "/BatchRequest" {
return s.handleBatch(body)
return s.withStateTransaction(func() (transport.RawReply, error) {
return s.handleBatch(body)
})
}
request, err := cryptox.DecryptBase64Payload(string(body), s.key)
if err != nil {
return transport.RawReply{}, fmt.Errorf("%s decrypt: %w", path, err)
}
code, response, err := s.dispatch(path, request)
return s.withStateTransaction(func() (transport.RawReply, error) {
code, response, err := s.dispatch(path, request)
if err != nil {
return transport.RawReply{}, err
}
encoded, err := protocol.Encode(code, response, s.key, time.Now().UnixMilli())
return transport.RawReply{Body: encoded}, err
})
}
func (s *Server) withStateTransaction(run func() (transport.RawReply, error)) (reply transport.RawReply, err error) {
if s.stateTx == nil {
return run()
}
operation, err := s.stateTx.BeginOperation()
if err != nil {
return transport.RawReply{}, fmt.Errorf("begin account transaction: %w", err)
}
finished := false
defer func() {
if finished {
return
}
rollbackErr := operation.Rollback()
if recovered := recover(); recovered != nil {
panic(recovered)
}
if rollbackErr != nil {
err = errors.Join(err, rollbackErr)
}
}()
reply, err = run()
if err != nil {
rollbackErr := operation.Rollback()
finished = true
if rollbackErr != nil {
return transport.RawReply{}, errors.Join(err, rollbackErr)
}
return transport.RawReply{}, err
}
encoded, err := protocol.Encode(code, response, s.key, time.Now().UnixMilli())
return transport.RawReply{Body: encoded}, err
if err := operation.Commit(); err != nil {
finished = true
return transport.RawReply{}, fmt.Errorf("commit account transaction: %w", err)
}
finished = true
return reply, nil
}
func (s *Server) handleBatch(body []byte) (transport.RawReply, error) {
+161
View File
@@ -4,10 +4,13 @@ import (
"encoding/base64"
"encoding/json"
"errors"
"os"
"path/filepath"
"testing"
"bd2server/internal/cryptox"
"bd2server/internal/protocol"
"bd2server/internal/statetx"
"bd2server/internal/transport"
"bd2server/internal/wire"
)
@@ -24,6 +27,100 @@ func (fakeLogin) Login(request, key []byte) ([]byte, error) {
type fakeDomain struct{}
type fakeStateGate struct{ err error }
func (g *fakeStateGate) Check() error { return g.err }
func (g *fakeStateGate) BeginOperation() (statetx.RequestOperation, error) {
if g.err != nil {
return nil, g.err
}
return fakeOperation{}, nil
}
type fakeOperation struct{}
func (fakeOperation) Commit() error { return nil }
func (fakeOperation) Rollback() error { return nil }
type mutatingDomain struct {
root string
fail bool
}
func (d *mutatingDomain) Handle(path string, request []byte) (int, []byte, bool, error) {
if path == "/InjectedFailure" {
return 0, nil, true, errors.New("injected domain failure")
}
if path != "/MutateTwoFiles" {
return 0, nil, false, nil
}
if err := atomicSessionTestReplace(filepath.Join(d.root, "wallet.json"), []byte("new-wallet")); err != nil {
return 0, nil, true, err
}
if err := atomicSessionTestReplace(filepath.Join(d.root, "items.json"), []byte("new-items")); err != nil {
return 0, nil, true, err
}
if d.fail {
return 0, nil, true, errors.New("injected domain failure")
}
return 77, nil, true, nil
}
func atomicSessionTestReplace(path string, data []byte) error {
temp, err := os.CreateTemp(filepath.Dir(path), ".session-test-*.tmp")
if err != nil {
return err
}
name := temp.Name()
defer os.Remove(name)
if _, err = temp.Write(data); err == nil {
err = temp.Sync()
}
if closeErr := temp.Close(); err == nil {
err = closeErr
}
if err != nil {
return err
}
if err := os.Remove(path); err != nil {
return err
}
return os.Rename(name, path)
}
func TestBatchUsesOneAccountTransaction(t *testing.T) {
root := t.TempDir()
for name, content := range map[string]string{"wallet.json": "old-wallet", "items.json": "old-items"} {
if err := os.WriteFile(filepath.Join(root, name), []byte(content), 0o600); err != nil {
t.Fatal(err)
}
}
coordinator, err := statetx.Open(root, []string{"wallet.json", "items.json"})
if err != nil {
t.Fatal(err)
}
server, _ := NewServer(fakeLogin{}, &mutatingDomain{root: root})
if err := server.AttachStateCoordinator(coordinator); err != nil {
t.Fatal(err)
}
reply := login(t, server)
requests := []protocol.BatchRequest{
{Path: "/MutateTwoFiles", RequestData: base64.StdEncoding.EncodeToString(wire.AppendVarint(nil, 1, 2))},
{Path: "/InjectedFailure", RequestData: base64.StdEncoding.EncodeToString(wire.AppendVarint(nil, 1, 3))},
}
plain, _ := json.Marshal(requests)
body, _ := cryptox.EncryptBase64(plain, server.KeyForTest())
if _, err := server.DispatchRaw("/BatchRequest", []byte(body), "s="+reply.Cookie); err == nil {
t.Fatal("partially failing batch was accepted")
}
for name, want := range map[string]string{"wallet.json": "old-wallet", "items.json": "old-items"} {
got, err := os.ReadFile(filepath.Join(root, name))
if err != nil || string(got) != want {
t.Fatalf("batch rollback %s=%q err=%v", name, got, err)
}
}
}
func (fakeDomain) Handle(path string, request []byte) (int, []byte, bool, error) {
if path == "/EmptyInfo" {
return 77, nil, true, nil
@@ -85,6 +182,70 @@ func TestSessionRejectsMissingCookieAndUnknownPath(t *testing.T) {
}
}
func TestStateGateStopsEveryRequestAfterPersistenceFailure(t *testing.T) {
server, _ := NewServer(fakeLogin{}, fakeDomain{})
gate := &fakeStateGate{}
if err := server.AttachStateCoordinator(gate); err != nil {
t.Fatal(err)
}
reply := login(t, server)
gate.err = errors.New("uncertain transaction")
request := wire.AppendVarint(nil, 1, 2)
body, _ := cryptox.EncryptBase64Payload(request, server.KeyForTest())
if _, err := server.DispatchRaw("/EmptyInfo", []byte(body), "s="+reply.Cookie); err == nil {
t.Fatal("request passed a failed account state gate")
}
if _, err := server.DispatchRaw("/LoginUser", []byte(body), ""); err == nil {
t.Fatal("login passed a failed account state gate")
}
}
func TestAuthenticatedRequestTransactionCommitsOrRollsBackAllFiles(t *testing.T) {
for _, test := range []struct {
name string
fail bool
want string
}{
{"commit", false, "new-"},
{"rollback", true, "old-"},
} {
t.Run(test.name, func(t *testing.T) {
root := t.TempDir()
for name, content := range map[string]string{"wallet.json": "old-wallet", "items.json": "old-items"} {
if err := os.WriteFile(filepath.Join(root, name), []byte(content), 0o600); err != nil {
t.Fatal(err)
}
}
coordinator, err := statetx.Open(root, []string{"wallet.json", "items.json"})
if err != nil {
t.Fatal(err)
}
server, _ := NewServer(fakeLogin{}, &mutatingDomain{root: root, fail: test.fail})
if err := server.AttachStateCoordinator(coordinator); err != nil {
t.Fatal(err)
}
reply := login(t, server)
request := wire.AppendVarint(nil, 1, 2)
body, _ := cryptox.EncryptBase64Payload(request, server.KeyForTest())
_, requestErr := server.DispatchRaw("/MutateTwoFiles", []byte(body), "s="+reply.Cookie)
if test.fail && requestErr == nil || !test.fail && requestErr != nil {
t.Fatalf("request err=%v", requestErr)
}
for _, name := range []string{"wallet", "items"} {
got, err := os.ReadFile(filepath.Join(root, name+".json"))
if err != nil || string(got) != test.want+name {
t.Fatalf("%s=%q err=%v", name, got, err)
}
}
if test.fail {
if _, err := server.DispatchRaw("/MutateTwoFiles", []byte(body), "s="+reply.Cookie); err == nil {
t.Fatal("server continued after rolling disk back behind published domain memory")
}
}
})
}
}
func TestNativeProgress(t *testing.T) {
server, _ := NewServer(fakeLogin{})
reply := login(t, server)
+2 -2
View File
@@ -325,7 +325,7 @@ func equipmentDiskFromProto(in *statev1.EquipmentInventory) equipmentDisk {
}
func equipmentFromProto(in *statev1.Equipment) equipment {
out := equipment{InvenIndex: in.InventoryIndex, ID: in.Id, Level: in.Level, UseChar: in.UseChar, KeepFlag: in.KeepFlag, LockFlag: in.LockFlag, SortID: in.SortId, Rank: append([]uint64(nil), in.Ranks...)}
out := equipment{InvenIndex: in.InventoryIndex, ID: in.Id, Level: in.Level, UseChar: in.UseChar, KeepFlag: in.KeepFlag, LockFlag: in.LockFlag, SortID: in.SortId, Rank: append([]uint64(nil), in.Ranks...), UpgradeAttempts: in.UpgradeAttempts}
for _, option := range in.MainOptions {
out.MainOption = append(out.MainOption, equipmentOption{GroupID: option.GroupId, ID: option.Id})
}
@@ -424,7 +424,7 @@ func collectionGrantFromProto(in *statev1.CollectionGrant) collectionGrant {
}
func walletDiskFromProto(in *statev1.Wallet) walletDisk {
out := walletDisk{Version: in.ClientVersion, Gold: in.Gold, FreeJewelry: in.FreeJewelry, Jewelry: in.Jewelry, Mileage: in.Mileage, HopePowder: in.HopePowder, Granted: map[string]bool{}, Spent: map[string]bool{}}
out := walletDisk{Version: in.ClientVersion, Gold: in.Gold, FreeJewelry: in.FreeJewelry, Jewelry: in.Jewelry, Mileage: in.Mileage, HopePowder: in.HopePowder, EquipMileage: in.EquipMileage, EquipMileageExchangeGage: in.EquipMileageExchangeGage, Granted: map[string]bool{}, Spent: map[string]bool{}}
for _, value := range in.GrantedIdentities {
out.Granted[value] = true
}
@@ -190,6 +190,29 @@ func TestHaskellMigrationPreservesSourceAndRejectsDuplicateCharacter(t *testing.
}
}
func TestRuntimeEquipmentAndMileageFieldsRoundTripThroughCurrentSchema(t *testing.T) {
equipment := equipmentDisk{
Version: "2.34.13", NextIndex: 910000002,
Equipment: []equipment{{InvenIndex: 910000001, ID: 943035, Rank: []uint64{1, 2, 3}, UpgradeAttempts: 27}},
Granted: map[string]uint64{"test": 910000001},
}
wallet := walletDisk{
Version: "2.34.13", EquipMileage: 19, EquipMileageExchangeGage: 845,
Granted: map[string]bool{}, Spent: map[string]bool{},
}
equipmentProto := equipmentProto(equipment)
walletProto := walletProto(wallet)
if len(equipmentProto.Equipment) != 1 || equipmentProto.Equipment[0].UpgradeAttempts != 27 || walletProto.EquipMileage != 19 || walletProto.EquipMileageExchangeGage != 845 {
t.Fatalf("runtime fields missing from protobuf: equipment=%+v wallet=%+v", equipmentProto, walletProto)
}
if got := equipmentDiskFromProto(equipmentProto); len(got.Equipment) != 1 || got.Equipment[0].UpgradeAttempts != 27 {
t.Fatalf("equipment runtime field lost: %+v", got)
}
if got := walletDiskFromProto(walletProto); got.EquipMileage != 19 || got.EquipMileageExchangeGage != 845 {
t.Fatalf("wallet runtime fields lost: %+v", got)
}
}
func hasViolation(violations []*controlv1.Violation, code string) bool {
for _, violation := range violations {
if violation.Code == code && violation.Severity == controlv1.Severity_SEVERITY_ERROR {
+31 -21
View File
@@ -30,6 +30,13 @@ var stateFiles = []string{
"items.json", "mail.json", "missions.json", "progress.json", "wallet.json",
}
// StateFiles returns the complete account-state filename set. The runtime
// transaction coordinator uses the same allow-list as validation/repair, so
// a newly added domain file cannot silently sit outside crash recovery.
func StateFiles() []string {
return append([]string(nil), stateFiles...)
}
func CompleteStateAvailable(dir string) (bool, error) {
dir = filepath.Clean(dir)
found := 0
@@ -154,17 +161,18 @@ type equipmentOption struct {
ID uint64 `json:"id"`
}
type equipment struct {
InvenIndex uint64 `json:"inven_index"`
ID uint64 `json:"id"`
Level uint64 `json:"level"`
UseChar uint64 `json:"use_char"`
KeepFlag uint64 `json:"keep_flag"`
LockFlag uint64 `json:"lock_flag"`
SortID uint64 `json:"sort_id"`
MainOption []equipmentOption `json:"main_option"`
SubOption []equipmentOption `json:"sub_option"`
PrivateOption *equipmentOption `json:"private_option"`
Rank []uint64 `json:"rank"`
InvenIndex uint64 `json:"inven_index"`
ID uint64 `json:"id"`
Level uint64 `json:"level"`
UseChar uint64 `json:"use_char"`
KeepFlag uint64 `json:"keep_flag"`
LockFlag uint64 `json:"lock_flag"`
SortID uint64 `json:"sort_id"`
MainOption []equipmentOption `json:"main_option"`
SubOption []equipmentOption `json:"sub_option"`
PrivateOption *equipmentOption `json:"private_option"`
Rank []uint64 `json:"rank"`
UpgradeAttempts uint64 `json:"upgrade_attempts"`
}
type equipmentDisk struct {
Version string `json:"version"`
@@ -252,14 +260,16 @@ type collectionDisk struct {
}
type walletDisk struct {
Version string `json:"version"`
Gold uint64 `json:"gold"`
FreeJewelry uint64 `json:"free_jewelry"`
Jewelry uint64 `json:"jewelry"`
Mileage uint64 `json:"mileage"`
HopePowder uint64 `json:"hope_powder"`
Granted map[string]bool `json:"granted"`
Spent map[string]bool `json:"spent"`
Version string `json:"version"`
Gold uint64 `json:"gold"`
FreeJewelry uint64 `json:"free_jewelry"`
Jewelry uint64 `json:"jewelry"`
Mileage uint64 `json:"mileage"`
HopePowder uint64 `json:"hope_powder"`
EquipMileage uint64 `json:"equip_mileage"`
EquipMileageExchangeGage uint64 `json:"equip_mileage_exchange_gage"`
Granted map[string]bool `json:"granted"`
Spent map[string]bool `json:"spent"`
}
type mailDisk struct {
Version string `json:"version"`
@@ -441,7 +451,7 @@ func equipmentProto(in equipmentDisk) *statev1.EquipmentInventory {
return out
}
func equipmentItemProto(v equipment) *statev1.Equipment {
out := &statev1.Equipment{InventoryIndex: v.InvenIndex, Id: v.ID, Level: v.Level, UseChar: v.UseChar, KeepFlag: v.KeepFlag, LockFlag: v.LockFlag, SortId: v.SortID, Ranks: append([]uint64(nil), v.Rank...)}
out := &statev1.Equipment{InventoryIndex: v.InvenIndex, Id: v.ID, Level: v.Level, UseChar: v.UseChar, KeepFlag: v.KeepFlag, LockFlag: v.LockFlag, SortId: v.SortID, Ranks: append([]uint64(nil), v.Rank...), UpgradeAttempts: v.UpgradeAttempts}
for _, o := range v.MainOption {
out.MainOptions = append(out.MainOptions, &statev1.EquipmentOption{GroupId: o.GroupID, Id: o.ID})
}
@@ -517,7 +527,7 @@ func fixedProto(v gachaFixed) *statev1.GachaFixed {
return &statev1.GachaFixed{FixedId: v.FixedID, Type: v.Type, Count: v.Count, ApplySortId: v.ApplySort}
}
func walletProto(in walletDisk) *statev1.Wallet {
return &statev1.Wallet{ClientVersion: in.Version, Gold: in.Gold, FreeJewelry: in.FreeJewelry, Jewelry: in.Jewelry, Mileage: in.Mileage, HopePowder: in.HopePowder, GrantedIdentities: sortedTrueKeys(in.Granted), SpentIdentities: sortedTrueKeys(in.Spent)}
return &statev1.Wallet{ClientVersion: in.Version, Gold: in.Gold, FreeJewelry: in.FreeJewelry, Jewelry: in.Jewelry, Mileage: in.Mileage, HopePowder: in.HopePowder, GrantedIdentities: sortedTrueKeys(in.Granted), SpentIdentities: sortedTrueKeys(in.Spent), EquipMileage: in.EquipMileage, EquipMileageExchangeGage: in.EquipMileageExchangeGage}
}
func namedCounts(in map[string]uint64) []*statev1.NamedCount {
out := make([]*statev1.NamedCount, 0, len(in))
+413
View File
@@ -0,0 +1,413 @@
package statetx
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
)
const requestFormat = 3
type undoRecord struct {
Name string `json:"name"`
Exists bool `json:"exists"`
SHA256 string `json:"sha256,omitempty"`
}
type undoManifest struct {
Format int `json:"format"`
Records []undoRecord `json:"records"`
}
type finalRecord struct {
Name string `json:"name"`
Exists bool `json:"exists"`
SHA256 string `json:"sha256,omitempty"`
}
type finalManifest struct {
Format int `json:"format"`
Records []finalRecord `json:"records"`
}
// Operation is a write-ahead transaction around one authenticated request.
// Begin keeps the coordinator locked until Commit or Rollback. Domain stores
// remain responsible for typed validation and their normal single-file
// atomic writes; the durable undo log makes the complete request all-or-none
// across the account's state files.
type Operation struct {
coordinator *Coordinator
journal string
undo undoManifest
done bool
}
type RequestOperation interface {
Commit() error
Rollback() error
}
func (s *Coordinator) BeginOperation() (RequestOperation, error) {
return s.Begin()
}
// Begin persists a complete undo generation before domain code is allowed to
// mutate state. Missing state files are represented explicitly so first-run
// accounts can be rolled back without inventing an empty JSON document.
func (s *Coordinator) Begin() (*Operation, error) {
s.mu.Lock()
unlock := true
defer func() {
if unlock {
s.mu.Unlock()
}
}()
if s.failed {
return nil, errors.New("statetx: previous transaction failed; restart required")
}
journal := filepath.Join(s.root, journalName)
preparing := filepath.Join(s.root, journalName+".preparing")
if _, err := os.Lstat(journal); err == nil {
s.failed = true
return nil, errors.New("statetx: existing journal; restart required")
} else if !errors.Is(err, os.ErrNotExist) {
return nil, err
}
if err := os.MkdirAll(s.root, 0o700); err != nil {
return nil, err
}
if info, err := os.Lstat(preparing); err == nil {
if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
return nil, errors.New("statetx: preparing path is not a directory")
}
if err := os.RemoveAll(preparing); err != nil {
return nil, err
}
} else if !errors.Is(err, os.ErrNotExist) {
return nil, err
}
if err := os.Mkdir(preparing, 0o700); err != nil {
return nil, err
}
prepared := false
defer func() {
if !prepared {
_ = os.RemoveAll(preparing)
}
}()
if err := os.Mkdir(filepath.Join(preparing, "before"), 0o700); err != nil {
return nil, err
}
names := make([]string, 0, len(s.allowed))
for name := range s.allowed {
names = append(names, name)
}
sort.Strings(names)
undo := undoManifest{Format: requestFormat, Records: make([]undoRecord, 0, len(names))}
for _, name := range names {
info, exists, err := s.statOptionalTarget(name)
if err != nil {
return nil, err
}
record := undoRecord{Name: name, Exists: exists}
if exists {
if cached, ok := s.cache[name]; ok && sameFileGeneration(cached.info, info) {
record.SHA256 = cached.digest
} else {
data, err := os.ReadFile(filepath.Join(s.root, name))
if err != nil {
return nil, err
}
record.SHA256 = digest(data)
}
s.cache[name] = fileDigest{info: info, digest: record.SHA256}
if err := snapshotBefore(filepath.Join(s.root, name), filepath.Join(preparing, "before", name)); err != nil {
return nil, err
}
} else {
delete(s.cache, name)
}
undo.Records = append(undo.Records, record)
}
encoded, err := json.Marshal(undo)
if err != nil {
return nil, err
}
if err := syncDir(filepath.Join(preparing, "before")); err != nil {
return nil, err
}
if err := writeAtomicSynced(filepath.Join(preparing, "prepared.json"), encoded); err != nil {
return nil, err
}
if err := syncDir(preparing); err != nil {
return nil, err
}
if err := replaceFile(preparing, journal); err != nil {
return nil, err
}
if err := syncDir(s.root); err != nil {
return nil, err
}
prepared = true
unlock = false
return &Operation{coordinator: s, journal: journal, undo: undo}, nil
}
// Commit records and validates the exact final generation before making it
// authoritative. Response encoding must be complete before this is called.
func (o *Operation) Commit() error {
if o == nil || o.coordinator == nil {
return errors.New("statetx: nil request transaction")
}
if o.done {
return errors.New("statetx: request transaction already finished")
}
s := o.coordinator
defer s.mu.Unlock()
o.done = true
final := finalManifest{Format: requestFormat, Records: make([]finalRecord, 0, len(o.undo.Records))}
finalCache := make(map[string]fileDigest, len(o.undo.Records))
for _, entry := range o.undo.Records {
info, exists, err := s.statOptionalTarget(entry.Name)
if err != nil {
return o.failAndRollback(fmt.Errorf("statetx: read final %s: %w", entry.Name, err))
}
record := finalRecord{Name: entry.Name, Exists: exists}
if exists {
if cached, ok := s.cache[entry.Name]; ok && sameFileGeneration(cached.info, info) {
record.SHA256 = cached.digest
} else {
data, err := os.ReadFile(filepath.Join(s.root, entry.Name))
if err != nil {
return o.failAndRollback(fmt.Errorf("statetx: read final %s: %w", entry.Name, err))
}
record.SHA256 = digest(data)
}
finalCache[entry.Name] = fileDigest{info: info, digest: record.SHA256}
}
final.Records = append(final.Records, record)
}
encoded, err := json.Marshal(final)
if err != nil {
return o.failAndRollback(err)
}
if err := writeAtomicSynced(filepath.Join(o.journal, "committed.json"), encoded); err != nil {
return o.failAndRollback(fmt.Errorf("statetx: write commit marker: %w", err))
}
if err := os.RemoveAll(o.journal); err != nil {
// committed is authoritative. Keep serving the successful response but
// reject every later request until startup validates and cleans it.
s.failed = true
return nil
}
s.cache = finalCache
return nil
}
// Rollback is used when domain execution or response encoding fails. If no
// file changed, the request can safely fail without poisoning the process. If
// typed stores already published any change in memory, disk is restored but
// the coordinator enters fail-stop until restart reloads all stores.
func (o *Operation) Rollback() error {
if o == nil || o.coordinator == nil {
return errors.New("statetx: nil request transaction")
}
if o.done {
return nil
}
s := o.coordinator
defer s.mu.Unlock()
o.done = true
changed, err := s.undoChanged(o.undo)
if err != nil {
s.failed = true
return err
}
if changed {
if err := s.restoreUndo(o.journal, o.undo); err != nil {
s.failed = true
return err
}
s.failed = true
return errors.New("statetx: request rolled back after state mutation; restart required")
}
if err := os.RemoveAll(o.journal); err != nil {
s.failed = true
return err
}
return nil
}
// Abort closes an operation for tests that simulate a process crash. It does
// not touch the journal or live files; the next Open performs recovery.
func (o *Operation) Abort() {
if o == nil || o.coordinator == nil || o.done {
return
}
o.done = true
o.coordinator.mu.Unlock()
}
func (o *Operation) failAndRollback(cause error) error {
s := o.coordinator
changed, inspectErr := s.undoChanged(o.undo)
if inspectErr != nil {
s.failed = true
return fmt.Errorf("%w; inspect rollback: %v", cause, inspectErr)
}
if changed {
if rollbackErr := s.restoreUndo(o.journal, o.undo); rollbackErr != nil {
s.failed = true
return fmt.Errorf("%w; rollback failed: %v", cause, rollbackErr)
}
s.failed = true
return fmt.Errorf("%w; state restored but restart required", cause)
}
if cleanupErr := os.RemoveAll(o.journal); cleanupErr != nil {
s.failed = true
return fmt.Errorf("%w; cleanup failed: %v", cause, cleanupErr)
}
return cause
}
func (s *Coordinator) readOptionalTarget(name string) ([]byte, bool, error) {
info, exists, err := s.statOptionalTarget(name)
if err != nil || !exists {
return nil, exists, err
}
data, err := os.ReadFile(filepath.Join(s.root, name))
if err != nil {
return nil, false, err
}
_ = info
return data, true, nil
}
func (s *Coordinator) statOptionalTarget(name string) (os.FileInfo, bool, error) {
if !s.allowed[name] {
return nil, false, fmt.Errorf("statetx: unregistered state file %q", name)
}
path := filepath.Join(s.root, name)
info, err := os.Lstat(path)
if errors.Is(err, os.ErrNotExist) {
return nil, false, nil
}
if err != nil {
return nil, false, err
}
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
return nil, false, fmt.Errorf("statetx: %s is not a regular state file", name)
}
return info, true, nil
}
func sameFileGeneration(left, right os.FileInfo) bool {
return left != nil && right != nil && os.SameFile(left, right) &&
left.Size() == right.Size() && left.ModTime() == right.ModTime()
}
func (s *Coordinator) undoChanged(undo undoManifest) (bool, error) {
for _, entry := range undo.Records {
info, exists, err := s.statOptionalTarget(entry.Name)
if err != nil {
return false, err
}
if exists != entry.Exists {
return true, nil
}
if exists {
cached, ok := s.cache[entry.Name]
if !ok || cached.digest != entry.SHA256 || !sameFileGeneration(cached.info, info) {
return true, nil
}
}
}
return false, nil
}
func (s *Coordinator) restoreUndo(journal string, undo undoManifest) error {
for _, entry := range undo.Records {
target := filepath.Join(s.root, entry.Name)
if entry.Exists {
image := filepath.Join(journal, "before", entry.Name)
data, err := os.ReadFile(image)
if err != nil || digest(data) != entry.SHA256 {
return fmt.Errorf("statetx: invalid undo image for %s", entry.Name)
}
if err := replaceFromImage(image, target); err != nil {
return fmt.Errorf("statetx: restore %s: %w", entry.Name, err)
}
continue
}
info, err := os.Lstat(target)
if errors.Is(err, os.ErrNotExist) {
continue
}
if err != nil {
return err
}
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
return fmt.Errorf("statetx: refuse to remove non-regular %s", entry.Name)
}
if err := os.Remove(target); err != nil {
return fmt.Errorf("statetx: remove first-run %s: %w", entry.Name, err)
}
}
if err := os.RemoveAll(journal); err != nil {
return fmt.Errorf("statetx: remove undo journal: %w", err)
}
return nil
}
func (s *Coordinator) recoverRequestLocked(journal string) error {
encoded, err := os.ReadFile(filepath.Join(journal, "prepared.json"))
if errors.Is(err, os.ErrNotExist) {
return os.RemoveAll(journal)
}
if err != nil {
return err
}
var undo undoManifest
if err := json.Unmarshal(encoded, &undo); err != nil || undo.Format != requestFormat || len(undo.Records) != len(s.allowed) {
return errors.New("statetx: malformed request undo manifest")
}
seen := make(map[string]bool, len(undo.Records))
for _, entry := range undo.Records {
if !s.allowed[entry.Name] || seen[entry.Name] || entry.Exists && entry.SHA256 == "" || !entry.Exists && entry.SHA256 != "" {
return errors.New("statetx: invalid request undo target")
}
seen[entry.Name] = true
if entry.Exists {
data, err := os.ReadFile(filepath.Join(journal, "before", entry.Name))
if err != nil || digest(data) != entry.SHA256 {
return fmt.Errorf("statetx: invalid request undo image for %s", entry.Name)
}
}
}
commitBytes, markerErr := os.ReadFile(filepath.Join(journal, "committed.json"))
if markerErr != nil && !errors.Is(markerErr, os.ErrNotExist) {
return markerErr
}
if errors.Is(markerErr, os.ErrNotExist) {
return s.restoreUndo(journal, undo)
}
var final finalManifest
if err := json.Unmarshal(commitBytes, &final); err != nil || final.Format != requestFormat || len(final.Records) != len(undo.Records) {
return errors.New("statetx: malformed request commit manifest")
}
seen = make(map[string]bool, len(final.Records))
for _, entry := range final.Records {
if !s.allowed[entry.Name] || seen[entry.Name] || entry.Exists && entry.SHA256 == "" || !entry.Exists && entry.SHA256 != "" {
return errors.New("statetx: invalid request final target")
}
seen[entry.Name] = true
current, exists, err := s.readOptionalTarget(entry.Name)
if err != nil || exists != entry.Exists || exists && digest(current) != entry.SHA256 {
return fmt.Errorf("statetx: committed request file %s is not the final image", entry.Name)
}
}
return os.RemoveAll(journal)
}
+16
View File
@@ -0,0 +1,16 @@
//go:build !windows
package statetx
import "os"
func replaceFile(source, destination string) error { return os.Rename(source, destination) }
func syncDir(path string) error {
dir, err := os.Open(path)
if err != nil {
return err
}
defer dir.Close()
return dir.Sync()
}
+38
View File
@@ -0,0 +1,38 @@
//go:build windows
package statetx
import (
"os"
"syscall"
"unsafe"
)
var moveFileEx = syscall.NewLazyDLL("kernel32.dll").NewProc("MoveFileExW")
func replaceFile(source, destination string) error {
from, err := syscall.UTF16PtrFromString(source)
if err != nil {
return err
}
to, err := syscall.UTF16PtrFromString(destination)
if err != nil {
return err
}
result, _, callErr := moveFileEx.Call(uintptr(unsafe.Pointer(from)), uintptr(unsafe.Pointer(to)), 0x1|0x8)
if result == 0 {
if callErr != syscall.Errno(0) {
return callErr
}
return os.ErrInvalid
}
return nil
}
func syncDir(path string) error {
// Windows does not support FlushFileBuffers on a directory handle. The
// phase file itself is flushed, and replaceFile uses MoveFileExW with
// MOVEFILE_WRITE_THROUGH for publishing its directory entry.
return nil
}
+28
View File
@@ -0,0 +1,28 @@
//go:build darwin
package statetx
import (
"os"
"golang.org/x/sys/unix"
)
// APFS clonefile creates an independent copy-on-write snapshot. HFS+ and
// other filesystems safely fall back to a synchronized byte copy.
func snapshotBefore(source, destination string) error {
if err := unix.Clonefile(source, destination, 0); err == nil {
file, openErr := os.OpenFile(destination, os.O_WRONLY, 0)
if openErr == nil {
openErr = file.Sync()
if closeErr := file.Close(); openErr == nil {
openErr = closeErr
}
}
if openErr == nil {
return nil
}
}
_ = os.Remove(destination)
return copySynced(source, destination)
}
+40
View File
@@ -0,0 +1,40 @@
//go:build linux
package statetx
import (
"os"
"golang.org/x/sys/unix"
)
// Linux first asks the filesystem for an FICLONE copy-on-write snapshot
// (btrfs, XFS and supporting filesystems). Byte copying remains the safe
// fallback; hard links are deliberately avoided because in-place writes by a
// future Store implementation would also mutate the undo image.
func snapshotBefore(source, destination string) error {
if err := reflinkLinux(source, destination); err == nil {
return nil
}
_ = os.Remove(destination)
return copySynced(source, destination)
}
func reflinkLinux(source, destination string) error {
src, err := os.Open(source)
if err != nil {
return err
}
defer src.Close()
dst, err := os.OpenFile(destination, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
if err != nil {
return err
}
if err = unix.IoctlFileClone(int(dst.Fd()), int(src.Fd())); err == nil {
err = dst.Sync()
}
if closeErr := dst.Close(); err == nil {
err = closeErr
}
return err
}
+7
View File
@@ -0,0 +1,7 @@
//go:build !windows && !linux && !darwin
package statetx
func snapshotBefore(source, destination string) error {
return copySynced(source, destination)
}
+131
View File
@@ -0,0 +1,131 @@
//go:build windows
package statetx
import (
"os"
"sync"
"unsafe"
"golang.org/x/sys/windows"
)
var getDiskFreeSpaceW = windows.NewLazySystemDLL("kernel32.dll").NewProc("GetDiskFreeSpaceW")
const fileSupportsBlockRefcounting = 0x08000000
type windowsVolumeSnapshotInfo struct {
clusterSize int64
blockClone bool
}
var windowsVolumeCache sync.Map
type duplicateExtentsData struct {
SourceHandle windows.Handle
SourceOffset int64
TargetOffset int64
ByteCount int64
}
// ReFS block cloning creates an independent copy-on-write file. NTFS does not
// expose file-level CoW cloning, so it uses a hard link to the immutable old
// inode; every account Store is required to publish through temp+replace.
// Unsupported Windows filesystems fall back to a synchronized byte copy.
func snapshotBefore(source, destination string) error {
if volume, err := volumeSnapshotInfo(source); err == nil && volume.blockClone {
if err := blockCloneWindows(source, destination, volume.clusterSize); err == nil {
return nil
}
_ = os.Remove(destination)
}
if err := os.Link(source, destination); err == nil {
return nil
}
return copySynced(source, destination)
}
func blockCloneWindows(source, destination string, clusterSize int64) error {
info, err := os.Stat(source)
if err != nil {
return err
}
size := info.Size()
cloneBytes := size / clusterSize * clusterSize
if cloneBytes == 0 {
return windows.ERROR_NOT_SUPPORTED
}
src, err := os.Open(source)
if err != nil {
return err
}
defer src.Close()
dst, err := os.OpenFile(destination, os.O_CREATE|os.O_EXCL|os.O_RDWR, 0o600)
if err != nil {
return err
}
if err = dst.Truncate(size); err == nil && size != 0 {
input := duplicateExtentsData{
SourceHandle: windows.Handle(src.Fd()),
ByteCount: cloneBytes,
}
var returned uint32
err = windows.DeviceIoControl(
windows.Handle(dst.Fd()),
windows.FSCTL_DUPLICATE_EXTENTS_TO_FILE,
(*byte)(unsafe.Pointer(&input)), uint32(unsafe.Sizeof(input)),
nil, 0, &returned, nil,
)
}
if err == nil && cloneBytes < size {
tail := make([]byte, size-cloneBytes)
if _, readErr := src.ReadAt(tail, cloneBytes); readErr != nil {
err = readErr
} else {
_, err = dst.WriteAt(tail, cloneBytes)
}
}
if err == nil {
err = dst.Sync()
}
if closeErr := dst.Close(); err == nil {
err = closeErr
}
return err
}
func volumeSnapshotInfo(path string) (windowsVolumeSnapshotInfo, error) {
pathUTF16, err := windows.UTF16PtrFromString(path)
if err != nil {
return windowsVolumeSnapshotInfo{}, err
}
volume := make([]uint16, windows.MAX_PATH+1)
if err := windows.GetVolumePathName(pathUTF16, &volume[0], uint32(len(volume))); err != nil {
return windowsVolumeSnapshotInfo{}, err
}
volumeName := windows.UTF16ToString(volume)
if cached, ok := windowsVolumeCache.Load(volumeName); ok {
return cached.(windowsVolumeSnapshotInfo), nil
}
var flags uint32
if err := windows.GetVolumeInformation(&volume[0], nil, 0, nil, nil, &flags, nil, 0); err != nil {
return windowsVolumeSnapshotInfo{}, err
}
var sectorsPerCluster, bytesPerSector uint32
result, _, callErr := getDiskFreeSpaceW.Call(
uintptr(unsafe.Pointer(&volume[0])),
uintptr(unsafe.Pointer(&sectorsPerCluster)),
uintptr(unsafe.Pointer(&bytesPerSector)),
0, 0,
)
if result == 0 {
return windowsVolumeSnapshotInfo{}, callErr
}
cluster := uint64(sectorsPerCluster) * uint64(bytesPerSector)
if cluster == 0 || cluster > uint64(^uint32(0)) {
return windowsVolumeSnapshotInfo{}, windows.ERROR_INVALID_DATA
}
resultInfo := windowsVolumeSnapshotInfo{clusterSize: int64(cluster), blockClone: flags&fileSupportsBlockRefcounting != 0}
windowsVolumeCache.Store(volumeName, resultInfo)
return resultInfo, nil
}
+200
View File
@@ -0,0 +1,200 @@
// Package statetx provides a crash-safe write-ahead transaction around one
// account request. It knows filenames and bytes only; domain stores retain all
// JSON and gameplay semantics.
package statetx
import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
)
const journalName = ".account-transaction"
type Coordinator struct {
mu sync.Mutex
root string
allowed map[string]bool
failed bool
cache map[string]fileDigest
}
type fileDigest struct {
info os.FileInfo
digest string
}
// Open validates the account boundary and recovers an interrupted request
// before any domain store reads its state.
func Open(root string, allowed []string) (*Coordinator, error) {
if len(allowed) == 0 {
return nil, errors.New("statetx: no state filenames")
}
s := &Coordinator{root: filepath.Clean(root), allowed: make(map[string]bool, len(allowed)), cache: make(map[string]fileDigest, len(allowed))}
for _, name := range allowed {
if err := validName(name); err != nil || s.allowed[name] {
return nil, fmt.Errorf("statetx: invalid or duplicate allowed filename %q", name)
}
s.allowed[name] = true
}
if err := s.recoverLocked(); err != nil {
return nil, err
}
if err := s.refreshCacheLocked(); err != nil {
return nil, err
}
return s, nil
}
func (s *Coordinator) refreshCacheLocked() error {
next := make(map[string]fileDigest, len(s.allowed))
for name := range s.allowed {
path := filepath.Join(s.root, name)
info, err := os.Lstat(path)
if errors.Is(err, os.ErrNotExist) {
continue
}
if err != nil {
return err
}
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
return fmt.Errorf("statetx: %s is not a regular state file", name)
}
data, err := os.ReadFile(path)
if err != nil {
return err
}
next[name] = fileDigest{info: info, digest: digest(data)}
}
s.cache = next
return nil
}
// Check is the request dispatcher fail-stop gate. Once memory could differ
// from a disk generation restored after an error, only restart may continue.
func (s *Coordinator) Check() error {
s.mu.Lock()
defer s.mu.Unlock()
if s.failed {
return errors.New("statetx: account persistence requires restart recovery")
}
return nil
}
func validName(name string) error {
if name == "" || name == "." || name == ".." || filepath.Base(name) != name || strings.ContainsAny(name, `/\:`) || name == journalName {
return errors.New("filename is not a direct child")
}
return nil
}
func digest(data []byte) string {
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:])
}
func (s *Coordinator) recoverLocked() error {
preparing := filepath.Join(s.root, journalName+".preparing")
if info, err := os.Lstat(preparing); err == nil {
if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
return errors.New("statetx: preparing path is not a directory")
}
if err := os.RemoveAll(preparing); err != nil {
return err
}
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
journal := filepath.Join(s.root, journalName)
info, err := os.Lstat(journal)
if errors.Is(err, os.ErrNotExist) {
return nil
}
if err != nil {
return err
}
if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
return errors.New("statetx: journal is not a directory")
}
if _, err := os.Lstat(filepath.Join(journal, "prepared.json")); err == nil {
return s.recoverRequestLocked(journal)
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
// Begin writes undo.json and then prepared before gameplay may execute.
// Therefore a journal without undo.json cannot have touched live state.
return os.RemoveAll(journal)
}
func writeAtomicSynced(path string, data []byte) error {
dir := filepath.Dir(path)
file, err := os.CreateTemp(dir, ".statetx-phase-*.tmp")
if err != nil {
return err
}
name := file.Name()
defer os.Remove(name)
if _, err = file.Write(data); err == nil {
err = file.Sync()
}
if closeErr := file.Close(); err == nil {
err = closeErr
}
if err != nil {
return err
}
if err := replaceFile(name, path); err != nil {
return err
}
return syncDir(dir)
}
func writeSynced(path string, data []byte) error {
f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
if err != nil {
return err
}
_, err = f.Write(data)
if err == nil {
err = f.Sync()
}
if closeErr := f.Close(); err == nil {
err = closeErr
}
return err
}
func copySynced(source, destination string) error {
data, err := os.ReadFile(source)
if err != nil {
return err
}
return writeSynced(destination, data)
}
func replaceFromImage(image, target string) error {
data, err := os.ReadFile(image)
if err != nil {
return err
}
file, err := os.CreateTemp(filepath.Dir(target), ".account-*.tmp")
if err != nil {
return err
}
defer os.Remove(file.Name())
if _, err = file.Write(data); err == nil {
err = file.Sync()
}
if closeErr := file.Close(); err == nil {
err = closeErr
}
if err != nil {
return err
}
return replaceFile(file.Name(), target)
}
+252
View File
@@ -0,0 +1,252 @@
package statetx
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"testing"
)
func openFixture(t *testing.T) (*Coordinator, string) {
t.Helper()
root := t.TempDir()
for name, content := range map[string]string{"wallet.json": "old-wallet", "items.json": "old-items"} {
if err := atomicTestReplace(filepath.Join(root, name), []byte(content)); err != nil {
t.Fatal(err)
}
}
s, err := Open(root, []string{"wallet.json", "items.json"})
if err != nil {
t.Fatal(err)
}
return s, root
}
func TestOpenRejectsUnsafeOrDuplicateTargets(t *testing.T) {
root := t.TempDir()
for _, names := range [][]string{{"../wallet.json"}, {"wallet.json", "wallet.json"}, {journalName}} {
if _, err := Open(root, names); err == nil {
t.Fatalf("unsafe allow-list accepted: %v", names)
}
}
}
func TestRequestOperationCommitsWholeGeneration(t *testing.T) {
s, root := openFixture(t)
op, err := s.Begin()
if err != nil {
t.Fatal(err)
}
for name, content := range map[string]string{"items.json": "new-items", "wallet.json": "new-wallet"} {
if err := os.WriteFile(filepath.Join(root, name), []byte(content), 0o600); err != nil {
t.Fatal(err)
}
}
if err := op.Commit(); err != nil {
t.Fatal(err)
}
assertFiles(t, root, map[string]string{"items.json": "new-items", "wallet.json": "new-wallet"})
}
func TestRequestCrashRollsBackEveryFileOnOpen(t *testing.T) {
s, root := openFixture(t)
op, err := s.Begin()
if err != nil {
t.Fatal(err)
}
if err := atomicTestReplace(filepath.Join(root, "items.json"), []byte("new-items")); err != nil {
t.Fatal(err)
}
op.Abort()
if _, err := Open(root, []string{"wallet.json", "items.json"}); err != nil {
t.Fatal(err)
}
assertFiles(t, root, map[string]string{"items.json": "old-items", "wallet.json": "old-wallet"})
}
func TestRequestRollbackWithoutMutationKeepsCoordinatorUsable(t *testing.T) {
s, _ := openFixture(t)
op, err := s.Begin()
if err != nil {
t.Fatal(err)
}
if err := op.Rollback(); err != nil {
t.Fatal(err)
}
second, err := s.Begin()
if err != nil {
t.Fatalf("clean rejection poisoned coordinator: %v", err)
}
if err := second.Commit(); err != nil {
t.Fatal(err)
}
}
func TestRequestRollbackAfterMutationRestoresAndFailsStop(t *testing.T) {
s, root := openFixture(t)
op, err := s.Begin()
if err != nil {
t.Fatal(err)
}
if err := atomicTestReplace(filepath.Join(root, "wallet.json"), []byte("partial-wallet")); err != nil {
t.Fatal(err)
}
if err := op.Rollback(); err == nil {
t.Fatal("mutating rollback did not require restart")
}
assertFiles(t, root, map[string]string{"wallet.json": "old-wallet"})
if _, err := s.Begin(); err == nil {
t.Fatal("coordinator accepted request after memory could diverge")
}
if _, err := Open(root, []string{"wallet.json", "items.json"}); err != nil {
t.Fatalf("clean restart could not reopen state: %v", err)
}
}
func TestFirstRunMissingFileIsRemovedByCrashRecovery(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "wallet.json"), []byte("old-wallet"), 0o600); err != nil {
t.Fatal(err)
}
s, err := Open(root, []string{"wallet.json", "items.json"})
if err != nil {
t.Fatal(err)
}
op, err := s.Begin()
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "items.json"), []byte("created-items"), 0o600); err != nil {
t.Fatal(err)
}
op.Abort()
if _, err := Open(root, []string{"wallet.json", "items.json"}); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(root, "items.json")); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("first-run file survived rollback: %v", err)
}
}
func TestRecoveryRejectsTamperedUndoWithoutChangingState(t *testing.T) {
s, root := openFixture(t)
op, err := s.Begin()
if err != nil {
t.Fatal(err)
}
op.Abort()
journal := filepath.Join(root, journalName)
if err := atomicTestReplace(filepath.Join(journal, "before", "wallet.json"), []byte("CORRUPT")); err != nil {
t.Fatal(err)
}
if _, err := Open(root, []string{"wallet.json", "items.json"}); err == nil {
t.Fatal("tampered undo was accepted")
}
assertFiles(t, root, map[string]string{"wallet.json": "old-wallet", "items.json": "old-items"})
}
func TestCommittedRecoveryValidatesFinalGeneration(t *testing.T) {
s, root := openFixture(t)
op, err := s.Begin()
if err != nil {
t.Fatal(err)
}
if err := atomicTestReplace(filepath.Join(root, "wallet.json"), []byte("new-wallet")); err != nil {
t.Fatal(err)
}
final := finalManifest{Format: requestFormat, Records: []finalRecord{
{Name: "items.json", Exists: true, SHA256: digest([]byte("old-items"))},
{Name: "wallet.json", Exists: true, SHA256: digest([]byte("new-wallet"))},
}}
encoded, _ := json.Marshal(final)
if err := writeAtomicSynced(filepath.Join(op.journal, "committed.json"), encoded); err != nil {
t.Fatal(err)
}
op.Abort()
if _, err := Open(root, []string{"wallet.json", "items.json"}); err != nil {
t.Fatal(err)
}
assertFiles(t, root, map[string]string{"wallet.json": "new-wallet", "items.json": "old-items"})
}
func TestSnapshotBeforeSurvivesAtomicLiveReplacement(t *testing.T) {
root := t.TempDir()
source := filepath.Join(root, "live.json")
snapshot := filepath.Join(root, "snapshot.json")
old := []byte("old generation that must remain immutable")
if err := os.WriteFile(source, old, 0o600); err != nil {
t.Fatal(err)
}
if err := snapshotBefore(source, snapshot); err != nil {
t.Fatal(err)
}
if err := atomicTestReplace(source, []byte("new generation")); err != nil {
t.Fatal(err)
}
got, err := os.ReadFile(snapshot)
if err != nil || string(got) != string(old) {
t.Fatalf("snapshot changed after live replace: %q err=%v", got, err)
}
}
func BenchmarkRequestTransactionRealState(b *testing.B) {
source := filepath.Clean(filepath.Join("..", "..", "..", "data", "state"))
if _, err := os.Stat(source); err != nil {
b.Skip("development state fixture is unavailable")
}
root := b.TempDir()
names := []string{"characters.json", "collection.json", "deck.json", "equipment.json", "items.json", "mail.json", "missions.json", "progress.json", "wallet.json"}
for _, name := range names {
data, err := os.ReadFile(filepath.Join(source, name))
if err != nil {
b.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, name), data, 0o600); err != nil {
b.Fatal(err)
}
}
coordinator, err := Open(root, names)
if err != nil {
b.Fatal(err)
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
op, err := coordinator.Begin()
if err != nil {
b.Fatal(err)
}
if err := op.Commit(); err != nil {
b.Fatal(err)
}
}
}
func atomicTestReplace(path string, data []byte) error {
temp, err := os.CreateTemp(filepath.Dir(path), ".statetx-test-*.tmp")
if err != nil {
return err
}
name := temp.Name()
defer os.Remove(name)
if _, err = temp.Write(data); err == nil {
err = temp.Sync()
}
if closeErr := temp.Close(); err == nil {
err = closeErr
}
if err != nil {
return err
}
return replaceFile(name, path)
}
func assertFiles(t *testing.T, root string, want map[string]string) {
t.Helper()
for name, expected := range want {
got, err := os.ReadFile(filepath.Join(root, name))
if err != nil || string(got) != expected {
t.Fatalf("%s=%q want=%q err=%v", name, got, expected, err)
}
}
}