feat: expand growth, equipment, and mail
- add character promotion and costume potential flows - add equipment management and enhancement - harden development mail and save tools
This commit is contained in:
@@ -143,10 +143,6 @@ func serve(args []string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// The current local save has completed the first-gacha tutorial gate.
|
|
||||||
// Deriving this UI flag from authoritative story progress prevents a new
|
|
||||||
// account from seeing ordinary pools before the tutorial unlocks them.
|
|
||||||
login.SetFirstGacha(progressState.QuestCleared(37, 21))
|
|
||||||
deckConfig, err := deck.LoadSeed(filepath.Clean(*deckSeed))
|
deckConfig, err := deck.LoadSeed(filepath.Clean(*deckSeed))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("load starter deck: %w", err)
|
return fmt.Errorf("load starter deck: %w", err)
|
||||||
@@ -159,6 +155,13 @@ func serve(args []string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("load owned inventory: %w", err)
|
return fmt.Errorf("load owned inventory: %w", err)
|
||||||
}
|
}
|
||||||
|
randomBoxes, err := gamedata.LoadRandomBoxDesign(filepath.Clean(*gameData), *gameDataVersion)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("load deterministic random-box GameData: %w", err)
|
||||||
|
}
|
||||||
|
if err := ownedItems.AttachRandomBoxes(randomBoxes); err != nil {
|
||||||
|
return fmt.Errorf("attach random-box GameData: %w", err)
|
||||||
|
}
|
||||||
gold, freeJewelry, jewelry, mileage, err := login.SeedCurrencies()
|
gold, freeJewelry, jewelry, mileage, err := login.SeedCurrencies()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("read account seed currency: %w", err)
|
return fmt.Errorf("read account seed currency: %w", err)
|
||||||
@@ -180,6 +183,9 @@ func serve(args []string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("load mail state: %w", err)
|
return fmt.Errorf("load mail state: %w", err)
|
||||||
}
|
}
|
||||||
|
if err := mailService.AttachSeedPath(filepath.Clean(*mailSeed)); err != nil {
|
||||||
|
return fmt.Errorf("watch mail seed: %w", err)
|
||||||
|
}
|
||||||
missionDesign, err := gamedata.LoadMissionDesign(filepath.Clean(*gameData), *gameDataVersion)
|
missionDesign, err := gamedata.LoadMissionDesign(filepath.Clean(*gameData), *gameDataVersion)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("load mission GameData: %w", err)
|
return fmt.Errorf("load mission GameData: %w", err)
|
||||||
@@ -201,6 +207,20 @@ func serve(args []string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("load owned equipment: %w", err)
|
return fmt.Errorf("load owned equipment: %w", err)
|
||||||
}
|
}
|
||||||
|
equipmentSlots, err := gamedata.LoadEquipmentSlots(filepath.Clean(*gameData), *gameDataVersion)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("load equipment slot GameData: %w", err)
|
||||||
|
}
|
||||||
|
if err := ownedEquipment.AttachSlots(equipmentSlots); err != nil {
|
||||||
|
return fmt.Errorf("attach equipment slot GameData: %w", err)
|
||||||
|
}
|
||||||
|
equipmentUpgrade, err := gamedata.LoadEquipmentUpgradeDesign(filepath.Clean(*gameData), *gameDataVersion)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("load equipment upgrade GameData: %w", err)
|
||||||
|
}
|
||||||
|
if err := ownedEquipment.AttachUpgrade(equipmentUpgrade, wallet, ownedItems); err != nil {
|
||||||
|
return fmt.Errorf("attach equipment upgrade GameData: %w", err)
|
||||||
|
}
|
||||||
collection, err := player.OpenCollectionStore(filepath.Join(filepath.Dir(*stateFile), "collection.json"), starter.Costumes)
|
collection, err := player.OpenCollectionStore(filepath.Join(filepath.Dir(*stateFile), "collection.json"), starter.Costumes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("load owned collection: %w", err)
|
return fmt.Errorf("load owned collection: %w", err)
|
||||||
@@ -217,6 +237,9 @@ func serve(args []string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
// The mapped client property is IsDoneFirstGachaPick. Its authoritative
|
||||||
|
// local state is the explicit GachaSubType=3 completion marker.
|
||||||
|
login.SetFirstGacha(gachaService.FirstGachaCompleted())
|
||||||
gachaService.AttachInventory(ownedItems)
|
gachaService.AttachInventory(ownedItems)
|
||||||
gachaService.AttachEquipmentGacha(equipmentGacha, ownedEquipment)
|
gachaService.AttachEquipmentGacha(equipmentGacha, ownedEquipment)
|
||||||
gachaService.AttachPreviewMission(func() error {
|
gachaService.AttachPreviewMission(func() error {
|
||||||
@@ -230,6 +253,11 @@ func serve(args []string) error {
|
|||||||
if err := collection.BindBaseCharacters(worldService.CharacterService().RawAll()); err != nil {
|
if err := collection.BindBaseCharacters(worldService.CharacterService().RawAll()); err != nil {
|
||||||
return fmt.Errorf("bind base collection characters: %w", err)
|
return fmt.Errorf("bind base collection characters: %w", err)
|
||||||
}
|
}
|
||||||
|
if reward, earned := worldService.EarnedQuestCostume(); earned {
|
||||||
|
if err := collection.AttachRewardCostume(reward); err != nil {
|
||||||
|
return fmt.Errorf("attach earned quest costume: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
if err := worldService.AttachCollection(collection); err != nil {
|
if err := worldService.AttachCollection(collection); err != nil {
|
||||||
return fmt.Errorf("attach gacha collection state: %w", err)
|
return fmt.Errorf("attach gacha collection state: %w", err)
|
||||||
}
|
}
|
||||||
@@ -247,7 +275,18 @@ func serve(args []string) error {
|
|||||||
if err := worldService.CharacterService().AttachMaxHealth(pictorialService.MaxHealth); err != nil {
|
if err := worldService.CharacterService().AttachMaxHealth(pictorialService.MaxHealth); err != nil {
|
||||||
return fmt.Errorf("attach pictorial character stats: %w", err)
|
return fmt.Errorf("attach pictorial character stats: %w", err)
|
||||||
}
|
}
|
||||||
battleService := battle.NewService(filepath.Clean(*gameData), *gameDataVersion, ownedItems)
|
if err := worldService.CharacterService().AttachWallet(wallet); err != nil {
|
||||||
|
return fmt.Errorf("attach character promotion wallet: %w", err)
|
||||||
|
}
|
||||||
|
costumePotentialDesign, err := gamedata.LoadCostumePotentialDesign(filepath.Clean(*gameData), *gameDataVersion)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("load costume potential GameData: %w", err)
|
||||||
|
}
|
||||||
|
costumePotentialService, err := player.NewCostumePotentialService(costumePotentialDesign, collection, worldService.CharacterService(), ownedItems, wallet)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
battleService := battle.NewService(filepath.Clean(*gameData), *gameDataVersion, ownedItems, worldService.CurrentPackID)
|
||||||
battleService.AttachTutorialWin(func() error {
|
battleService.AttachTutorialWin(func() error {
|
||||||
return missionService.CompleteMission(gamedata.MissionKey{GroupType: 0, GroupID: 1, ID: 113})
|
return missionService.CompleteMission(gamedata.MissionKey{GroupType: 0, GroupID: 1, ID: 113})
|
||||||
})
|
})
|
||||||
@@ -263,6 +302,7 @@ func serve(args []string) error {
|
|||||||
deckStateStore,
|
deckStateStore,
|
||||||
ownedItems,
|
ownedItems,
|
||||||
ownedEquipment,
|
ownedEquipment,
|
||||||
|
costumePotentialService,
|
||||||
starter,
|
starter,
|
||||||
mailService,
|
mailService,
|
||||||
gachaService,
|
gachaService,
|
||||||
|
|||||||
@@ -21,18 +21,24 @@ type Service struct {
|
|||||||
round uint64
|
round uint64
|
||||||
monster uint64
|
monster uint64
|
||||||
deck uint64
|
deck uint64
|
||||||
|
pack int
|
||||||
initialBlue [][]byte
|
initialBlue [][]byte
|
||||||
gameDataRoot string
|
gameDataRoot string
|
||||||
gameDataVersion string
|
gameDataVersion string
|
||||||
inventory *player.Inventory
|
inventory *player.Inventory
|
||||||
|
currentPack func() (int, error)
|
||||||
|
loadRewards func(string, string, int, uint64) ([]gamedata.BattleReward, error)
|
||||||
buffs func() ([]gamedata.PictorialBuffStat, error)
|
buffs func() ([]gamedata.PictorialBuffStat, error)
|
||||||
onTutorialWin func() error
|
onTutorialWin func() error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) AttachTutorialWin(callback func() error) { s.onTutorialWin = callback }
|
func (s *Service) AttachTutorialWin(callback func() error) { s.onTutorialWin = callback }
|
||||||
|
|
||||||
func NewService(gameDataRoot, gameDataVersion string, inventory *player.Inventory) *Service {
|
func NewService(gameDataRoot, gameDataVersion string, inventory *player.Inventory, currentPack func() (int, error)) *Service {
|
||||||
return &Service{gameDataRoot: gameDataRoot, gameDataVersion: gameDataVersion, inventory: inventory}
|
return &Service{
|
||||||
|
gameDataRoot: gameDataRoot, gameDataVersion: gameDataVersion,
|
||||||
|
inventory: inventory, currentPack: currentPack, loadRewards: gamedata.BattleDeckRewards,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) AttachPictorialBuffs(buffs func() ([]gamedata.PictorialBuffStat, error)) {
|
func (s *Service) AttachPictorialBuffs(buffs func() ([]gamedata.PictorialBuffStat, error)) {
|
||||||
@@ -73,10 +79,19 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error)
|
|||||||
if err != nil || !modeFound || mode == 0 {
|
if err != nil || !modeFound || mode == 0 {
|
||||||
return 0, nil, true, errors.New("battle: missing battle mode")
|
return 0, nil, true, errors.New("battle: missing battle mode")
|
||||||
}
|
}
|
||||||
s.entered, s.index, s.round, s.initialBlue = true, 0, 0, nil
|
packID := 0
|
||||||
s.monster, _, _ = wire.Varint(request, 3)
|
if s.currentPack != nil {
|
||||||
s.deck = deck
|
packID, err = s.currentPack()
|
||||||
slog.Info("team trace: battle entered", "monster", s.monster, "enemyDeck", deck, "mode", mode)
|
if err != nil {
|
||||||
|
return 0, nil, true, fmt.Errorf("battle: resolve current pack: %w", err)
|
||||||
|
}
|
||||||
|
if packID <= 0 {
|
||||||
|
return 0, nil, true, errors.New("battle: current pack is invalid")
|
||||||
|
}
|
||||||
|
} else if s.inventory != nil && s.gameDataRoot != "" {
|
||||||
|
return 0, nil, true, errors.New("battle: current pack resolver is unavailable")
|
||||||
|
}
|
||||||
|
monster, _, _ := wire.Varint(request, 3)
|
||||||
response := wire.AppendVarint(nil, 2, deck)
|
response := wire.AppendVarint(nil, 2, deck)
|
||||||
if s.buffs != nil {
|
if s.buffs != nil {
|
||||||
buffs, err := s.buffs()
|
buffs, err := s.buffs()
|
||||||
@@ -94,6 +109,9 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error)
|
|||||||
}
|
}
|
||||||
// The local engine is the normal deterministic engine.
|
// The local engine is the normal deterministic engine.
|
||||||
response = wire.AppendVarint(response, 6, 1)
|
response = wire.AppendVarint(response, 6, 1)
|
||||||
|
s.entered, s.index, s.round, s.initialBlue = true, 0, 0, nil
|
||||||
|
s.monster, s.deck, s.pack = monster, deck, packID
|
||||||
|
slog.Info("team trace: battle entered", "pack", packID, "monster", monster, "enemyDeck", deck, "mode", mode)
|
||||||
return 52, response, true, nil
|
return 52, response, true, nil
|
||||||
case "/BattleRetry":
|
case "/BattleRetry":
|
||||||
if !s.entered {
|
if !s.entered {
|
||||||
@@ -170,11 +188,18 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error)
|
|||||||
}
|
}
|
||||||
rewardBundle := false
|
rewardBundle := false
|
||||||
if result == 1 && s.inventory != nil && s.monster != 0 && s.gameDataRoot != "" {
|
if result == 1 && s.inventory != nil && s.monster != 0 && s.gameDataRoot != "" {
|
||||||
rewards, rewardErr := gamedata.BattleDeckRewards(s.gameDataRoot, s.gameDataVersion, 21, s.deck)
|
if s.pack <= 0 {
|
||||||
if rewardErr != nil {
|
return 0, nil, true, errors.New("battle: victory has no locked pack")
|
||||||
return 0, nil, true, fmt.Errorf("battle: monster %d deck %d rewards: %w", s.monster, s.deck, rewardErr)
|
|
||||||
}
|
}
|
||||||
items, grantErr := s.inventory.GrantOnce(fmt.Sprintf("pack21:monster%d:deck%d", s.monster, s.deck), rewards)
|
loader := s.loadRewards
|
||||||
|
if loader == nil {
|
||||||
|
loader = gamedata.BattleDeckRewards
|
||||||
|
}
|
||||||
|
rewards, rewardErr := loader(s.gameDataRoot, s.gameDataVersion, s.pack, s.deck)
|
||||||
|
if rewardErr != nil {
|
||||||
|
return 0, nil, true, fmt.Errorf("battle: pack %d monster %d deck %d rewards: %w", s.pack, s.monster, s.deck, rewardErr)
|
||||||
|
}
|
||||||
|
items, grantErr := s.inventory.GrantOnce(fmt.Sprintf("pack%d:monster%d:deck%d", s.pack, s.monster, s.deck), rewards)
|
||||||
if grantErr != nil {
|
if grantErr != nil {
|
||||||
return 0, nil, true, grantErr
|
return 0, nil, true, grantErr
|
||||||
}
|
}
|
||||||
@@ -198,10 +223,10 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error)
|
|||||||
}
|
}
|
||||||
response = wire.AppendBytes(response, field, nil)
|
response = wire.AppendBytes(response, field, nil)
|
||||||
}
|
}
|
||||||
s.entered, s.deck, s.initialBlue = false, 0, nil
|
s.entered, s.deck, s.pack, s.initialBlue = false, 0, 0, nil
|
||||||
return 15, response, true, nil
|
return 15, response, true, nil
|
||||||
case "/BattleExit":
|
case "/BattleExit":
|
||||||
s.entered, s.index, s.round, s.deck, s.initialBlue = false, 0, 0, 0, nil
|
s.entered, s.index, s.round, s.deck, s.pack, s.initialBlue = false, 0, 0, 0, 0, nil
|
||||||
return 388, nil, true, nil
|
return 388, nil, true, nil
|
||||||
}
|
}
|
||||||
panic("unreachable")
|
panic("unreachable")
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
package battle
|
package battle
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"bd2server/internal/gamedata"
|
"bd2server/internal/gamedata"
|
||||||
|
"bd2server/internal/player"
|
||||||
"bd2server/internal/wire"
|
"bd2server/internal/wire"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -120,3 +122,44 @@ func TestBattleEnterUsesSamePictorialSnapshotAsAllCharRefresh(t *testing.T) {
|
|||||||
t.Fatalf("battle buffs count=%d err=%v", count, err)
|
t.Fatalf("battle buffs count=%d err=%v", count, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBattleVictoryLocksPackAtEnterForRewardsAndIdentity(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
inventory, err := player.OpenInventory(filepath.Join(dir, "items.json"), &player.Starter{Version: "2.34.13"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
currentPack := 22
|
||||||
|
s := NewService("test-root", "test-version", inventory, func() (int, error) {
|
||||||
|
return currentPack, nil
|
||||||
|
})
|
||||||
|
var loadedPack int
|
||||||
|
var loadedDeck uint64
|
||||||
|
s.loadRewards = func(_, _ string, packID int, deckID uint64) ([]gamedata.BattleReward, error) {
|
||||||
|
loadedPack, loadedDeck = packID, deckID
|
||||||
|
return []gamedata.BattleReward{{Type: 8, ID: 8, Count: 3}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
enter := wire.AppendVarint(request(1), 3, 7)
|
||||||
|
enter = wire.AppendVarint(enter, 4, 9)
|
||||||
|
enter = wire.AppendVarint(enter, 5, 1)
|
||||||
|
if _, _, _, err := s.Handle("/BattleEnter", enter); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// A later world transition must not change the identity of an in-flight
|
||||||
|
// battle; the pack is captured at BattleEnter.
|
||||||
|
currentPack = 21
|
||||||
|
end := wire.AppendVarint(request(2), 2, 1)
|
||||||
|
if _, _, _, err := s.Handle("/BattleEnd", end); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if loadedPack != 22 || loadedDeck != 9 {
|
||||||
|
t.Fatalf("reward lookup pack/deck=%d/%d, want 22/9", loadedPack, loadedDeck)
|
||||||
|
}
|
||||||
|
if got := inventory.GrantedItems("pack22:monster7:deck9"); len(got) != 1 || got[0].ID != 8 || got[0].Count != 3 {
|
||||||
|
t.Fatalf("pack22 reward grant=%+v", got)
|
||||||
|
}
|
||||||
|
if got := inventory.GrantedItems("pack21:monster7:deck9"); len(got) != 0 {
|
||||||
|
t.Fatalf("reward leaked into pack21 identity: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -47,6 +47,13 @@ func (s *Service) AttachEquipmentGacha(catalog *gamedata.EquipmentGachaCatalog,
|
|||||||
s.equipmentCatalog, s.equipmentInventory = catalog, inventory
|
s.equipmentCatalog, s.equipmentInventory = catalog, inventory
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FirstGachaCompleted reflects UserDBInfo.IsFirstGacha. The official symbol
|
||||||
|
// map names the client-side property IsDoneFirstGachaPick, and the client sets
|
||||||
|
// it after a GachaSubType=3 purchase succeeds.
|
||||||
|
func (s *Service) FirstGachaCompleted() bool {
|
||||||
|
return s.collection.FirstGachaCompleted()
|
||||||
|
}
|
||||||
|
|
||||||
// BeginSession is called by the transport session after each successful
|
// BeginSession is called by the transport session after each successful
|
||||||
// login. Request sequence numbers restart with the client, so the login ID is
|
// login. Request sequence numbers restart with the client, so the login ID is
|
||||||
// part of the durable idempotency key: retries in one login remain idempotent,
|
// part of the durable idempotency key: retries in one login remain idempotent,
|
||||||
@@ -408,7 +415,7 @@ func (s *Service) buyEquipment(seq, buyType uint64, tickets []player.Item, desig
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return 146, nil, true, err
|
return 146, nil, true, err
|
||||||
}
|
}
|
||||||
entry := player.Equipment{ID: equipmentID, SortID: uint64(sort)}
|
entry := player.Equipment{ID: equipmentID, SortID: uint64(sort), Rank: []uint64{0, 0, 0}}
|
||||||
for _, o := range main {
|
for _, o := range main {
|
||||||
entry.MainOption = append(entry.MainOption, player.EquipmentOption{GroupID: o.GroupID, ID: o.ID})
|
entry.MainOption = append(entry.MainOption, player.EquipmentOption{GroupID: o.GroupID, ID: o.ID})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,6 +74,9 @@ func TestInfinitePreviewAndFreeConfirmationPersist(t *testing.T) {
|
|||||||
if err != nil || !ok || code != 61 {
|
if err != nil || !ok || code != 61 {
|
||||||
t.Fatalf("confirm code=%d ok=%v err=%v", code, ok, err)
|
t.Fatalf("confirm code=%d ok=%v err=%v", code, ok, err)
|
||||||
}
|
}
|
||||||
|
if service.FirstGachaCompleted() {
|
||||||
|
t.Fatal("infinite product incorrectly completed the subtype-3 first pick")
|
||||||
|
}
|
||||||
reward, found, err := wire.Bytes(response, 1)
|
reward, found, err := wire.Bytes(response, 1)
|
||||||
if err != nil || !found || countFields(reward, 2) != 1 || countFields(reward, 3) != 1 || countFields(reward, 9) != 9 || countFields(reward, 8) != 4 {
|
if err != nil || !found || countFields(reward, 2) != 1 || countFields(reward, 3) != 1 || countFields(reward, 9) != 9 || countFields(reward, 8) != 4 {
|
||||||
t.Fatalf("reward chars=%d costumes=%d upgrades=%d exchanges=%d found=%v err=%v", countFields(reward, 2), countFields(reward, 3), countFields(reward, 9), countFields(reward, 8), found, err)
|
t.Fatalf("reward chars=%d costumes=%d upgrades=%d exchanges=%d found=%v err=%v", countFields(reward, 2), countFields(reward, 3), countFields(reward, 9), countFields(reward, 8), found, err)
|
||||||
|
|||||||
@@ -19,6 +19,23 @@ func TestInstalledPack21FirstMonsterRewards(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestInstalledPack22DeckAbsentFromPack21Rewards(t *testing.T) {
|
||||||
|
root := os.Getenv("BD2_REAL_GAMEDATA")
|
||||||
|
if root == "" {
|
||||||
|
t.Skip("BD2_REAL_GAMEDATA not configured")
|
||||||
|
}
|
||||||
|
rewards, err := BattleDeckRewards(root, "20260910162539", 22, 9)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(rewards) != 1 || rewards[0] != (BattleReward{Type: 8, ID: 14, Count: 1}) {
|
||||||
|
t.Fatalf("pack22 deck9 rewards = %+v, want type8/item14 x1", rewards)
|
||||||
|
}
|
||||||
|
if _, err := BattleDeckRewards(root, "20260910162539", 21, 9); err == nil {
|
||||||
|
t.Fatal("pack21 unexpectedly contains pack22-only deck9")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestInstalledTutorialGrowthReachesLevel20(t *testing.T) {
|
func TestInstalledTutorialGrowthReachesLevel20(t *testing.T) {
|
||||||
root := os.Getenv("BD2_REAL_GAMEDATA")
|
root := os.Getenv("BD2_REAL_GAMEDATA")
|
||||||
if root == "" {
|
if root == "" {
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
package gamedata
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CostumePotentialCost struct{ Type, ID, Count uint64 }
|
||||||
|
|
||||||
|
type CostumePotentialNode struct {
|
||||||
|
ID uint64
|
||||||
|
ConditionGrade uint64
|
||||||
|
Prerequisites []uint64
|
||||||
|
Costs []CostumePotentialCost
|
||||||
|
}
|
||||||
|
|
||||||
|
type CostumePotentialDesign struct {
|
||||||
|
Nodes map[uint64]map[uint64]CostumePotentialNode
|
||||||
|
CostumeUnique map[uint64]uint64
|
||||||
|
CharacterGrade map[uint64]uint64
|
||||||
|
CharacterUnique map[uint64]uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadCostumePotentialDesign(root, version string) (*CostumePotentialDesign, error) {
|
||||||
|
plain, err := ReadQuestDatabase(root, version)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
dir, err := os.MkdirTemp("", "bd2-costume-potential-")
|
||||||
|
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 loadCostumePotentialDesign(db)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadCostumePotentialDesign(db *sql.DB) (*CostumePotentialDesign, error) {
|
||||||
|
d := &CostumePotentialDesign{Nodes: map[uint64]map[uint64]CostumePotentialNode{}, CostumeUnique: map[uint64]uint64{}, CharacterGrade: map[uint64]uint64{}, CharacterUnique: map[uint64]uint64{}}
|
||||||
|
rows, err := db.Query("SELECT groupId,id,ProtoBuf FROM CostumeNodeTable ORDER BY groupId,id")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for rows.Next() {
|
||||||
|
var groupID, id uint64
|
||||||
|
var proto []byte
|
||||||
|
if err := rows.Scan(&groupID, &id, &proto); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
counts, _ := packedInts(proto, 1)
|
||||||
|
ids, _ := packedInts(proto, 2)
|
||||||
|
types, _ := packedInts(proto, 3)
|
||||||
|
if len(counts) == 0 || len(counts) != len(ids) || len(counts) != len(types) {
|
||||||
|
rows.Close()
|
||||||
|
return nil, fmt.Errorf("gamedata: invalid costume potential costs %d/%d", groupID, id)
|
||||||
|
}
|
||||||
|
grade, _ := packedInts(proto, 17)
|
||||||
|
conditions, _ := packedInts(proto, 18)
|
||||||
|
node := CostumePotentialNode{ID: id, Prerequisites: append([]uint64(nil), conditions...)}
|
||||||
|
if len(grade) == 1 {
|
||||||
|
node.ConditionGrade = grade[0]
|
||||||
|
} else if len(grade) > 1 {
|
||||||
|
rows.Close()
|
||||||
|
return nil, fmt.Errorf("gamedata: invalid costume potential grade %d/%d", groupID, id)
|
||||||
|
}
|
||||||
|
for i := range counts {
|
||||||
|
if counts[i] == 0 || types[i] == 0 || (types[i] != 4 && ids[i] == 0) {
|
||||||
|
rows.Close()
|
||||||
|
return nil, fmt.Errorf("gamedata: invalid costume potential cost %d/%d", groupID, id)
|
||||||
|
}
|
||||||
|
node.Costs = append(node.Costs, CostumePotentialCost{Type: types[i], ID: ids[i], Count: counts[i]})
|
||||||
|
}
|
||||||
|
if d.Nodes[groupID] == nil {
|
||||||
|
d.Nodes[groupID] = map[uint64]CostumePotentialNode{}
|
||||||
|
}
|
||||||
|
d.Nodes[groupID][id] = node
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rows, err = db.Query("SELECT id,ProtoBuf FROM CostumeTable")
|
||||||
|
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
|
||||||
|
}
|
||||||
|
unique, _ := packedInts(proto, 27)
|
||||||
|
if len(unique) == 1 {
|
||||||
|
d.CostumeUnique[id] = unique[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rows, err = db.Query("SELECT id,ProtoBuf FROM CharTable")
|
||||||
|
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, 10)
|
||||||
|
unique, _ := packedInts(proto, 20)
|
||||||
|
if len(grade) == 1 && len(unique) == 1 {
|
||||||
|
d.CharacterGrade[id] = grade[0]
|
||||||
|
d.CharacterUnique[id] = unique[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(d.Nodes) == 0 {
|
||||||
|
return nil, fmt.Errorf("gamedata: CostumeNodeTable is empty")
|
||||||
|
}
|
||||||
|
return d, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *CostumePotentialDesign) Validate(costumeID, characterID, grade uint64, active, requested []uint64) ([]CostumePotentialCost, error) {
|
||||||
|
if d == nil || costumeID == 0 || characterID == 0 || len(requested) == 0 {
|
||||||
|
return nil, fmt.Errorf("gamedata: invalid costume potential request")
|
||||||
|
}
|
||||||
|
if d.CostumeUnique[costumeID] == 0 || d.CostumeUnique[costumeID] != d.CharacterUnique[characterID] {
|
||||||
|
return nil, fmt.Errorf("gamedata: costume %d does not belong to character %d", costumeID, characterID)
|
||||||
|
}
|
||||||
|
if grade == 0 {
|
||||||
|
grade = d.CharacterGrade[characterID]
|
||||||
|
}
|
||||||
|
known := make(map[uint64]bool, len(active)+len(requested))
|
||||||
|
for _, id := range active {
|
||||||
|
known[id] = true
|
||||||
|
}
|
||||||
|
for _, id := range requested {
|
||||||
|
if id == 0 || known[id] {
|
||||||
|
return nil, fmt.Errorf("gamedata: duplicate costume potential node %d", id)
|
||||||
|
}
|
||||||
|
known[id] = true
|
||||||
|
}
|
||||||
|
var costs []CostumePotentialCost
|
||||||
|
for _, id := range requested {
|
||||||
|
node, exists := d.Nodes[costumeID][id]
|
||||||
|
if !exists {
|
||||||
|
return nil, fmt.Errorf("gamedata: costume %d has no potential node %d", costumeID, id)
|
||||||
|
}
|
||||||
|
if node.ConditionGrade > grade {
|
||||||
|
return nil, fmt.Errorf("gamedata: potential node %d requires growth grade %d", id, node.ConditionGrade)
|
||||||
|
}
|
||||||
|
for _, prerequisite := range node.Prerequisites {
|
||||||
|
if !known[prerequisite] {
|
||||||
|
return nil, fmt.Errorf("gamedata: potential node %d requires node %d", id, prerequisite)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
costs = append(costs, node.Costs...)
|
||||||
|
}
|
||||||
|
return costs, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package gamedata
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCostumePotentialValidatePrerequisitesAndAggregate(t *testing.T) {
|
||||||
|
d := &CostumePotentialDesign{
|
||||||
|
Nodes: map[uint64]map[uint64]CostumePotentialNode{100: {
|
||||||
|
1: {ID: 1, Costs: []CostumePotentialCost{{Type: 4, Count: 10}}},
|
||||||
|
2: {ID: 2, ConditionGrade: 2, Prerequisites: []uint64{1}, Costs: []CostumePotentialCost{{Type: 8, ID: 7, Count: 3}}},
|
||||||
|
}},
|
||||||
|
CostumeUnique: map[uint64]uint64{100: 9}, CharacterGrade: map[uint64]uint64{200: 2}, CharacterUnique: map[uint64]uint64{200: 9},
|
||||||
|
}
|
||||||
|
costs, err := d.Validate(100, 200, 0, nil, []uint64{1, 2})
|
||||||
|
if err != nil || len(costs) != 2 {
|
||||||
|
t.Fatalf("valid chain costs=%+v err=%v", costs, err)
|
||||||
|
}
|
||||||
|
if _, err := d.Validate(100, 200, 0, nil, []uint64{2}); err == nil {
|
||||||
|
t.Fatal("missing prerequisite accepted")
|
||||||
|
}
|
||||||
|
if _, err := d.Validate(100, 201, 0, nil, []uint64{1}); err == nil {
|
||||||
|
t.Fatal("wrong character accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCostumePotentialAgainstInstalledVersion23413(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 := LoadCostumePotentialDesign(root, "20260910162539")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
nodes := make([]uint64, 0, len(design.Nodes[65103]))
|
||||||
|
for id := range design.Nodes[65103] {
|
||||||
|
nodes = append(nodes, id)
|
||||||
|
}
|
||||||
|
costs, err := design.Validate(65103, 6514, 0, nil, nodes)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(nodes) != 17 || len(costs) < 17 {
|
||||||
|
t.Fatalf("installed costume 65103 nodes=%d costs=%d", len(nodes), len(costs))
|
||||||
|
}
|
||||||
|
questCosts, err := design.Validate(3501, 354, 0, nil, []uint64{1})
|
||||||
|
if err != nil || len(questCosts) != 1 || questCosts[0].Type != 4 || questCosts[0].Count == 0 {
|
||||||
|
t.Fatalf("installed quest reward costume 3501 first node costs=%+v err=%v", questCosts, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,7 +34,7 @@ func ReadDatabase(root, version, logical string) ([]byte, error) {
|
|||||||
|
|
||||||
// questDatabaseEntry is the member in the 2.34.13 GameData archive that
|
// questDatabaseEntry is the member in the 2.34.13 GameData archive that
|
||||||
// contains the shared QuestTable* SQLite database, including QuestTable21.
|
// contains the shared QuestTable* SQLite database, including QuestTable21.
|
||||||
// Quest data is not stored in a per-map "pack21" database.
|
// Quest data is not stored in an individual per-pack database.
|
||||||
const questDatabaseEntry = "9F251C63BC72551C681EE75D328FA090D56E444B"
|
const questDatabaseEntry = "9F251C63BC72551C681EE75D328FA090D56E444B"
|
||||||
|
|
||||||
// ReadQuestDatabase extracts the shared quest database. The member name is
|
// ReadQuestDatabase extracts the shared quest database. The member name is
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package gamedata
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LoadEquipmentSlots reads EquipmentTable.SlotType for every installed item.
|
||||||
|
// SlotType is a proto3 enum, so an absent field is the valid first slot (0).
|
||||||
|
func LoadEquipmentSlots(root, version string) (map[uint64]uint64, error) {
|
||||||
|
plain, err := ReadQuestDatabase(root, version)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
dir, err := os.MkdirTemp("", "bd2-equipment-slots-")
|
||||||
|
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 loadEquipmentSlots(db)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadEquipmentSlots(db *sql.DB) (map[uint64]uint64, error) {
|
||||||
|
rows, err := db.Query("SELECT id,ProtoBuf FROM EquipmentTable")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
slots := make(map[uint64]uint64)
|
||||||
|
for rows.Next() {
|
||||||
|
var id uint64
|
||||||
|
var proto []byte
|
||||||
|
if err := rows.Scan(&id, &proto); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
values, err := packedInts(proto, 20)
|
||||||
|
if err != nil || len(values) > 1 {
|
||||||
|
return nil, fmt.Errorf("gamedata: equipment %d malformed slot: %v", id, err)
|
||||||
|
}
|
||||||
|
var slot uint64
|
||||||
|
if len(values) == 1 {
|
||||||
|
slot = values[0]
|
||||||
|
}
|
||||||
|
if slot > 4 {
|
||||||
|
return nil, fmt.Errorf("gamedata: equipment %d invalid slot %d", id, slot)
|
||||||
|
}
|
||||||
|
slots[id] = slot
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(slots) == 0 {
|
||||||
|
return nil, fmt.Errorf("gamedata: EquipmentTable is empty")
|
||||||
|
}
|
||||||
|
return slots, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package gamedata
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLoadEquipmentSlotsKeepsProtoDefaultSlotZero(t *testing.T) {
|
||||||
|
db, err := sql.Open("sqlite", ":memory:")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
if _, err := db.Exec("CREATE TABLE EquipmentTable (id INTEGER PRIMARY KEY, ProtoBuf BLOB)"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := db.Exec("INSERT INTO EquipmentTable VALUES (?,?),(?,?)", 10010, []byte{}, 943619, testVarintField(nil, 20, 4)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
slots, err := loadEquipmentSlots(db)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(slots) != 2 || slots[10010] != 0 || slots[943619] != 4 {
|
||||||
|
t.Fatalf("slots=%v", slots)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
package gamedata
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
type EquipmentUpgradeLevel struct {
|
||||||
|
Level, GrowthPoint uint64
|
||||||
|
Costs []PromotionCost
|
||||||
|
SuccessRatio float64
|
||||||
|
}
|
||||||
|
|
||||||
|
type EquipmentUpgradeDesign struct {
|
||||||
|
MaxLevel map[uint64]uint64
|
||||||
|
Group map[uint64]uint64
|
||||||
|
RankGroup map[uint64]uint64
|
||||||
|
Levels map[[2]uint64]EquipmentUpgradeLevel
|
||||||
|
RankRatio map[[2]uint64][]float64
|
||||||
|
roll func(float64) (bool, error)
|
||||||
|
rankRoll func([]float64) (uint64, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadEquipmentUpgradeDesign(root, version string) (*EquipmentUpgradeDesign, error) {
|
||||||
|
plain, err := ReadQuestDatabase(root, version)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
dir, err := os.MkdirTemp("", "bd2-equipment-upgrade-")
|
||||||
|
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 loadEquipmentUpgradeDesign(db)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadEquipmentUpgradeDesign(db *sql.DB) (*EquipmentUpgradeDesign, error) {
|
||||||
|
d := &EquipmentUpgradeDesign{
|
||||||
|
MaxLevel: map[uint64]uint64{}, Group: map[uint64]uint64{}, RankGroup: map[uint64]uint64{},
|
||||||
|
Levels: map[[2]uint64]EquipmentUpgradeLevel{}, RankRatio: map[[2]uint64][]float64{},
|
||||||
|
roll: cryptoRatioRoll, rankRoll: cryptoRankRoll,
|
||||||
|
}
|
||||||
|
groupMaximum := map[uint64]uint64{}
|
||||||
|
rankGroups := map[uint64]bool{}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
groups, _ := packedInts(proto, 4)
|
||||||
|
maximum, _ := packedInts(proto, 13)
|
||||||
|
rankGroup, _ := packedInts(proto, 19)
|
||||||
|
if len(groups) != 1 || len(maximum) != 1 || len(rankGroup) != 1 || maximum[0] == 0 || rankGroup[0] == 0 {
|
||||||
|
rows.Close()
|
||||||
|
return nil, fmt.Errorf("gamedata: equipment %d invalid upgrade design", id)
|
||||||
|
}
|
||||||
|
d.Group[id], d.MaxLevel[id], d.RankGroup[id] = groups[0], maximum[0], rankGroup[0]
|
||||||
|
rankGroups[rankGroup[0]] = true
|
||||||
|
if prior, found := groupMaximum[groups[0]]; found && prior != maximum[0] {
|
||||||
|
rows.Close()
|
||||||
|
return nil, fmt.Errorf("gamedata: equipment growth group %d has inconsistent maxima", groups[0])
|
||||||
|
}
|
||||||
|
groupMaximum[groups[0]] = maximum[0]
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rows, err = db.Query("SELECT groupId,id,ProtoBuf FROM EquipmentGrowthTable")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for rows.Next() {
|
||||||
|
var group, level uint64
|
||||||
|
var proto []byte
|
||||||
|
if err := rows.Scan(&group, &level, &proto); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
maximum, referenced := groupMaximum[group]
|
||||||
|
if !referenced {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// EquipmentGrowthTable includes the terminal maximum-level row, which
|
||||||
|
// intentionally has no next-upgrade cost or success ratio.
|
||||||
|
if level >= maximum {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
counts, _ := packedInts(proto, 7)
|
||||||
|
ids, _ := packedInts(proto, 8)
|
||||||
|
types, _ := packedInts(proto, 9)
|
||||||
|
point, _ := packedInts(proto, 5)
|
||||||
|
ratio, present, err := fixed64Double(proto, 10)
|
||||||
|
if err != nil || !present || ratio < 0 || ratio > 1 || len(point) != 1 || len(counts) == 0 || len(counts) != len(ids) || len(counts) != len(types) {
|
||||||
|
rows.Close()
|
||||||
|
return nil, fmt.Errorf("gamedata: equipment growth %d/%d malformed", group, level)
|
||||||
|
}
|
||||||
|
entry := EquipmentUpgradeLevel{Level: level, GrowthPoint: point[0], SuccessRatio: ratio}
|
||||||
|
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 growth %d/%d invalid cost", group, level)
|
||||||
|
}
|
||||||
|
entry.Costs = append(entry.Costs, PromotionCost{Type: types[i], ID: ids[i], Count: counts[i]})
|
||||||
|
}
|
||||||
|
d.Levels[[2]uint64{group, level}] = entry
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
if !rankGroups[group] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ratios, err := fixed32Floats(proto, 4)
|
||||||
|
if err != nil || slot < 1 || slot > 3 || len(ratios) != 4 {
|
||||||
|
rows.Close()
|
||||||
|
return nil, fmt.Errorf("gamedata: equipment rank %d/%d malformed", group, slot)
|
||||||
|
}
|
||||||
|
var total float64
|
||||||
|
for _, ratio := range ratios {
|
||||||
|
if ratio < 0 || ratio > 1 || math.IsNaN(ratio) {
|
||||||
|
rows.Close()
|
||||||
|
return nil, fmt.Errorf("gamedata: equipment rank %d/%d invalid ratio", group, slot)
|
||||||
|
}
|
||||||
|
total += ratio
|
||||||
|
}
|
||||||
|
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.RankRatio[[2]uint64{group, slot}] = append([]float64(nil), ratios...)
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for group := range rankGroups {
|
||||||
|
for slot := uint64(1); slot <= 3; slot++ {
|
||||||
|
if len(d.RankRatio[[2]uint64{group, slot}]) != 4 {
|
||||||
|
return nil, fmt.Errorf("gamedata: missing equipment rank %d/%d", group, slot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return d, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *EquipmentUpgradeDesign) Level(equipmentID, level uint64) (EquipmentUpgradeLevel, uint64, error) {
|
||||||
|
if d == nil || equipmentID == 0 {
|
||||||
|
return EquipmentUpgradeLevel{}, 0, fmt.Errorf("gamedata: invalid equipment upgrade request")
|
||||||
|
}
|
||||||
|
maximum, exists := d.MaxLevel[equipmentID]
|
||||||
|
if !exists {
|
||||||
|
return EquipmentUpgradeLevel{}, 0, fmt.Errorf("gamedata: unknown equipment %d", equipmentID)
|
||||||
|
}
|
||||||
|
if level >= maximum {
|
||||||
|
return EquipmentUpgradeLevel{}, maximum, fmt.Errorf("gamedata: equipment %d already at maximum level %d", equipmentID, maximum)
|
||||||
|
}
|
||||||
|
entry, exists := d.Levels[[2]uint64{d.Group[equipmentID], level}]
|
||||||
|
if !exists {
|
||||||
|
return EquipmentUpgradeLevel{}, maximum, fmt.Errorf("gamedata: missing equipment growth %d/%d", d.Group[equipmentID], level)
|
||||||
|
}
|
||||||
|
return entry, maximum, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *EquipmentUpgradeDesign) Roll(ratio float64) (bool, error) {
|
||||||
|
if d == nil {
|
||||||
|
return false, fmt.Errorf("gamedata: equipment upgrade roller unavailable")
|
||||||
|
}
|
||||||
|
if d.roll == nil {
|
||||||
|
return cryptoRatioRoll(ratio)
|
||||||
|
}
|
||||||
|
return d.roll(ratio)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RollRank returns the official C/B/A/S grade number (1..4) for the slot
|
||||||
|
// unlocked at +3, +6 or +9. The distributions come from EquipmentRankTable.
|
||||||
|
func (d *EquipmentUpgradeDesign) RollRank(equipmentID, slot uint64) (uint64, error) {
|
||||||
|
if d == nil || slot < 1 || slot > 3 {
|
||||||
|
return 0, fmt.Errorf("gamedata: invalid equipment rank request")
|
||||||
|
}
|
||||||
|
group, exists := d.RankGroup[equipmentID]
|
||||||
|
if !exists {
|
||||||
|
return 0, fmt.Errorf("gamedata: unknown equipment rank design %d", equipmentID)
|
||||||
|
}
|
||||||
|
ratios := d.RankRatio[[2]uint64{group, slot}]
|
||||||
|
if len(ratios) != 4 {
|
||||||
|
return 0, fmt.Errorf("gamedata: missing equipment rank %d/%d", group, slot)
|
||||||
|
}
|
||||||
|
if d.rankRoll != nil {
|
||||||
|
return d.rankRoll(ratios)
|
||||||
|
}
|
||||||
|
return cryptoRankRoll(ratios)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cryptoRatioRoll(ratio float64) (bool, error) {
|
||||||
|
if ratio <= 0 {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if ratio >= 1 {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
value, err := cryptoUnitFloat()
|
||||||
|
return value < ratio && !math.IsNaN(ratio), err
|
||||||
|
}
|
||||||
|
|
||||||
|
func cryptoRankRoll(ratios []float64) (uint64, error) {
|
||||||
|
if len(ratios) != 4 {
|
||||||
|
return 0, fmt.Errorf("gamedata: invalid equipment rank ratios")
|
||||||
|
}
|
||||||
|
value, err := cryptoUnitFloat()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
var cumulative float64
|
||||||
|
for i, ratio := range ratios {
|
||||||
|
cumulative += ratio
|
||||||
|
if value < cumulative || i == len(ratios)-1 {
|
||||||
|
return uint64(i + 1), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, fmt.Errorf("gamedata: equipment rank roll failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
func cryptoUnitFloat() (float64, error) {
|
||||||
|
var raw [8]byte
|
||||||
|
if _, err := rand.Read(raw[:]); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
value := binary.LittleEndian.Uint64(raw[:]) >> 11
|
||||||
|
return float64(value) / float64(uint64(1)<<53), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package gamedata
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/binary"
|
||||||
|
"math"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"bd2server/internal/wire"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEquipmentUpgradeDesignReadsCostsAndRatio(t *testing.T) {
|
||||||
|
db, err := sql.Open("sqlite", ":memory:")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
if _, err := db.Exec("CREATE TABLE EquipmentTable (id INTEGER PRIMARY KEY, ProtoBuf BLOB); CREATE TABLE EquipmentGrowthTable (groupId INTEGER,id INTEGER,ProtoBuf BLOB,PRIMARY KEY(groupId,id)); CREATE TABLE EquipmentRankTable (groupId INTEGER,id INTEGER,ProtoBuf BLOB,PRIMARY KEY(groupId,id))"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
equipment := testVarintField(testVarintField(testVarintField(nil, 4, 954), 13, 9), 19, 904)
|
||||||
|
growth := testVarintField(testVarintField(nil, 4, 954), 5, 670)
|
||||||
|
growth = testPackedField(growth, 7, 960)
|
||||||
|
growth = testPackedField(growth, 8, 0)
|
||||||
|
growth = testPackedField(growth, 9, 4)
|
||||||
|
growth = wire.AppendDouble(growth, 10, 0.7)
|
||||||
|
if _, err := db.Exec("INSERT INTO EquipmentTable VALUES (?,?)", 943035, equipment); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := db.Exec("INSERT INTO EquipmentGrowthTable VALUES (?,?,?)", 954, 0, growth); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var ratio []byte
|
||||||
|
for _, value := range []float32{1, 0, 0, 0} {
|
||||||
|
var raw [4]byte
|
||||||
|
binary.LittleEndian.PutUint32(raw[:], math.Float32bits(value))
|
||||||
|
ratio = append(ratio, raw[:]...)
|
||||||
|
}
|
||||||
|
for slot := 1; slot <= 3; slot++ {
|
||||||
|
if _, err := db.Exec("INSERT INTO EquipmentRankTable VALUES (?,?,?)", 904, slot, wire.AppendBytes(nil, 4, ratio)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
design, err := loadEquipmentUpgradeDesign(db)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
level, maximum, err := design.Level(943035, 0)
|
||||||
|
if err != nil || maximum != 9 || level.GrowthPoint != 670 || len(level.Costs) != 1 || level.Costs[0] != (PromotionCost{Type: 4, Count: 960}) || math.Abs(level.SuccessRatio-0.7) > 1e-12 {
|
||||||
|
t.Fatalf("level=%+v maximum=%d err=%v", level, maximum, err)
|
||||||
|
}
|
||||||
|
if rank, err := design.RollRank(943035, 1); err != nil || rank != 1 {
|
||||||
|
t.Fatalf("rank=%d err=%v", rank, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEquipmentUpgradeAgainstInstalledVersion23413(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 := LoadEquipmentUpgradeDesign(root, "20260910162539")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
level, maximum, err := design.Level(943035, 0)
|
||||||
|
if err != nil || maximum != 9 || len(level.Costs) != 1 || level.Costs[0] != (PromotionCost{Type: 4, Count: 960}) || level.SuccessRatio != 1 {
|
||||||
|
t.Fatalf("installed level=%+v maximum=%d err=%v", level, maximum, err)
|
||||||
|
}
|
||||||
|
ratios := design.RankRatio[[2]uint64{904, 1}]
|
||||||
|
if design.RankGroup[943035] != 904 || len(ratios) != 4 || math.Abs(ratios[0]-0.45) > 1e-6 || math.Abs(ratios[3]-0.01) > 1e-6 {
|
||||||
|
t.Fatalf("installed rank group=%d ratios=%v", design.RankGroup[943035], ratios)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,353 @@
|
|||||||
|
package gamedata
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PromotionCost is one exact CharGrowthTable.ClassupItem{Type,Id,Count} row.
|
||||||
|
// Currency (type 4) has no inventory index; the other rows are owned items.
|
||||||
|
type PromotionCost struct{ Type, ID, Count uint64 }
|
||||||
|
|
||||||
|
// PromotionGrowthResult is the authoritative result of one CharGrowth request
|
||||||
|
// that can cross one or more class-up boundaries. Costs contains the exact
|
||||||
|
// cumulative class-up costs selected by the request's aggregate gold amount.
|
||||||
|
type PromotionGrowthResult struct {
|
||||||
|
CharacterID uint64
|
||||||
|
Level uint64
|
||||||
|
Exp uint64
|
||||||
|
Costs []PromotionCost
|
||||||
|
Refunds []GrowthMaterial
|
||||||
|
}
|
||||||
|
|
||||||
|
// CharacterPromotion reads the installed character and growth tables. At a
|
||||||
|
// stage cap the client sends its class-up materials through CharGrowth, then
|
||||||
|
// detects a promotion by comparing the response character ID to the old ID.
|
||||||
|
func CharacterPromotion(root, version string, charID int, level, exp uint64) (uint64, []PromotionCost, error) {
|
||||||
|
plain, err := ReadQuestDatabase(root, version)
|
||||||
|
if err != nil {
|
||||||
|
return 0, nil, err
|
||||||
|
}
|
||||||
|
dir, err := os.MkdirTemp("", "bd2-promotion-")
|
||||||
|
if err != nil {
|
||||||
|
return 0, nil, err
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(dir)
|
||||||
|
path := filepath.Join(dir, "common.db")
|
||||||
|
if err := os.WriteFile(path, plain, 0o600); err != nil {
|
||||||
|
return 0, nil, err
|
||||||
|
}
|
||||||
|
db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro")
|
||||||
|
if err != nil {
|
||||||
|
return 0, nil, err
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
return characterPromotion(db, charID, level, exp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CharacterGrowthPromotions applies a client's combined class-up/growth
|
||||||
|
// request. The UI aggregates gold and class-up resources for every boundary
|
||||||
|
// crossed by the selected target level, while ordinary EXP resources remain
|
||||||
|
// in the same request. Gold therefore identifies an exact prefix of the
|
||||||
|
// CharTable.NextCharId chain; each prefix cost is then subtracted from the
|
||||||
|
// submitted resources and only the remainder contributes EXP.
|
||||||
|
func CharacterGrowthPromotions(root, version string, charID int, level, exp uint64, submitted []PromotionCost) (PromotionGrowthResult, error) {
|
||||||
|
plain, err := ReadQuestDatabase(root, version)
|
||||||
|
if err != nil {
|
||||||
|
return PromotionGrowthResult{}, err
|
||||||
|
}
|
||||||
|
dir, err := os.MkdirTemp("", "bd2-promotion-growth-")
|
||||||
|
if err != nil {
|
||||||
|
return PromotionGrowthResult{}, err
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(dir)
|
||||||
|
path := filepath.Join(dir, "common.db")
|
||||||
|
if err := os.WriteFile(path, plain, 0o600); err != nil {
|
||||||
|
return PromotionGrowthResult{}, err
|
||||||
|
}
|
||||||
|
db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro")
|
||||||
|
if err != nil {
|
||||||
|
return PromotionGrowthResult{}, err
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
return characterGrowthPromotions(db, charID, level, exp, submitted)
|
||||||
|
}
|
||||||
|
|
||||||
|
type promotionStage struct {
|
||||||
|
fromID, nextID uint64
|
||||||
|
maximum uint64
|
||||||
|
costs []PromotionCost
|
||||||
|
}
|
||||||
|
|
||||||
|
func characterGrowthPromotions(db *sql.DB, charID int, level, exp uint64, submitted []PromotionCost) (PromotionGrowthResult, error) {
|
||||||
|
if charID <= 0 || level == 0 || len(submitted) == 0 {
|
||||||
|
return PromotionGrowthResult{}, fmt.Errorf("gamedata: invalid combined promotion input")
|
||||||
|
}
|
||||||
|
requested := make(map[[2]uint64]uint64, len(submitted))
|
||||||
|
var requestedGold uint64
|
||||||
|
for _, cost := range submitted {
|
||||||
|
if cost.Count == 0 || (cost.Type != 4 && (cost.Type != 8 || cost.ID == 0)) {
|
||||||
|
return PromotionGrowthResult{}, fmt.Errorf("gamedata: invalid submitted promotion material")
|
||||||
|
}
|
||||||
|
key := [2]uint64{cost.Type, cost.ID}
|
||||||
|
if cost.Count > ^uint64(0)-requested[key] {
|
||||||
|
return PromotionGrowthResult{}, fmt.Errorf("gamedata: submitted promotion material overflow")
|
||||||
|
}
|
||||||
|
requested[key] += cost.Count
|
||||||
|
if cost.Type == 4 {
|
||||||
|
if cost.ID != 0 || requestedGold != 0 {
|
||||||
|
return PromotionGrowthResult{}, fmt.Errorf("gamedata: invalid submitted promotion currency")
|
||||||
|
}
|
||||||
|
requestedGold = cost.Count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if requestedGold == 0 {
|
||||||
|
return PromotionGrowthResult{}, fmt.Errorf("gamedata: combined promotion has no gold")
|
||||||
|
}
|
||||||
|
|
||||||
|
currentID := uint64(charID)
|
||||||
|
var stages []promotionStage
|
||||||
|
var cumulative []PromotionCost
|
||||||
|
var cumulativeGold uint64
|
||||||
|
for len(stages) < 32 {
|
||||||
|
nextID, costs, maximum, err := promotionDefinition(db, currentID)
|
||||||
|
if err != nil {
|
||||||
|
return PromotionGrowthResult{}, err
|
||||||
|
}
|
||||||
|
stages = append(stages, promotionStage{fromID: currentID, nextID: nextID, maximum: maximum, costs: costs})
|
||||||
|
cumulative = append(cumulative, costs...)
|
||||||
|
for _, cost := range costs {
|
||||||
|
if cost.Type == 4 {
|
||||||
|
if cost.Count > ^uint64(0)-cumulativeGold {
|
||||||
|
return PromotionGrowthResult{}, fmt.Errorf("gamedata: cumulative promotion gold overflow")
|
||||||
|
}
|
||||||
|
cumulativeGold += cost.Count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cumulativeGold == requestedGold {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if cumulativeGold > requestedGold {
|
||||||
|
return PromotionGrowthResult{}, fmt.Errorf("gamedata: submitted gold %d does not match a promotion prefix", requestedGold)
|
||||||
|
}
|
||||||
|
currentID = nextID
|
||||||
|
}
|
||||||
|
if cumulativeGold != requestedGold {
|
||||||
|
return PromotionGrowthResult{}, fmt.Errorf("gamedata: submitted gold %d exceeds the promotion chain", requestedGold)
|
||||||
|
}
|
||||||
|
|
||||||
|
remaining := make(map[[2]uint64]uint64, len(requested))
|
||||||
|
for key, count := range requested {
|
||||||
|
remaining[key] = count
|
||||||
|
}
|
||||||
|
for _, cost := range cumulative {
|
||||||
|
key := [2]uint64{cost.Type, cost.ID}
|
||||||
|
if remaining[key] < cost.Count {
|
||||||
|
return PromotionGrowthResult{}, fmt.Errorf("gamedata: missing cumulative promotion cost %d/%d x%d", cost.Type, cost.ID, cost.Count-remaining[key])
|
||||||
|
}
|
||||||
|
remaining[key] -= cost.Count
|
||||||
|
}
|
||||||
|
if remaining[[2]uint64{4, 0}] != 0 {
|
||||||
|
return PromotionGrowthResult{}, fmt.Errorf("gamedata: unexpected promotion gold remainder")
|
||||||
|
}
|
||||||
|
|
||||||
|
var gained uint64
|
||||||
|
for key, count := range remaining {
|
||||||
|
if count == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if key[0] != 8 || key[1] == 0 {
|
||||||
|
return PromotionGrowthResult{}, fmt.Errorf("gamedata: unexpected combined promotion material %d/%d x%d", key[0], key[1], count)
|
||||||
|
}
|
||||||
|
var proto []byte
|
||||||
|
if err := db.QueryRow("SELECT ProtoBuf FROM ResourceTable WHERE id=?", key[1]).Scan(&proto); err != nil {
|
||||||
|
return PromotionGrowthResult{}, fmt.Errorf("gamedata: promotion growth resource %d: %w", key[1], err)
|
||||||
|
}
|
||||||
|
values, err := packedInts(proto, 9)
|
||||||
|
if err != nil || len(values) != 1 || values[0] == 0 {
|
||||||
|
return PromotionGrowthResult{}, fmt.Errorf("gamedata: resource %d is not an experience material", key[1])
|
||||||
|
}
|
||||||
|
if count > (^uint64(0)-gained)/values[0] {
|
||||||
|
return PromotionGrowthResult{}, fmt.Errorf("gamedata: combined promotion experience overflow")
|
||||||
|
}
|
||||||
|
gained += count * values[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
currentID = uint64(charID)
|
||||||
|
currentLevel := level
|
||||||
|
currentExp := exp
|
||||||
|
for _, stage := range stages {
|
||||||
|
if currentID != stage.fromID {
|
||||||
|
return PromotionGrowthResult{}, fmt.Errorf("gamedata: invalid promotion stage sequence")
|
||||||
|
}
|
||||||
|
definition, err := growthDefinition(db, currentID)
|
||||||
|
if err != nil {
|
||||||
|
return PromotionGrowthResult{}, err
|
||||||
|
}
|
||||||
|
currentLevel, currentExp, gained, err = applyGrowthExperience(db, definition, currentLevel, currentExp, gained)
|
||||||
|
if err != nil {
|
||||||
|
return PromotionGrowthResult{}, err
|
||||||
|
}
|
||||||
|
if currentLevel != stage.maximum {
|
||||||
|
return PromotionGrowthResult{}, fmt.Errorf("gamedata: insufficient experience to reach promotion cap %d", definition.maximum)
|
||||||
|
}
|
||||||
|
currentID = stage.nextID
|
||||||
|
}
|
||||||
|
finalDefinition, err := growthDefinition(db, currentID)
|
||||||
|
if err != nil {
|
||||||
|
return PromotionGrowthResult{}, err
|
||||||
|
}
|
||||||
|
currentLevel, currentExp, gained, err = applyGrowthExperience(db, finalDefinition, currentLevel, currentExp, gained)
|
||||||
|
if err != nil {
|
||||||
|
return PromotionGrowthResult{}, err
|
||||||
|
}
|
||||||
|
refunds, err := refundGrowthResources(db, gained)
|
||||||
|
if err != nil {
|
||||||
|
return PromotionGrowthResult{}, err
|
||||||
|
}
|
||||||
|
return PromotionGrowthResult{CharacterID: currentID, Level: currentLevel, Exp: currentExp, Costs: cumulative, Refunds: refunds}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type characterGrowthDefinition struct {
|
||||||
|
group, maximum uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
func growthDefinition(db *sql.DB, charID uint64) (characterGrowthDefinition, error) {
|
||||||
|
var charProto []byte
|
||||||
|
if err := db.QueryRow("SELECT ProtoBuf FROM CharTable WHERE id=?", charID).Scan(&charProto); err != nil {
|
||||||
|
return characterGrowthDefinition{}, fmt.Errorf("gamedata: character %d: %w", charID, err)
|
||||||
|
}
|
||||||
|
growthIDs, _ := packedInts(charProto, 1)
|
||||||
|
if len(growthIDs) != 1 {
|
||||||
|
return characterGrowthDefinition{}, fmt.Errorf("gamedata: character %d has no growth definition", charID)
|
||||||
|
}
|
||||||
|
var growthProto []byte
|
||||||
|
if err := db.QueryRow("SELECT ProtoBuf FROM CharGrowthTable WHERE id=?", growthIDs[0]).Scan(&growthProto); err != nil {
|
||||||
|
return characterGrowthDefinition{}, err
|
||||||
|
}
|
||||||
|
groups, _ := packedInts(growthProto, 1)
|
||||||
|
maximum, _ := packedInts(growthProto, 9)
|
||||||
|
if len(groups) != 1 || len(maximum) != 1 {
|
||||||
|
return characterGrowthDefinition{}, fmt.Errorf("gamedata: invalid growth definition %d", growthIDs[0])
|
||||||
|
}
|
||||||
|
return characterGrowthDefinition{group: groups[0], maximum: maximum[0]}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func promotionDefinition(db *sql.DB, charID uint64) (uint64, []PromotionCost, uint64, error) {
|
||||||
|
definition, err := growthDefinition(db, charID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, nil, 0, err
|
||||||
|
}
|
||||||
|
var charProto []byte
|
||||||
|
if err := db.QueryRow("SELECT ProtoBuf FROM CharTable WHERE id=?", charID).Scan(&charProto); err != nil {
|
||||||
|
return 0, nil, 0, err
|
||||||
|
}
|
||||||
|
next, _ := packedInts(charProto, 15)
|
||||||
|
if len(next) != 1 || next[0] == 0 {
|
||||||
|
return 0, nil, 0, fmt.Errorf("gamedata: character %d has no next stage", charID)
|
||||||
|
}
|
||||||
|
growthIDs, _ := packedInts(charProto, 1)
|
||||||
|
var growthProto []byte
|
||||||
|
if err := db.QueryRow("SELECT ProtoBuf FROM CharGrowthTable WHERE id=?", growthIDs[0]).Scan(&growthProto); err != nil {
|
||||||
|
return 0, nil, 0, err
|
||||||
|
}
|
||||||
|
types, _ := packedInts(growthProto, 4)
|
||||||
|
ids, _ := packedInts(growthProto, 3)
|
||||||
|
counts, _ := packedInts(growthProto, 2)
|
||||||
|
if len(types) == 0 || len(types) != len(ids) || len(types) != len(counts) {
|
||||||
|
return 0, nil, 0, fmt.Errorf("gamedata: invalid promotion costs for character %d", charID)
|
||||||
|
}
|
||||||
|
costs := make([]PromotionCost, len(types))
|
||||||
|
for i := range costs {
|
||||||
|
costs[i] = PromotionCost{Type: types[i], ID: ids[i], Count: counts[i]}
|
||||||
|
}
|
||||||
|
return next[0], costs, definition.maximum, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyGrowthExperience(db *sql.DB, definition characterGrowthDefinition, level, exp, gained uint64) (uint64, uint64, uint64, error) {
|
||||||
|
if level > definition.maximum {
|
||||||
|
return 0, 0, 0, fmt.Errorf("gamedata: level %d exceeds growth maximum %d", level, definition.maximum)
|
||||||
|
}
|
||||||
|
for gained > 0 && level < definition.maximum {
|
||||||
|
var proto []byte
|
||||||
|
if err := db.QueryRow("SELECT ProtoBuf FROM CharLevelTable WHERE GroupId=? AND id=?", definition.group, level).Scan(&proto); err != nil {
|
||||||
|
return 0, 0, 0, err
|
||||||
|
}
|
||||||
|
required, _ := packedInts(proto, 8)
|
||||||
|
if len(required) != 1 || required[0] == 0 || exp >= required[0] {
|
||||||
|
return 0, 0, 0, fmt.Errorf("gamedata: invalid level curve group %d level %d", definition.group, level)
|
||||||
|
}
|
||||||
|
need := required[0] - exp
|
||||||
|
if gained < need {
|
||||||
|
exp += gained
|
||||||
|
gained = 0
|
||||||
|
break
|
||||||
|
}
|
||||||
|
gained -= need
|
||||||
|
level++
|
||||||
|
exp = 0
|
||||||
|
}
|
||||||
|
if level == definition.maximum {
|
||||||
|
exp = 0
|
||||||
|
}
|
||||||
|
return level, exp, gained, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func characterPromotion(db *sql.DB, charID int, level, exp uint64) (uint64, []PromotionCost, error) {
|
||||||
|
if charID <= 0 || level == 0 {
|
||||||
|
return 0, nil, fmt.Errorf("gamedata: invalid promotion character or level")
|
||||||
|
}
|
||||||
|
var current []byte
|
||||||
|
if err := db.QueryRow("SELECT ProtoBuf FROM CharTable WHERE id=?", charID).Scan(¤t); err != nil {
|
||||||
|
return 0, nil, fmt.Errorf("gamedata: promotion character %d: %w", charID, err)
|
||||||
|
}
|
||||||
|
next, err := packedInts(current, 15)
|
||||||
|
if err != nil || len(next) != 1 || next[0] == 0 {
|
||||||
|
return 0, nil, fmt.Errorf("gamedata: character %d has no next stage", charID)
|
||||||
|
}
|
||||||
|
growthID, err := packedInts(current, 1)
|
||||||
|
if err != nil || len(growthID) != 1 {
|
||||||
|
return 0, nil, fmt.Errorf("gamedata: character %d has no growth definition", charID)
|
||||||
|
}
|
||||||
|
var growth []byte
|
||||||
|
if err := db.QueryRow("SELECT ProtoBuf FROM CharGrowthTable WHERE id=?", growthID[0]).Scan(&growth); err != nil {
|
||||||
|
return 0, nil, err
|
||||||
|
}
|
||||||
|
maximum, _ := packedInts(growth, 9)
|
||||||
|
group, _ := packedInts(growth, 1)
|
||||||
|
if len(maximum) != 1 || len(group) != 1 || level != maximum[0] {
|
||||||
|
return 0, nil, fmt.Errorf("gamedata: character %d has not reached its promotion level", charID)
|
||||||
|
}
|
||||||
|
var curve []byte
|
||||||
|
if err := db.QueryRow("SELECT ProtoBuf FROM CharLevelTable WHERE GroupId=? AND id=?", group[0], level).Scan(&curve); err != nil {
|
||||||
|
return 0, nil, err
|
||||||
|
}
|
||||||
|
required, _ := packedInts(curve, 8)
|
||||||
|
if len(required) != 0 && exp < required[0] {
|
||||||
|
return 0, nil, fmt.Errorf("gamedata: character %d has not reached its promotion experience", charID)
|
||||||
|
}
|
||||||
|
var nextProto []byte
|
||||||
|
if err := db.QueryRow("SELECT ProtoBuf FROM CharTable WHERE id=?", next[0]).Scan(&nextProto); err != nil {
|
||||||
|
return 0, nil, fmt.Errorf("gamedata: next character %d: %w", next[0], err)
|
||||||
|
}
|
||||||
|
nextGrowth, _ := packedInts(nextProto, 1)
|
||||||
|
if len(nextGrowth) != 1 || nextGrowth[0] == growthID[0] {
|
||||||
|
return 0, nil, fmt.Errorf("gamedata: invalid promotion %d -> %d", charID, next[0])
|
||||||
|
}
|
||||||
|
types, _ := packedInts(growth, 4)
|
||||||
|
ids, _ := packedInts(growth, 3)
|
||||||
|
counts, _ := packedInts(growth, 2)
|
||||||
|
if len(types) == 0 || len(types) != len(ids) || len(types) != len(counts) {
|
||||||
|
return 0, nil, fmt.Errorf("gamedata: invalid promotion costs for character %d", charID)
|
||||||
|
}
|
||||||
|
costs := make([]PromotionCost, len(types))
|
||||||
|
for i := range costs {
|
||||||
|
if counts[i] == 0 || (types[i] != 4 && ids[i] == 0) {
|
||||||
|
return 0, nil, fmt.Errorf("gamedata: invalid promotion cost for character %d", charID)
|
||||||
|
}
|
||||||
|
costs[i] = PromotionCost{Type: types[i], ID: ids[i], Count: counts[i]}
|
||||||
|
}
|
||||||
|
return next[0], costs, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
package gamedata
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/binary"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCharacterGrowthPromotionsAgainstInstalledVersion23413(t *testing.T) {
|
||||||
|
root := os.Getenv("BD2_TEST_GAMEDATA_ROOT")
|
||||||
|
if root == "" {
|
||||||
|
t.Skip("set BD2_TEST_GAMEDATA_ROOT for installed GameData integration test")
|
||||||
|
}
|
||||||
|
submitted := []PromotionCost{
|
||||||
|
{Type: 8, ID: 9, Count: 753},
|
||||||
|
{Type: 8, ID: 11, Count: 1},
|
||||||
|
{Type: 8, ID: 12, Count: 2},
|
||||||
|
{Type: 8, ID: 13, Count: 3},
|
||||||
|
{Type: 8, ID: 14, Count: 4},
|
||||||
|
{Type: 4, Count: 10000},
|
||||||
|
}
|
||||||
|
result, err := CharacterGrowthPromotions(root, "20260910162539", 6510, 1, 0, submitted)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if result.CharacterID != 6514 || result.Level != 100 || result.Exp != 0 || len(result.Costs) != 8 {
|
||||||
|
t.Fatalf("installed 2.34.13 combined promotion=%+v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testVarintField(dst []byte, field int, value uint64) []byte {
|
||||||
|
dst = binary.AppendUvarint(dst, uint64(field<<3))
|
||||||
|
return binary.AppendUvarint(dst, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPackedField(dst []byte, field int, values ...uint64) []byte {
|
||||||
|
var packed []byte
|
||||||
|
for _, value := range values {
|
||||||
|
packed = binary.AppendUvarint(packed, value)
|
||||||
|
}
|
||||||
|
dst = binary.AppendUvarint(dst, uint64(field<<3|2))
|
||||||
|
dst = binary.AppendUvarint(dst, uint64(len(packed)))
|
||||||
|
return append(dst, packed...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCharacterPromotionUsesCharAndGrowthTables(t *testing.T) {
|
||||||
|
db, err := sql.Open("sqlite", ":memory:")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
for _, schema := range []string{
|
||||||
|
"CREATE TABLE CharTable (id INTEGER PRIMARY KEY, ProtoBuf BLOB)",
|
||||||
|
"CREATE TABLE CharGrowthTable (id INTEGER PRIMARY KEY, ProtoBuf BLOB)",
|
||||||
|
"CREATE TABLE CharLevelTable (GroupId INTEGER, id INTEGER, ProtoBuf BLOB, PRIMARY KEY(GroupId,id))",
|
||||||
|
} {
|
||||||
|
if _, err := db.Exec(schema); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
current := testVarintField(nil, 1, 101)
|
||||||
|
current = testVarintField(current, 15, 351)
|
||||||
|
next := testVarintField(nil, 1, 102)
|
||||||
|
growth := testVarintField(nil, 1, 101)
|
||||||
|
growth = testPackedField(growth, 2, 1, 1000)
|
||||||
|
growth = testPackedField(growth, 3, 11, 0)
|
||||||
|
growth = testPackedField(growth, 4, 8, 4)
|
||||||
|
growth = testVarintField(growth, 9, 20)
|
||||||
|
if _, err := db.Exec("INSERT INTO CharTable VALUES (?,?),(?,?)", 350, current, 351, next); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := db.Exec("INSERT INTO CharGrowthTable VALUES (?,?)", 101, growth); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := db.Exec("INSERT INTO CharLevelTable VALUES (?,?,?)", 101, 20, []byte{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
nextID, costs, err := characterPromotion(db, 350, 20, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if nextID != 351 || len(costs) != 2 || costs[0] != (PromotionCost{Type: 8, ID: 11, Count: 1}) || costs[1] != (PromotionCost{Type: 4, Count: 1000}) {
|
||||||
|
t.Fatalf("promotion next=%d costs=%+v", nextID, costs)
|
||||||
|
}
|
||||||
|
if _, _, err := characterPromotion(db, 350, 19, 0); err == nil {
|
||||||
|
t.Fatal("promotion before the stage cap was accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCharacterGrowthPromotionsCrossesTwoStagesWithCumulativeCosts(t *testing.T) {
|
||||||
|
db, err := sql.Open("sqlite", ":memory:")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
for _, schema := range []string{
|
||||||
|
"CREATE TABLE CharTable (id INTEGER PRIMARY KEY, ProtoBuf BLOB)",
|
||||||
|
"CREATE TABLE CharGrowthTable (id INTEGER PRIMARY KEY, ProtoBuf BLOB)",
|
||||||
|
"CREATE TABLE CharLevelTable (GroupId INTEGER, id INTEGER, ProtoBuf BLOB, PRIMARY KEY(GroupId,id))",
|
||||||
|
"CREATE TABLE ResourceTable (id INTEGER PRIMARY KEY, ProtoBuf BLOB)",
|
||||||
|
} {
|
||||||
|
if _, err := db.Exec(schema); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, row := range []struct {
|
||||||
|
id, growth, next uint64
|
||||||
|
}{{352, 103, 353}, {353, 104, 354}, {354, 105, 0}} {
|
||||||
|
proto := testVarintField(nil, 1, row.growth)
|
||||||
|
if row.next != 0 {
|
||||||
|
proto = testVarintField(proto, 15, row.next)
|
||||||
|
}
|
||||||
|
if _, err := db.Exec("INSERT INTO CharTable VALUES (?,?)", row.id, proto); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
growth103 := testVarintField(nil, 1, 103)
|
||||||
|
growth103 = testPackedField(growth103, 2, 3, 3000)
|
||||||
|
growth103 = testPackedField(growth103, 3, 13, 0)
|
||||||
|
growth103 = testPackedField(growth103, 4, 8, 4)
|
||||||
|
growth103 = testVarintField(growth103, 9, 60)
|
||||||
|
growth104 := testVarintField(nil, 1, 104)
|
||||||
|
growth104 = testPackedField(growth104, 2, 4, 4000)
|
||||||
|
growth104 = testPackedField(growth104, 3, 14, 0)
|
||||||
|
growth104 = testPackedField(growth104, 4, 8, 4)
|
||||||
|
growth104 = testVarintField(growth104, 9, 80)
|
||||||
|
growth105 := testVarintField(testVarintField(nil, 1, 105), 9, 100)
|
||||||
|
for _, row := range []struct {
|
||||||
|
id uint64
|
||||||
|
proto []byte
|
||||||
|
}{{103, growth103}, {104, growth104}, {105, growth105}} {
|
||||||
|
if _, err := db.Exec("INSERT INTO CharGrowthTable VALUES (?,?)", row.id, row.proto); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, curve := range []struct{ group, first, maximum uint64 }{{103, 1, 60}, {104, 60, 80}, {105, 80, 100}} {
|
||||||
|
for level := curve.first; level < curve.maximum; level++ {
|
||||||
|
proto := testVarintField(nil, 8, 10)
|
||||||
|
if _, err := db.Exec("INSERT INTO CharLevelTable VALUES (?,?,?)", curve.group, level, proto); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resource := testVarintField(nil, 9, 10)
|
||||||
|
if _, err := db.Exec("INSERT INTO ResourceTable VALUES (?,?)", 9, resource); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
submitted := []PromotionCost{
|
||||||
|
{Type: 8, ID: 9, Count: 40},
|
||||||
|
{Type: 8, ID: 13, Count: 3},
|
||||||
|
{Type: 8, ID: 14, Count: 4},
|
||||||
|
{Type: 4, Count: 7000},
|
||||||
|
}
|
||||||
|
result, err := characterGrowthPromotions(db, 352, 60, 0, submitted)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if result.CharacterID != 354 || result.Level != 100 || result.Exp != 0 || len(result.Refunds) != 0 {
|
||||||
|
t.Fatalf("combined result=%+v", result)
|
||||||
|
}
|
||||||
|
if len(result.Costs) != 4 {
|
||||||
|
t.Fatalf("cumulative costs=%+v", result.Costs)
|
||||||
|
}
|
||||||
|
fromLevelOne := append([]PromotionCost(nil), submitted...)
|
||||||
|
fromLevelOne[0].Count = 99
|
||||||
|
result, err = characterGrowthPromotions(db, 352, 1, 0, fromLevelOne)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if result.CharacterID != 354 || result.Level != 100 || result.Exp != 0 {
|
||||||
|
t.Fatalf("level-one combined result=%+v", result)
|
||||||
|
}
|
||||||
|
bad := append([]PromotionCost(nil), submitted...)
|
||||||
|
bad[len(bad)-1].Count = 6000
|
||||||
|
if _, err := characterGrowthPromotions(db, 352, 60, 0, bad); err == nil {
|
||||||
|
t.Fatal("non-prefix aggregate gold was accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
package gamedata
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RandomBoxDesign contains only boxes whose GameData reward graph is wholly
|
||||||
|
// deterministic. The client asks the server to open every RandomBox, but an
|
||||||
|
// offline server must not invent an outcome for weighted boxes. Deterministic
|
||||||
|
// material boxes (including the engraving-scroll and essence boxes) are safe
|
||||||
|
// to resolve directly from the authoritative RewardGroupTable.
|
||||||
|
type RandomBoxDesign struct {
|
||||||
|
rewards map[uint64][]BattleReward
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadRandomBoxDesign reads RandomBoxTable -> RewardGroupTable from the
|
||||||
|
// installed common GameData. A box is deliberately omitted if its group is
|
||||||
|
// empty, weighted, malformed, cyclic, or resolves to a non-ItemDBInfo reward;
|
||||||
|
// callers then fail closed when a client tries to open it.
|
||||||
|
func LoadRandomBoxDesign(root, version string) (*RandomBoxDesign, error) {
|
||||||
|
plain, err := ReadQuestDatabase(root, version)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
dir, err := os.MkdirTemp("", "bd2-random-box-")
|
||||||
|
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()
|
||||||
|
|
||||||
|
groups := map[uint64][]BattleReward{}
|
||||||
|
rows, err := db.Query("SELECT id, ProtoBuf FROM RewardGroupTable")
|
||||||
|
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
|
||||||
|
}
|
||||||
|
ids, err := packedInts(raw, 5)
|
||||||
|
if err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return nil, fmt.Errorf("gamedata: random box reward group %d item IDs: %w", id, err)
|
||||||
|
}
|
||||||
|
types, err := packedInts(raw, 6)
|
||||||
|
if err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return nil, fmt.Errorf("gamedata: random box reward group %d item types: %w", id, err)
|
||||||
|
}
|
||||||
|
counts, err := packedInts(raw, 4)
|
||||||
|
if err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return nil, fmt.Errorf("gamedata: random box reward group %d item counts: %w", id, err)
|
||||||
|
}
|
||||||
|
ratios, err := packedInts(raw, 8)
|
||||||
|
if err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return nil, fmt.Errorf("gamedata: random box reward group %d ratios: %w", id, err)
|
||||||
|
}
|
||||||
|
// A single reward is deterministic. Ratios are presentation/probability
|
||||||
|
// metadata in that case; a multi-entry group is intentionally not used.
|
||||||
|
if len(ids) != 1 || len(types) != 1 || len(counts) != 1 || ids[0] == 0 || !itemDBInfoRewardType(types[0]) || counts[0] == 0 || (len(ratios) != 0 && len(ratios) != 1) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
groups[id] = []BattleReward{{Type: types[0], ID: ids[0], Count: counts[0]}}
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
design := &RandomBoxDesign{rewards: map[uint64][]BattleReward{}}
|
||||||
|
rows, err = db.Query("SELECT id, ProtoBuf FROM RandomBoxTable")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for rows.Next() {
|
||||||
|
var rowID uint64
|
||||||
|
var raw []byte
|
||||||
|
if err := rows.Scan(&rowID, &raw); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ids, err := packedInts(raw, 4)
|
||||||
|
if err != nil || len(ids) != 1 || ids[0] != rowID {
|
||||||
|
rows.Close()
|
||||||
|
return nil, fmt.Errorf("gamedata: malformed random box %d", rowID)
|
||||||
|
}
|
||||||
|
groupIDs, err := packedInts(raw, 9)
|
||||||
|
if err != nil || len(groupIDs) != 1 || groupIDs[0] == 0 {
|
||||||
|
rows.Close()
|
||||||
|
return nil, fmt.Errorf("gamedata: random box %d reward group: %w", rowID, err)
|
||||||
|
}
|
||||||
|
if rewards, ok := groups[groupIDs[0]]; ok {
|
||||||
|
design.rewards[rowID] = append([]BattleReward(nil), rewards...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return design, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// itemDBInfoRewardType is the ItemDBInfo part of DataManager.GetItemInfo for
|
||||||
|
// the static tables this server persists in Inventory. Equipment, characters,
|
||||||
|
// costumes and currency each have a different domain store/response field, so
|
||||||
|
// a RandomBox resolving to one of them is intentionally not opened here.
|
||||||
|
func itemDBInfoRewardType(itemType uint64) bool {
|
||||||
|
switch itemType {
|
||||||
|
case 5, 7, 8, 9, 13, 14, 17, 27, 29:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open returns the exact aggregate for count opens. It never samples a
|
||||||
|
// weighted group, which avoids silently fabricating a random result.
|
||||||
|
func (d *RandomBoxDesign) Open(boxID, count uint64) ([]BattleReward, error) {
|
||||||
|
if d == nil || count == 0 {
|
||||||
|
return nil, errors.New("gamedata: invalid random box open")
|
||||||
|
}
|
||||||
|
rewards, ok := d.rewards[boxID]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("gamedata: random box %d is not deterministic ItemDBInfo loot", boxID)
|
||||||
|
}
|
||||||
|
result := make([]BattleReward, len(rewards))
|
||||||
|
for i, reward := range rewards {
|
||||||
|
if reward.Count > ^uint64(0)/count {
|
||||||
|
return nil, errors.New("gamedata: random box reward count overflow")
|
||||||
|
}
|
||||||
|
result[i] = reward
|
||||||
|
result[i].Count *= count
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package gamedata
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRandomBoxOpenAggregatesOnlyKnownDeterministicReward(t *testing.T) {
|
||||||
|
design := &RandomBoxDesign{rewards: map[uint64][]BattleReward{
|
||||||
|
7: {{Type: 8, ID: 704, Count: 1}},
|
||||||
|
}}
|
||||||
|
got, err := design.Open(7, 100000)
|
||||||
|
if err != nil || len(got) != 1 || got[0] != (BattleReward{Type: 8, ID: 704, Count: 100000}) {
|
||||||
|
t.Fatalf("Open deterministic box = %+v, %v", got, err)
|
||||||
|
}
|
||||||
|
if _, err := design.Open(8, 1); err == nil {
|
||||||
|
t.Fatal("accepted an unknown or weighted box")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// This locks the actual 2.34.13 row behind the installed GameData opt-in,
|
||||||
|
// rather than replacing GameData lookup with a hand-written item mapping.
|
||||||
|
func TestLoadRandomBoxDesignInstalledEngravingEssence(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 := LoadRandomBoxDesign(root, "20260910162539")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err := design.Open(433302, 100000) // Essence of Perseverance box.
|
||||||
|
if err != nil || len(got) != 1 || got[0] != (BattleReward{Type: 8, ID: 704, Count: 100000}) {
|
||||||
|
t.Fatalf("433302 deterministic contents = %+v, %v", got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,24 @@ import (
|
|||||||
const packetCode = 131
|
const packetCode = 131
|
||||||
const openPacketCode = 132
|
const openPacketCode = 132
|
||||||
|
|
||||||
|
// itemDBInfoTypes are ElementType values whose successful mail claim is
|
||||||
|
// represented by ItemDBInfo in RewardDBInfoBundle. They are deliberately
|
||||||
|
// separate from character (6), equipment (10), and costume (11): the client
|
||||||
|
// requires CharDBInfo, EquipDBInfo, and CostumeDBInfo for those rewards, and
|
||||||
|
// this mailbox owns no such domain stores. The values are from 2.34.13
|
||||||
|
// DataManager.GetItemInfo and ItemDBInfo, not inferred from table names.
|
||||||
|
var itemDBInfoTypes = map[uint64]bool{
|
||||||
|
5: true, // food
|
||||||
|
7: true, // cooking recipe/result
|
||||||
|
8: true, // resource
|
||||||
|
9: true, // random box
|
||||||
|
13: true, // quest item
|
||||||
|
14: true, // use item
|
||||||
|
17: true, // collection item
|
||||||
|
27: true, // my-room item
|
||||||
|
29: true, // instant-use item
|
||||||
|
}
|
||||||
|
|
||||||
// MailDBInfo is the persisted shape used by the client. A mail either has a
|
// MailDBInfo is the persisted shape used by the client. A mail either has a
|
||||||
// literal title/body (ordinary system mail), or a TemplateID and Sender (the
|
// literal title/body (ordinary system mail), or a TemplateID and Sender (the
|
||||||
// localized/template-driven mail form). Reward fields are parallel arrays:
|
// localized/template-driven mail form). Reward fields are parallel arrays:
|
||||||
@@ -169,12 +187,19 @@ type stateSnapshot struct {
|
|||||||
type Service struct {
|
type Service struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
Starter *Starter
|
Starter *Starter
|
||||||
|
seedPath string
|
||||||
|
seedStamp fileStamp
|
||||||
path string
|
path string
|
||||||
inventory *player.Inventory
|
inventory *player.Inventory
|
||||||
wallet *player.Wallet
|
wallet *player.Wallet
|
||||||
state stateSnapshot
|
state stateSnapshot
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type fileStamp struct {
|
||||||
|
size int64
|
||||||
|
modTime int64
|
||||||
|
}
|
||||||
|
|
||||||
func OpenService(path string, starter *Starter, inventory *player.Inventory, wallet *player.Wallet) (*Service, error) {
|
func OpenService(path string, starter *Starter, inventory *player.Inventory, wallet *player.Wallet) (*Service, error) {
|
||||||
if path == "" || starter == nil || inventory == nil || wallet == nil {
|
if path == "" || starter == nil || inventory == nil || wallet == nil {
|
||||||
return nil, errors.New("mail: invalid service configuration")
|
return nil, errors.New("mail: invalid service configuration")
|
||||||
@@ -203,6 +228,25 @@ func OpenService(path string, starter *Starter, inventory *player.Inventory, wal
|
|||||||
return s, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
// A changed file is validated before replacing the in-memory starter. A bad
|
||||||
|
// replacement leaves the last known-good starter intact and fails that request.
|
||||||
|
func (s *Service) AttachSeedPath(path string) error {
|
||||||
|
if s == nil || path == "" {
|
||||||
|
return errors.New("mail: invalid seed watch path")
|
||||||
|
}
|
||||||
|
stamp, err := seedFileStamp(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.seedPath, s.seedStamp = filepath.Clean(path), stamp
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) {
|
func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) {
|
||||||
if path != "/MailInfo" && path != "/MailOpen" {
|
if path != "/MailInfo" && path != "/MailOpen" {
|
||||||
return 0, nil, false, nil
|
return 0, nil, false, nil
|
||||||
@@ -217,12 +261,46 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error)
|
|||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
if path == "/MailInfo" {
|
if path == "/MailInfo" {
|
||||||
|
if err := s.reloadSeedIfChanged(); err != nil {
|
||||||
|
return 0, nil, true, err
|
||||||
|
}
|
||||||
return packetCode, s.info(), true, nil
|
return packetCode, s.info(), true, nil
|
||||||
}
|
}
|
||||||
response, err := s.open(request)
|
response, err := s.open(request)
|
||||||
return openPacketCode, response, true, err
|
return openPacketCode, response, true, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func seedFileStamp(path string) (fileStamp, error) {
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
return fileStamp{}, fmt.Errorf("mail: stat watched seed: %w", err)
|
||||||
|
}
|
||||||
|
if !info.Mode().IsRegular() {
|
||||||
|
return fileStamp{}, errors.New("mail: watched seed is not a regular file")
|
||||||
|
}
|
||||||
|
return fileStamp{size: info.Size(), modTime: info.ModTime().UnixNano()}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// reloadSeedIfChanged is called with s.mu held.
|
||||||
|
func (s *Service) reloadSeedIfChanged() error {
|
||||||
|
if s.seedPath == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
stamp, err := seedFileStamp(s.seedPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if stamp == s.seedStamp {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
next, err := Load(s.seedPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("mail: reject changed seed and retain last known-good mailbox: %w", err)
|
||||||
|
}
|
||||||
|
s.Starter, s.seedStamp = next, stamp
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) info() []byte {
|
func (s *Service) info() []byte {
|
||||||
var result []byte
|
var result []byte
|
||||||
remaining := uint64(0)
|
remaining := uint64(0)
|
||||||
@@ -278,13 +356,19 @@ func (s *Service) open(request []byte) ([]byte, error) {
|
|||||||
currency := wire.AppendVarint(nil, 3, reward.Type)
|
currency := wire.AppendVarint(nil, 3, reward.Type)
|
||||||
currency = wire.AppendVarint(currency, 4, reward.Count)
|
currency = wire.AppendVarint(currency, 4, reward.Count)
|
||||||
bundle = wire.AppendBytes(bundle, 1, currency)
|
bundle = wire.AppendBytes(bundle, 1, currency)
|
||||||
case 8:
|
case 28:
|
||||||
|
// DataManager recognizes this type, but RewardDBInfoBundle
|
||||||
|
// carries MyRoomTrophyDBInfo in a separate field. Encoding it as
|
||||||
|
// ItemDBInfo would make the local state and client model disagree.
|
||||||
|
return nil, errors.New("mail: my-room trophy rewards require MyRoomTrophyDBInfo")
|
||||||
|
default:
|
||||||
|
if !itemDBInfoTypes[reward.Type] {
|
||||||
|
return nil, fmt.Errorf("mail: unsupported reward type %d", reward.Type)
|
||||||
|
}
|
||||||
if reward.ID == 0 || reward.Count == 0 {
|
if reward.ID == 0 || reward.Count == 0 {
|
||||||
return nil, errors.New("mail: invalid item reward")
|
return nil, errors.New("mail: invalid item reward")
|
||||||
}
|
}
|
||||||
items = append(items, gamedata.BattleReward{Type: reward.Type, ID: reward.ID, Count: reward.Count})
|
items = append(items, gamedata.BattleReward{Type: reward.Type, ID: reward.ID, Count: reward.Count})
|
||||||
default:
|
|
||||||
return nil, fmt.Errorf("mail: unsupported reward type %d", reward.Type)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if _, err := s.wallet.GrantQuestOnce(identity+":currency", rewards); err != nil {
|
if _, err := s.wallet.GrantQuestOnce(identity+":currency", rewards); err != nil {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package mail
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -30,7 +31,7 @@ func TestStarterAnswersMailInfoWithoutCapture(t *testing.T) {
|
|||||||
func TestMailOpenGrantsItemsAndCurrencyAndPersists(t *testing.T) {
|
func TestMailOpenGrantsItemsAndCurrencyAndPersists(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
seed := &Starter{Version: "2.34.13", MailCount: 3, MaxMailID: 12, Mails: []MailDBInfo{
|
seed := &Starter{Version: "2.34.13", MailCount: 3, MaxMailID: 12, Mails: []MailDBInfo{
|
||||||
{MailID: 11, MailType: 2, ExpiresAt: 100, SentAt: 10, RewardTypes: []uint64{3}, RewardIDs: []uint64{0}, RewardCounts: []uint64{70}},
|
{MailID: 11, MailType: 2, ExpiresAt: 100, SentAt: 10, RewardTypes: []uint64{3, 4}, RewardIDs: []uint64{0, 0}, RewardCounts: []uint64{70, 123456789}},
|
||||||
{MailID: 12, MailType: 2, ExpiresAt: 100, SentAt: 10, RewardTypes: []uint64{8}, RewardIDs: []uint64{9}, RewardCounts: []uint64{10}},
|
{MailID: 12, MailType: 2, ExpiresAt: 100, SentAt: 10, RewardTypes: []uint64{8}, RewardIDs: []uint64{9}, RewardCounts: []uint64{10}},
|
||||||
}}
|
}}
|
||||||
starter := &player.Starter{Version: "2.34.13"}
|
starter := &player.Starter{Version: "2.34.13"}
|
||||||
@@ -38,7 +39,7 @@ func TestMailOpenGrantsItemsAndCurrencyAndPersists(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
wallet, err := player.OpenWallet(filepath.Join(dir, "wallet.json"), player.Currency{})
|
wallet, err := player.OpenWallet(filepath.Join(dir, "wallet.json"), player.Currency{Gold: 321})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -55,6 +56,9 @@ func TestMailOpenGrantsItemsAndCurrencyAndPersists(t *testing.T) {
|
|||||||
if wallet.Snapshot().FreeJewelry != 70 {
|
if wallet.Snapshot().FreeJewelry != 70 {
|
||||||
t.Fatalf("wallet=%+v", wallet.Snapshot())
|
t.Fatalf("wallet=%+v", wallet.Snapshot())
|
||||||
}
|
}
|
||||||
|
if wallet.Snapshot().Gold != 123457110 {
|
||||||
|
t.Fatalf("gold did not stack into wallet: %+v", wallet.Snapshot())
|
||||||
|
}
|
||||||
if len(inv.All()) != 1 || inv.All()[0].ID != 9 || inv.All()[0].Count != 10 {
|
if len(inv.All()) != 1 || inv.All()[0].ID != 9 || inv.All()[0].Count != 10 {
|
||||||
t.Fatalf("items=%+v", inv.All())
|
t.Fatalf("items=%+v", inv.All())
|
||||||
}
|
}
|
||||||
@@ -84,6 +88,98 @@ func TestMailOpenGrantsItemsAndCurrencyAndPersists(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMailOpenGrantsNonResourceItemDBInfoType(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
seed := &Starter{Version: "2.34.13", MailCount: 2, MaxMailID: 13, Mails: []MailDBInfo{{
|
||||||
|
MailID: 13, MailType: 2, ExpiresAt: 100, SentAt: 10,
|
||||||
|
RewardTypes: []uint64{14}, RewardIDs: []uint64{1}, RewardCounts: []uint64{2},
|
||||||
|
}}}
|
||||||
|
starter := &player.Starter{Version: "2.34.13"}
|
||||||
|
inv, err := player.OpenInventory(filepath.Join(dir, "items.json"), starter)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
wallet, err := player.OpenWallet(filepath.Join(dir, "wallet.json"), player.Currency{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
service, err := OpenService(filepath.Join(dir, "mail.json"), seed, inv, wallet)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
request := wire.AppendVarint(nil, 1, 1)
|
||||||
|
request = wire.AppendBytes(request, 2, packed([]uint64{13}))
|
||||||
|
if _, _, _, err := service.Handle("/MailOpen", request); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
items := inv.All()
|
||||||
|
if len(items) != 1 || items[0].ID != 1 || items[0].Type != 14 || items[0].Count != 2 {
|
||||||
|
t.Fatalf("items=%+v", items)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWatchedSeedReloadsOnlyValidAtomicReplacement(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
seedPath := filepath.Join(dir, "seed.json")
|
||||||
|
first := &Starter{Version: "2.34.13", MailCount: 2, MaxMailID: 11, Mails: []MailDBInfo{{MailID: 11, MailType: 2, ExpiresAt: 100, SentAt: 10}}}
|
||||||
|
if err := first.Write(seedPath); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
starter, err := Load(seedPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
inv, err := player.OpenInventory(filepath.Join(dir, "items.json"), &player.Starter{Version: "2.34.13"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
wallet, err := player.OpenWallet(filepath.Join(dir, "wallet.json"), player.Currency{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
service, err := OpenService(filepath.Join(dir, "mail.json"), starter, inv, wallet)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := service.AttachSeedPath(seedPath); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
request := wire.AppendVarint(nil, 1, 1)
|
||||||
|
if _, response, _, err := service.Handle("/MailInfo", request); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
} else if id, _, _ := wire.Varint(mustFirstMail(t, response), 1); id != 11 {
|
||||||
|
t.Fatalf("initial mail ID=%d", id)
|
||||||
|
}
|
||||||
|
second := &Starter{Version: "2.34.13", MailCount: 2, MaxMailID: 12, Mails: []MailDBInfo{{MailID: 12, MailType: 2, ExpiresAt: 100, SentAt: 10}}}
|
||||||
|
if err := second.Write(seedPath); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, response, _, err := service.Handle("/MailInfo", request); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
} else if id, _, _ := wire.Varint(mustFirstMail(t, response), 1); id != 12 {
|
||||||
|
t.Fatalf("reloaded mail ID=%d", id)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(seedPath, []byte("not json"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, _, _, err := service.Handle("/MailInfo", request); err == nil {
|
||||||
|
t.Fatal("accepted malformed watched seed")
|
||||||
|
}
|
||||||
|
// The malformed file did not replace the last known-good mailbox.
|
||||||
|
if service.Starter.Mails[0].MailID != 12 {
|
||||||
|
t.Fatalf("last known-good seed lost: %+v", service.Starter.Mails)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustFirstMail(t *testing.T, response []byte) []byte {
|
||||||
|
t.Helper()
|
||||||
|
entry, found, err := wire.Bytes(response, 1)
|
||||||
|
if err != nil || !found {
|
||||||
|
t.Fatalf("missing first mail: found=%v err=%v", found, err)
|
||||||
|
}
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
func TestMailOpenAcceptsOfficialPackedRequest(t *testing.T) {
|
func TestMailOpenAcceptsOfficialPackedRequest(t *testing.T) {
|
||||||
request := []byte{0x08, 0xef, 0x01, 0x12, 0x0f, 0x85, 0xc1, 0xe1, 0xce, 0x30, 0x88, 0xc1, 0xe1, 0xce, 0x30, 0x8a, 0xc1, 0xe1, 0xce, 0x30}
|
request := []byte{0x08, 0xef, 0x01, 0x12, 0x0f, 0x85, 0xc1, 0xe1, 0xce, 0x30, 0x88, 0xc1, 0xe1, 0xce, 0x30, 0x8a, 0xc1, 0xe1, 0xce, 0x30}
|
||||||
ids, err := requestMailIDs(request)
|
ids, err := requestMailIDs(request)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -82,6 +83,11 @@ type GachaSelection struct {
|
|||||||
ItemID uint64 `json:"item_id"`
|
ItemID uint64 `json:"item_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
const FirstGachaCompletedIdentity = "account:first-gacha-completed"
|
||||||
|
|
||||||
type collectionSnapshot struct {
|
type collectionSnapshot struct {
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
NextCharacterIndex uint64 `json:"next_character_index"`
|
NextCharacterIndex uint64 `json:"next_character_index"`
|
||||||
@@ -92,6 +98,7 @@ type collectionSnapshot struct {
|
|||||||
Characters []Character `json:"characters,omitempty"`
|
Characters []Character `json:"characters,omitempty"`
|
||||||
Costumes []Costume `json:"costumes,omitempty"`
|
Costumes []Costume `json:"costumes,omitempty"`
|
||||||
BaseCostumeLevels map[string]uint64 `json:"base_costume_levels,omitempty"`
|
BaseCostumeLevels map[string]uint64 `json:"base_costume_levels,omitempty"`
|
||||||
|
CostumePotential map[string][]uint64 `json:"costume_potential"`
|
||||||
GachaSelections map[string][]GachaSelection `json:"gacha_selections,omitempty"`
|
GachaSelections map[string][]GachaSelection `json:"gacha_selections,omitempty"`
|
||||||
StepUpProgress map[string]uint64 `json:"step_up_progress,omitempty"`
|
StepUpProgress map[string]uint64 `json:"step_up_progress,omitempty"`
|
||||||
GachaUsers map[string]GachaUserState `json:"gacha_users,omitempty"`
|
GachaUsers map[string]GachaUserState `json:"gacha_users,omitempty"`
|
||||||
@@ -117,7 +124,8 @@ func OpenCollectionStore(path string, base []Costume) (*CollectionStore, error)
|
|||||||
s := &CollectionStore{path: filepath.Clean(path), base: append([]Costume(nil), base...), data: collectionSnapshot{
|
s := &CollectionStore{path: filepath.Clean(path), base: append([]Costume(nil), base...), data: collectionSnapshot{
|
||||||
Version: "2.34.13", NextCharacterIndex: 920000001, NextCostumeIndex: 930000001,
|
Version: "2.34.13", NextCharacterIndex: 920000001, NextCostumeIndex: 930000001,
|
||||||
BaseCostumeLevels: map[string]uint64{}, GachaSelections: map[string][]GachaSelection{},
|
BaseCostumeLevels: map[string]uint64{}, GachaSelections: map[string][]GachaSelection{},
|
||||||
StepUpProgress: map[string]uint64{}, GachaUsers: map[string]GachaUserState{}, GachaFixed: map[string]GachaFixedState{},
|
CostumePotential: map[string][]uint64{},
|
||||||
|
StepUpProgress: map[string]uint64{}, GachaUsers: map[string]GachaUserState{}, GachaFixed: map[string]GachaFixedState{},
|
||||||
GachaApplied: map[string]bool{}, GachaPointExchange: map[string]GachaPointExchange{}, Grants: map[string]CollectionGrant{},
|
GachaApplied: map[string]bool{}, GachaPointExchange: map[string]GachaPointExchange{}, Grants: map[string]CollectionGrant{},
|
||||||
}}
|
}}
|
||||||
b, err := os.ReadFile(s.path)
|
b, err := os.ReadFile(s.path)
|
||||||
@@ -136,6 +144,9 @@ func OpenCollectionStore(path string, base []Costume) (*CollectionStore, error)
|
|||||||
if s.data.BaseCostumeLevels == nil {
|
if s.data.BaseCostumeLevels == nil {
|
||||||
s.data.BaseCostumeLevels = map[string]uint64{}
|
s.data.BaseCostumeLevels = map[string]uint64{}
|
||||||
}
|
}
|
||||||
|
if s.data.CostumePotential == nil {
|
||||||
|
return nil, errors.New("player: collection save requires costume_potential; migrate the development save")
|
||||||
|
}
|
||||||
if s.data.GachaSelections == nil {
|
if s.data.GachaSelections == nil {
|
||||||
s.data.GachaSelections = map[string][]GachaSelection{}
|
s.data.GachaSelections = map[string][]GachaSelection{}
|
||||||
}
|
}
|
||||||
@@ -154,12 +165,23 @@ func OpenCollectionStore(path string, base []Costume) (*CollectionStore, error)
|
|||||||
if s.data.GachaPointExchange == nil {
|
if s.data.GachaPointExchange == nil {
|
||||||
s.data.GachaPointExchange = map[string]GachaPointExchange{}
|
s.data.GachaPointExchange = map[string]GachaPointExchange{}
|
||||||
}
|
}
|
||||||
|
if marker, exists := s.data.Grants[FirstGachaCompletedIdentity]; exists && !emptyCollectionGrant(marker) {
|
||||||
|
return nil, errors.New("player: invalid first-gacha completion marker")
|
||||||
|
}
|
||||||
if err := validateCharacters(s.data.Characters); err != nil && len(s.data.Characters) != 0 {
|
if err := validateCharacters(s.data.Characters); err != nil && len(s.data.Characters) != 0 {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return s, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func emptyCollectionGrant(grant CollectionGrant) bool {
|
||||||
|
return len(grant.CharacterIndices) == 0 && len(grant.CostumeIndices) == 0 &&
|
||||||
|
len(grant.Upgrades) == 0 && len(grant.Exchanges) == 0 &&
|
||||||
|
len(grant.ViewCostumeIDs) == 0 && grant.GachaGroupID == 0 &&
|
||||||
|
grant.GachaPoint == 0 && len(grant.GachaFixed) == 0 &&
|
||||||
|
len(grant.SelectionApplySortIDs) == 0
|
||||||
|
}
|
||||||
|
|
||||||
// AttachBaseCharacters supplies the character instances owned by the world
|
// AttachBaseCharacters supplies the character instances owned by the world
|
||||||
// and starter stores. Collection characters must share those instances: a new
|
// and starter stores. Collection characters must share those instances: a new
|
||||||
// costume for an already-owned CharTable ID is not a second character. The
|
// costume for an already-owned CharTable ID is not a second character. The
|
||||||
@@ -242,6 +264,29 @@ func (s *CollectionStore) BindBaseCharacters(base []Character) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AttachRewardCostume joins an already-earned quest costume to the same
|
||||||
|
// account-owned view used by gacha and starter costumes. The quest condition
|
||||||
|
// must be checked by the caller; this does not grant an unearned costume.
|
||||||
|
func (s *CollectionStore) AttachRewardCostume(reward Costume) error {
|
||||||
|
if reward.InvenIndex == 0 || reward.ID == 0 || reward.UseChar == 0 {
|
||||||
|
return errors.New("player: invalid earned quest costume")
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
for _, existing := range s.base {
|
||||||
|
if existing.InvenIndex == reward.InvenIndex || existing.ID == reward.ID {
|
||||||
|
return fmt.Errorf("player: duplicate earned quest costume %d", reward.InvenIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, existing := range s.data.Costumes {
|
||||||
|
if existing.InvenIndex == reward.InvenIndex || existing.ID == reward.ID {
|
||||||
|
return fmt.Errorf("player: earned quest costume overlaps collection %d", reward.InvenIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.base = append(s.base, reward)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// RepairStepUpProgress derives the completed step count from durable grants
|
// RepairStepUpProgress derives the completed step count from durable grants
|
||||||
// written before step-up progress had its own save field. Grant identities may
|
// written before step-up progress had its own save field. Grant identities may
|
||||||
// contain either the legacy request sequence or the newer login-session ID.
|
// contain either the legacy request sequence or the newer login-session ID.
|
||||||
@@ -399,6 +444,9 @@ func (s *CollectionStore) GrantRegularPurchase(identity string, costumeIDs []uin
|
|||||||
}
|
}
|
||||||
return s.grantCostumes(identity, costumeIDs, design.Character, func(next *collectionSnapshot, grant *CollectionGrant) error {
|
return s.grantCostumes(identity, costumeIDs, design.Character, func(next *collectionSnapshot, grant *CollectionGrant) error {
|
||||||
applyGachaPurchase(next, identity, costumeIDs, purchase, grant)
|
applyGachaPurchase(next, identity, costumeIDs, purchase, grant)
|
||||||
|
if purchase.Group.GachaSubType == 3 {
|
||||||
|
next.Grants[FirstGachaCompletedIdentity] = CollectionGrant{}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -639,12 +687,49 @@ func (s *CollectionStore) FindCharacter(index uint64) (Character, bool) {
|
|||||||
return Character{}, false
|
return Character{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *CollectionStore) UpdateCharacter(character Character) error {
|
// CanUpdateCharacter validates a growth mutation before any currency or
|
||||||
|
// inventory is charged. Promotion changes character.ID but not InvenIndex.
|
||||||
|
func (s *CollectionStore) CanUpdateCharacter(oldID uint64, character Character) error {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
|
return s.validateCharacterUpdate(oldID, character)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CollectionStore) validateCharacterUpdate(oldID uint64, character Character) error {
|
||||||
|
if oldID == 0 || character.ID == 0 || character.InvenIndex == 0 {
|
||||||
|
return errors.New("player: invalid collection character update")
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, existing := range s.data.Characters {
|
||||||
|
if existing.InvenIndex == character.InvenIndex {
|
||||||
|
if existing.ID != oldID {
|
||||||
|
return fmt.Errorf("player: collection character %d changed before growth", character.InvenIndex)
|
||||||
|
}
|
||||||
|
found = true
|
||||||
|
} else if existing.ID == character.ID {
|
||||||
|
return fmt.Errorf("player: duplicate collection character design %d", character.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return fmt.Errorf("player: collection character %d not found", character.InvenIndex)
|
||||||
|
}
|
||||||
|
for _, existing := range s.baseCharacters {
|
||||||
|
if existing.ID == character.ID {
|
||||||
|
return fmt.Errorf("player: duplicate base character design %d", character.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CollectionStore) UpdateCharacter(oldID uint64, character Character) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if err := s.validateCharacterUpdate(oldID, character); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
next := cloneCollection(s.data)
|
next := cloneCollection(s.data)
|
||||||
for i := range next.Characters {
|
for i := range next.Characters {
|
||||||
if next.Characters[i].InvenIndex == character.InvenIndex && next.Characters[i].ID == character.ID {
|
if next.Characters[i].InvenIndex == character.InvenIndex {
|
||||||
next.Characters[i] = character
|
next.Characters[i] = character
|
||||||
return s.commit(next)
|
return s.commit(next)
|
||||||
}
|
}
|
||||||
@@ -660,8 +745,63 @@ func (s *CollectionStore) Costumes() []Costume {
|
|||||||
if level := s.data.BaseCostumeLevels[strconv.FormatUint(result[i].InvenIndex, 10)]; level > result[i].Level {
|
if level := s.data.BaseCostumeLevels[strconv.FormatUint(result[i].InvenIndex, 10)]; level > result[i].Level {
|
||||||
result[i].Level = level
|
result[i].Level = level
|
||||||
}
|
}
|
||||||
|
result[i].PotentialIDs = append([]uint64(nil), s.data.CostumePotential[strconv.FormatUint(result[i].InvenIndex, 10)]...)
|
||||||
}
|
}
|
||||||
return append(result, s.data.Costumes...)
|
collection := append([]Costume(nil), s.data.Costumes...)
|
||||||
|
for i := range collection {
|
||||||
|
collection[i].PotentialIDs = append([]uint64(nil), s.data.CostumePotential[strconv.FormatUint(collection[i].InvenIndex, 10)]...)
|
||||||
|
}
|
||||||
|
return append(result, collection...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CollectionStore) ValidateCostumePotentialActivation(costumeIndex uint64, nodes []uint64) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return s.validateCostumePotentialActivation(costumeIndex, nodes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CollectionStore) validateCostumePotentialActivation(costumeIndex uint64, nodes []uint64) error {
|
||||||
|
if costumeIndex == 0 || len(nodes) == 0 {
|
||||||
|
return errors.New("player: invalid costume potential activation")
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, costume := range s.base {
|
||||||
|
found = found || costume.InvenIndex == costumeIndex
|
||||||
|
}
|
||||||
|
for _, costume := range s.data.Costumes {
|
||||||
|
found = found || costume.InvenIndex == costumeIndex
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return fmt.Errorf("player: costume %d not found", costumeIndex)
|
||||||
|
}
|
||||||
|
key := strconv.FormatUint(costumeIndex, 10)
|
||||||
|
active := make(map[uint64]bool, len(s.data.CostumePotential[key])+len(nodes))
|
||||||
|
for _, id := range s.data.CostumePotential[key] {
|
||||||
|
if id == 0 || active[id] {
|
||||||
|
return fmt.Errorf("player: invalid saved potential node for costume %d", costumeIndex)
|
||||||
|
}
|
||||||
|
active[id] = true
|
||||||
|
}
|
||||||
|
for _, id := range nodes {
|
||||||
|
if id == 0 || active[id] {
|
||||||
|
return fmt.Errorf("player: duplicate potential node %d for costume %d", id, costumeIndex)
|
||||||
|
}
|
||||||
|
active[id] = true
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CollectionStore) ActivateCostumePotential(costumeIndex uint64, nodes []uint64) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if err := s.validateCostumePotentialActivation(costumeIndex, nodes); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
next := cloneCollection(s.data)
|
||||||
|
key := strconv.FormatUint(costumeIndex, 10)
|
||||||
|
next.CostumePotential[key] = append(next.CostumePotential[key], nodes...)
|
||||||
|
sort.Slice(next.CostumePotential[key], func(i, j int) bool { return next.CostumePotential[key][i] < next.CostumePotential[key][j] })
|
||||||
|
return s.commit(next)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *CollectionStore) Grant(identity string) (CollectionGrant, bool) {
|
func (s *CollectionStore) Grant(identity string) (CollectionGrant, bool) {
|
||||||
@@ -671,6 +811,13 @@ func (s *CollectionStore) Grant(identity string) (CollectionGrant, bool) {
|
|||||||
return cloneGrant(grant), ok
|
return cloneGrant(grant), ok
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *CollectionStore) FirstGachaCompleted() bool {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
_, completed := s.data.Grants[FirstGachaCompletedIdentity]
|
||||||
|
return completed
|
||||||
|
}
|
||||||
|
|
||||||
func (s *CollectionStore) CharacterByIndex(index uint64) (Character, bool) {
|
func (s *CollectionStore) CharacterByIndex(index uint64) (Character, bool) {
|
||||||
for _, character := range s.Characters() {
|
for _, character := range s.Characters() {
|
||||||
if character.InvenIndex == index {
|
if character.InvenIndex == index {
|
||||||
@@ -732,6 +879,10 @@ func cloneCollection(in collectionSnapshot) collectionSnapshot {
|
|||||||
for k, v := range in.BaseCostumeLevels {
|
for k, v := range in.BaseCostumeLevels {
|
||||||
out.BaseCostumeLevels[k] = v
|
out.BaseCostumeLevels[k] = v
|
||||||
}
|
}
|
||||||
|
out.CostumePotential = make(map[string][]uint64, len(in.CostumePotential))
|
||||||
|
for k, v := range in.CostumePotential {
|
||||||
|
out.CostumePotential[k] = append([]uint64(nil), v...)
|
||||||
|
}
|
||||||
out.GachaSelections = make(map[string][]GachaSelection, len(in.GachaSelections))
|
out.GachaSelections = make(map[string][]GachaSelection, len(in.GachaSelections))
|
||||||
for k, v := range in.GachaSelections {
|
for k, v := range in.GachaSelections {
|
||||||
out.GachaSelections[k] = append([]GachaSelection(nil), v...)
|
out.GachaSelections[k] = append([]GachaSelection(nil), v...)
|
||||||
|
|||||||
@@ -7,6 +7,74 @@ import (
|
|||||||
"bd2server/internal/gamedata"
|
"bd2server/internal/gamedata"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestEarnedQuestCostumeSharesPotentialLedgerAndKeepsSingleOwnedInstance(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "collection.json")
|
||||||
|
store, err := OpenCollectionStore(path, []Costume{{InvenIndex: 123, ID: 60101, UseChar: 44}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
reward := Costume{InvenIndex: 609338889, ID: 3501, UseChar: 535607162}
|
||||||
|
if err := store.AttachRewardCostume(reward); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := store.Costumes(); len(got) != 2 || got[1].InvenIndex != reward.InvenIndex {
|
||||||
|
t.Fatalf("earned costume view=%+v", got)
|
||||||
|
}
|
||||||
|
if err := store.ActivateCostumePotential(reward.InvenIndex, []uint64{1, 2}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, found := store.CostumeByIndex(reward.InvenIndex)
|
||||||
|
if !found || len(got.PotentialIDs) != 2 || got.PotentialIDs[0] != 1 || got.PotentialIDs[1] != 2 {
|
||||||
|
t.Fatalf("earned costume potential=%+v found=%v", got, found)
|
||||||
|
}
|
||||||
|
restarted, err := OpenCollectionStore(path, []Costume{{InvenIndex: 123, ID: 60101, UseChar: 44}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := restarted.AttachRewardCostume(reward); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, found = restarted.CostumeByIndex(reward.InvenIndex)
|
||||||
|
if !found || len(got.PotentialIDs) != 2 {
|
||||||
|
t.Fatalf("restarted earned costume potential=%+v found=%v", got, found)
|
||||||
|
}
|
||||||
|
if err := restarted.AttachRewardCostume(reward); err == nil {
|
||||||
|
t.Fatal("duplicate earned quest costume accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateCollectionCharacterAcrossPromotionChangesDesignNotInstance(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "collection.json")
|
||||||
|
store, err := OpenCollectionStore(path, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
store.data.Characters = []Character{{InvenIndex: 920000054, ID: 6510, Level: 1}}
|
||||||
|
if err := store.commit(store.data); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
promoted := Character{InvenIndex: 920000054, ID: 6514, Level: 100}
|
||||||
|
if err := store.CanUpdateCharacter(6510, promoted); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := store.UpdateCharacter(6510, promoted); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
loaded, err := OpenCollectionStore(path, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got, found := loaded.FindCharacter(920000054); !found || got.ID != 6514 || got.Level != 100 {
|
||||||
|
t.Fatalf("persisted promoted collection character=%+v found=%v", got, found)
|
||||||
|
}
|
||||||
|
if err := loaded.UpdateCharacter(6510, promoted); err == nil {
|
||||||
|
t.Fatal("stale old design accepted")
|
||||||
|
}
|
||||||
|
if err := loaded.CanUpdateCharacter(6514, Character{InvenIndex: 920000055, ID: 6514, Level: 100}); err == nil {
|
||||||
|
t.Fatal("unknown instance accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRepairCostumeOverflowCapsLegacyLevelAndIsIdempotent(t *testing.T) {
|
func TestRepairCostumeOverflowCapsLegacyLevelAndIsIdempotent(t *testing.T) {
|
||||||
path := filepath.Join(t.TempDir(), "collection.json")
|
path := filepath.Join(t.TempDir(), "collection.json")
|
||||||
store, err := OpenCollectionStore(path, nil)
|
store, err := OpenCollectionStore(path, nil)
|
||||||
@@ -140,6 +208,55 @@ func TestGrantDifferentCostumesForSameCharacterReusesCharacter(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFirstGachaCompletionIsExplicitAndAtomic(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "collection.json")
|
||||||
|
store, err := OpenCollectionStore(path, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if store.FirstGachaCompleted() {
|
||||||
|
t.Fatal("new collection reports completed first gacha")
|
||||||
|
}
|
||||||
|
catalog, err := gamedata.NewRegularGachaCatalog(
|
||||||
|
map[uint64]gamedata.RegularGacha{20: {ID: 20, Count: 1, PriceType: 3, Price: 1, Pool: []gamedata.WeightedCostume{{ID: 64901, Weight: 1}}}},
|
||||||
|
map[uint64]gamedata.CharacterDesign{64901: {ID: 6490, HP: 166, CostumeMaxLevel: 5}},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, err = store.GrantRegularPurchase("regular-gacha:20:seq:1", []uint64{64901}, catalog, GachaPurchase{
|
||||||
|
Group: gamedata.GachaGroupDesign{ID: 2, GachaSubType: 3},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !store.FirstGachaCompleted() {
|
||||||
|
t.Fatal("subtype-3 purchase did not persist first-gacha completion")
|
||||||
|
}
|
||||||
|
restored, err := OpenCollectionStore(path, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !restored.FirstGachaCompleted() {
|
||||||
|
t.Fatal("first-gacha completion did not survive restart")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFirstGachaCompletionRejectsRewardPayload(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "collection.json")
|
||||||
|
store, err := OpenCollectionStore(path, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
store.data.Grants[FirstGachaCompletedIdentity] = CollectionGrant{ViewCostumeIDs: []uint64{1}}
|
||||||
|
if err := store.commit(store.data); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := OpenCollectionStore(path, nil); err == nil {
|
||||||
|
t.Fatal("collection accepted a non-empty first-gacha marker")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestGrantCostumeForBaseCharacterDoesNotCreateCharacter(t *testing.T) {
|
func TestGrantCostumeForBaseCharacterDoesNotCreateCharacter(t *testing.T) {
|
||||||
store, err := OpenCollectionStore(filepath.Join(t.TempDir(), "collection.json"), nil)
|
store, err := OpenCollectionStore(filepath.Join(t.TempDir(), "collection.json"), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
package player
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"bd2server/internal/gamedata"
|
||||||
|
"bd2server/internal/wire"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CostumePotentialService struct {
|
||||||
|
design *gamedata.CostumePotentialDesign
|
||||||
|
collection *CollectionStore
|
||||||
|
characters *CharacterStore
|
||||||
|
inventory *Inventory
|
||||||
|
wallet *Wallet
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCostumePotentialService(design *gamedata.CostumePotentialDesign, collection *CollectionStore, characters *CharacterStore, inventory *Inventory, wallet *Wallet) (*CostumePotentialService, error) {
|
||||||
|
if design == nil || collection == nil || characters == nil || inventory == nil || wallet == nil {
|
||||||
|
return nil, errors.New("player: incomplete costume potential service")
|
||||||
|
}
|
||||||
|
return &CostumePotentialService{design: design, collection: collection, characters: characters, inventory: inventory, wallet: wallet}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CostumePotentialService) Handle(path string, request []byte) (int, []byte, bool, error) {
|
||||||
|
if path != "/CostumeNodeActivation" {
|
||||||
|
return 0, nil, false, nil
|
||||||
|
}
|
||||||
|
seq, found, err := wire.Varint(request, 1)
|
||||||
|
if err != nil || !found || seq == 0 {
|
||||||
|
return 0, nil, true, errors.New("player: CostumeNodeActivation missing sequence")
|
||||||
|
}
|
||||||
|
characterIndex, found, err := wire.Varint(request, 2)
|
||||||
|
if err != nil || !found || characterIndex == 0 {
|
||||||
|
return 0, nil, true, errors.New("player: CostumeNodeActivation missing character")
|
||||||
|
}
|
||||||
|
costumeIndex, found, err := wire.Varint(request, 3)
|
||||||
|
if err != nil || !found || costumeIndex == 0 {
|
||||||
|
return 0, nil, true, errors.New("player: CostumeNodeActivation missing costume")
|
||||||
|
}
|
||||||
|
var nodes []uint64
|
||||||
|
var materials []Item
|
||||||
|
err = wire.Walk(request, func(field wire.Field) error {
|
||||||
|
switch field.Number {
|
||||||
|
case 4:
|
||||||
|
if field.Type != 0 && field.Type != 2 {
|
||||||
|
return errors.New("player: invalid potential node field")
|
||||||
|
}
|
||||||
|
for data := field.Value; len(data) != 0; {
|
||||||
|
id, count := binary.Uvarint(data)
|
||||||
|
if count <= 0 || id == 0 {
|
||||||
|
return errors.New("player: invalid potential node ID")
|
||||||
|
}
|
||||||
|
nodes = append(nodes, id)
|
||||||
|
data = data[count:]
|
||||||
|
}
|
||||||
|
case 5:
|
||||||
|
if field.Type != 2 {
|
||||||
|
return errors.New("player: invalid potential 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 potential material")
|
||||||
|
}
|
||||||
|
materials = append(materials, item)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return 0, nil, true, err
|
||||||
|
}
|
||||||
|
if len(nodes) == 0 || len(materials) == 0 {
|
||||||
|
return 0, nil, true, errors.New("player: CostumeNodeActivation has no nodes or materials")
|
||||||
|
}
|
||||||
|
character, found := s.characters.Find(characterIndex)
|
||||||
|
if !found {
|
||||||
|
return 0, nil, true, fmt.Errorf("player: unknown potential character %d", characterIndex)
|
||||||
|
}
|
||||||
|
costume, found := s.collection.CostumeByIndex(costumeIndex)
|
||||||
|
if !found || costume.UseChar != characterIndex {
|
||||||
|
return 0, nil, true, fmt.Errorf("player: potential costume %d is not owned by character %d", costumeIndex, characterIndex)
|
||||||
|
}
|
||||||
|
if err := s.collection.ValidateCostumePotentialActivation(costumeIndex, nodes); err != nil {
|
||||||
|
return 0, nil, true, err
|
||||||
|
}
|
||||||
|
costs, err := s.design.Validate(costume.ID, character.ID, 0, costume.PotentialIDs, nodes)
|
||||||
|
if err != nil {
|
||||||
|
return 0, nil, true, fmt.Errorf("player: validate costume potential GameData: %w", err)
|
||||||
|
}
|
||||||
|
want := make(map[[2]uint64]uint64)
|
||||||
|
for _, cost := range costs {
|
||||||
|
key := [2]uint64{cost.Type, cost.ID}
|
||||||
|
if cost.Count > ^uint64(0)-want[key] {
|
||||||
|
return 0, nil, true, errors.New("player: costume potential cost overflow")
|
||||||
|
}
|
||||||
|
want[key] += cost.Count
|
||||||
|
}
|
||||||
|
got := make(map[[2]uint64]uint64)
|
||||||
|
var items []Item
|
||||||
|
var gold uint64
|
||||||
|
for _, material := range materials {
|
||||||
|
key := [2]uint64{material.Type, material.ID}
|
||||||
|
if material.Type == 4 {
|
||||||
|
if material.InvenIndex != 0 || material.ID != 0 || gold != 0 {
|
||||||
|
return 0, nil, true, errors.New("player: invalid costume potential currency")
|
||||||
|
}
|
||||||
|
gold = material.Count
|
||||||
|
} else {
|
||||||
|
if material.Type != 8 {
|
||||||
|
return 0, nil, true, fmt.Errorf("player: unsupported costume potential material type %d", material.Type)
|
||||||
|
}
|
||||||
|
items = append(items, material)
|
||||||
|
}
|
||||||
|
if material.Count > ^uint64(0)-got[key] {
|
||||||
|
return 0, nil, true, errors.New("player: submitted costume potential material overflow")
|
||||||
|
}
|
||||||
|
got[key] += material.Count
|
||||||
|
}
|
||||||
|
if len(got) != len(want) {
|
||||||
|
return 0, nil, true, fmt.Errorf("player: costume potential material kinds mismatch: request=%v GameData=%v", got, want)
|
||||||
|
}
|
||||||
|
for key, count := range want {
|
||||||
|
if got[key] != count {
|
||||||
|
return 0, nil, true, fmt.Errorf("player: costume potential material %d/%d=%d want %d", key[0], key[1], got[key], count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(items) != 0 {
|
||||||
|
if err := s.inventory.CanConsume(items); err != nil {
|
||||||
|
return 0, nil, true, fmt.Errorf("player: validate costume potential items: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if gold != 0 && !s.wallet.CanSpendGold(gold) {
|
||||||
|
return 0, nil, true, errors.New("player: insufficient gold for costume potential")
|
||||||
|
}
|
||||||
|
sortedNodes := append([]uint64(nil), nodes...)
|
||||||
|
sort.Slice(sortedNodes, func(i, j int) bool { return sortedNodes[i] < sortedNodes[j] })
|
||||||
|
parts := make([]string, len(sortedNodes))
|
||||||
|
for i, id := range sortedNodes {
|
||||||
|
parts[i] = strconv.FormatUint(id, 10)
|
||||||
|
}
|
||||||
|
identity := "costume-potential:" + strconv.FormatUint(costumeIndex, 10) + ":" + strings.Join(parts, ",")
|
||||||
|
if gold != 0 {
|
||||||
|
if _, err := s.wallet.SpendGoldOnce(identity, gold); err != nil {
|
||||||
|
return 0, nil, true, fmt.Errorf("player: spend costume potential gold: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(items) != 0 {
|
||||||
|
if err := s.inventory.Consume(items); err != nil {
|
||||||
|
return 0, nil, true, fmt.Errorf("player: consume costume potential items: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := s.collection.ActivateCostumePotential(costumeIndex, nodes); err != nil {
|
||||||
|
return 0, nil, true, fmt.Errorf("player: persist costume potential: %w", err)
|
||||||
|
}
|
||||||
|
return 261, nil, true, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package player
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"bd2server/internal/gamedata"
|
||||||
|
"bd2server/internal/wire"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCostumeNodeActivationSupportsSingleAndOneClickSets(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
inventory, err := OpenInventory(filepath.Join(dir, "items.json"), &Starter{Version: "2.34.13"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
materials, err := inventory.GrantOnce("potential", []gamedata.BattleReward{{Type: 8, ID: 114, Count: 5}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
wallet, err := OpenWallet(filepath.Join(dir, "wallet.json"), Currency{Gold: 300})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
characters, err := OpenCharacterStore(filepath.Join(dir, "characters.json"), []Character{{InvenIndex: 77, ID: 6514, Level: 100}}, inventory, "", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
collection, err := OpenCollectionStore(filepath.Join(dir, "collection.json"), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
collection.data.Costumes = []Costume{{InvenIndex: 88, ID: 65103, UseChar: 77}}
|
||||||
|
if err := collection.commit(collection.data); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
design := &gamedata.CostumePotentialDesign{
|
||||||
|
Nodes: map[uint64]map[uint64]gamedata.CostumePotentialNode{65103: {
|
||||||
|
1: {ID: 1, Costs: []gamedata.CostumePotentialCost{{Type: 4, Count: 100}}},
|
||||||
|
2: {ID: 2, Prerequisites: []uint64{1}, Costs: []gamedata.CostumePotentialCost{{Type: 8, ID: 114, Count: 2}}},
|
||||||
|
3: {ID: 3, Prerequisites: []uint64{2}, Costs: []gamedata.CostumePotentialCost{{Type: 8, ID: 114, Count: 3}, {Type: 4, Count: 200}}},
|
||||||
|
}},
|
||||||
|
CostumeUnique: map[uint64]uint64{65103: 651},
|
||||||
|
CharacterGrade: map[uint64]uint64{6514: 5},
|
||||||
|
CharacterUnique: map[uint64]uint64{6514: 651},
|
||||||
|
}
|
||||||
|
service, err := NewCostumePotentialService(design, collection, characters, inventory, wallet)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
request := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 77), 3, 88)
|
||||||
|
request = wire.AppendVarint(request, 4, 1)
|
||||||
|
request = wire.AppendBytes(request, 5, ItemWire(Item{Type: 4, Count: 100}))
|
||||||
|
code, response, handled, err := service.Handle("/CostumeNodeActivation", request)
|
||||||
|
if err != nil || !handled || code != 261 || len(response) != 0 {
|
||||||
|
t.Fatalf("single activation code=%d handled=%v response=%x err=%v", code, handled, response, err)
|
||||||
|
}
|
||||||
|
oneClick := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, 2), 2, 77), 3, 88)
|
||||||
|
oneClick = wire.AppendVarint(wire.AppendVarint(oneClick, 4, 2), 4, 3)
|
||||||
|
item := materials[0]
|
||||||
|
oneClick = wire.AppendBytes(oneClick, 5, ItemWire(item))
|
||||||
|
oneClick = wire.AppendBytes(oneClick, 5, ItemWire(Item{Type: 4, Count: 200}))
|
||||||
|
code, _, handled, err = service.Handle("/CostumeNodeActivation", oneClick)
|
||||||
|
if err != nil || !handled || code != 261 {
|
||||||
|
t.Fatalf("one-click activation code=%d handled=%v err=%v", code, handled, err)
|
||||||
|
}
|
||||||
|
if wallet.Snapshot().Gold != 0 {
|
||||||
|
t.Fatalf("gold=%d", wallet.Snapshot().Gold)
|
||||||
|
}
|
||||||
|
costume, found := collection.CostumeByIndex(88)
|
||||||
|
if !found || len(costume.PotentialIDs) != 3 || costume.PotentialIDs[0] != 1 || costume.PotentialIDs[2] != 3 {
|
||||||
|
t.Fatalf("activated costume=%+v found=%v", costume, found)
|
||||||
|
}
|
||||||
|
var encoded []uint64
|
||||||
|
if err := wire.Walk(CostumeWire(costume), func(field wire.Field) error {
|
||||||
|
if field.Number == 8 {
|
||||||
|
v, _ := binary.Uvarint(field.Value)
|
||||||
|
encoded = append(encoded, v)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(encoded) != 3 {
|
||||||
|
t.Fatalf("wire potential IDs=%v", encoded)
|
||||||
|
}
|
||||||
|
if _, _, _, err := service.Handle("/CostumeNodeActivation", oneClick); err == nil {
|
||||||
|
t.Fatal("already active nodes accepted")
|
||||||
|
}
|
||||||
|
if wallet.Snapshot().Gold != 0 {
|
||||||
|
t.Fatal("duplicate request charged wallet")
|
||||||
|
}
|
||||||
|
}
|
||||||
+669
-16
@@ -6,25 +6,31 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"bd2server/internal/gamedata"
|
||||||
"bd2server/internal/wire"
|
"bd2server/internal/wire"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Equipment is one server-owned equipment instance. The immutable definition
|
// Equipment is one server-owned equipment instance. The immutable definition
|
||||||
// (name, icon, slot and base stats) remains in the client's local GameData.
|
// (name, icon, slot and base stats) remains in the client's local GameData.
|
||||||
type Equipment struct {
|
type Equipment struct {
|
||||||
InvenIndex uint64 `json:"inven_index"`
|
InvenIndex uint64 `json:"inven_index"`
|
||||||
ID uint64 `json:"id"`
|
ID uint64 `json:"id"`
|
||||||
Level uint64 `json:"level"`
|
Level uint64 `json:"level"`
|
||||||
UseChar uint64 `json:"use_char,omitempty"`
|
UseChar uint64 `json:"use_char,omitempty"`
|
||||||
KeepFlag uint64 `json:"keep_flag,omitempty"`
|
KeepFlag uint64 `json:"keep_flag,omitempty"`
|
||||||
LockFlag uint64 `json:"lock_flag,omitempty"`
|
LockFlag uint64 `json:"lock_flag,omitempty"`
|
||||||
SortID uint64 `json:"sort_id,omitempty"`
|
SortID uint64 `json:"sort_id,omitempty"`
|
||||||
MainOption []EquipmentOption `json:"main_option,omitempty"`
|
Mark string `json:"mark,omitempty"`
|
||||||
SubOption []EquipmentOption `json:"sub_option,omitempty"`
|
MainOption []EquipmentOption `json:"main_option,omitempty"`
|
||||||
PrivateOption *EquipmentOption `json:"private_option,omitempty"`
|
SubOption []EquipmentOption `json:"sub_option,omitempty"`
|
||||||
Rank []uint64 `json:"rank,omitempty"`
|
PrivateOption *EquipmentOption `json:"private_option,omitempty"`
|
||||||
|
Rank []uint64 `json:"rank,omitempty"`
|
||||||
|
UpgradeAttempts uint64 `json:"upgrade_attempts"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// EquipmentOption is the exact EquipmentOptionTable composite key used by an
|
// EquipmentOption is the exact EquipmentOptionTable composite key used by an
|
||||||
@@ -48,6 +54,36 @@ type EquipmentInventory struct {
|
|||||||
path string
|
path string
|
||||||
owned equipmentSnapshot
|
owned equipmentSnapshot
|
||||||
characters *CharacterStore
|
characters *CharacterStore
|
||||||
|
slots map[uint64]uint64
|
||||||
|
upgrade *gamedata.EquipmentUpgradeDesign
|
||||||
|
wallet *Wallet
|
||||||
|
inventory *Inventory
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *EquipmentInventory) AttachUpgrade(design *gamedata.EquipmentUpgradeDesign, wallet *Wallet, inventory *Inventory) error {
|
||||||
|
if design == nil || wallet == nil || inventory == nil {
|
||||||
|
return errors.New("player: incomplete equipment upgrade configuration")
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.upgrade, s.wallet, s.inventory = design, wallet, inventory
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *EquipmentInventory) AttachSlots(slots map[uint64]uint64) error {
|
||||||
|
if len(slots) == 0 {
|
||||||
|
return errors.New("player: empty equipment slot design")
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.slots = make(map[uint64]uint64, len(slots))
|
||||||
|
for id, slot := range slots {
|
||||||
|
if id == 0 || slot > 4 {
|
||||||
|
return errors.New("player: invalid equipment slot design")
|
||||||
|
}
|
||||||
|
s.slots[id] = slot
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *EquipmentInventory) AttachCharacters(characters *CharacterStore) error {
|
func (s *EquipmentInventory) AttachCharacters(characters *CharacterStore) error {
|
||||||
@@ -71,12 +107,28 @@ func OpenEquipmentInventory(path string) (*EquipmentInventory, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
var shape struct {
|
||||||
|
Equipment []map[string]json.RawMessage `json:"equipment"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &shape); err != nil {
|
||||||
|
return nil, fmt.Errorf("player: decode equipment shape: %w", err)
|
||||||
|
}
|
||||||
|
for _, entry := range shape.Equipment {
|
||||||
|
if _, present := entry["upgrade_attempts"]; !present {
|
||||||
|
return nil, errors.New("player: equipment save requires upgrade_attempts; migrate the development save")
|
||||||
|
}
|
||||||
|
}
|
||||||
if err := json.Unmarshal(data, &s.owned); err != nil {
|
if err := json.Unmarshal(data, &s.owned); err != nil {
|
||||||
return nil, fmt.Errorf("player: decode equipment: %w", err)
|
return nil, fmt.Errorf("player: decode equipment: %w", err)
|
||||||
}
|
}
|
||||||
if s.owned.Version != "2.34.13" || s.owned.NextIndex < 910000001 || s.owned.Granted == nil {
|
if s.owned.Version != "2.34.13" || s.owned.NextIndex < 910000001 || s.owned.Granted == nil {
|
||||||
return nil, errors.New("player: invalid saved equipment")
|
return nil, errors.New("player: invalid saved equipment")
|
||||||
}
|
}
|
||||||
|
for _, entry := range s.owned.Equipment {
|
||||||
|
if len(entry.Rank) != 3 {
|
||||||
|
return nil, fmt.Errorf("player: equipment %d requires exactly three rank slots, found %d; repair the development save before starting", entry.InvenIndex, len(entry.Rank))
|
||||||
|
}
|
||||||
|
}
|
||||||
return s, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,7 +154,7 @@ func (s *EquipmentInventory) GrantOnce(identity string, equipmentID uint64) (Equ
|
|||||||
// GrantGeneratedOnce saves an independently generated gacha instance. Retry
|
// GrantGeneratedOnce saves an independently generated gacha instance. Retry
|
||||||
// calls return the original rolls rather than creating a second copy.
|
// calls return the original rolls rather than creating a second copy.
|
||||||
func (s *EquipmentInventory) GrantGeneratedOnce(identity string, entry Equipment) (Equipment, error) {
|
func (s *EquipmentInventory) GrantGeneratedOnce(identity string, entry Equipment) (Equipment, error) {
|
||||||
if identity == "" || entry.ID == 0 {
|
if identity == "" || entry.ID == 0 || len(entry.Rank) != 3 {
|
||||||
return Equipment{}, errors.New("player: invalid generated equipment")
|
return Equipment{}, errors.New("player: invalid generated equipment")
|
||||||
}
|
}
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
@@ -119,6 +171,14 @@ func (s *EquipmentInventory) grantLocked(identity string, entry Equipment) (Equi
|
|||||||
}
|
}
|
||||||
return Equipment{}, errors.New("player: equipment grant index is missing")
|
return Equipment{}, errors.New("player: equipment grant index is missing")
|
||||||
}
|
}
|
||||||
|
if len(entry.Rank) == 0 {
|
||||||
|
// Rank is a fixed three-slot client field (unlock thresholds +3/+6/+9).
|
||||||
|
// Even an unenhanced item must have three explicit zero values.
|
||||||
|
entry.Rank = []uint64{0, 0, 0}
|
||||||
|
}
|
||||||
|
if len(entry.Rank) != 3 {
|
||||||
|
return Equipment{}, errors.New("player: equipment requires exactly three rank slots")
|
||||||
|
}
|
||||||
entry.InvenIndex = s.owned.NextIndex
|
entry.InvenIndex = s.owned.NextIndex
|
||||||
next := equipmentSnapshot{Version: s.owned.Version, NextIndex: s.owned.NextIndex + 1,
|
next := equipmentSnapshot{Version: s.owned.Version, NextIndex: s.owned.NextIndex + 1,
|
||||||
Equipment: append([]Equipment(nil), s.owned.Equipment...), Granted: make(map[string]uint64, len(s.owned.Granted)+1)}
|
Equipment: append([]Equipment(nil), s.owned.Equipment...), Granted: make(map[string]uint64, len(s.owned.Granted)+1)}
|
||||||
@@ -171,9 +231,7 @@ func EquipmentWire(entry Equipment) []byte {
|
|||||||
base = wire.AppendBytes(base, 5, equipmentOptionWire(*entry.PrivateOption))
|
base = wire.AppendBytes(base, 5, equipmentOptionWire(*entry.PrivateOption))
|
||||||
}
|
}
|
||||||
for _, rank := range entry.Rank {
|
for _, rank := range entry.Rank {
|
||||||
if rank != 0 {
|
base = wire.AppendVarint(base, 6, rank)
|
||||||
base = wire.AppendVarint(base, 6, rank)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
var out []byte
|
var out []byte
|
||||||
out = wire.AppendVarint(out, 1, entry.InvenIndex)
|
out = wire.AppendVarint(out, 1, entry.InvenIndex)
|
||||||
@@ -190,6 +248,9 @@ func EquipmentWire(entry Equipment) []byte {
|
|||||||
if entry.SortID != 0 {
|
if entry.SortID != 0 {
|
||||||
out = wire.AppendVarint(out, 7, entry.SortID)
|
out = wire.AppendVarint(out, 7, entry.SortID)
|
||||||
}
|
}
|
||||||
|
if entry.Mark != "" {
|
||||||
|
out = wire.AppendBytes(out, 8, []byte(entry.Mark))
|
||||||
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,7 +260,7 @@ func equipmentOptionWire(option EquipmentOption) []byte {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *EquipmentInventory) Handle(path string, request []byte) (int, []byte, bool, error) {
|
func (s *EquipmentInventory) Handle(path string, request []byte) (int, []byte, bool, error) {
|
||||||
if path != "/EquipInfo" && path != "/EquipUse" {
|
if path != "/EquipInfo" && path != "/EquipUse" && path != "/EquipClear" && path != "/EquipChange" && path != "/EquipUpgrade" && path != "/EquipSequenceUpgrade" && path != "/EquipMarkSet" && path != "/EquipMarkDelete" && path != "/EquipLock" {
|
||||||
return 0, nil, false, nil
|
return 0, nil, false, nil
|
||||||
}
|
}
|
||||||
if seq, found, err := wire.Varint(request, 1); err != nil || !found || seq == 0 {
|
if seq, found, err := wire.Varint(request, 1); err != nil || !found || seq == 0 {
|
||||||
@@ -208,6 +269,24 @@ func (s *EquipmentInventory) Handle(path string, request []byte) (int, []byte, b
|
|||||||
if path == "/EquipUse" {
|
if path == "/EquipUse" {
|
||||||
return s.use(request)
|
return s.use(request)
|
||||||
}
|
}
|
||||||
|
if path == "/EquipClear" {
|
||||||
|
return s.clear(request)
|
||||||
|
}
|
||||||
|
if path == "/EquipUpgrade" {
|
||||||
|
return s.upgradeOnce(request)
|
||||||
|
}
|
||||||
|
if path == "/EquipSequenceUpgrade" {
|
||||||
|
return s.upgradeSequence(request)
|
||||||
|
}
|
||||||
|
if path == "/EquipChange" {
|
||||||
|
return s.change(request)
|
||||||
|
}
|
||||||
|
if path == "/EquipMarkSet" || path == "/EquipMarkDelete" {
|
||||||
|
return s.mark(path, request)
|
||||||
|
}
|
||||||
|
if path == "/EquipLock" {
|
||||||
|
return s.lock(request)
|
||||||
|
}
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
var response []byte
|
var response []byte
|
||||||
@@ -217,6 +296,580 @@ func (s *EquipmentInventory) Handle(path string, request []byte) (int, []byte, b
|
|||||||
return 34, response, true, nil
|
return 34, response, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
equipUpgradeSuccess = iota
|
||||||
|
equipUpgradeFail
|
||||||
|
equipUpgradeStopMaxLevel
|
||||||
|
equipUpgradeStopSuccess
|
||||||
|
equipUpgradeStopNotEnough
|
||||||
|
equipUpgradeStopGoldLimit
|
||||||
|
equipUpgradeStopTargetLevel
|
||||||
|
equipUpgradeStopMaxTryCount
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *EquipmentInventory) upgradeOnce(request []byte) (int, []byte, bool, error) {
|
||||||
|
index, found, err := wire.Varint(request, 2)
|
||||||
|
if err != nil || !found || index == 0 {
|
||||||
|
return 0, nil, true, errors.New("player: EquipUpgrade missing equipment")
|
||||||
|
}
|
||||||
|
var materials []Item
|
||||||
|
err = wire.Walk(request, func(field wire.Field) error {
|
||||||
|
if field.Number != 3 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if field.Type != 2 {
|
||||||
|
return errors.New("player: EquipUpgrade invalid material")
|
||||||
|
}
|
||||||
|
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 errors.New("player: EquipUpgrade invalid material")
|
||||||
|
}
|
||||||
|
materials = append(materials, item)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return 0, nil, true, err
|
||||||
|
}
|
||||||
|
if len(materials) == 0 {
|
||||||
|
return 0, nil, true, errors.New("player: EquipUpgrade has no material")
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
entry, success, _, _, err := s.attemptUpgradeLocked(index, materials)
|
||||||
|
s.mu.Unlock()
|
||||||
|
if err != nil {
|
||||||
|
return 0, nil, true, err
|
||||||
|
}
|
||||||
|
result := uint64(equipUpgradeFail)
|
||||||
|
if success {
|
||||||
|
result = equipUpgradeSuccess
|
||||||
|
}
|
||||||
|
response := wire.AppendBytes(nil, 1, EquipmentWire(entry))
|
||||||
|
if result != 0 {
|
||||||
|
response = wire.AppendVarint(response, 2, result)
|
||||||
|
}
|
||||||
|
if character, ok := s.equippedCharacter(entry); ok {
|
||||||
|
response = wire.AppendBytes(response, 3, CharacterWire(character))
|
||||||
|
}
|
||||||
|
return 37, response, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *EquipmentInventory) upgradeSequence(request []byte) (int, []byte, bool, error) {
|
||||||
|
index, found, err := wire.Varint(request, 2)
|
||||||
|
if err != nil || !found || index == 0 {
|
||||||
|
return 0, nil, true, errors.New("player: EquipSequenceUpgrade missing equipment")
|
||||||
|
}
|
||||||
|
count, found, err := wire.Varint(request, 3)
|
||||||
|
if err != nil || !found || count == 0 || count > 100000 {
|
||||||
|
return 0, nil, true, errors.New("player: EquipSequenceUpgrade invalid attempt count")
|
||||||
|
}
|
||||||
|
goldLimit, _, err := wire.Varint(request, 5)
|
||||||
|
if err != nil {
|
||||||
|
return 0, nil, true, errors.New("player: EquipSequenceUpgrade invalid gold limit")
|
||||||
|
}
|
||||||
|
target, _, err := wire.Varint(request, 6)
|
||||||
|
if err != nil {
|
||||||
|
return 0, nil, true, errors.New("player: EquipSequenceUpgrade invalid target")
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
if s.upgrade == nil || s.wallet == nil {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return 0, nil, true, errors.New("player: equipment upgrade unavailable")
|
||||||
|
}
|
||||||
|
position := s.equipmentPositionLocked(index)
|
||||||
|
if position < 0 {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return 0, nil, true, fmt.Errorf("player: EquipSequenceUpgrade unknown equipment %d", index)
|
||||||
|
}
|
||||||
|
maximum := s.upgrade.MaxLevel[s.owned.Equipment[position].ID]
|
||||||
|
if target == 0 || target > maximum {
|
||||||
|
target = maximum
|
||||||
|
}
|
||||||
|
var attempts, usedGold uint64
|
||||||
|
result := uint64(equipUpgradeStopMaxTryCount)
|
||||||
|
var consumed []Item
|
||||||
|
var lack []Item
|
||||||
|
for attempts < count {
|
||||||
|
entry := s.owned.Equipment[position]
|
||||||
|
if entry.Level >= maximum {
|
||||||
|
result = equipUpgradeStopMaxLevel
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if entry.Level >= target {
|
||||||
|
result = equipUpgradeStopTargetLevel
|
||||||
|
break
|
||||||
|
}
|
||||||
|
level, _, designErr := s.upgrade.Level(entry.ID, entry.Level)
|
||||||
|
if designErr != nil {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return 0, nil, true, designErr
|
||||||
|
}
|
||||||
|
materials, gold, costErr := s.selectUpgradeCosts(level.Costs)
|
||||||
|
if costErr != nil {
|
||||||
|
result = equipUpgradeStopNotEnough
|
||||||
|
lack = upgradeLackItems(level.Costs)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if goldLimit != 0 && usedGold+gold > goldLimit {
|
||||||
|
result = equipUpgradeStopGoldLimit
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if gold != 0 && !s.wallet.CanSpendGold(gold) {
|
||||||
|
result = equipUpgradeStopNotEnough
|
||||||
|
lack = upgradeLackItems(level.Costs)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
updated, success, spent, actual, attemptErr := s.attemptUpgradeLocked(index, materials)
|
||||||
|
if attemptErr != nil {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return 0, nil, true, attemptErr
|
||||||
|
}
|
||||||
|
attempts++
|
||||||
|
usedGold += spent
|
||||||
|
consumed = append(consumed, actual...)
|
||||||
|
position = s.equipmentPositionLocked(index)
|
||||||
|
if success && updated.Level >= target {
|
||||||
|
result = equipUpgradeStopTargetLevel
|
||||||
|
if updated.Level >= maximum {
|
||||||
|
result = equipUpgradeStopMaxLevel
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entry := s.owned.Equipment[position]
|
||||||
|
s.mu.Unlock()
|
||||||
|
response := wire.AppendBytes(nil, 1, EquipmentWire(entry))
|
||||||
|
if character, ok := s.equippedCharacter(entry); 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))
|
||||||
|
}
|
||||||
|
if usedGold != 0 {
|
||||||
|
response = wire.AppendVarint(response, 7, usedGold)
|
||||||
|
}
|
||||||
|
return 170, response, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func upgradeLackItems(costs []gamedata.PromotionCost) []Item {
|
||||||
|
items := make([]Item, 0, len(costs))
|
||||||
|
for _, cost := range costs {
|
||||||
|
items = append(items, Item{ID: cost.ID, Type: cost.Type, Count: cost.Count})
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *EquipmentInventory) equipmentPositionLocked(index uint64) int {
|
||||||
|
for i := range s.owned.Equipment {
|
||||||
|
if s.owned.Equipment[i].InvenIndex == index {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *EquipmentInventory) selectUpgradeCosts(costs []gamedata.PromotionCost) ([]Item, uint64, error) {
|
||||||
|
var selected []Item
|
||||||
|
var gold uint64
|
||||||
|
for _, cost := range costs {
|
||||||
|
switch cost.Type {
|
||||||
|
case 4:
|
||||||
|
if cost.ID != 0 || cost.Count == 0 || gold != 0 {
|
||||||
|
return nil, 0, errors.New("player: invalid equipment upgrade gold cost")
|
||||||
|
}
|
||||||
|
gold = cost.Count
|
||||||
|
selected = append(selected, Item{Type: 4, Count: cost.Count})
|
||||||
|
case 8:
|
||||||
|
items, err := s.inventory.SelectMutable(cost.Type, cost.ID, cost.Count)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
selected = append(selected, items...)
|
||||||
|
default:
|
||||||
|
return nil, 0, fmt.Errorf("player: unsupported equipment upgrade cost type %d", cost.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return selected, gold, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *EquipmentInventory) attemptUpgradeLocked(index uint64, materials []Item) (Equipment, bool, uint64, []Item, error) {
|
||||||
|
if s.upgrade == nil || s.wallet == nil {
|
||||||
|
return Equipment{}, false, 0, nil, errors.New("player: equipment upgrade unavailable")
|
||||||
|
}
|
||||||
|
position := s.equipmentPositionLocked(index)
|
||||||
|
if position < 0 {
|
||||||
|
return Equipment{}, false, 0, nil, fmt.Errorf("player: unknown equipment %d", index)
|
||||||
|
}
|
||||||
|
current := s.owned.Equipment[position]
|
||||||
|
level, _, err := s.upgrade.Level(current.ID, current.Level)
|
||||||
|
if err != nil {
|
||||||
|
return Equipment{}, false, 0, nil, err
|
||||||
|
}
|
||||||
|
want := make(map[[2]uint64]uint64, len(level.Costs))
|
||||||
|
for _, cost := range level.Costs {
|
||||||
|
want[[2]uint64{cost.Type, cost.ID}] += cost.Count
|
||||||
|
}
|
||||||
|
got := make(map[[2]uint64]uint64)
|
||||||
|
var gold uint64
|
||||||
|
var items []Item
|
||||||
|
for _, material := range materials {
|
||||||
|
got[[2]uint64{material.Type, material.ID}] += material.Count
|
||||||
|
if material.Type == 4 {
|
||||||
|
if material.InvenIndex != 0 || material.ID != 0 || gold != 0 {
|
||||||
|
return Equipment{}, false, 0, nil, errors.New("player: invalid equipment upgrade currency")
|
||||||
|
}
|
||||||
|
gold = material.Count
|
||||||
|
} else {
|
||||||
|
items = append(items, material)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(got) != len(want) {
|
||||||
|
return Equipment{}, false, 0, nil, fmt.Errorf("player: equipment upgrade material kinds mismatch")
|
||||||
|
}
|
||||||
|
for key, count := range want {
|
||||||
|
if got[key] != count {
|
||||||
|
return Equipment{}, false, 0, nil, fmt.Errorf("player: equipment upgrade material %d/%d=%d want=%d", key[0], key[1], got[key], count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if gold != 0 && !s.wallet.CanSpendGold(gold) {
|
||||||
|
return Equipment{}, false, 0, nil, errors.New("player: insufficient gold for equipment upgrade")
|
||||||
|
}
|
||||||
|
if len(items) != 0 {
|
||||||
|
if s.inventory == nil {
|
||||||
|
return Equipment{}, false, 0, nil, errors.New("player: equipment upgrade inventory unavailable")
|
||||||
|
}
|
||||||
|
if err := s.inventory.CanConsume(items); err != nil {
|
||||||
|
return Equipment{}, false, 0, nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Draw before any cross-store write: RNG failure must never charge the
|
||||||
|
// player. Ordinary enhancement also unlocks an official grade at the
|
||||||
|
// +3/+6/+9 pivots; smelting can later change those grades, but is separate.
|
||||||
|
success, err := s.upgrade.Roll(level.SuccessRatio)
|
||||||
|
if err != nil {
|
||||||
|
return Equipment{}, false, 0, nil, fmt.Errorf("player: roll equipment upgrade: %w", err)
|
||||||
|
}
|
||||||
|
var rankSlot, rankValue uint64
|
||||||
|
if success {
|
||||||
|
nextLevel := current.Level + 1
|
||||||
|
switch nextLevel {
|
||||||
|
case 3:
|
||||||
|
rankSlot = 1
|
||||||
|
case 6:
|
||||||
|
rankSlot = 2
|
||||||
|
case 9:
|
||||||
|
rankSlot = 3
|
||||||
|
}
|
||||||
|
if rankSlot != 0 {
|
||||||
|
if len(current.Rank) != 3 || current.Rank[rankSlot-1] != 0 {
|
||||||
|
return Equipment{}, false, 0, nil, fmt.Errorf("player: equipment %d invalid rank state at +%d", index, nextLevel)
|
||||||
|
}
|
||||||
|
rankValue, err = s.upgrade.RollRank(current.ID, rankSlot)
|
||||||
|
if err != nil {
|
||||||
|
return Equipment{}, false, 0, nil, fmt.Errorf("player: roll equipment rank: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
attempt := current.UpgradeAttempts + 1
|
||||||
|
identity := "equip-upgrade:" + strconv.FormatUint(index, 10) + ":" + strconv.FormatUint(attempt, 10)
|
||||||
|
if gold != 0 {
|
||||||
|
if _, err := s.wallet.SpendGoldOnce(identity, gold); err != nil {
|
||||||
|
return Equipment{}, false, 0, nil, fmt.Errorf("player: spend equipment upgrade gold: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(items) != 0 {
|
||||||
|
if err := s.inventory.Consume(items); err != nil {
|
||||||
|
return Equipment{}, false, 0, nil, fmt.Errorf("player: consume equipment upgrade items: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
next := cloneEquipmentSnapshot(s.owned)
|
||||||
|
next.Equipment[position].UpgradeAttempts = attempt
|
||||||
|
if success {
|
||||||
|
next.Equipment[position].Level++
|
||||||
|
if rankSlot != 0 {
|
||||||
|
next.Equipment[position].Rank[rankSlot-1] = rankValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := s.commitLocked(next, "upgrade"); err != nil {
|
||||||
|
return Equipment{}, false, 0, nil, err
|
||||||
|
}
|
||||||
|
return next.Equipment[position], success, gold, materials, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *EquipmentInventory) equippedCharacter(entry Equipment) (Character, bool) {
|
||||||
|
if entry.UseChar == 0 || s.characters == nil {
|
||||||
|
return Character{}, false
|
||||||
|
}
|
||||||
|
return s.characters.Find(entry.UseChar)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *EquipmentInventory) clear(request []byte) (int, []byte, bool, error) {
|
||||||
|
equipmentIndex, found, err := wire.Varint(request, 2)
|
||||||
|
if err != nil || !found || equipmentIndex == 0 {
|
||||||
|
return 0, nil, true, errors.New("player: EquipClear missing equipment")
|
||||||
|
}
|
||||||
|
characterIndex, found, err := wire.Varint(request, 3)
|
||||||
|
if err != nil || !found || characterIndex == 0 {
|
||||||
|
return 0, nil, true, errors.New("player: EquipClear missing character")
|
||||||
|
}
|
||||||
|
if s.characters == nil {
|
||||||
|
return 0, nil, true, errors.New("player: EquipClear character store unavailable")
|
||||||
|
}
|
||||||
|
if _, found := s.characters.Find(characterIndex); !found {
|
||||||
|
return 0, nil, true, fmt.Errorf("player: EquipClear unknown character %d", characterIndex)
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
next := cloneEquipmentSnapshot(s.owned)
|
||||||
|
position := -1
|
||||||
|
for i := range next.Equipment {
|
||||||
|
if next.Equipment[i].InvenIndex == equipmentIndex {
|
||||||
|
position = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if position < 0 {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return 0, nil, true, fmt.Errorf("player: EquipClear unknown equipment %d", equipmentIndex)
|
||||||
|
}
|
||||||
|
if next.Equipment[position].UseChar != characterIndex {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return 0, nil, true, fmt.Errorf("player: EquipClear equipment %d is not used by character %d", equipmentIndex, characterIndex)
|
||||||
|
}
|
||||||
|
next.Equipment[position].UseChar = 0
|
||||||
|
if err := s.commitLocked(next, "clear"); err != nil {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return 0, nil, true, err
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
// Re-query after the equipment mutation so the shared stat calculator
|
||||||
|
// returns HP with the cleared item excluded.
|
||||||
|
character, found := s.characters.Find(characterIndex)
|
||||||
|
if !found {
|
||||||
|
return 0, nil, true, fmt.Errorf("player: EquipClear character %d disappeared", characterIndex)
|
||||||
|
}
|
||||||
|
return 36, wire.AppendBytes(nil, 1, CharacterWire(character)), true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *EquipmentInventory) lock(request []byte) (int, []byte, bool, error) {
|
||||||
|
equipmentIndex, found, err := wire.Varint(request, 2)
|
||||||
|
if err != nil || !found || equipmentIndex == 0 {
|
||||||
|
return 0, nil, true, errors.New("player: EquipLock missing equipment")
|
||||||
|
}
|
||||||
|
// LockFlag is int32, but proto3 omits its zero value. A missing field 3 is
|
||||||
|
// therefore the normal unlock request; present values are restricted to 1.
|
||||||
|
lockFlag, present, err := wire.Varint(request, 3)
|
||||||
|
if err != nil || (present && lockFlag != 1) {
|
||||||
|
return 0, nil, true, errors.New("player: EquipLock invalid lock flag")
|
||||||
|
}
|
||||||
|
if !present {
|
||||||
|
lockFlag = 0
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
next := cloneEquipmentSnapshot(s.owned)
|
||||||
|
position := -1
|
||||||
|
for i := range next.Equipment {
|
||||||
|
if next.Equipment[i].InvenIndex == equipmentIndex {
|
||||||
|
position = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if position < 0 {
|
||||||
|
return 0, nil, true, fmt.Errorf("player: EquipLock unknown equipment %d", equipmentIndex)
|
||||||
|
}
|
||||||
|
next.Equipment[position].LockFlag = lockFlag
|
||||||
|
if err := s.commitLocked(next, "lock"); err != nil {
|
||||||
|
return 0, nil, true, err
|
||||||
|
}
|
||||||
|
return 38, nil, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *EquipmentInventory) mark(path string, request []byte) (int, []byte, bool, error) {
|
||||||
|
equipmentIndex, found, err := wire.Varint(request, 2)
|
||||||
|
if err != nil || !found || equipmentIndex == 0 {
|
||||||
|
return 0, nil, true, fmt.Errorf("player: %s missing equipment", strings.TrimPrefix(path, "/"))
|
||||||
|
}
|
||||||
|
mark := ""
|
||||||
|
packetCode := 397
|
||||||
|
if path == "/EquipMarkSet" {
|
||||||
|
raw, present, fieldErr := wire.Bytes(request, 3)
|
||||||
|
if fieldErr != nil || !present || !validEquipmentMark(raw) {
|
||||||
|
return 0, nil, true, errors.New("player: EquipMarkSet invalid mark")
|
||||||
|
}
|
||||||
|
mark = string(raw)
|
||||||
|
packetCode = 396
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
next := cloneEquipmentSnapshot(s.owned)
|
||||||
|
position := -1
|
||||||
|
for i := range next.Equipment {
|
||||||
|
if next.Equipment[i].InvenIndex == equipmentIndex {
|
||||||
|
position = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if position < 0 {
|
||||||
|
return 0, nil, true, fmt.Errorf("player: %s unknown equipment %d", strings.TrimPrefix(path, "/"), equipmentIndex)
|
||||||
|
}
|
||||||
|
next.Equipment[position].Mark = mark
|
||||||
|
if err := s.commitLocked(next, strings.TrimPrefix(path, "/")); err != nil {
|
||||||
|
return 0, nil, true, err
|
||||||
|
}
|
||||||
|
return packetCode, nil, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EquipmentInfo.MakeStringCustomMarkData emits either "~<icon>|<color>"
|
||||||
|
// (icon 1..15) or a text mark followed by "|<color>". Text input is capped
|
||||||
|
// by the 2-byte MAXIMUM_CUSTOMMARK_TEXT_SIZE in CustomSettingPopupUI; a
|
||||||
|
// single character is prefixed with '_' so it cannot be confused with an ID.
|
||||||
|
func validEquipmentMark(raw []byte) bool {
|
||||||
|
if len(raw) == 0 || len(raw) > 8 || !utf8.Valid(raw) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
parts := strings.Split(string(raw), "|")
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
color, err := strconv.Atoi(parts[1])
|
||||||
|
if err != nil || color < 0 || color > 5 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
left := parts[0]
|
||||||
|
if strings.HasPrefix(left, "~") {
|
||||||
|
icon, err := strconv.Atoi(strings.TrimPrefix(left, "~"))
|
||||||
|
return err == nil && icon >= 1 && icon <= 15
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(left, "_") {
|
||||||
|
text := strings.TrimPrefix(left, "_")
|
||||||
|
return utf8.RuneCountInString(text) == 1 && len([]byte(text)) <= 2
|
||||||
|
}
|
||||||
|
return len(left) >= 1 && len([]byte(left)) <= 2
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *EquipmentInventory) change(request []byte) (int, []byte, bool, error) {
|
||||||
|
equipmentIndex, found, err := wire.Varint(request, 2)
|
||||||
|
if err != nil || !found || equipmentIndex == 0 {
|
||||||
|
return 0, nil, true, errors.New("player: EquipChange missing equipment")
|
||||||
|
}
|
||||||
|
characterIndex, found, err := wire.Varint(request, 3)
|
||||||
|
if err != nil || !found || characterIndex == 0 {
|
||||||
|
return 0, nil, true, errors.New("player: EquipChange missing character")
|
||||||
|
}
|
||||||
|
if s.characters == nil {
|
||||||
|
return 0, nil, true, errors.New("player: EquipChange character store unavailable")
|
||||||
|
}
|
||||||
|
if _, found := s.characters.Find(characterIndex); !found {
|
||||||
|
return 0, nil, true, fmt.Errorf("player: EquipChange unknown character %d", characterIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
if len(s.slots) == 0 {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return 0, nil, true, errors.New("player: EquipChange slot design unavailable")
|
||||||
|
}
|
||||||
|
next := cloneEquipmentSnapshot(s.owned)
|
||||||
|
position := -1
|
||||||
|
for i := range next.Equipment {
|
||||||
|
if next.Equipment[i].InvenIndex == equipmentIndex {
|
||||||
|
position = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if position < 0 {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return 0, nil, true, fmt.Errorf("player: EquipChange unknown equipment %d", equipmentIndex)
|
||||||
|
}
|
||||||
|
selected := next.Equipment[position]
|
||||||
|
slot, exists := s.slots[selected.ID]
|
||||||
|
if !exists {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return 0, nil, true, fmt.Errorf("player: EquipChange equipment design %d not found", selected.ID)
|
||||||
|
}
|
||||||
|
if selected.UseChar != 0 && selected.UseChar != characterIndex {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return 0, nil, true, fmt.Errorf("player: EquipChange equipment %d belongs to another character", equipmentIndex)
|
||||||
|
}
|
||||||
|
replaced := false
|
||||||
|
for i := range next.Equipment {
|
||||||
|
current := &next.Equipment[i]
|
||||||
|
if current.InvenIndex == equipmentIndex || current.UseChar != characterIndex {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
currentSlot, known := s.slots[current.ID]
|
||||||
|
if !known {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return 0, nil, true, fmt.Errorf("player: EquipChange equipped design %d not found", current.ID)
|
||||||
|
}
|
||||||
|
if currentSlot == slot {
|
||||||
|
current.UseChar = 0
|
||||||
|
replaced = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !replaced {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return 0, nil, true, fmt.Errorf("player: EquipChange character %d has no equipment in slot %d", characterIndex, slot)
|
||||||
|
}
|
||||||
|
next.Equipment[position].UseChar = characterIndex
|
||||||
|
if err := s.commitLocked(next, "change"); err != nil {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return 0, nil, true, err
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
// Max HP depends on the now-current equipment set, so build CharInfo only
|
||||||
|
// after the atomic equipment save is visible to the shared stat calculator.
|
||||||
|
character, found := s.characters.Find(characterIndex)
|
||||||
|
if !found {
|
||||||
|
return 0, nil, true, fmt.Errorf("player: EquipChange character %d disappeared", characterIndex)
|
||||||
|
}
|
||||||
|
// PacketCodeTypeProto orders EquipChange at 45 (EquipInfo is 34).
|
||||||
|
return 45, wire.AppendBytes(nil, 1, CharacterWire(character)), true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneEquipmentSnapshot(current equipmentSnapshot) equipmentSnapshot {
|
||||||
|
next := equipmentSnapshot{Version: current.Version, NextIndex: current.NextIndex,
|
||||||
|
Equipment: append([]Equipment(nil), current.Equipment...), Granted: make(map[string]uint64, len(current.Granted))}
|
||||||
|
for key, value := range current.Granted {
|
||||||
|
next.Granted[key] = value
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *EquipmentInventory) commitLocked(next equipmentSnapshot, operation string) error {
|
||||||
|
data, err := json.Marshal(next)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
dir := filepath.Dir(s.path)
|
||||||
|
f, err := os.CreateTemp(dir, ".equipment-*.tmp")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer os.Remove(f.Name())
|
||||||
|
if _, err = f.Write(data); err == nil {
|
||||||
|
err = f.Sync()
|
||||||
|
}
|
||||||
|
if closeErr := f.Close(); err == nil {
|
||||||
|
err = closeErr
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
err = os.Rename(f.Name(), s.path)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("player: persist equipment %s: %w", operation, err)
|
||||||
|
}
|
||||||
|
s.owned = next
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *EquipmentInventory) All() []Equipment {
|
func (s *EquipmentInventory) All() []Equipment {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
|
|||||||
@@ -1,12 +1,298 @@
|
|||||||
package player
|
package player
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/binary"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"bd2server/internal/gamedata"
|
||||||
"bd2server/internal/wire"
|
"bd2server/internal/wire"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestEquipmentUpgradeAndSequenceUseGameDataCosts(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
wallet, err := OpenWallet(filepath.Join(dir, "wallet.json"), Currency{Gold: 1000})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
store, err := OpenEquipmentInventory(filepath.Join(dir, "equipment.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
design := &gamedata.EquipmentUpgradeDesign{
|
||||||
|
MaxLevel: map[uint64]uint64{943035: 3}, Group: map[uint64]uint64{943035: 954},
|
||||||
|
RankGroup: map[uint64]uint64{943035: 904}, RankRatio: map[[2]uint64][]float64{{904, 1}: {1, 0, 0, 0}},
|
||||||
|
Levels: map[[2]uint64]gamedata.EquipmentUpgradeLevel{
|
||||||
|
{954, 0}: {Level: 0, Costs: []gamedata.PromotionCost{{Type: 4, Count: 100}}, SuccessRatio: 1},
|
||||||
|
{954, 1}: {Level: 1, Costs: []gamedata.PromotionCost{{Type: 4, Count: 200}}, SuccessRatio: 1},
|
||||||
|
{954, 2}: {Level: 2, Costs: []gamedata.PromotionCost{{Type: 4, Count: 300}}, SuccessRatio: 1},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
items, err := OpenInventory(filepath.Join(dir, "items.json"), &Starter{Version: "2.34.13"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := store.AttachUpgrade(design, wallet, items); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
entry, err := store.GrantOnce("upgrade", 943035)
|
||||||
|
if 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: 100}))
|
||||||
|
code, response, handled, err := store.Handle("/EquipUpgrade", request)
|
||||||
|
if err != nil || !handled || code != 37 {
|
||||||
|
t.Fatalf("single upgrade code=%d handled=%v err=%v", code, handled, err)
|
||||||
|
}
|
||||||
|
encoded, found, err := wire.Bytes(response, 1)
|
||||||
|
if err != nil || !found {
|
||||||
|
t.Fatalf("single upgrade equipment: %v", err)
|
||||||
|
}
|
||||||
|
base, _, _ := wire.Bytes(encoded, 5)
|
||||||
|
if level, _, _ := wire.Varint(base, 2); level != 1 {
|
||||||
|
t.Fatalf("single upgraded level=%d", level)
|
||||||
|
}
|
||||||
|
sequence := wire.AppendVarint(wire.AppendVarint(nil, 1, 2), 2, entry.InvenIndex)
|
||||||
|
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 {
|
||||||
|
t.Fatalf("sequence upgrade code=%d handled=%v err=%v", code, handled, err)
|
||||||
|
}
|
||||||
|
if result, _, _ := wire.Varint(response, 3); result != equipUpgradeStopMaxLevel {
|
||||||
|
t.Fatalf("sequence result=%d", result)
|
||||||
|
}
|
||||||
|
if attempts, _, _ := wire.Varint(response, 4); attempts != 2 {
|
||||||
|
t.Fatalf("sequence attempts=%d", attempts)
|
||||||
|
}
|
||||||
|
if used, _, _ := wire.Varint(response, 7); used != 500 {
|
||||||
|
t.Fatalf("sequence used gold=%d", used)
|
||||||
|
}
|
||||||
|
if wallet.Snapshot().Gold != 400 {
|
||||||
|
t.Fatalf("wallet gold=%d", wallet.Snapshot().Gold)
|
||||||
|
}
|
||||||
|
restored, err := OpenEquipmentInventory(filepath.Join(dir, "equipment.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := restored.All(); len(got) != 1 || got[0].Level != 3 || got[0].UpgradeAttempts != 3 {
|
||||||
|
t.Fatalf("persisted upgrade=%+v", got)
|
||||||
|
}
|
||||||
|
if rank := restored.All()[0].Rank; len(rank) != 3 || rank[0] != 1 || rank[1] != 0 || rank[2] != 0 {
|
||||||
|
t.Fatalf("+3 should roll only the first rank: %v", rank)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEquipmentUpgradeFailureConsumesGoldWithoutLevel(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
wallet, err := OpenWallet(filepath.Join(dir, "wallet.json"), Currency{Gold: 200})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
store, err := OpenEquipmentInventory(filepath.Join(dir, "equipment.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
design := &gamedata.EquipmentUpgradeDesign{MaxLevel: map[uint64]uint64{1: 1}, Group: map[uint64]uint64{1: 2}, Levels: map[[2]uint64]gamedata.EquipmentUpgradeLevel{{2, 0}: {Costs: []gamedata.PromotionCost{{Type: 4, Count: 50}}, SuccessRatio: 0}}}
|
||||||
|
items, err := OpenInventory(filepath.Join(dir, "items.json"), &Starter{Version: "2.34.13"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := store.AttachUpgrade(design, wallet, items); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
entry, err := store.GrantOnce("failure", 1)
|
||||||
|
if 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: 50}))
|
||||||
|
_, response, _, err := store.Handle("/EquipUpgrade", request)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if result, _, _ := wire.Varint(response, 2); result != equipUpgradeFail {
|
||||||
|
t.Fatalf("failure result=%d", result)
|
||||||
|
}
|
||||||
|
if got := store.All()[0]; got.Level != 0 || got.UpgradeAttempts != 1 {
|
||||||
|
t.Fatalf("failed upgrade state=%+v", got)
|
||||||
|
}
|
||||||
|
if wallet.Snapshot().Gold != 150 {
|
||||||
|
t.Fatalf("failure gold=%d", wallet.Snapshot().Gold)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEquipmentUpgradeConsumesResourceStacksAndSequenceStopsWhenEmpty(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
wallet, err := OpenWallet(filepath.Join(dir, "wallet.json"), Currency{Gold: 1})
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
resources, err := inventory.GrantOnce("upgrade-items", []gamedata.BattleReward{{Type: 8, ID: 3001, Count: 2}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
store, err := OpenEquipmentInventory(filepath.Join(dir, "equipment.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
design := &gamedata.EquipmentUpgradeDesign{
|
||||||
|
MaxLevel: map[uint64]uint64{10: 3}, Group: map[uint64]uint64{10: 20},
|
||||||
|
RankGroup: map[uint64]uint64{10: 30}, RankRatio: map[[2]uint64][]float64{{30, 1}: {1, 0, 0, 0}},
|
||||||
|
Levels: map[[2]uint64]gamedata.EquipmentUpgradeLevel{
|
||||||
|
{20, 0}: {Costs: []gamedata.PromotionCost{{Type: 8, ID: 3001, Count: 1}}, SuccessRatio: 1},
|
||||||
|
{20, 1}: {Costs: []gamedata.PromotionCost{{Type: 8, ID: 3001, Count: 1}}, SuccessRatio: 1},
|
||||||
|
{20, 2}: {Costs: []gamedata.PromotionCost{{Type: 8, ID: 3001, Count: 1}}, SuccessRatio: 1},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := store.AttachUpgrade(design, wallet, inventory); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
equipment, err := store.GrantOnce("resource-upgrade", 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
first := resources[0]
|
||||||
|
first.Count = 1
|
||||||
|
request := wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, equipment.InvenIndex)
|
||||||
|
request = wire.AppendBytes(request, 3, ItemWire(first))
|
||||||
|
if _, _, _, err := store.Handle("/EquipUpgrade", request); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sequence := wire.AppendVarint(wire.AppendVarint(nil, 1, 2), 2, equipment.InvenIndex)
|
||||||
|
sequence = wire.AppendVarint(sequence, 3, 10)
|
||||||
|
sequence = wire.AppendVarint(sequence, 6, 3)
|
||||||
|
_, response, _, err := store.Handle("/EquipSequenceUpgrade", sequence)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if result, _, _ := wire.Varint(response, 3); result != equipUpgradeStopNotEnough {
|
||||||
|
t.Fatalf("resource sequence result=%d", result)
|
||||||
|
}
|
||||||
|
var lack []byte
|
||||||
|
if err := wire.Walk(response, func(field wire.Field) error {
|
||||||
|
if field.Number == 6 {
|
||||||
|
lack = field.Value
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if itemType, _, _ := wire.Varint(lack, 3); itemType != 8 {
|
||||||
|
t.Fatalf("missing resource type=%d", itemType)
|
||||||
|
}
|
||||||
|
if attempts, _, _ := wire.Varint(response, 4); attempts != 1 {
|
||||||
|
t.Fatalf("resource sequence attempts=%d", attempts)
|
||||||
|
}
|
||||||
|
if got := store.All()[0]; got.Level != 2 {
|
||||||
|
t.Fatalf("resource upgraded level=%d", got.Level)
|
||||||
|
}
|
||||||
|
if err := inventory.CanConsume([]Item{first}); err == nil {
|
||||||
|
t.Fatal("resource stack was not exhausted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEquipmentCustomMarkSetDeletePersistsAndReturnsInInfo(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "equipment.json")
|
||||||
|
store, err := OpenEquipmentInventory(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
entry, err := store.GrantOnce("marked", 943035)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
set := wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, entry.InvenIndex)
|
||||||
|
set = wire.AppendBytes(set, 3, []byte("~15|5"))
|
||||||
|
code, response, handled, err := store.Handle("/EquipMarkSet", set)
|
||||||
|
if err != nil || !handled || code != 396 || len(response) != 0 {
|
||||||
|
t.Fatalf("mark set code=%d handled=%v response=%x err=%v", code, handled, response, err)
|
||||||
|
}
|
||||||
|
restored, err := OpenEquipmentInventory(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
infoRequest := wire.AppendVarint(nil, 1, 2)
|
||||||
|
_, info, _, err := restored.Handle("/EquipInfo", infoRequest)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
equipment, found, err := wire.Bytes(info, 1)
|
||||||
|
if err != nil || !found {
|
||||||
|
t.Fatalf("marked equipment missing: %v", err)
|
||||||
|
}
|
||||||
|
mark, found, err := wire.Bytes(equipment, 8)
|
||||||
|
if err != nil || !found || string(mark) != "~15|5" {
|
||||||
|
t.Fatalf("mark=%q found=%v err=%v", mark, found, err)
|
||||||
|
}
|
||||||
|
deleteRequest := wire.AppendVarint(wire.AppendVarint(nil, 1, 3), 2, entry.InvenIndex)
|
||||||
|
code, response, handled, err = restored.Handle("/EquipMarkDelete", deleteRequest)
|
||||||
|
if err != nil || !handled || code != 397 || len(response) != 0 {
|
||||||
|
t.Fatalf("mark delete code=%d handled=%v response=%x err=%v", code, handled, response, err)
|
||||||
|
}
|
||||||
|
cleared, err := OpenEquipmentInventory(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := cleared.All(); len(got) != 1 || got[0].Mark != "" {
|
||||||
|
t.Fatalf("cleared equipment=%+v", got)
|
||||||
|
}
|
||||||
|
for _, invalid := range []string{"", "~0|0", "~16|0", "~1|6", "abc|0", "x", "_|0"} {
|
||||||
|
request := wire.AppendVarint(wire.AppendVarint(nil, 1, 4), 2, entry.InvenIndex)
|
||||||
|
if invalid != "" {
|
||||||
|
request = wire.AppendBytes(request, 3, []byte(invalid))
|
||||||
|
}
|
||||||
|
if _, _, _, err := cleared.Handle("/EquipMarkSet", request); err == nil {
|
||||||
|
t.Fatalf("invalid mark %q accepted", invalid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEquipmentLockAndProtoDefaultUnlockPersist(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "equipment.json")
|
||||||
|
store, err := OpenEquipmentInventory(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
entry, err := store.GrantOnce("lockable", 943035)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
lock := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, entry.InvenIndex), 3, 1)
|
||||||
|
code, response, handled, err := store.Handle("/EquipLock", lock)
|
||||||
|
if err != nil || !handled || code != 38 || len(response) != 0 {
|
||||||
|
t.Fatalf("lock code=%d handled=%v response=%x err=%v", code, handled, response, err)
|
||||||
|
}
|
||||||
|
locked, err := OpenEquipmentInventory(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := locked.All(); len(got) != 1 || got[0].LockFlag != 1 {
|
||||||
|
t.Fatalf("locked equipment=%+v", got)
|
||||||
|
}
|
||||||
|
unlock := wire.AppendVarint(wire.AppendVarint(nil, 1, 2), 2, entry.InvenIndex)
|
||||||
|
code, response, handled, err = locked.Handle("/EquipLock", unlock)
|
||||||
|
if err != nil || !handled || code != 38 || len(response) != 0 {
|
||||||
|
t.Fatalf("unlock code=%d handled=%v response=%x err=%v", code, handled, response, err)
|
||||||
|
}
|
||||||
|
unlocked, err := OpenEquipmentInventory(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := unlocked.All(); len(got) != 1 || got[0].LockFlag != 0 {
|
||||||
|
t.Fatalf("unlocked equipment=%+v", got)
|
||||||
|
}
|
||||||
|
invalid := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, 3), 2, entry.InvenIndex), 3, 2)
|
||||||
|
if _, _, _, err := unlocked.Handle("/EquipLock", invalid); err == nil {
|
||||||
|
t.Fatal("invalid lock flag accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestEquipmentGrantPersistsAndIsIdempotent(t *testing.T) {
|
func TestEquipmentGrantPersistsAndIsIdempotent(t *testing.T) {
|
||||||
path := filepath.Join(t.TempDir(), "equipment.json")
|
path := filepath.Join(t.TempDir(), "equipment.json")
|
||||||
store, err := OpenEquipmentInventory(path)
|
store, err := OpenEquipmentInventory(path)
|
||||||
@@ -43,6 +329,19 @@ func TestEquipmentGrantPersistsAndIsIdempotent(t *testing.T) {
|
|||||||
if index != first.InvenIndex || id != 10010 {
|
if index != first.InvenIndex || id != 10010 {
|
||||||
t.Fatalf("wire index=%d id=%d", index, id)
|
t.Fatalf("wire index=%d id=%d", index, id)
|
||||||
}
|
}
|
||||||
|
var ranks []uint64
|
||||||
|
if err := wire.Walk(base, func(field wire.Field) error {
|
||||||
|
if field.Number == 6 {
|
||||||
|
value, _ := binary.Uvarint(field.Value)
|
||||||
|
ranks = append(ranks, value)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(ranks) != 3 || ranks[0] != 0 || ranks[1] != 0 || ranks[2] != 0 {
|
||||||
|
t.Fatalf("initial rank slots=%v, want three explicit zeroes", ranks)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEquipmentUsePersistsCharacterBinding(t *testing.T) {
|
func TestEquipmentUsePersistsCharacterBinding(t *testing.T) {
|
||||||
@@ -101,3 +400,125 @@ func TestEquipmentUsePersistsCharacterBinding(t *testing.T) {
|
|||||||
t.Fatalf("useChar=%d found=%v err=%v", useChar, found, err)
|
t.Fatalf("useChar=%d found=%v err=%v", useChar, found, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestEquipmentClearPersistsUnboundEquipmentAndReturnsCharacter(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
items, err := OpenInventory(filepath.Join(dir, "items.json"), &Starter{Version: "2.34.13"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
characters, err := OpenCharacterStore(filepath.Join(dir, "characters.json"),
|
||||||
|
[]Character{{InvenIndex: 535607162, ID: 350, Level: 20}}, items, "", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
path := filepath.Join(dir, "equipment.json")
|
||||||
|
equipment, err := OpenEquipmentInventory(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := equipment.AttachCharacters(characters); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
entry, err := equipment.GrantOnce("equipped", 943035)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
equipment.owned.Equipment[0].UseChar = 535607162
|
||||||
|
equipment.owned.Equipment[0].LockFlag = 1 // Lock prevents disposal, not unequipping.
|
||||||
|
if err := equipment.commitLocked(cloneEquipmentSnapshot(equipment.owned), "test setup"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
request := wire.AppendVarint(wire.AppendVarint(nil, 1, 9), 2, entry.InvenIndex)
|
||||||
|
request = wire.AppendVarint(request, 3, 535607162)
|
||||||
|
code, response, handled, err := equipment.Handle("/EquipClear", request)
|
||||||
|
if err != nil || !handled || code != 36 {
|
||||||
|
t.Fatalf("clear code=%d handled=%v err=%v", code, handled, err)
|
||||||
|
}
|
||||||
|
character, found, err := wire.Bytes(response, 1)
|
||||||
|
if err != nil || !found {
|
||||||
|
t.Fatalf("missing clear character: %v", err)
|
||||||
|
}
|
||||||
|
if index, _, _ := wire.Varint(character, 1); index != 535607162 {
|
||||||
|
t.Fatalf("clear character index=%d", index)
|
||||||
|
}
|
||||||
|
restored, err := OpenEquipmentInventory(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := restored.All(); len(got) != 1 || got[0].UseChar != 0 || got[0].LockFlag != 1 {
|
||||||
|
t.Fatalf("cleared equipment=%+v", got)
|
||||||
|
}
|
||||||
|
if _, _, _, err := equipment.Handle("/EquipClear", request); err == nil {
|
||||||
|
t.Fatal("already-cleared equipment accepted")
|
||||||
|
}
|
||||||
|
wrongCharacter := wire.AppendVarint(wire.AppendVarint(nil, 1, 10), 2, entry.InvenIndex)
|
||||||
|
wrongCharacter = wire.AppendVarint(wrongCharacter, 3, 999)
|
||||||
|
if _, _, _, err := equipment.Handle("/EquipClear", wrongCharacter); err == nil {
|
||||||
|
t.Fatal("unknown character accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEquipmentChangeReplacesOnlyMatchingGameDataSlot(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
items, err := OpenInventory(filepath.Join(dir, "items.json"), &Starter{Version: "2.34.13"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
characters, err := OpenCharacterStore(filepath.Join(dir, "characters.json"),
|
||||||
|
[]Character{{InvenIndex: 535607162, ID: 350, Level: 20}}, items, "", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
path := filepath.Join(dir, "equipment.json")
|
||||||
|
equipment, err := OpenEquipmentInventory(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := equipment.AttachCharacters(characters); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := equipment.AttachSlots(map[uint64]uint64{10010: 0, 943009: 0, 943619: 4}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
old, err := equipment.GrantOnce("old", 10010)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
replacement, err := equipment.GrantOnce("replacement", 943009)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
otherSlot, err := equipment.GrantOnce("other-slot", 943619)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
old.UseChar = 535607162
|
||||||
|
otherSlot.UseChar = 535607162
|
||||||
|
equipment.owned.Equipment[0] = old
|
||||||
|
equipment.owned.Equipment[2] = otherSlot
|
||||||
|
if err := equipment.commitLocked(cloneEquipmentSnapshot(equipment.owned), "test setup"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
request := wire.AppendVarint(wire.AppendVarint(nil, 1, 7), 2, replacement.InvenIndex)
|
||||||
|
request = wire.AppendVarint(request, 3, 535607162)
|
||||||
|
code, response, handled, err := equipment.Handle("/EquipChange", request)
|
||||||
|
if err != nil || !handled || code != 45 {
|
||||||
|
t.Fatalf("change code=%d handled=%v err=%v", code, handled, err)
|
||||||
|
}
|
||||||
|
character, found, err := wire.Bytes(response, 1)
|
||||||
|
if err != nil || !found {
|
||||||
|
t.Fatalf("missing response character: %v", err)
|
||||||
|
}
|
||||||
|
if index, _, _ := wire.Varint(character, 1); index != 535607162 {
|
||||||
|
t.Fatalf("response character=%d", index)
|
||||||
|
}
|
||||||
|
restored, err := OpenEquipmentInventory(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got := restored.All()
|
||||||
|
if got[0].UseChar != 0 || got[1].UseChar != 535607162 || got[2].UseChar != 535607162 {
|
||||||
|
t.Fatalf("changed equipment=%+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"bd2server/internal/gamedata"
|
"bd2server/internal/gamedata"
|
||||||
@@ -29,7 +30,19 @@ type CharacterStore struct {
|
|||||||
gameDataVersion string
|
gameDataVersion string
|
||||||
grow func(Character, []gamedata.GrowthMaterial) (uint64, uint64, []gamedata.GrowthMaterial, error)
|
grow func(Character, []gamedata.GrowthMaterial) (uint64, uint64, []gamedata.GrowthMaterial, error)
|
||||||
collection *CollectionStore
|
collection *CollectionStore
|
||||||
|
wallet *Wallet
|
||||||
maxHealth func(Character) (uint64, error)
|
maxHealth func(Character) (uint64, error)
|
||||||
|
promoteGrowth func(Character, []gamedata.PromotionCost) (gamedata.PromotionGrowthResult, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CharacterStore) AttachWallet(wallet *Wallet) error {
|
||||||
|
if wallet == nil {
|
||||||
|
return errors.New("player: nil growth wallet")
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.wallet = wallet
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// AttachMaxHealth makes growth and post-battle revival consume the same
|
// AttachMaxHealth makes growth and post-battle revival consume the same
|
||||||
@@ -62,6 +75,9 @@ func OpenCharacterStore(path string, seed []Character, inventory *Inventory, gam
|
|||||||
s.grow = func(character Character, materials []gamedata.GrowthMaterial) (uint64, uint64, []gamedata.GrowthMaterial, error) {
|
s.grow = func(character Character, materials []gamedata.GrowthMaterial) (uint64, uint64, []gamedata.GrowthMaterial, error) {
|
||||||
return gamedata.CharacterGrowth(s.gameDataRoot, s.gameDataVersion, int(character.ID), character.Level, character.Exp, materials)
|
return gamedata.CharacterGrowth(s.gameDataRoot, s.gameDataVersion, int(character.ID), character.Level, character.Exp, materials)
|
||||||
}
|
}
|
||||||
|
s.promoteGrowth = func(character Character, submitted []gamedata.PromotionCost) (gamedata.PromotionGrowthResult, error) {
|
||||||
|
return gamedata.CharacterGrowthPromotions(s.gameDataRoot, s.gameDataVersion, int(character.ID), character.Level, character.Exp, submitted)
|
||||||
|
}
|
||||||
data, err := os.ReadFile(s.path)
|
data, err := os.ReadFile(s.path)
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
return s, validateCharacters(s.characters)
|
return s, validateCharacters(s.characters)
|
||||||
@@ -216,7 +232,7 @@ func (s *CharacterStore) Handle(path string, request []byte) (int, []byte, bool,
|
|||||||
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 {
|
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
|
return err
|
||||||
}
|
}
|
||||||
if item.InvenIndex == 0 || item.ID == 0 || item.Type == 0 || item.Count == 0 {
|
if item.Type == 0 || item.Count == 0 || (item.Type != 4 && (item.InvenIndex == 0 || item.ID == 0)) {
|
||||||
return errors.New("player: incomplete growth material")
|
return errors.New("player: incomplete growth material")
|
||||||
}
|
}
|
||||||
materials = append(materials, item)
|
materials = append(materials, item)
|
||||||
@@ -247,6 +263,16 @@ func (s *CharacterStore) Handle(path string, request []byte) (int, []byte, bool,
|
|||||||
if position < 0 && !fromCollection {
|
if position < 0 && !fromCollection {
|
||||||
return 0, nil, true, fmt.Errorf("player: unknown character inventory index %d", index)
|
return 0, nil, true, fmt.Errorf("player: unknown character inventory index %d", index)
|
||||||
}
|
}
|
||||||
|
isPromotion := false
|
||||||
|
for _, material := range materials {
|
||||||
|
if material.Type == 4 {
|
||||||
|
isPromotion = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if isPromotion {
|
||||||
|
return s.promoteCharacter(current, position, fromCollection, materials)
|
||||||
|
}
|
||||||
growthMaterials := make([]gamedata.GrowthMaterial, len(materials))
|
growthMaterials := make([]gamedata.GrowthMaterial, len(materials))
|
||||||
for i, material := range materials {
|
for i, material := range materials {
|
||||||
if material.Type != 8 {
|
if material.Type != 8 {
|
||||||
@@ -275,7 +301,7 @@ func (s *CharacterStore) Handle(path string, request []byte) (int, []byte, bool,
|
|||||||
return 0, nil, true, fmt.Errorf("player: consume growth material: %w", err)
|
return 0, nil, true, fmt.Errorf("player: consume growth material: %w", err)
|
||||||
}
|
}
|
||||||
if fromCollection {
|
if fromCollection {
|
||||||
if err := s.collection.UpdateCharacter(current); err != nil {
|
if err := s.collection.UpdateCharacter(current.ID, current); err != nil {
|
||||||
return 0, nil, true, fmt.Errorf("player: persist collection character growth: %w", err)
|
return 0, nil, true, fmt.Errorf("player: persist collection character growth: %w", err)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -295,6 +321,100 @@ func (s *CharacterStore) Handle(path string, request []byte) (int, []byte, bool,
|
|||||||
return 433, response, true, nil
|
return 433, response, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *CharacterStore) promoteCharacter(current Character, position int, fromCollection bool, materials []Item) (int, []byte, bool, error) {
|
||||||
|
var items []Item
|
||||||
|
requested := make(map[[2]uint64]uint64)
|
||||||
|
var gold uint64
|
||||||
|
for _, material := range materials {
|
||||||
|
if material.Type == 4 {
|
||||||
|
if material.InvenIndex != 0 || material.ID != 0 || gold != 0 {
|
||||||
|
return 0, nil, true, errors.New("player: invalid promotion currency")
|
||||||
|
}
|
||||||
|
gold = material.Count
|
||||||
|
} else if material.Type == 8 {
|
||||||
|
items = append(items, material)
|
||||||
|
key := [2]uint64{8, material.ID}
|
||||||
|
if material.Count > ^uint64(0)-requested[key] {
|
||||||
|
return 0, nil, true, errors.New("player: promotion material overflow")
|
||||||
|
}
|
||||||
|
requested[key] += material.Count
|
||||||
|
} else {
|
||||||
|
return 0, nil, true, fmt.Errorf("player: unsupported promotion item type %d", material.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if gold == 0 {
|
||||||
|
return 0, nil, true, errors.New("player: promotion has no gold cost")
|
||||||
|
}
|
||||||
|
submitted := make([]gamedata.PromotionCost, 0, len(requested)+1)
|
||||||
|
for key, count := range requested {
|
||||||
|
submitted = append(submitted, gamedata.PromotionCost{Type: key[0], ID: key[1], Count: count})
|
||||||
|
}
|
||||||
|
submitted = append(submitted, gamedata.PromotionCost{Type: 4, Count: gold})
|
||||||
|
result, err := s.promoteGrowth(current, submitted)
|
||||||
|
if err != nil {
|
||||||
|
return 0, nil, true, fmt.Errorf("player: calculate combined character promotion: %w; request=%+v", err, materials)
|
||||||
|
}
|
||||||
|
if gold != 0 && (s.wallet == nil || !s.wallet.CanSpendGold(gold)) {
|
||||||
|
return 0, nil, true, errors.New("player: insufficient gold for promotion")
|
||||||
|
}
|
||||||
|
if len(items) == 0 {
|
||||||
|
return 0, nil, true, errors.New("player: promotion has no item material")
|
||||||
|
}
|
||||||
|
previousID := current.ID
|
||||||
|
current.ID = result.CharacterID
|
||||||
|
current.Level = result.Level
|
||||||
|
current.Exp = result.Exp
|
||||||
|
if fromCollection {
|
||||||
|
if err := s.collection.CanUpdateCharacter(previousID, current); err != nil {
|
||||||
|
return 0, nil, true, fmt.Errorf("player: validate promoted collection character: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := s.inventory.CanConsume(items); err != nil {
|
||||||
|
return 0, nil, true, fmt.Errorf("player: validate promotion items: %w", err)
|
||||||
|
}
|
||||||
|
if s.maxHealth != nil {
|
||||||
|
maxHealth := s.maxHealth
|
||||||
|
s.mu.Unlock()
|
||||||
|
hp, healthErr := maxHealth(current)
|
||||||
|
s.mu.Lock()
|
||||||
|
if healthErr != nil {
|
||||||
|
return 0, nil, true, fmt.Errorf("player: calculate promoted character health: %w", healthErr)
|
||||||
|
}
|
||||||
|
current.HP = hp
|
||||||
|
}
|
||||||
|
if gold != 0 {
|
||||||
|
identity := "char-promote:" + strconv.FormatUint(current.InvenIndex, 10) + ":" + strconv.FormatUint(current.ID, 10)
|
||||||
|
if _, err := s.wallet.SpendGoldOnce(identity, gold); err != nil {
|
||||||
|
return 0, nil, true, fmt.Errorf("player: consume promotion gold: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
returned, err := s.inventory.ConsumeAndRefund(items, result.Refunds)
|
||||||
|
if err != nil {
|
||||||
|
return 0, nil, true, fmt.Errorf("player: consume promotion items: %w", err)
|
||||||
|
}
|
||||||
|
if fromCollection {
|
||||||
|
if err := s.collection.UpdateCharacter(previousID, current); err != nil {
|
||||||
|
return 0, nil, true, fmt.Errorf("player: persist promoted collection character: %w", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
next := append([]Character(nil), s.characters...)
|
||||||
|
next[position] = current
|
||||||
|
if err := s.persist(next); err != nil {
|
||||||
|
return 0, nil, true, fmt.Errorf("player: persist promoted character: %w", err)
|
||||||
|
}
|
||||||
|
s.characters = next
|
||||||
|
}
|
||||||
|
response := wire.AppendBytes(nil, 1, CharacterWire(current))
|
||||||
|
var bundle []byte
|
||||||
|
for _, item := range returned {
|
||||||
|
bundle = wire.AppendBytes(bundle, 1, ItemWire(item))
|
||||||
|
}
|
||||||
|
if len(bundle) != 0 {
|
||||||
|
response = wire.AppendBytes(response, 2, bundle)
|
||||||
|
}
|
||||||
|
return 433, response, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
// charImmortal completes the automatic post-battle revival for characters
|
// charImmortal completes the automatic post-battle revival for characters
|
||||||
// whose TalentSkillTable.ClassType is 14. The story character 6010 has
|
// whose TalentSkillTable.ClassType is 14. The story character 6010 has
|
||||||
// ValueList[0]=10000 at every talent level (100%). The authoritative maximum
|
// ValueList[0]=10000 at every talent level (100%). The authoritative maximum
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package player
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -184,6 +185,231 @@ func TestGrowthAndImmortalShareDynamicMaximumHealth(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCharacterPromotionUsesExactGameDataCosts(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
inventory, err := OpenInventory(filepath.Join(dir, "items.json"), &Starter{Version: "2.34.13"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
items, err := inventory.GrantOnce("promotion-material", []gamedata.BattleReward{{Type: 8, ID: 11, Count: 2}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
wallet, err := OpenWallet(filepath.Join(dir, "wallet.json"), Currency{Gold: 1500})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
characters, err := OpenCharacterStore(filepath.Join(dir, "characters.json"), []Character{{InvenIndex: 77, ID: 350, Level: 20, HP: 513}}, inventory, "", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := characters.AttachWallet(wallet); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
characters.promoteGrowth = func(character Character, submitted []gamedata.PromotionCost) (gamedata.PromotionGrowthResult, error) {
|
||||||
|
if character.ID != 350 || character.Level != 20 {
|
||||||
|
return gamedata.PromotionGrowthResult{}, fmt.Errorf("not promotable: %+v", character)
|
||||||
|
}
|
||||||
|
if !promotionCostsEqual(submitted, []gamedata.PromotionCost{{Type: 8, ID: 11, Count: 1}, {Type: 4, Count: 1000}}) {
|
||||||
|
return gamedata.PromotionGrowthResult{}, fmt.Errorf("unexpected submitted costs: %+v", submitted)
|
||||||
|
}
|
||||||
|
return gamedata.PromotionGrowthResult{CharacterID: 351, Level: 20, Costs: submitted}, nil
|
||||||
|
}
|
||||||
|
item := items[0]
|
||||||
|
item.Count = 1
|
||||||
|
request := wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 77)
|
||||||
|
request = wire.AppendBytes(request, 3, ItemWire(item))
|
||||||
|
request = wire.AppendBytes(request, 3, ItemWire(Item{Type: 4, Count: 1000}))
|
||||||
|
code, response, handled, err := characters.Handle("/CharGrowth", request)
|
||||||
|
if err != nil || !handled || code != 433 {
|
||||||
|
t.Fatalf("promote code=%d handled=%v err=%v", code, handled, err)
|
||||||
|
}
|
||||||
|
encoded, found, err := wire.Bytes(response, 1)
|
||||||
|
if err != nil || !found {
|
||||||
|
t.Fatalf("missing promoted character %v", err)
|
||||||
|
}
|
||||||
|
if id, _, _ := wire.Varint(encoded, 2); id != 351 {
|
||||||
|
t.Fatalf("promoted id=%d", id)
|
||||||
|
}
|
||||||
|
if snapshot := wallet.Snapshot(); snapshot.Gold != 500 {
|
||||||
|
t.Fatalf("gold after promotion=%d", snapshot.Gold)
|
||||||
|
}
|
||||||
|
loaded, err := OpenCharacterStore(filepath.Join(dir, "characters.json"), nil, inventory, "", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := loaded.All(); len(got) != 1 || got[0].ID != 351 {
|
||||||
|
t.Fatalf("persisted promotion=%+v", got)
|
||||||
|
}
|
||||||
|
if err := inventory.Consume([]Item{item}); err != nil {
|
||||||
|
t.Fatalf("remaining material x1 should exist: %v", err)
|
||||||
|
}
|
||||||
|
if _, _, _, err := characters.Handle("/CharGrowth", request); err == nil {
|
||||||
|
t.Fatal("duplicate class-up accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCharacterGrowthPromotesAndLevelsInOneRequestAcrossStacks(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
inventory, err := OpenInventory(filepath.Join(dir, "items.json"), &Starter{Version: "2.34.13"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
first, err := inventory.GrantOnce("slime-a", []gamedata.BattleReward{{Type: 8, ID: 9, Count: 7}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
second, err := inventory.GrantOnce("slime-b", []gamedata.BattleReward{{Type: 8, ID: 9, Count: 103}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
classUp, err := inventory.GrantOnce("class-up", []gamedata.BattleReward{{Type: 8, ID: 12, Count: 3}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
wallet, err := OpenWallet(filepath.Join(dir, "wallet.json"), Currency{Gold: 3000})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
characters, err := OpenCharacterStore(filepath.Join(dir, "characters.json"), []Character{{InvenIndex: 77, ID: 351, Level: 40}}, inventory, "", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := characters.AttachWallet(wallet); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
characters.promoteGrowth = func(character Character, submitted []gamedata.PromotionCost) (gamedata.PromotionGrowthResult, error) {
|
||||||
|
if character.ID != 351 || character.Level != 40 {
|
||||||
|
return gamedata.PromotionGrowthResult{}, fmt.Errorf("unexpected stage: %+v", character)
|
||||||
|
}
|
||||||
|
if !promotionCostsEqual(submitted, []gamedata.PromotionCost{{Type: 8, ID: 9, Count: 110}, {Type: 8, ID: 12, Count: 2}, {Type: 4, Count: 2000}}) {
|
||||||
|
return gamedata.PromotionGrowthResult{}, fmt.Errorf("unexpected submitted costs: %+v", submitted)
|
||||||
|
}
|
||||||
|
return gamedata.PromotionGrowthResult{CharacterID: 352, Level: 60, Costs: submitted, Refunds: []gamedata.GrowthMaterial{{ID: 7, Count: 1}}}, nil
|
||||||
|
}
|
||||||
|
request := wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 77)
|
||||||
|
for _, material := range []Item{first[0], second[0], {InvenIndex: classUp[0].InvenIndex, ID: 12, Type: 8, Count: 2}, {Type: 4, Count: 2000}} {
|
||||||
|
request = wire.AppendBytes(request, 3, ItemWire(material))
|
||||||
|
}
|
||||||
|
code, response, handled, err := characters.Handle("/CharGrowth", request)
|
||||||
|
if err != nil || !handled || code != 433 {
|
||||||
|
t.Fatalf("combined growth code=%d handled=%v err=%v", code, handled, err)
|
||||||
|
}
|
||||||
|
encoded, found, err := wire.Bytes(response, 1)
|
||||||
|
if err != nil || !found {
|
||||||
|
t.Fatalf("missing character: %v", err)
|
||||||
|
}
|
||||||
|
if id, _, _ := wire.Varint(encoded, 2); id != 352 {
|
||||||
|
t.Fatalf("promoted character id=%d", id)
|
||||||
|
}
|
||||||
|
if level, _, _ := wire.Varint(encoded, 4); level != 60 {
|
||||||
|
t.Fatalf("grown level=%d", level)
|
||||||
|
}
|
||||||
|
if wallet.Snapshot().Gold != 1000 {
|
||||||
|
t.Fatalf("gold=%d", wallet.Snapshot().Gold)
|
||||||
|
}
|
||||||
|
if err := inventory.Consume([]Item{first[0]}); err == nil {
|
||||||
|
t.Fatal("first experience stack was not consumed")
|
||||||
|
}
|
||||||
|
if err := inventory.Consume([]Item{second[0]}); err == nil {
|
||||||
|
t.Fatal("second experience stack was not consumed")
|
||||||
|
}
|
||||||
|
if err := inventory.Consume([]Item{{InvenIndex: classUp[0].InvenIndex, ID: 12, Type: 8, Count: 1}}); err != nil {
|
||||||
|
t.Fatalf("one unspent class-up material must remain: %v", err)
|
||||||
|
}
|
||||||
|
if bundle, found, err := wire.Bytes(response, 2); err != nil || !found || len(bundle) == 0 {
|
||||||
|
t.Fatalf("missing refunded slime bundle: found=%v err=%v", found, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func promotionCostsEqual(got, want []gamedata.PromotionCost) bool {
|
||||||
|
counts := make(map[[2]uint64]uint64, len(got))
|
||||||
|
for _, cost := range got {
|
||||||
|
counts[[2]uint64{cost.Type, cost.ID}] += cost.Count
|
||||||
|
}
|
||||||
|
if len(counts) != len(want) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, cost := range want {
|
||||||
|
if counts[[2]uint64{cost.Type, cost.ID}] != cost.Count {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollectionCharacterCombinedGrowthChangesIDWithoutChargingTwice(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
inventory, err := OpenInventory(filepath.Join(dir, "items.json"), &Starter{Version: "2.34.13"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
items, err := inventory.GrantOnce("growth-materials", []gamedata.BattleReward{{Type: 8, ID: 9, Count: 800}, {Type: 8, ID: 11, Count: 1}, {Type: 8, ID: 12, Count: 2}, {Type: 8, ID: 13, Count: 3}, {Type: 8, ID: 14, Count: 4}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
wallet, err := OpenWallet(filepath.Join(dir, "wallet.json"), Currency{Gold: 12000})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
collectionPath := filepath.Join(dir, "collection.json")
|
||||||
|
collection, err := OpenCollectionStore(collectionPath, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
collection.data.Characters = []Character{{InvenIndex: 920000054, ID: 6510, Level: 1}}
|
||||||
|
if err := collection.commit(collection.data); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
characters, err := OpenCharacterStore(filepath.Join(dir, "characters.json"), []Character{{InvenIndex: 77, ID: 350, Level: 1}}, inventory, "", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := characters.AttachWallet(wallet); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := characters.AttachCollection(collection); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
characters.promoteGrowth = func(character Character, submitted []gamedata.PromotionCost) (gamedata.PromotionGrowthResult, error) {
|
||||||
|
if character.ID != 6510 || character.Level != 1 || !promotionCostsEqual(submitted, []gamedata.PromotionCost{{Type: 8, ID: 9, Count: 753}, {Type: 8, ID: 11, Count: 1}, {Type: 8, ID: 12, Count: 2}, {Type: 8, ID: 13, Count: 3}, {Type: 8, ID: 14, Count: 4}, {Type: 4, Count: 10000}}) {
|
||||||
|
return gamedata.PromotionGrowthResult{}, fmt.Errorf("unexpected combined growth: %+v %+v", character, submitted)
|
||||||
|
}
|
||||||
|
return gamedata.PromotionGrowthResult{CharacterID: 6514, Level: 100, Refunds: []gamedata.GrowthMaterial{{ID: 7, Count: 3}}}, nil
|
||||||
|
}
|
||||||
|
request := wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 920000054)
|
||||||
|
for i, material := range items {
|
||||||
|
if i == 0 {
|
||||||
|
material.Count = 753
|
||||||
|
}
|
||||||
|
request = wire.AppendBytes(request, 3, ItemWire(material))
|
||||||
|
}
|
||||||
|
request = wire.AppendBytes(request, 3, ItemWire(Item{Type: 4, Count: 10000}))
|
||||||
|
code, _, handled, err := characters.Handle("/CharGrowth", request)
|
||||||
|
if err != nil || !handled || code != 433 {
|
||||||
|
t.Fatalf("collection promotion code=%d handled=%v err=%v", code, handled, err)
|
||||||
|
}
|
||||||
|
if got, found := collection.FindCharacter(920000054); !found || got.ID != 6514 || got.Level != 100 {
|
||||||
|
t.Fatalf("promoted collection character=%+v found=%v", got, found)
|
||||||
|
}
|
||||||
|
if wallet.Snapshot().Gold != 2000 {
|
||||||
|
t.Fatalf("gold=%d", wallet.Snapshot().Gold)
|
||||||
|
}
|
||||||
|
if _, _, _, err := characters.Handle("/CharGrowth", request); err == nil {
|
||||||
|
t.Fatal("replay of the old promotion was accepted")
|
||||||
|
}
|
||||||
|
if wallet.Snapshot().Gold != 2000 {
|
||||||
|
t.Fatal("replay charged gold again")
|
||||||
|
}
|
||||||
|
reloaded, err := OpenCollectionStore(collectionPath, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got, found := reloaded.FindCharacter(920000054); !found || got.ID != 6514 || got.Level != 100 {
|
||||||
|
t.Fatalf("reloaded promoted collection character=%+v found=%v", got, found)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCharacterStoreMergesNewSeedCharacters(t *testing.T) {
|
func TestCharacterStoreMergesNewSeedCharacters(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
starter := &Starter{Version: "2.34.13"}
|
starter := &Starter{Version: "2.34.13"}
|
||||||
|
|||||||
+166
-23
@@ -16,10 +16,11 @@ import (
|
|||||||
// Inventory holds player-owned rewards separately from the immutable starter
|
// Inventory holds player-owned rewards separately from the immutable starter
|
||||||
// seed. GrantOnce uses a battle identity to prevent double-credit on retries.
|
// seed. GrantOnce uses a battle identity to prevent double-credit on retries.
|
||||||
type Inventory struct {
|
type Inventory struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
path string
|
path string
|
||||||
starter *Starter
|
starter *Starter
|
||||||
owned ownedSnapshot
|
randomBoxes *gamedata.RandomBoxDesign
|
||||||
|
owned ownedSnapshot
|
||||||
}
|
}
|
||||||
|
|
||||||
type ownedSnapshot struct {
|
type ownedSnapshot struct {
|
||||||
@@ -55,9 +56,12 @@ func OpenInventory(path string, starter *Starter) (*Inventory, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Inventory) Handle(path string, request []byte) (int, []byte, bool, error) {
|
func (s *Inventory) Handle(path string, request []byte) (int, []byte, bool, error) {
|
||||||
if path != "/ItemInfo" {
|
if path != "/ItemInfo" && path != "/UseRandomBox" {
|
||||||
return 0, nil, false, nil
|
return 0, nil, false, nil
|
||||||
}
|
}
|
||||||
|
if path == "/UseRandomBox" {
|
||||||
|
return s.useRandomBox(request)
|
||||||
|
}
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
items := make([]Item, 0, len(s.starter.Items)+len(s.owned.Items))
|
items := make([]Item, 0, len(s.starter.Items)+len(s.owned.Items))
|
||||||
@@ -66,6 +70,83 @@ func (s *Inventory) Handle(path string, request []byte) (int, []byte, bool, erro
|
|||||||
return (&Starter{Version: "2.34.13", Items: items}).Handle(path, request)
|
return (&Starter{Version: "2.34.13", Items: items}).Handle(path, request)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AttachRandomBoxes installs the version-validated deterministic RandomBox
|
||||||
|
// definitions. It is supplied at process startup from real GameData rather
|
||||||
|
// than accepting a client-supplied reward.
|
||||||
|
func (s *Inventory) AttachRandomBoxes(design *gamedata.RandomBoxDesign) error {
|
||||||
|
if s == nil || design == nil {
|
||||||
|
return errors.New("player: nil random box design")
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.randomBoxes = design
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Inventory) useRandomBox(request []byte) (int, []byte, bool, error) {
|
||||||
|
seq, present, err := wire.Varint(request, 1)
|
||||||
|
if err != nil || !present || seq == 0 {
|
||||||
|
return 0, nil, true, errors.New("player: UseRandomBox invalid sequence")
|
||||||
|
}
|
||||||
|
index, present, err := wire.Varint(request, 2)
|
||||||
|
if err != nil || !present || index == 0 {
|
||||||
|
return 0, nil, true, errors.New("player: UseRandomBox invalid inventory index")
|
||||||
|
}
|
||||||
|
count, present, err := wire.Varint(request, 3)
|
||||||
|
if err != nil || !present || count == 0 || count > uint64(^uint32(0)>>1) {
|
||||||
|
return 0, nil, true, errors.New("player: UseRandomBox invalid use count")
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if s.randomBoxes == nil {
|
||||||
|
return 0, nil, true, errors.New("player: UseRandomBox design unavailable")
|
||||||
|
}
|
||||||
|
boxAt := -1
|
||||||
|
for i, item := range s.owned.Items {
|
||||||
|
if item.InvenIndex == index {
|
||||||
|
boxAt = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if boxAt < 0 {
|
||||||
|
return 0, nil, true, fmt.Errorf("player: UseRandomBox unknown inventory index %d", index)
|
||||||
|
}
|
||||||
|
box := s.owned.Items[boxAt]
|
||||||
|
if box.Type != 9 || box.Count < count {
|
||||||
|
return 0, nil, true, errors.New("player: UseRandomBox item or count mismatch")
|
||||||
|
}
|
||||||
|
rewards, err := s.randomBoxes.Open(box.ID, count)
|
||||||
|
if err != nil {
|
||||||
|
return 0, nil, true, err
|
||||||
|
}
|
||||||
|
next := cloneOwnedSnapshot(s.owned)
|
||||||
|
if box.Count == count {
|
||||||
|
next.Items = append(next.Items[:boxAt], next.Items[boxAt+1:]...)
|
||||||
|
} else {
|
||||||
|
next.Items[boxAt].Count -= count
|
||||||
|
}
|
||||||
|
granted := make([]Item, 0, len(rewards))
|
||||||
|
for _, reward := range rewards {
|
||||||
|
if reward.Type == 0 || reward.ID == 0 || reward.Count == 0 {
|
||||||
|
return 0, nil, true, errors.New("player: UseRandomBox invalid GameData reward")
|
||||||
|
}
|
||||||
|
item := Item{InvenIndex: next.NextIndex, ID: reward.ID, Type: reward.Type, Count: reward.Count, TimeValue: uint64(time.Now().UnixMilli())}
|
||||||
|
next.NextIndex++
|
||||||
|
next.Items = append(next.Items, item)
|
||||||
|
granted = append(granted, item)
|
||||||
|
}
|
||||||
|
if err := s.commitOwned(next); err != nil {
|
||||||
|
return 0, nil, true, err
|
||||||
|
}
|
||||||
|
s.owned = next
|
||||||
|
var bundle []byte
|
||||||
|
for _, item := range granted {
|
||||||
|
bundle = wire.AppendBytes(bundle, 1, ItemWire(item))
|
||||||
|
}
|
||||||
|
return 143, wire.AppendBytes(nil, 1, bundle), true, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Inventory) All() []Item {
|
func (s *Inventory) All() []Item {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
@@ -75,6 +156,33 @@ func (s *Inventory) All() []Item {
|
|||||||
return items
|
return items
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SelectMutable returns concrete owned stacks for a server-calculated cost.
|
||||||
|
// Starter seed items are immutable and deliberately excluded, matching
|
||||||
|
// Consume. Results preserve inventory order and split the final stack exactly.
|
||||||
|
func (s *Inventory) SelectMutable(itemType, id, count uint64) ([]Item, error) {
|
||||||
|
if itemType == 0 || id == 0 || count == 0 {
|
||||||
|
return nil, errors.New("player: invalid mutable item selection")
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
remaining := count
|
||||||
|
var selected []Item
|
||||||
|
for _, item := range s.owned.Items {
|
||||||
|
if item.Type != itemType || item.ID != id || item.Count == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
use := min(item.Count, remaining)
|
||||||
|
copy := item
|
||||||
|
copy.Count = use
|
||||||
|
selected = append(selected, copy)
|
||||||
|
remaining -= use
|
||||||
|
if remaining == 0 {
|
||||||
|
return selected, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("player: insufficient mutable item %d/%d: have %d want %d", itemType, id, count-remaining, count)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Inventory) GrantOnce(identity string, rewards []gamedata.BattleReward) ([]Item, error) {
|
func (s *Inventory) GrantOnce(identity string, rewards []gamedata.BattleReward) ([]Item, error) {
|
||||||
if identity == "" {
|
if identity == "" {
|
||||||
return nil, errors.New("player: missing reward identity")
|
return nil, errors.New("player: missing reward identity")
|
||||||
@@ -84,14 +192,7 @@ func (s *Inventory) GrantOnce(identity string, rewards []gamedata.BattleReward)
|
|||||||
if s.owned.Granted[identity] {
|
if s.owned.Granted[identity] {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
next := ownedSnapshot{Version: s.owned.Version, NextIndex: s.owned.NextIndex,
|
next := cloneOwnedSnapshot(s.owned)
|
||||||
Items: append([]Item(nil), s.owned.Items...), Granted: make(map[string]bool, len(s.owned.Granted)+1), GrantItems: make(map[string][]uint64, len(s.owned.GrantItems)+1)}
|
|
||||||
for k, v := range s.owned.Granted {
|
|
||||||
next.Granted[k] = v
|
|
||||||
}
|
|
||||||
for k, v := range s.owned.GrantItems {
|
|
||||||
next.GrantItems[k] = append([]uint64(nil), v...)
|
|
||||||
}
|
|
||||||
newItems := make([]Item, 0, len(rewards))
|
newItems := make([]Item, 0, len(rewards))
|
||||||
for _, r := range rewards {
|
for _, r := range rewards {
|
||||||
if r.ID == 0 || r.Type == 0 || r.Count == 0 {
|
if r.ID == 0 || r.Type == 0 || r.Count == 0 {
|
||||||
@@ -104,17 +205,39 @@ func (s *Inventory) GrantOnce(identity string, rewards []gamedata.BattleReward)
|
|||||||
next.GrantItems[identity] = append(next.GrantItems[identity], item.InvenIndex)
|
next.GrantItems[identity] = append(next.GrantItems[identity], item.InvenIndex)
|
||||||
}
|
}
|
||||||
next.Granted[identity] = true
|
next.Granted[identity] = true
|
||||||
|
if err := s.commitOwned(next); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s.owned = next
|
||||||
|
return newItems, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneOwnedSnapshot(current ownedSnapshot) ownedSnapshot {
|
||||||
|
next := ownedSnapshot{Version: current.Version, NextIndex: current.NextIndex,
|
||||||
|
Items: append([]Item(nil), current.Items...), Granted: make(map[string]bool, len(current.Granted)+1), GrantItems: make(map[string][]uint64, len(current.GrantItems)+1)}
|
||||||
|
for k, v := range current.Granted {
|
||||||
|
next.Granted[k] = v
|
||||||
|
}
|
||||||
|
for k, v := range current.GrantItems {
|
||||||
|
next.GrantItems[k] = append([]uint64(nil), v...)
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
// commitOwned atomically persists a fully validated next snapshot. Caller
|
||||||
|
// holds s.mu, so a box decrement and its resulting reward cannot split.
|
||||||
|
func (s *Inventory) commitOwned(next ownedSnapshot) error {
|
||||||
data, err := json.Marshal(next)
|
data, err := json.Marshal(next)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return err
|
||||||
}
|
}
|
||||||
dir := filepath.Dir(s.path)
|
dir := filepath.Dir(s.path)
|
||||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||||
return nil, err
|
return err
|
||||||
}
|
}
|
||||||
f, err := os.CreateTemp(dir, ".inventory-*.tmp")
|
f, err := os.CreateTemp(dir, ".inventory-*.tmp")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return err
|
||||||
}
|
}
|
||||||
defer os.Remove(f.Name())
|
defer os.Remove(f.Name())
|
||||||
if _, err = f.Write(data); err == nil {
|
if _, err = f.Write(data); err == nil {
|
||||||
@@ -123,14 +246,10 @@ func (s *Inventory) GrantOnce(identity string, rewards []gamedata.BattleReward)
|
|||||||
if closeErr := f.Close(); err == nil {
|
if closeErr := f.Close(); err == nil {
|
||||||
err = closeErr
|
err = closeErr
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err == nil {
|
||||||
return nil, err
|
err = os.Rename(f.Name(), s.path)
|
||||||
}
|
}
|
||||||
if err := os.Rename(f.Name(), s.path); err != nil {
|
return err
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
s.owned = next
|
|
||||||
return newItems, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GrantedItems returns the stable instances created by a previous GrantOnce.
|
// GrantedItems returns the stable instances created by a previous GrantOnce.
|
||||||
@@ -153,6 +272,30 @@ func (s *Inventory) GrantedItems(identity string) []Item {
|
|||||||
|
|
||||||
// Consume atomically removes the requested counts from mutable owned items.
|
// Consume atomically removes the requested counts from mutable owned items.
|
||||||
// Starter seed entries are immutable and are deliberately not accepted here.
|
// Starter seed entries are immutable and are deliberately not accepted here.
|
||||||
|
func (s *Inventory) CanConsume(requested []Item) error {
|
||||||
|
if len(requested) == 0 {
|
||||||
|
return errors.New("player: no items to consume")
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
remaining := make(map[uint64]Item, len(s.owned.Items))
|
||||||
|
for _, item := range s.owned.Items {
|
||||||
|
remaining[item.InvenIndex] = item
|
||||||
|
}
|
||||||
|
for _, want := range requested {
|
||||||
|
if want.InvenIndex == 0 || want.ID == 0 || want.Type == 0 || want.Count == 0 {
|
||||||
|
return errors.New("player: invalid item consumption")
|
||||||
|
}
|
||||||
|
item, ok := remaining[want.InvenIndex]
|
||||||
|
if !ok || item.ID != want.ID || item.Type != want.Type || item.Count < want.Count {
|
||||||
|
return fmt.Errorf("player: item %d consumption mismatch", want.InvenIndex)
|
||||||
|
}
|
||||||
|
item.Count -= want.Count
|
||||||
|
remaining[want.InvenIndex] = item
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Inventory) Consume(requested []Item) error {
|
func (s *Inventory) Consume(requested []Item) error {
|
||||||
_, err := s.ConsumeAndRefund(requested, nil)
|
_, err := s.ConsumeAndRefund(requested, nil)
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package player
|
package player
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -41,3 +42,59 @@ func TestBattleRewardPersistsWithoutDuplicate(t *testing.T) {
|
|||||||
t.Fatalf("item id=%d", got)
|
t.Fatalf("item id=%d", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUseRandomBoxPersistsExactStackAndRewardFromInstalledGameData(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 := gamedata.LoadRandomBoxDesign(root, "20260910162539")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
path := filepath.Join(t.TempDir(), "items.json")
|
||||||
|
inv, err := OpenInventory(path, &Starter{Version: "2.34.13"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := inv.AttachRandomBoxes(design); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
boxes, err := inv.GrantOnce("mail:box", []gamedata.BattleReward{{Type: 9, ID: 433302, Count: 100000}})
|
||||||
|
if err != nil || len(boxes) != 1 {
|
||||||
|
t.Fatalf("grant box = %+v, %v", boxes, err)
|
||||||
|
}
|
||||||
|
request := wire.AppendVarint(nil, 1, 73)
|
||||||
|
request = wire.AppendVarint(request, 2, boxes[0].InvenIndex)
|
||||||
|
request = wire.AppendVarint(request, 3, 100000)
|
||||||
|
code, response, handled, err := inv.Handle("/UseRandomBox", request)
|
||||||
|
if err != nil || !handled || code != 143 {
|
||||||
|
t.Fatalf("UseRandomBox code=%d handled=%t err=%v", code, handled, err)
|
||||||
|
}
|
||||||
|
bundle, found, err := wire.Bytes(response, 1)
|
||||||
|
if err != nil || !found {
|
||||||
|
t.Fatalf("UseRandomBox reward bundle missing: found=%t err=%v", found, err)
|
||||||
|
}
|
||||||
|
item, found, err := wire.Bytes(bundle, 1)
|
||||||
|
if err != nil || !found {
|
||||||
|
t.Fatalf("UseRandomBox ItemDBInfo missing: found=%t err=%v", found, err)
|
||||||
|
}
|
||||||
|
id, _, _ := wire.Varint(item, 2)
|
||||||
|
typ, _, _ := wire.Varint(item, 3)
|
||||||
|
count, _, _ := wire.Varint(item, 4)
|
||||||
|
if id != 704 || typ != 8 || count != 100000 {
|
||||||
|
t.Fatalf("UseRandomBox response item id=%d type=%d count=%d", id, typ, count)
|
||||||
|
}
|
||||||
|
all := inv.All()
|
||||||
|
if len(all) != 1 || all[0].ID != 704 || all[0].Type != 8 || all[0].Count != 100000 {
|
||||||
|
t.Fatalf("UseRandomBox persisted inventory=%+v", all)
|
||||||
|
}
|
||||||
|
restored, err := OpenInventory(path, &Starter{Version: "2.34.13"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
all = restored.All()
|
||||||
|
if len(all) != 1 || all[0].ID != 704 || all[0].Count != 100000 {
|
||||||
|
t.Fatalf("UseRandomBox restored inventory=%+v", all)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ type Costume struct {
|
|||||||
Level uint64 `json:"level,omitempty"`
|
Level uint64 `json:"level,omitempty"`
|
||||||
UseChar uint64 `json:"use_char,omitempty"`
|
UseChar uint64 `json:"use_char,omitempty"`
|
||||||
SortID uint64 `json:"sort_id,omitempty"`
|
SortID uint64 `json:"sort_id,omitempty"`
|
||||||
PotentialID uint64 `json:"potential_id,omitempty"`
|
PotentialIDs []uint64 `json:"-"`
|
||||||
DesignID uint64 `json:"design_id,omitempty"`
|
DesignID uint64 `json:"design_id,omitempty"`
|
||||||
BurstLevel uint64 `json:"burst_level,omitempty"`
|
BurstLevel uint64 `json:"burst_level,omitempty"`
|
||||||
TimeValue uint64 `json:"time_value,omitempty"`
|
TimeValue uint64 `json:"time_value,omitempty"`
|
||||||
@@ -177,7 +177,9 @@ func CostumeWire(entry Costume) []byte {
|
|||||||
costume = wire.AppendBytes(costume, 5, book)
|
costume = wire.AppendBytes(costume, 5, book)
|
||||||
}
|
}
|
||||||
costume = add(costume, 6, entry.SortID)
|
costume = add(costume, 6, entry.SortID)
|
||||||
costume = add(costume, 8, entry.PotentialID)
|
for _, id := range entry.PotentialIDs {
|
||||||
|
costume = wire.AppendVarint(costume, 8, id)
|
||||||
|
}
|
||||||
costume = add(costume, 9, entry.DesignID)
|
costume = add(costume, 9, entry.DesignID)
|
||||||
costume = add(costume, 10, entry.BurstLevel)
|
costume = add(costume, 10, entry.BurstLevel)
|
||||||
costume = add(costume, 12, entry.TimeValue)
|
costume = add(costume, 12, entry.TimeValue)
|
||||||
|
|||||||
@@ -77,6 +77,36 @@ func (s *Wallet) CanSpendFreeJewelry(amount uint64) bool {
|
|||||||
return amount > 0 && s.state.FreeJewelry >= amount
|
return amount > 0 && s.state.FreeJewelry >= amount
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Wallet) CanSpendGold(amount uint64) bool {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return amount > 0 && s.state.Gold >= amount
|
||||||
|
}
|
||||||
|
|
||||||
|
// SpendGoldOnce covers the currency part of a GameData-defined class-up.
|
||||||
|
// Its identity is the old character instance/stage so an interrupted request
|
||||||
|
// cannot charge the same promotion a second time.
|
||||||
|
func (s *Wallet) SpendGoldOnce(identity string, amount uint64) (Currency, error) {
|
||||||
|
if identity == "" || amount == 0 {
|
||||||
|
return Currency{}, errors.New("player: invalid gold spend")
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if s.state.Spent[identity] {
|
||||||
|
return s.state.Currency, nil
|
||||||
|
}
|
||||||
|
if s.state.Gold < amount {
|
||||||
|
return Currency{}, errors.New("player: insufficient gold")
|
||||||
|
}
|
||||||
|
next := cloneWallet(s.state)
|
||||||
|
next.Gold -= amount
|
||||||
|
next.Spent[identity] = true
|
||||||
|
if err := s.commit(next); err != nil {
|
||||||
|
return Currency{}, err
|
||||||
|
}
|
||||||
|
return next.Currency, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Wallet) WasSpent(identity string) bool {
|
func (s *Wallet) WasSpent(identity string) bool {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
|
|||||||
@@ -149,9 +149,9 @@ func OpenStore(path string) (*Store, error) {
|
|||||||
s.cleared[questKey(packID, questID)] = struct{}{}
|
s.cleared[questKey(packID, questID)] = struct{}{}
|
||||||
}
|
}
|
||||||
if legacy {
|
if legacy {
|
||||||
// Install the unambiguous pack+quest schema immediately. Pack 21 and 22
|
// Install the unambiguous pack+quest schema immediately. Adjacent story
|
||||||
// both number quests from one, so waiting until the first pack22 update
|
// packs reuse quest numbers, so delaying migration until a later-pack
|
||||||
// would risk overwriting valid pack21 progress.
|
// update would risk overwriting valid earlier-pack progress.
|
||||||
if err := s.commit(s.position, s.tutorials, s.quests, s.cleared); err != nil {
|
if err := s.commit(s.position, s.tutorials, s.quests, s.cleared); err != nil {
|
||||||
return nil, fmt.Errorf("progress: migrate legacy save: %w", err)
|
return nil, fmt.Errorf("progress: migrate legacy save: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
+65
-21
@@ -11,6 +11,7 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"sort"
|
"sort"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"bd2server/internal/deck"
|
"bd2server/internal/deck"
|
||||||
"bd2server/internal/gamedata"
|
"bd2server/internal/gamedata"
|
||||||
@@ -64,7 +65,13 @@ func Load(seedPath, gameDataRoot, gameDataVersion, characterStatePath string, st
|
|||||||
return nil, fmt.Errorf("world: start quest %d is absent from QuestTable%d", seed.StartQuestID, seed.PackID)
|
return nil, fmt.Errorf("world: start quest %d is absent from QuestTable%d", seed.StartQuestID, seed.PackID)
|
||||||
}
|
}
|
||||||
transition := transitions[seed.PackID]
|
transition := transitions[seed.PackID]
|
||||||
service := &Service{seed: seed, state: state, starter: starter, equipment: equipment, inventory: inventory, wallet: wallet, characters: characters, quests: quests, transition: transition, packs: packs, transitions: transitions}
|
activePack := seed.PackID
|
||||||
|
if saved, found := state.Position(); found {
|
||||||
|
if _, known := packs[saved.PackID]; known {
|
||||||
|
activePack = saved.PackID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
service := &Service{seed: seed, state: state, starter: starter, equipment: equipment, inventory: inventory, wallet: wallet, characters: characters, quests: quests, transition: transition, packs: packs, transitions: transitions, activePack: activePack}
|
||||||
if err := service.MigrateClearedRewards(); err != nil {
|
if err := service.MigrateClearedRewards(); err != nil {
|
||||||
return nil, fmt.Errorf("world: migrate cleared quest rewards: %w", err)
|
return nil, fmt.Errorf("world: migrate cleared quest rewards: %w", err)
|
||||||
}
|
}
|
||||||
@@ -73,20 +80,49 @@ func Load(seedPath, gameDataRoot, gameDataVersion, characterStatePath string, st
|
|||||||
|
|
||||||
func (s *Service) CharacterService() *player.CharacterStore { return s.characters }
|
func (s *Service) CharacterService() *player.CharacterStore { return s.characters }
|
||||||
|
|
||||||
|
func (s *Service) EarnedQuestCostume() (player.Costume, bool) {
|
||||||
|
return s.seed.RewardCostume, s.state.QuestCleared(s.seed.BattleUnlockQuestID, s.seed.PackID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CurrentPackID returns the story pack selected by the latest successful
|
||||||
|
// PackInGameInfo request. BattleEnter does not carry a pack field, so battle
|
||||||
|
// sessions lock this value when they begin. On restart, Load seeds it from the
|
||||||
|
// persisted position and finally falls back to the versioned starter pack.
|
||||||
|
func (s *Service) CurrentPackID() (int, error) {
|
||||||
|
s.activePackMu.RLock()
|
||||||
|
packID := s.activePack
|
||||||
|
s.activePackMu.RUnlock()
|
||||||
|
if packID == 0 {
|
||||||
|
packID = s.seed.PackID
|
||||||
|
}
|
||||||
|
if _, known := s.questsFor(packID); !known || !s.packUnlocked(packID) {
|
||||||
|
return 0, fmt.Errorf("world: current pack %d is unavailable", packID)
|
||||||
|
}
|
||||||
|
return packID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) setCurrentPack(packID int) {
|
||||||
|
s.activePackMu.Lock()
|
||||||
|
s.activePack = packID
|
||||||
|
s.activePackMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
seed Seed
|
seed Seed
|
||||||
state *progress.Store
|
state *progress.Store
|
||||||
starter *player.Starter
|
starter *player.Starter
|
||||||
equipment *player.EquipmentInventory
|
equipment *player.EquipmentInventory
|
||||||
inventory *player.Inventory
|
inventory *player.Inventory
|
||||||
wallet *player.Wallet
|
wallet *player.Wallet
|
||||||
characters *player.CharacterStore
|
characters *player.CharacterStore
|
||||||
collection *player.CollectionStore
|
collection *player.CollectionStore
|
||||||
decks *deck.Store
|
decks *deck.Store
|
||||||
quests map[int]gamedata.QuestDesign
|
quests map[int]gamedata.QuestDesign
|
||||||
transition gamedata.PackTransition
|
transition gamedata.PackTransition
|
||||||
packs map[int]map[int]gamedata.QuestDesign
|
packs map[int]map[int]gamedata.QuestDesign
|
||||||
transitions map[int]gamedata.PackTransition
|
transitions map[int]gamedata.PackTransition
|
||||||
|
activePackMu sync.RWMutex
|
||||||
|
activePack int
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) AttachCollection(collection *player.CollectionStore) error {
|
func (s *Service) AttachCollection(collection *player.CollectionStore) error {
|
||||||
@@ -138,7 +174,9 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error)
|
|||||||
for _, costume := range costumes {
|
for _, costume := range costumes {
|
||||||
response = wire.AppendBytes(response, 1, encodeCostume(costume))
|
response = wire.AppendBytes(response, 1, encodeCostume(costume))
|
||||||
}
|
}
|
||||||
response = wire.AppendBytes(response, 1, encodeCostume(s.seed.RewardCostume))
|
if s.collection == nil {
|
||||||
|
response = wire.AppendBytes(response, 1, encodeCostume(s.seed.RewardCostume))
|
||||||
|
}
|
||||||
return 40, response, true, nil
|
return 40, response, true, nil
|
||||||
case "/PackInGameInfo":
|
case "/PackInGameInfo":
|
||||||
pack, err := requestPack(request)
|
pack, err := requestPack(request)
|
||||||
@@ -148,6 +186,7 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error)
|
|||||||
if !s.packUnlocked(pack) {
|
if !s.packUnlocked(pack) {
|
||||||
return 0, nil, true, fmt.Errorf("%w: unsupported pack %d", ErrInvalidRequest, pack)
|
return 0, nil, true, fmt.Errorf("%w: unsupported pack %d", ErrInvalidRequest, pack)
|
||||||
}
|
}
|
||||||
|
s.setCurrentPack(pack)
|
||||||
slog.Info("team trace: deliver pack progress", "pack", pack, "clearedQuests", s.state.ClearedQuests(pack), "storyCharacters", s.storyCharacters(pack))
|
slog.Info("team trace: deliver pack progress", "pack", pack, "clearedQuests", s.state.ClearedQuests(pack), "storyCharacters", s.storyCharacters(pack))
|
||||||
return 5, s.packInfoFor(pack), true, nil
|
return 5, s.packInfoFor(pack), true, nil
|
||||||
case "/QuestClear":
|
case "/QuestClear":
|
||||||
@@ -170,6 +209,11 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error)
|
|||||||
if err := s.state.ClearQuest(quest, pack); err != nil {
|
if err := s.state.ClearQuest(quest, pack); err != nil {
|
||||||
return 0, nil, true, fmt.Errorf("world: clear quest: %w", err)
|
return 0, nil, true, fmt.Errorf("world: clear quest: %w", err)
|
||||||
}
|
}
|
||||||
|
if s.collection != nil && quest == s.seed.BattleUnlockQuestID && pack == s.seed.PackID {
|
||||||
|
if err := s.collection.AttachRewardCostume(s.seed.RewardCostume); err != nil {
|
||||||
|
return 0, nil, true, fmt.Errorf("world: attach cleared quest costume: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
slog.Info("team trace: quest cleared", "pack", pack, "quest", quest, "changesBattleDeck", pack == s.seed.PackID && quest == s.seed.BattleUnlockQuestID)
|
slog.Info("team trace: quest cleared", "pack", pack, "quest", quest, "changesBattleDeck", pack == s.seed.PackID && quest == s.seed.BattleUnlockQuestID)
|
||||||
return 18, s.clearResponse(pack, quest, design.Rewards[0], items, questEquipment), true, nil
|
return 18, s.clearResponse(pack, quest, design.Rewards[0], items, questEquipment), true, nil
|
||||||
default:
|
default:
|
||||||
@@ -223,8 +267,8 @@ func (s *Service) transitionFor(packID int) gamedata.PackTransition {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// packUnlocked follows the static story chain and requires every preceding
|
// packUnlocked follows the static story chain and requires every preceding
|
||||||
// pack to be complete. This accepts pack22 after pack21 quest38 without
|
// pack to be complete. This accepts the configured next story pack only after
|
||||||
// accidentally exposing arbitrary tables from the shared GameData database.
|
// its predecessor is complete, without exposing arbitrary GameData tables.
|
||||||
func (s *Service) packUnlocked(packID int) bool {
|
func (s *Service) packUnlocked(packID int) bool {
|
||||||
current := s.seed.PackID
|
current := s.seed.PackID
|
||||||
for steps := 0; steps < 64 && current != 0; steps++ {
|
for steps := 0; steps < 64 && current != 0; steps++ {
|
||||||
@@ -370,7 +414,7 @@ func (s *Service) grantQuestRewards(packID, quest int, designRewards []gamedata.
|
|||||||
}
|
}
|
||||||
|
|
||||||
// packInfo is the canonical protobuf encoding of the semantic new-account
|
// packInfo is the canonical protobuf encoding of the semantic new-account
|
||||||
// pack21 state. Its first-call bytes match the 2.34.13 observed response.
|
// starter-pack state. Its first-call bytes match the 2.34.13 observed response.
|
||||||
func (s *Service) packInfo() []byte {
|
func (s *Service) packInfo() []byte {
|
||||||
return s.packInfoFor(s.seed.PackID)
|
return s.packInfoFor(s.seed.PackID)
|
||||||
}
|
}
|
||||||
@@ -410,8 +454,8 @@ func (s *Service) packInfoFor(packID int) []byte {
|
|||||||
}
|
}
|
||||||
out = wire.AppendString(out, 4, position)
|
out = wire.AppendString(out, 4, position)
|
||||||
// The remaining starter-only records were observed in the official
|
// The remaining starter-only records were observed in the official
|
||||||
// pack21 response. They represent reputation, hunting-ground, statue, and
|
// starter-pack response. They represent reputation, hunting-ground, statue,
|
||||||
// reward state, not generic pack defaults, so a newly entered pack22 must
|
// and reward state, not generic defaults, so a newly entered later pack must
|
||||||
// not inherit them.
|
// not inherit them.
|
||||||
if packID != s.seed.PackID {
|
if packID != s.seed.PackID {
|
||||||
visit := wire.AppendVarint(nil, 5, uint64(packID))
|
visit := wire.AppendVarint(nil, 5, uint64(packID))
|
||||||
@@ -653,7 +697,7 @@ func (s *Service) PictorialCostumes() []player.Costume {
|
|||||||
if s.collection != nil {
|
if s.collection != nil {
|
||||||
result = s.collection.Costumes()
|
result = s.collection.Costumes()
|
||||||
}
|
}
|
||||||
if s.state.QuestCleared(s.seed.BattleUnlockQuestID, s.seed.PackID) {
|
if s.collection == nil && s.state.QuestCleared(s.seed.BattleUnlockQuestID, s.seed.PackID) {
|
||||||
result = append(result, s.seed.RewardCostume)
|
result = append(result, s.seed.RewardCostume)
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -252,6 +252,10 @@ func TestPack22InitializesWithIndependentQuestIdentity(t *testing.T) {
|
|||||||
if err != nil || !handled || code != 5 {
|
if err != nil || !handled || code != 5 {
|
||||||
t.Fatalf("pack22 init code=%d handled=%v err=%v", code, handled, err)
|
t.Fatalf("pack22 init code=%d handled=%v err=%v", code, handled, err)
|
||||||
}
|
}
|
||||||
|
currentPack, err := s.CurrentPackID()
|
||||||
|
if err != nil || currentPack != 22 {
|
||||||
|
t.Fatalf("current pack after pack22 init=%d err=%v", currentPack, err)
|
||||||
|
}
|
||||||
active, found, err := wire.Bytes(response, 2)
|
active, found, err := wire.Bytes(response, 2)
|
||||||
if err != nil || !found {
|
if err != nil || !found {
|
||||||
t.Fatalf("pack22 active quest missing: %v", err)
|
t.Fatalf("pack22 active quest missing: %v", err)
|
||||||
|
|||||||
@@ -8,6 +8,28 @@ Python 版本为 3.11 以上;GameData 分页 AES 解密需要 `pycryptodome`
|
|||||||
python -m pip install pycryptodome
|
python -m pip install pycryptodome
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 客户端源码镜像与 Proto 查看
|
||||||
|
|
||||||
|
客户端反编译源码、混淆翻译表和官方会话抓包是开发取证输入;这些工具只读取客户端目录,绝不原地改写。两个输出目录都必须位于 `--source` 之外。为避免清空人工文件,输出已存在时只有带本工具清单的目录才能被重新生成。
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# 用 ObfuscationTranslation 的 “混淆名⇨含义” 映射生成可检索 C# 镜像。
|
||||||
|
# 替换只作用于代码中的 C# 标识符;注释、字符串和字符字面量原样保留。
|
||||||
|
python .\tools\python\deobfuscate_client_source.py `
|
||||||
|
--source "<Assembly-CSharp目录>" `
|
||||||
|
--mapping "<ObfuscationTranslation.obfuscate>" `
|
||||||
|
--output .\tmp\client-source-readable
|
||||||
|
|
||||||
|
# 从 *Reflection.cs 内嵌的 FileDescriptorProto 重建真正的 .proto。
|
||||||
|
python .\tools\python\extract_client_proto.py `
|
||||||
|
--source "<Assembly-CSharp目录>" `
|
||||||
|
--output .\tmp\client-proto
|
||||||
|
```
|
||||||
|
|
||||||
|
反混淆输出根下的 `.bd2-deobfuscate-manifest.json` 记录有效映射、冲突改名、未处理警告和替换统计。映射含命名空间或路径时,工具取末段并规范为一个合法标识符;发生同名时加入稳定后缀。该镜像用于阅读与检索,并不承诺可编译。
|
||||||
|
|
||||||
|
Proto 输出根按 descriptor 原始文件名保存可读 `.proto`;`client-descriptors.pb` 是无损 `FileDescriptorSet`,用于保留文本渲染器暂未展开的复杂 options。`.bd2-proto-extract-manifest.json` 记录 package、依赖、源 Reflection、descriptor/.proto SHA-256 和渲染警告。
|
||||||
|
|
||||||
## 查询 GameData
|
## 查询 GameData
|
||||||
|
|
||||||
无需运行 `go test` 或构建服务端:
|
无需运行 `go test` 或构建服务端:
|
||||||
@@ -84,6 +106,43 @@ python .\tools\python\save_checkpoint.py restore .\data\state\checkpoints\某检
|
|||||||
python .\tools\python\save_checkpoint.py restore .\data\state\checkpoints\某检查点 --apply
|
python .\tools\python\save_checkpoint.py restore .\data\state\checkpoints\某检查点 --apply
|
||||||
```
|
```
|
||||||
|
|
||||||
|
开发期存档格式发生变化时,不在运行时加入旧格式猜测或兼容分支。应编写一次性 Python 编辑器,先演练,再在客户端和服务端停止后创建完整检查点并原子改档。例如首次抽卡完成状态:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python .\tools\python\set_first_gacha_state.py --state .\data\state --completed
|
||||||
|
python .\tools\python\set_first_gacha_state.py --state .\data\state --completed --apply
|
||||||
|
```
|
||||||
|
|
||||||
|
正式运行时代码只接受编辑后的最终语义;开发工具不进入发布包。
|
||||||
|
|
||||||
|
## 临时邮件物品发放
|
||||||
|
|
||||||
|
`dev_mail_grant.py` 是独立的、仅监听环回地址的开发期浏览器工具。它只读取指定版本的 GameData,列出已验证、可走 `ItemDBInfo` 领取路径的有名道具:`FoodTable`(类型 5)、`CookingTable`(7)、`ResourceTable`(8)、`QuestItemTable`(13)、`UseItemTable`(14)、`CollectionTable`(17)、`MyRoomItemTable`(27)和 `InstantUseItemTable`(29)。`ResourceTable.Type=2` 的场景/展示哨兵和无可用名称行不提供;固定内容随机箱只用于反查内容物的真实 ID,所有 type 9 随机箱均不作为邮件选项。另提供单一金币货币条目 `type4/id0`,填写的数量在领取后直接叠加至钱包,不再发送“金币随机箱”。工具本身既不属于 `bd2server.exe`,也不修改 `data/state` 的九份账号状态。
|
||||||
|
|
||||||
|
启动工具时,`--mail-seed` 是只读的当前基础邮件种子;`--output` 是新生成的完整临时种子。工具启动后在浏览器打开 `http://127.0.0.1:8765/`:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python .\tools\python\dev_mail_grant.py serve `
|
||||||
|
--game-data "E:\bd2\dl\GameData" `
|
||||||
|
--game-data-version "20260910162539" `
|
||||||
|
--mail-seed .\go\seed\v2_34_13\mail.json `
|
||||||
|
--output .\data\dev\mail-grants.json
|
||||||
|
```
|
||||||
|
|
||||||
|
工具启动时立即原子写出规范化的完整 `--output`(尚未发放也一样),因此首次启用时可先启动工具、再让本地服务端监听这个输出文件。每次发放同样原子更新该文件。`bd2server` 的邮件服务会在下一次正常 `/MailInfo` 请求检查它:网页发放后重新打开或刷新游戏邮箱即可看到新邮件,**不需要每次重启服务器**。服务仅在启动时需要加入(或替换为)以下参数:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
--mail-seed ".\data\dev\mail-grants.json"
|
||||||
|
```
|
||||||
|
|
||||||
|
客户端 `MailDBInfo.ItemType`、`ItemId` 和 `ItemCount` 均为 `int32`,所以该工具把单附件数量限制为 `1..2147483647`;每封工具邮件固定只有一个附件。当前官方样本中单封最多观察到 5 个附件,但没有证据证明这是协议上限,因此工具不据此宣称或实施“5 件”上限。客户端邮箱 UI 按一次请求加载最多 100 封普通邮件,现有本地服务目前回传全部未开封邮件,故大量历史未领取邮件的实际 UI 表现尚待验证。现有本地 `/MailOpen` 对同一邮件 ID 的领取由 `data/state/mail.json` 的 `opened` 集合持久化,重试不会重复发奖;工具会在完整种子中分配唯一递增邮件 ID。
|
||||||
|
|
||||||
|
曾从开发工具发放的 `ResourceTable 90045`(金币遗失物品)会在客户端邮件详情打开时因缺少 `CostumeTable id405` 崩溃。修复工具 `repair_invalid_dev_mail_resources.py` 默认只演练;确需移除已误领的 `90045/90046` 实例时,先停止客户端与服务端,再用 `--apply` 自动创建、校验九文件备份并只修 `items.json`,保留邮件已领取与发放台账。
|
||||||
|
|
||||||
|
这不是“所有 GameData 表都可发放”的虚假承诺:角色(元素类型 6)、装备(10)、服装(11)和我的房间奖杯(28)在客户端 `RewardDBInfoBundle` 中分别必须使用 `CharDBInfo`、`EquipDBInfo`、`CostumeDBInfo`、`MyRoomTrophyDBInfo`,而当前本地邮件服务尚未连接相应领域存档,工具不会提供它们;直接伪装成 `ItemDBInfo` 会造成客户端状态错误。付费/普通货币之外的特殊货币亦不在当前本地钱包实现范围内。客户端 `DataManager.GetItemDTO` 对 `ContentTicket`(19)和 `LobbySettingItem`(25)没有可用于 `ItemDBInfo` 领取的 DTO 分支,故也没有提供;`GetItemInfo` 的显示分支不足以证明可安全存储。若要补齐这些类型,需要先实现对应的服务端存储、去重及正确 reward-bundle 字段,不需要客户端 patch。
|
||||||
|
|
||||||
|
热载只接受经过 `mail.Starter.Validate` 校验的完整 JSON 种子:文件未变化时不会重新读取;被检测到的坏替换会保留上一次已验证邮箱,并使该次 `/MailInfo` 请求失败而不会部分加载。工具本身总是完整写临时文件、`fsync` 后原子替换,正常发放不会让服务器看到半文件。
|
||||||
|
|
||||||
## 任务 38 定向回档
|
## 任务 38 定向回档
|
||||||
|
|
||||||
该工具只撤销 pack21 quest38 的进度、四项物品和 1500 金币,保留其他账号资产;兼容旧存档和 v2 `pack:quest` 键:
|
该工具只撤销 pack21 quest38 的进度、四项物品和 1500 金币,保留其他账号资产;兼容旧存档和 v2 `pack:quest` 键:
|
||||||
|
|||||||
@@ -0,0 +1,372 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Create a searchable, read-only deobfuscated mirror of C# client source.
|
||||||
|
|
||||||
|
The original source tree is never modified. Mapping files use ``left⇨right``;
|
||||||
|
``#ReverseOrder`` reverses mappings on subsequent lines. Output is a mirror
|
||||||
|
with a JSON manifest describing every effective replacement and limitation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
from typing import Iterable
|
||||||
|
import unicodedata
|
||||||
|
|
||||||
|
|
||||||
|
TOOL = "bd2.deobfuscate_client_source"
|
||||||
|
MANIFEST = ".bd2-deobfuscate-manifest.json"
|
||||||
|
ARROW = "⇨"
|
||||||
|
CSHARP_KEYWORDS = {
|
||||||
|
"abstract", "as", "base", "bool", "break", "byte", "case", "catch", "char",
|
||||||
|
"checked", "class", "const", "continue", "decimal", "default", "delegate", "do",
|
||||||
|
"double", "else", "enum", "event", "explicit", "extern", "false", "finally",
|
||||||
|
"fixed", "float", "for", "foreach", "goto", "if", "implicit", "in", "int",
|
||||||
|
"interface", "internal", "is", "lock", "long", "namespace", "new", "null",
|
||||||
|
"object", "operator", "out", "override", "params", "private", "protected", "public",
|
||||||
|
"readonly", "ref", "return", "sbyte", "sealed", "short", "sizeof", "stackalloc",
|
||||||
|
"static", "string", "struct", "switch", "this", "throw", "true", "try", "typeof",
|
||||||
|
"uint", "ulong", "unchecked", "unsafe", "ushort", "using", "virtual", "void",
|
||||||
|
"volatile", "while",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def is_within(child: Path, parent: Path) -> bool:
|
||||||
|
try:
|
||||||
|
child.relative_to(parent)
|
||||||
|
return True
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def require_distinct_trees(source: Path, output: Path) -> tuple[Path, Path]:
|
||||||
|
source, output = source.resolve(), output.resolve()
|
||||||
|
if not source.is_dir():
|
||||||
|
raise ValueError(f"source is not a directory: {source}")
|
||||||
|
if source == output or is_within(output, source) or is_within(source, output):
|
||||||
|
raise ValueError("output must be outside, and not contain, source")
|
||||||
|
return source, output
|
||||||
|
|
||||||
|
|
||||||
|
def managed_output(output: Path) -> bool:
|
||||||
|
manifest = output / MANIFEST
|
||||||
|
if not manifest.is_file():
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
return json.loads(manifest.read_text(encoding="utf-8")).get("tool") == TOOL
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def prepare_stage(output: Path) -> Path:
|
||||||
|
if output.exists() and not managed_output(output):
|
||||||
|
raise FileExistsError(
|
||||||
|
f"refusing to overwrite non-managed output directory: {output}"
|
||||||
|
)
|
||||||
|
output.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
return Path(tempfile.mkdtemp(prefix=f".{output.name}.", dir=output.parent))
|
||||||
|
|
||||||
|
|
||||||
|
def publish_stage(stage: Path, output: Path) -> None:
|
||||||
|
if output.exists():
|
||||||
|
# The caller already established that this is a directory created by us.
|
||||||
|
shutil.rmtree(output)
|
||||||
|
stage.replace(output)
|
||||||
|
|
||||||
|
|
||||||
|
def identifier_from_meaning(value: str) -> str:
|
||||||
|
"""Turn a map value into one legal, readable C# identifier.
|
||||||
|
|
||||||
|
Translation databases sometimes store a qualified path such as
|
||||||
|
``Net.Player/UserInfo``. An identifier replacement must be a single token,
|
||||||
|
so the useful terminal component is selected before normalising it.
|
||||||
|
"""
|
||||||
|
parts = [part for part in re.split(r"(?:\.|::|/|\\)+", value.strip()) if part]
|
||||||
|
raw = parts[-1] if parts else value.strip()
|
||||||
|
result = []
|
||||||
|
for index, char in enumerate(raw):
|
||||||
|
if (char == "_" or char.isascii() and char.isalpha() or
|
||||||
|
index > 0 and char.isascii() and char.isdigit()):
|
||||||
|
result.append(char)
|
||||||
|
elif char.isascii() and char.isdigit() and index == 0:
|
||||||
|
result.extend(("_", char))
|
||||||
|
else:
|
||||||
|
result.append("_")
|
||||||
|
name = "".join(result).strip("_") or "unnamed"
|
||||||
|
if name[0].isdigit():
|
||||||
|
name = "_" + name
|
||||||
|
if name in CSHARP_KEYWORDS:
|
||||||
|
name = "_" + name
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def is_identifier_start(char: str) -> bool:
|
||||||
|
return char == "_" or unicodedata.category(char) in {
|
||||||
|
"Lu", "Ll", "Lt", "Lm", "Lo", "Nl",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def is_identifier_continue(char: str) -> bool:
|
||||||
|
return is_identifier_start(char) or unicodedata.category(char) in {
|
||||||
|
"Mn", "Mc", "Nd", "Pc", "Cf",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_mapping(path: Path) -> tuple[list[dict], list[str]]:
|
||||||
|
"""Read the mapping while retaining malformed/conflicting entries as notes."""
|
||||||
|
entries: list[dict] = []
|
||||||
|
warnings: list[str] = []
|
||||||
|
for line_number, raw_line in enumerate(path.read_text(encoding="utf-8-sig").splitlines(), 1):
|
||||||
|
line = raw_line.strip()
|
||||||
|
if not line or line.startswith("//"):
|
||||||
|
continue
|
||||||
|
if line.casefold() == "#reverseorder":
|
||||||
|
# Official files carry this as format metadata, but their actual
|
||||||
|
# rows are still visibly obfuscated-name ⇨ readable-name.
|
||||||
|
continue
|
||||||
|
if ARROW not in line:
|
||||||
|
if not line.startswith("#"):
|
||||||
|
warnings.append(f"line {line_number}: ignored (no {ARROW!r})")
|
||||||
|
continue
|
||||||
|
left, right = (part.strip() for part in line.split(ARROW, 1))
|
||||||
|
source, meaning = left, right
|
||||||
|
if not source or not meaning:
|
||||||
|
warnings.append(f"line {line_number}: ignored (empty mapping side)")
|
||||||
|
continue
|
||||||
|
if not source or not is_identifier_start(source[0]) or not all(
|
||||||
|
is_identifier_continue(char) for char in source[1:]):
|
||||||
|
warnings.append(f"line {line_number}: ignored (non-identifier source {source!r})")
|
||||||
|
continue
|
||||||
|
entries.append({
|
||||||
|
"line": line_number,
|
||||||
|
"source": source,
|
||||||
|
"meaning": meaning,
|
||||||
|
})
|
||||||
|
return entries, warnings
|
||||||
|
|
||||||
|
|
||||||
|
def build_replacements(entries: Iterable[dict]) -> tuple[dict[str, str], list[dict], list[str]]:
|
||||||
|
replacements: dict[str, str] = {}
|
||||||
|
report: list[dict] = []
|
||||||
|
warnings: list[str] = []
|
||||||
|
used: set[str] = set()
|
||||||
|
grouped: dict[str, list[dict]] = {}
|
||||||
|
for entry in entries:
|
||||||
|
grouped.setdefault(entry["source"], []).append(entry)
|
||||||
|
for source, candidates in grouped.items():
|
||||||
|
meanings = {entry["meaning"] for entry in candidates}
|
||||||
|
if len(meanings) != 1:
|
||||||
|
warnings.append(
|
||||||
|
f"source {source!r}: ignored ambiguous scoped mappings "
|
||||||
|
f"({len(meanings)} meanings)"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
entry = candidates[0]
|
||||||
|
base = identifier_from_meaning(entry["meaning"])
|
||||||
|
replacement = base
|
||||||
|
collision = False
|
||||||
|
if replacement in used:
|
||||||
|
collision = True
|
||||||
|
suffix = re.sub(r"[^A-Za-z0-9_]", "_", source)
|
||||||
|
if not suffix.strip("_"):
|
||||||
|
suffix = "_".join(f"u{ord(char):04X}" for char in source)
|
||||||
|
replacement = f"{base}__from_{suffix}"
|
||||||
|
number = 2
|
||||||
|
while replacement in used:
|
||||||
|
replacement = f"{base}__from_{suffix}_{number}"
|
||||||
|
number += 1
|
||||||
|
used.add(replacement)
|
||||||
|
replacements[source] = replacement
|
||||||
|
report.append({
|
||||||
|
**entry,
|
||||||
|
"replacement": replacement,
|
||||||
|
"sanitized": replacement != entry["meaning"],
|
||||||
|
"name_collision": collision,
|
||||||
|
})
|
||||||
|
return replacements, report, warnings
|
||||||
|
|
||||||
|
|
||||||
|
def _consume_quoted(text: str, start: int, quote: str) -> int:
|
||||||
|
"""Return the index after a C# string/character/raw-string literal."""
|
||||||
|
quotes = 0
|
||||||
|
while start + quotes < len(text) and text[start + quotes] == quote:
|
||||||
|
quotes += 1
|
||||||
|
if quote == '"' and quotes >= 3:
|
||||||
|
end_marker = quote * quotes
|
||||||
|
end = text.find(end_marker, start + quotes)
|
||||||
|
return len(text) if end < 0 else end + quotes
|
||||||
|
index = start + 1
|
||||||
|
verbatim = start > 0 and text[start - 1] == "@"
|
||||||
|
while index < len(text):
|
||||||
|
if verbatim and quote == '"' and text.startswith('""', index):
|
||||||
|
index += 2
|
||||||
|
elif text[index] == quote:
|
||||||
|
return index + 1
|
||||||
|
elif not verbatim and text[index] == "\\":
|
||||||
|
index += 2
|
||||||
|
else:
|
||||||
|
index += 1
|
||||||
|
return len(text)
|
||||||
|
|
||||||
|
|
||||||
|
def replace_csharp_identifiers(text: str, replacements: dict[str, str]) -> tuple[str, int]:
|
||||||
|
"""Replace code identifiers only; comments and literal payloads stay exact."""
|
||||||
|
output: list[str] = []
|
||||||
|
index = changed = 0
|
||||||
|
length = len(text)
|
||||||
|
while index < length:
|
||||||
|
if text.startswith("//", index):
|
||||||
|
end = text.find("\n", index)
|
||||||
|
end = length if end < 0 else end
|
||||||
|
output.append(text[index:end])
|
||||||
|
index = end
|
||||||
|
elif text.startswith("/*", index):
|
||||||
|
end = text.find("*/", index + 2)
|
||||||
|
end = length if end < 0 else end + 2
|
||||||
|
output.append(text[index:end])
|
||||||
|
index = end
|
||||||
|
elif text[index] in "\"'":
|
||||||
|
end = _consume_quoted(text, index, text[index])
|
||||||
|
output.append(text[index:end])
|
||||||
|
index = end
|
||||||
|
elif is_identifier_start(text[index]):
|
||||||
|
end = index + 1
|
||||||
|
while end < length and is_identifier_continue(text[end]):
|
||||||
|
end += 1
|
||||||
|
token = text[index:end]
|
||||||
|
replacement = replacements.get(token, token)
|
||||||
|
output.append(replacement)
|
||||||
|
changed += replacement != token
|
||||||
|
index = end
|
||||||
|
else:
|
||||||
|
output.append(text[index])
|
||||||
|
index += 1
|
||||||
|
return "".join(output), changed
|
||||||
|
|
||||||
|
|
||||||
|
def read_csharp(path: Path) -> tuple[str, str] | None:
|
||||||
|
data = path.read_bytes()
|
||||||
|
if data.startswith(b"\xff\xfe"):
|
||||||
|
return data[2:].decode("utf-16-le"), "utf-16-le"
|
||||||
|
if data.startswith(b"\xfe\xff"):
|
||||||
|
return data[2:].decode("utf-16-be"), "utf-16-be"
|
||||||
|
try:
|
||||||
|
return data.decode("utf-8-sig"), "utf-8-sig" if data.startswith(b"\xef\xbb\xbf") else "utf-8"
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def write_csharp(path: Path, text: str, encoding: str) -> None:
|
||||||
|
if encoding == "utf-16-le":
|
||||||
|
path.write_bytes(b"\xff\xfe" + text.encode(encoding))
|
||||||
|
elif encoding == "utf-16-be":
|
||||||
|
path.write_bytes(b"\xfe\xff" + text.encode(encoding))
|
||||||
|
elif encoding == "utf-8-sig":
|
||||||
|
path.write_bytes(text.encode(encoding))
|
||||||
|
else:
|
||||||
|
path.write_text(text, encoding="utf-8", newline="")
|
||||||
|
|
||||||
|
|
||||||
|
def destination_for(relative: Path, replacements: dict[str, str], occupied: set[Path]) -> Path:
|
||||||
|
if relative.suffix.casefold() != ".cs":
|
||||||
|
return relative
|
||||||
|
stem = replacements.get(relative.stem, relative.stem)
|
||||||
|
candidate = relative.with_name(stem + relative.suffix)
|
||||||
|
if candidate not in occupied:
|
||||||
|
return candidate
|
||||||
|
number = 2
|
||||||
|
while True:
|
||||||
|
candidate = relative.with_name(f"{stem}__file_{number}{relative.suffix}")
|
||||||
|
if candidate not in occupied:
|
||||||
|
return candidate
|
||||||
|
number += 1
|
||||||
|
|
||||||
|
|
||||||
|
def deobfuscate(source: Path, mapping: Path, output: Path) -> dict:
|
||||||
|
source, output = require_distinct_trees(source, output)
|
||||||
|
mapping = mapping.resolve()
|
||||||
|
if not mapping.is_file():
|
||||||
|
raise ValueError(f"mapping is not a file: {mapping}")
|
||||||
|
entries, warnings = parse_mapping(mapping)
|
||||||
|
replacements, mapping_report, replacement_warnings = build_replacements(entries)
|
||||||
|
warnings.extend(replacement_warnings)
|
||||||
|
stage = prepare_stage(output)
|
||||||
|
files: list[dict] = []
|
||||||
|
occupied: set[Path] = set()
|
||||||
|
try:
|
||||||
|
for input_path in sorted(path for path in source.rglob("*") if path.is_file()):
|
||||||
|
relative = input_path.relative_to(source)
|
||||||
|
destination = destination_for(relative, replacements, occupied)
|
||||||
|
occupied.add(destination)
|
||||||
|
destination_path = stage / destination
|
||||||
|
destination_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
record = {"source": relative.as_posix(), "output": destination.as_posix()}
|
||||||
|
if input_path.suffix.casefold() == ".cs":
|
||||||
|
decoded = read_csharp(input_path)
|
||||||
|
if decoded is None:
|
||||||
|
shutil.copy2(input_path, destination_path)
|
||||||
|
record.update({"action": "copied", "reason": "unsupported text encoding"})
|
||||||
|
warnings.append(f"{relative}: copied without replacement (unsupported encoding)")
|
||||||
|
else:
|
||||||
|
text, encoding = decoded
|
||||||
|
translated, changed = replace_csharp_identifiers(text, replacements)
|
||||||
|
write_csharp(destination_path, translated, encoding)
|
||||||
|
record.update({"action": "translated", "identifier_replacements": changed})
|
||||||
|
else:
|
||||||
|
shutil.copy2(input_path, destination_path)
|
||||||
|
record["action"] = "copied"
|
||||||
|
files.append(record)
|
||||||
|
manifest = {
|
||||||
|
"tool": TOOL,
|
||||||
|
"source": str(source),
|
||||||
|
"mapping": str(mapping),
|
||||||
|
"mapping_entries": mapping_report,
|
||||||
|
"files": files,
|
||||||
|
"warnings": warnings,
|
||||||
|
"statistics": {
|
||||||
|
"mapping_entries": len(mapping_report),
|
||||||
|
"files": len(files),
|
||||||
|
"csharp_files": sum(item["source"].casefold().endswith(".cs") for item in files),
|
||||||
|
"identifier_replacements": sum(item.get("identifier_replacements", 0) for item in files),
|
||||||
|
},
|
||||||
|
"limitations": [
|
||||||
|
"Only C# identifier tokens are changed; comments and literal contents are preserved.",
|
||||||
|
"Qualified mapping values become their terminal identifier component.",
|
||||||
|
"This is a searchable mirror, not a promise that the transformed source compiles.",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
(stage / MANIFEST).write_text(
|
||||||
|
json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
publish_stage(stage, output)
|
||||||
|
return manifest
|
||||||
|
except Exception:
|
||||||
|
shutil.rmtree(stage, ignore_errors=True)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--source", required=True, type=Path, help="client Assembly-CSharp directory")
|
||||||
|
parser.add_argument("--mapping", required=True, type=Path, help="ObfuscationTranslation file")
|
||||||
|
parser.add_argument("--output", required=True, type=Path, help="mirror directory outside --source")
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = build_parser().parse_args()
|
||||||
|
try:
|
||||||
|
manifest = deobfuscate(args.source, args.mapping, args.output)
|
||||||
|
except (OSError, ValueError, UnicodeError) as exc:
|
||||||
|
raise SystemExit(f"deobfuscate_client_source: {exc}")
|
||||||
|
stats = manifest["statistics"]
|
||||||
|
print(f"wrote {args.output.resolve()} ({stats['files']} files, {stats['identifier_replacements']} replacements)")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,490 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Local-only browser tool for adding ItemDBInfo-compatible GameData items to mail.
|
||||||
|
|
||||||
|
This program deliberately is not part of bd2server. It reads the selected
|
||||||
|
2.34.13 GameData archive and writes a complete replacement mail seed using an
|
||||||
|
atomic rename. Start bd2server once with --mail-seed pointing at the same
|
||||||
|
--output; subsequent grants are hot-loaded by the normal /MailInfo request.
|
||||||
|
It never reads or changes data/state.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
python tools/python/dev_mail_grant.py serve `
|
||||||
|
--game-data E:\\bd2\\dl\\GameData --game-data-version 20260910162539 `
|
||||||
|
--mail-seed go\\seed\\v2_34_13\\mail.json --output data\\dev\\mail-grants.json
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import html
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from http import HTTPStatus
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
# gamedata_db is the repository's reviewed, read-only GameData decryptor.
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||||
|
from gamedata_db import read_database, walk_wire # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
VERSION = "2.34.13"
|
||||||
|
MAX_INT32 = (1 << 31) - 1
|
||||||
|
|
||||||
|
# These are the local server's ItemDBInfo-backed ElementTypes. The mapping is
|
||||||
|
# checked against 2.34.13 DataManager.GetItemInfo; characters, equipment,
|
||||||
|
# costumes, and trophies use separate RewardDBInfoBundle fields and are not
|
||||||
|
# falsely offered by this tool.
|
||||||
|
ITEM_SOURCES = (
|
||||||
|
("ResourceTable", 8, 4, 7, "资源"),
|
||||||
|
("FoodTable", 5, 7, 10, "料理"),
|
||||||
|
("CookingTable", 7, 3, 11, "烹饪配方"),
|
||||||
|
("RandomBoxTable", 9, 4, 7, "随机箱"),
|
||||||
|
("QuestItemTable", 13, 2, 5, "任务物品"),
|
||||||
|
("UseItemTable", 14, 3, 6, "使用物品"),
|
||||||
|
("CollectionTable", 17, 3, 7, "收藏品"),
|
||||||
|
("MyRoomItemTable", 27, 7, 17, "我的房间物品"),
|
||||||
|
("InstantUseItemTable", 29, 1, 3, "即时使用物品"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _varint(value: Any) -> int:
|
||||||
|
if not isinstance(value, int) or value < 0:
|
||||||
|
raise ValueError("expected a non-negative protobuf varint")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def fields(proto: bytes) -> dict[int, list[Any]]:
|
||||||
|
result: dict[int, list[Any]] = {}
|
||||||
|
for number, wire_type, value in walk_wire(proto):
|
||||||
|
if wire_type != 0 and wire_type != 2:
|
||||||
|
continue
|
||||||
|
result.setdefault(number, []).append(value)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def first_varint(values: dict[int, list[Any]], number: int) -> int:
|
||||||
|
entries = values.get(number, [])
|
||||||
|
if len(entries) != 1:
|
||||||
|
return 0
|
||||||
|
return _varint(entries[0])
|
||||||
|
|
||||||
|
|
||||||
|
def first_text(values: dict[int, list[Any]], number: int) -> str:
|
||||||
|
entries = values.get(number, [])
|
||||||
|
if len(entries) != 1 or not isinstance(entries[0], bytes):
|
||||||
|
return ""
|
||||||
|
return entries[0].decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def packed_varints(values: dict[int, list[Any]], number: int) -> list[int]:
|
||||||
|
"""Decode proto3 packed/repeated uint fields without guessing their shape."""
|
||||||
|
result: list[int] = []
|
||||||
|
for entry in values.get(number, []):
|
||||||
|
if isinstance(entry, int):
|
||||||
|
result.append(_varint(entry))
|
||||||
|
continue
|
||||||
|
if not isinstance(entry, bytes):
|
||||||
|
raise ValueError(f"field {number} is not a protobuf varint")
|
||||||
|
offset = 0
|
||||||
|
while offset < len(entry):
|
||||||
|
value = 0
|
||||||
|
for shift in range(0, 70, 7):
|
||||||
|
if offset >= len(entry):
|
||||||
|
raise ValueError(f"truncated packed protobuf field {number}")
|
||||||
|
byte = entry[offset]
|
||||||
|
offset += 1
|
||||||
|
value |= (byte & 0x7f) << shift
|
||||||
|
if byte < 0x80:
|
||||||
|
result.append(value)
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
raise ValueError(f"oversized packed protobuf field {number}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def open_readonly_database(root: Path, version: str) -> tuple[sqlite3.Connection, Path]:
|
||||||
|
"""Open the current common database in a private read-only SQLite file."""
|
||||||
|
plain = read_database(root, version, "quest")
|
||||||
|
handle = tempfile.NamedTemporaryFile(prefix="bd2-dev-mail-", suffix=".db", delete=False)
|
||||||
|
path = Path(handle.name)
|
||||||
|
try:
|
||||||
|
handle.write(plain)
|
||||||
|
handle.flush()
|
||||||
|
finally:
|
||||||
|
handle.close()
|
||||||
|
try:
|
||||||
|
connection = sqlite3.connect(path.resolve().as_uri() + "?mode=ro", uri=True)
|
||||||
|
connection.execute("PRAGMA query_only=ON")
|
||||||
|
return connection, path
|
||||||
|
except Exception:
|
||||||
|
path.unlink(missing_ok=True)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def _usable_display_name(value: str) -> bool:
|
||||||
|
return bool(value) and not value.startswith("<未找到本地化文本 #") and value not in {"(不使用)", "(不使用)"}
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_direct_mail_item(item: dict[str, Any]) -> bool:
|
||||||
|
# RandomBox requires a second protocol and its entered count is not the
|
||||||
|
# final reward count, so this direct-mail form never exposes type 9.
|
||||||
|
if item["element_type"] == 9:
|
||||||
|
return False
|
||||||
|
# ResourceTable Type=2 rows are field-object presentation sentinels, not
|
||||||
|
# inventory materials. 90045/90046 even point at costume IDs for their
|
||||||
|
# field popup and crash ItemInfoPopupUI when presented as normal resources.
|
||||||
|
if item.get("source_table") == "ResourceTable" and item.get("resource_type") == 2:
|
||||||
|
return False
|
||||||
|
return _usable_display_name(item["name"])
|
||||||
|
|
||||||
|
|
||||||
|
def map_fixed_boxes_to_direct_items(
|
||||||
|
items: list[dict[str, Any]],
|
||||||
|
fixed_boxes: dict[int, tuple[int, int, int]],
|
||||||
|
product_aliases: dict[int, dict[str, int]],
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Hide deterministic boxes and expose their contained ItemDBInfo directly.
|
||||||
|
|
||||||
|
The quantity entered in the development form is the final material count;
|
||||||
|
the source box multiplier is deliberately informational and is not applied.
|
||||||
|
"""
|
||||||
|
by_key = {(item["element_type"], item["id"]): item for item in items}
|
||||||
|
target_aliases: dict[tuple[int, int], dict[str, int]] = {}
|
||||||
|
source_boxes: dict[tuple[int, int], list[tuple[int, int]]] = {}
|
||||||
|
for box_id, (reward_type, reward_id, reward_count) in fixed_boxes.items():
|
||||||
|
target_key = (reward_type, reward_id)
|
||||||
|
target = by_key.get(target_key)
|
||||||
|
box = by_key.get((9, box_id))
|
||||||
|
if target is None or box is None:
|
||||||
|
continue
|
||||||
|
source_boxes.setdefault(target_key, []).append((box_id, reward_count))
|
||||||
|
aliases = target_aliases.setdefault(target_key, {})
|
||||||
|
for alias, frequency in product_aliases.get(box_id, {}).items():
|
||||||
|
if _usable_display_name(alias):
|
||||||
|
aliases[alias] = aliases.get(alias, 0) + frequency
|
||||||
|
|
||||||
|
for target_key, aliases in target_aliases.items():
|
||||||
|
target = by_key[target_key]
|
||||||
|
canonical = max(aliases, key=lambda value: (aliases[value], -len(value), value)) if aliases else ""
|
||||||
|
if not _usable_display_name(target["name"]):
|
||||||
|
if canonical:
|
||||||
|
target["name"] = canonical
|
||||||
|
# Keep only the dominant authoritative product label. Minority product
|
||||||
|
# names can describe expiry/conversion products (for example a ticket
|
||||||
|
# which converts to 女神之泪) and must not pollute direct-item search.
|
||||||
|
target["aliases"] = [canonical] if canonical and canonical != target["name"] else []
|
||||||
|
boxes = source_boxes[target_key]
|
||||||
|
preview = "、".join(str(box_id) for box_id, _ in boxes[:4])
|
||||||
|
if len(boxes) > 4:
|
||||||
|
preview += f" 等 {len(boxes)} 个"
|
||||||
|
details = ["开发邮件直接发放此物品(无需开箱)"]
|
||||||
|
if target["aliases"]:
|
||||||
|
details.append("商品名:" + canonical)
|
||||||
|
details.append("固定箱映射:" + preview)
|
||||||
|
target["details"] = ";".join(details)
|
||||||
|
|
||||||
|
return [item for item in items if _safe_direct_mail_item(item)]
|
||||||
|
|
||||||
|
|
||||||
|
def load_items(root: Path, version: str) -> list[dict[str, Any]]:
|
||||||
|
"""Return every safe ItemDBInfo-backed static item, with Chinese names."""
|
||||||
|
connection, temporary = open_readonly_database(root, version)
|
||||||
|
try:
|
||||||
|
names: dict[int, str] = {}
|
||||||
|
for row_id, proto in connection.execute("SELECT id, ProtoBuf FROM LocalTextTable"):
|
||||||
|
decoded = fields(proto)
|
||||||
|
# LocalTextTable: id=2, text_cn=4, text_en=5 (client descriptor).
|
||||||
|
text_id = first_varint(decoded, 2) or int(row_id)
|
||||||
|
name = first_text(decoded, 4) or first_text(decoded, 5) or first_text(decoded, 3)
|
||||||
|
if name:
|
||||||
|
names[text_id] = name
|
||||||
|
|
||||||
|
items: list[dict[str, Any]] = []
|
||||||
|
for table, element_type, id_field, name_field, category in ITEM_SOURCES:
|
||||||
|
for row_id, proto in connection.execute(f"SELECT id, ProtoBuf FROM {table} ORDER BY id"):
|
||||||
|
decoded = fields(proto)
|
||||||
|
item_id = first_varint(decoded, id_field) or int(row_id)
|
||||||
|
name_text_id = first_varint(decoded, name_field)
|
||||||
|
items.append({
|
||||||
|
"id": item_id,
|
||||||
|
"element_type": element_type,
|
||||||
|
"name": names.get(name_text_id, f"<未找到本地化文本 #{name_text_id}>"),
|
||||||
|
"category": category,
|
||||||
|
"source_table": table,
|
||||||
|
"name_text_id": name_text_id,
|
||||||
|
"resource_type": first_varint(decoded, 13) if table == "ResourceTable" else None,
|
||||||
|
})
|
||||||
|
# Currency is not an ItemDBInfo row. MailOpen and RewardDBInfoBundle
|
||||||
|
# represent it as type=Gold(4), id=0 and the entered count, which the
|
||||||
|
# wallet atomically adds to the existing balance.
|
||||||
|
items.append({
|
||||||
|
"id": 0,
|
||||||
|
"element_type": 4,
|
||||||
|
"name": "金币",
|
||||||
|
"category": "货币(直接入账)",
|
||||||
|
"source_table": "Currency",
|
||||||
|
"name_text_id": 0,
|
||||||
|
"resource_type": None,
|
||||||
|
"details": "邮件领取后直接叠加到金币余额,不生成背包物品或随机箱",
|
||||||
|
})
|
||||||
|
if not items:
|
||||||
|
raise ValueError("可领取的 ItemDBInfo 静态表为空")
|
||||||
|
|
||||||
|
# A RandomBox is itself a legitimate ItemDBInfo attachment, but its
|
||||||
|
# visible name is often a generic box name while players search for a
|
||||||
|
# guaranteed material inside it (for example 女神之泪). Follow only
|
||||||
|
# one-entry RewardGroupTable definitions: that is a real, deterministic
|
||||||
|
# GameData relationship, not an invented unpack result. Cash-product
|
||||||
|
# display names are aliases as well, so names such as 光明圣石 which are
|
||||||
|
# used by a product but not by the ResourceTable row remain searchable.
|
||||||
|
by_key = {(item["element_type"], item["id"]): item for item in items}
|
||||||
|
groups: dict[int, tuple[int, int, int] | None] = {}
|
||||||
|
for group_id, proto in connection.execute("SELECT id, ProtoBuf FROM RewardGroupTable"):
|
||||||
|
decoded = fields(proto)
|
||||||
|
reward_ids = packed_varints(decoded, 5)
|
||||||
|
reward_types = packed_varints(decoded, 6)
|
||||||
|
reward_counts = packed_varints(decoded, 4)
|
||||||
|
if len(reward_ids) != 1 or len(reward_types) != 1 or len(reward_counts) != 1:
|
||||||
|
groups[int(group_id)] = None
|
||||||
|
continue
|
||||||
|
reward_id, reward_type, reward_count = reward_ids[0], reward_types[0], reward_counts[0]
|
||||||
|
valid_id = reward_id == 0 if reward_type in {3, 4} else reward_id != 0
|
||||||
|
groups[int(group_id)] = (reward_type, reward_id, reward_count) if reward_type and valid_id and reward_count else None
|
||||||
|
|
||||||
|
fixed_boxes: dict[int, tuple[int, int, int]] = {}
|
||||||
|
for box_id, proto in connection.execute("SELECT id, ProtoBuf FROM RandomBoxTable"):
|
||||||
|
decoded = fields(proto)
|
||||||
|
reward_group_id = first_varint(decoded, 9)
|
||||||
|
reward = groups.get(reward_group_id)
|
||||||
|
if reward is None:
|
||||||
|
continue
|
||||||
|
reward_type, reward_id, reward_count = reward
|
||||||
|
contained = by_key.get((reward_type, reward_id))
|
||||||
|
if contained is None:
|
||||||
|
continue
|
||||||
|
fixed_boxes[int(box_id)] = (reward_type, reward_id, reward_count)
|
||||||
|
|
||||||
|
product_aliases: dict[int, dict[str, int]] = {}
|
||||||
|
for (proto,) in connection.execute("SELECT ProtoBuf FROM CashProductTable"):
|
||||||
|
decoded = fields(proto)
|
||||||
|
box_id = first_varint(decoded, 14)
|
||||||
|
product_text_id = first_varint(decoded, 11)
|
||||||
|
product_name = names.get(product_text_id, "")
|
||||||
|
if box_id and product_name and box_id in fixed_boxes:
|
||||||
|
aliases = product_aliases.setdefault(box_id, {})
|
||||||
|
aliases[product_name] = aliases.get(product_name, 0) + 1
|
||||||
|
|
||||||
|
# A deterministic type-9 wrapper is unsuitable for this developer
|
||||||
|
# mailbox: the user wants the material count they entered, immediately
|
||||||
|
# usable after MailOpen. Replace such choices with their authoritative
|
||||||
|
# contained ItemDBInfo rather than requiring /UseRandomBox afterwards.
|
||||||
|
return map_fixed_boxes_to_direct_items(items, fixed_boxes, product_aliases)
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
temporary.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def load_seed(path: Path) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
value = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except OSError as exc:
|
||||||
|
raise ValueError(f"无法读取邮件种子 {path}: {exc}") from exc
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise ValueError(f"邮件种子不是 JSON: {exc}") from exc
|
||||||
|
if value.get("version") != VERSION or not isinstance(value.get("mails"), list):
|
||||||
|
raise ValueError(f"邮件种子必须是 version={VERSION} 且含 mails 数组")
|
||||||
|
ids: set[int] = set()
|
||||||
|
for entry in value["mails"]:
|
||||||
|
mail_id = entry.get("mail_id")
|
||||||
|
if not isinstance(mail_id, int) or mail_id <= 0 or mail_id in ids:
|
||||||
|
raise ValueError("邮件种子含零、非整数或重复的 mail_id")
|
||||||
|
ids.add(mail_id)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def normalise_seed(seed: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Make the server sentinel fields agree with the complete mail list."""
|
||||||
|
result = dict(seed)
|
||||||
|
result["version"] = VERSION
|
||||||
|
result["mails"] = list(seed["mails"])
|
||||||
|
result["mail_count"] = len(result["mails"]) + 1
|
||||||
|
result["max_mail_id"] = max((entry["mail_id"] for entry in result["mails"]), default=0)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def atomic_json(path: Path, value: dict[str, Any]) -> None:
|
||||||
|
path = path.resolve()
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
||||||
|
try:
|
||||||
|
with temporary.open("w", encoding="utf-8", newline="\n") as stream:
|
||||||
|
json.dump(value, stream, ensure_ascii=False, indent=2)
|
||||||
|
stream.write("\n")
|
||||||
|
stream.flush()
|
||||||
|
os.fsync(stream.fileno())
|
||||||
|
os.replace(temporary, path)
|
||||||
|
finally:
|
||||||
|
temporary.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
class MailGrantStore:
|
||||||
|
def __init__(self, source: Path, output: Path, items: list[dict[str, Any]], expires_days: int):
|
||||||
|
self.source = source.resolve()
|
||||||
|
self.output = output.resolve()
|
||||||
|
self.items = items
|
||||||
|
self.item_keys = {(item["element_type"], item["id"]) for item in items}
|
||||||
|
self.expires_days = expires_days
|
||||||
|
self.lock = threading.Lock()
|
||||||
|
self.seed = normalise_seed(load_seed(self.output if self.output.exists() else self.source))
|
||||||
|
# Write the complete baseline immediately. The game server can
|
||||||
|
# therefore begin watching --output before the first browser grant.
|
||||||
|
if not self.output.exists():
|
||||||
|
atomic_json(self.output, self.seed)
|
||||||
|
|
||||||
|
def grant(self, payload: Any) -> dict[str, Any]:
|
||||||
|
with self.lock:
|
||||||
|
return self._grant_locked(payload)
|
||||||
|
|
||||||
|
def _grant_locked(self, payload: Any) -> dict[str, Any]:
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise ValueError("请求必须是 JSON 对象")
|
||||||
|
item_id = payload.get("item_id")
|
||||||
|
element_type = payload.get("element_type")
|
||||||
|
count = payload.get("count")
|
||||||
|
if not isinstance(item_id, int) or not isinstance(element_type, int) or (element_type, item_id) not in self.item_keys:
|
||||||
|
raise ValueError("element_type 与 item_id 必须是当前直接邮件列表中的安全组合")
|
||||||
|
if not isinstance(count, int) or not 1 <= count <= MAX_INT32:
|
||||||
|
raise ValueError(f"数量必须是 1 到 {MAX_INT32}")
|
||||||
|
title = payload.get("title", "开发测试物品")
|
||||||
|
body = payload.get("body", "由本地开发邮件工具发放。")
|
||||||
|
if not isinstance(title, str) or not isinstance(body, str):
|
||||||
|
raise ValueError("标题和正文必须是字符串")
|
||||||
|
title, body = title.strip(), body.strip()
|
||||||
|
if not title or len(title) > 500 or len(body) > 5000:
|
||||||
|
raise ValueError("标题不能为空且不超过 500 字符;正文不超过 5000 字符")
|
||||||
|
|
||||||
|
current_ids = {entry["mail_id"] for entry in self.seed["mails"]}
|
||||||
|
mail_id = max(current_ids, default=13_000_000_000) + 1
|
||||||
|
# MailDBInfo's InvenIndex is int64 in the 2.34.13 client descriptor.
|
||||||
|
if mail_id > (1 << 63) - 1:
|
||||||
|
raise ValueError("没有可用的正 int64 邮件 ID")
|
||||||
|
now = int(time.time() * 1000)
|
||||||
|
expires = now + self.expires_days * 24 * 60 * 60 * 1000
|
||||||
|
entry = {
|
||||||
|
"mail_id": mail_id,
|
||||||
|
"mail_type": 2,
|
||||||
|
"title": title,
|
||||||
|
"body": body,
|
||||||
|
"expires_at": expires,
|
||||||
|
"reward_types": [element_type],
|
||||||
|
"reward_ids": [item_id],
|
||||||
|
"reward_counts": [count],
|
||||||
|
"sent_at": now,
|
||||||
|
}
|
||||||
|
next_seed = normalise_seed({**self.seed, "mails": [*self.seed["mails"], entry]})
|
||||||
|
atomic_json(self.output, next_seed)
|
||||||
|
self.seed = next_seed
|
||||||
|
return {"mail": entry, "output": str(self.output), "restart_required": False}
|
||||||
|
|
||||||
|
|
||||||
|
PAGE = """<!doctype html><meta charset=utf-8><title>BD2 开发邮件发放</title>
|
||||||
|
<style>body{font:14px system-ui;max-width:1060px;margin:2rem auto;padding:0 1rem}input,textarea,button{font:inherit;padding:.4rem}input{width:100%}table{border-collapse:collapse;width:100%;margin:0}th,td{border:1px solid #ccc;padding:.4rem;text-align:left}tr:hover{background:#f5f5f5}#status{white-space:pre-wrap;margin:1rem 0}.small{color:#555}.pick{white-space:nowrap}#item-picker{margin:1rem 0;border:1px solid #ccc;border-radius:.35rem;padding:.55rem}#item-picker summary{cursor:pointer;font-weight:600}#item-picker[open] summary{margin-bottom:.75rem}.item-list{max-height:min(40vh,28rem);overflow:auto;border:1px solid #ccc;margin-top:1rem}.item-list thead th{position:sticky;top:0;background:#fff}.item-list table{min-width:760px}</style>
|
||||||
|
<h1>BD2 开发邮件发放</h1><p class=small>只列出可由当前邮件链路直接领取的安全物品和货币。固定内容随机箱已映射成真实内容物;其他随机箱与“遗失物品”等内部哨兵不会显示。金币直接叠加到钱包。提交会原子写入临时邮件种子;重新打开或刷新游戏邮箱即可热载,无需重启服务端。</p>
|
||||||
|
<details id=item-picker><summary>选择开发测试物品 <span id=count class=small></span></summary><label>搜索(ID、名称、类别、固定箱映射)<input id=q></label><div class=item-list><table><thead><tr><th>ID</th><th>类型</th><th>名称</th><th>类别/内容</th><th></th></tr></thead><tbody id=items></tbody></table></div></details>
|
||||||
|
<h2>发放一个附件</h2><form id=form><label>物品 ID<input id=item_id required readonly></label><input id=element_type required readonly type=hidden><label>数量(1–2147483647)<input id=quantity type=number min=1 max=2147483647 value=1 required></label><label>邮件标题<input id=title value="开发测试物品" required maxlength=500></label><label>正文<textarea id=body maxlength=5000>由本地开发邮件工具发放。</textarea></label><p><button>写入临时邮件种子</button></p></form><pre id=status></pre>
|
||||||
|
<script>let all=[];const $=id=>document.getElementById(id);function render(){let q=$('q').value.toLowerCase();let matches=all.filter(x=>(x.id+' '+x.element_type+' '+x.name+' '+x.category+' '+(x.details||'')).toLowerCase().includes(q));let rows=matches.slice(0,500);$('count').textContent=`(匹配 ${matches.length} / ${all.length} 项;显示前 ${rows.length} 项)`; $('items').innerHTML=rows.map(x=>`<tr><td>${x.id}</td><td>${x.element_type}</td><td>${esc(x.name)}</td><td>${esc(x.category+(x.details?':'+x.details:''))}</td><td class=pick><button onclick="pick(${x.element_type},${x.id})">选择</button></td></tr>`).join('')}function esc(s){let d=document.createElement('div');d.textContent=s;return d.innerHTML}function pick(t,id){$('item_id').value=id;$('element_type').value=t;$('item-picker').open=false;$('quantity').focus();$('form').scrollIntoView({block:'nearest',behavior:'smooth'})}$('q').oninput=render;$('form').onsubmit=async e=>{e.preventDefault();let r=await fetch('/api/grants',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({item_id:+$('item_id').value,element_type:+$('element_type').value,count:+$('quantity').value,title:$('title').value,body:$('body').value})});let x=await r.json();$('status').textContent=r.ok?`已写入邮件 #${x.mail.mail_id}。\n重新打开或刷新游戏邮箱即可看到并领取;服务端无需重启。\n货币会直接叠加,固定箱映射会直接发放内容物。\n热载文件:${x.output}`:x.error};fetch('/api/items').then(r=>r.json()).then(x=>{all=x.items;render()});</script>"""
|
||||||
|
|
||||||
|
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
store: MailGrantStore
|
||||||
|
|
||||||
|
def do_GET(self) -> None: # noqa: N802
|
||||||
|
if self.path == "/":
|
||||||
|
self.reply(HTTPStatus.OK, "text/html; charset=utf-8", PAGE.encode())
|
||||||
|
elif self.path == "/api/items":
|
||||||
|
self.reply_json(HTTPStatus.OK, {"items": self.store.items})
|
||||||
|
else:
|
||||||
|
self.reply_json(HTTPStatus.NOT_FOUND, {"error": "not found"})
|
||||||
|
|
||||||
|
def do_POST(self) -> None: # noqa: N802
|
||||||
|
if self.path != "/api/grants":
|
||||||
|
self.reply_json(HTTPStatus.NOT_FOUND, {"error": "not found"})
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
length = int(self.headers.get("Content-Length", "0"))
|
||||||
|
if length <= 0 or length > 32_768:
|
||||||
|
raise ValueError("请求体大小无效")
|
||||||
|
result = self.store.grant(json.loads(self.rfile.read(length)))
|
||||||
|
self.reply_json(HTTPStatus.CREATED, result)
|
||||||
|
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
||||||
|
self.reply_json(HTTPStatus.BAD_REQUEST, {"error": str(exc)})
|
||||||
|
|
||||||
|
def reply(self, status: HTTPStatus, content_type: str, body: bytes) -> None:
|
||||||
|
self.send_response(status)
|
||||||
|
self.send_header("Content-Type", content_type)
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def reply_json(self, status: HTTPStatus, value: dict[str, Any]) -> None:
|
||||||
|
self.reply(status, "application/json; charset=utf-8", json.dumps(value, ensure_ascii=False).encode())
|
||||||
|
|
||||||
|
def log_message(self, format: str, *args: object) -> None:
|
||||||
|
print("dev-mail:", format % args)
|
||||||
|
|
||||||
|
|
||||||
|
def serve(args: argparse.Namespace) -> int:
|
||||||
|
if args.expires_days < 1 or args.expires_days > 3650:
|
||||||
|
raise ValueError("--expires-days 必须是 1 到 3650")
|
||||||
|
items = load_items(args.game_data, args.game_data_version)
|
||||||
|
store = MailGrantStore(args.mail_seed, args.output, items, args.expires_days)
|
||||||
|
Handler.store = store
|
||||||
|
server = ThreadingHTTPServer((args.listen_host, args.listen_port), Handler)
|
||||||
|
print(f"已读取 {len(items)} 个可由 ItemDBInfo 领取的 GameData 物品。")
|
||||||
|
print(f"浏览器打开:http://{args.listen_host}:{args.listen_port}/")
|
||||||
|
print(f"临时邮件种子:{store.output}")
|
||||||
|
print("此服务不修改 data/state;bd2server 指向该 seed 后,每次 /MailInfo 自动热载。")
|
||||||
|
try:
|
||||||
|
server.serve_forever()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\n开发邮件工具已停止。")
|
||||||
|
finally:
|
||||||
|
server.server_close()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def parser() -> argparse.ArgumentParser:
|
||||||
|
result = argparse.ArgumentParser(description=__doc__)
|
||||||
|
commands = result.add_subparsers(dest="command", required=True)
|
||||||
|
command = commands.add_parser("serve", help="start the loopback browser UI")
|
||||||
|
command.add_argument("--game-data", type=Path, required=True, help="GameData root")
|
||||||
|
command.add_argument("--game-data-version", required=True, help="validated GameData version")
|
||||||
|
command.add_argument("--mail-seed", type=Path, required=True, help="base mail seed; read only")
|
||||||
|
command.add_argument("--output", type=Path, required=True, help="generated development mail seed")
|
||||||
|
command.add_argument("--listen-host", default="127.0.0.1", help="loopback host (default: 127.0.0.1)")
|
||||||
|
command.add_argument("--listen-port", default=8765, type=int, help="loopback port (default: 8765)")
|
||||||
|
command.add_argument("--expires-days", default=365, type=int, help="development mail validity (default: 365)")
|
||||||
|
command.set_defaults(run=serve)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = parser().parse_args()
|
||||||
|
try:
|
||||||
|
if args.listen_host not in {"127.0.0.1", "localhost", "::1"}:
|
||||||
|
raise ValueError("开发邮件服务只允许监听本机回环地址")
|
||||||
|
return args.run(args)
|
||||||
|
except (OSError, ValueError, sqlite3.Error) as exc:
|
||||||
|
print(f"dev_mail_grant: {exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,398 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Reconstruct .proto files from descriptors embedded in generated C#.
|
||||||
|
|
||||||
|
The client does not ship original .proto sources. Each *Reflection.cs embeds a
|
||||||
|
serialized FileDescriptorProto; this tool extracts those descriptors, writes a
|
||||||
|
lossless FileDescriptorSet, and renders readable .proto source files.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from pathlib import Path, PurePosixPath
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
from google.protobuf import descriptor_pb2
|
||||||
|
|
||||||
|
|
||||||
|
TOOL = "bd2.extract_client_proto"
|
||||||
|
MANIFEST = ".bd2-proto-extract-manifest.json"
|
||||||
|
DESCRIPTOR_SET = "client-descriptors.pb"
|
||||||
|
# Protobuf C# generator uses both string.Concat(new string[] {...}) and a
|
||||||
|
# direct literal/ordinary string concatenation depending on generator version.
|
||||||
|
REFLECTION_RE = re.compile(
|
||||||
|
r"Convert\.FromBase64String\s*\((?P<body>.*?)\)\s*,\s*new\s+FileDescriptor",
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
STRING_RE = re.compile(r'"([A-Za-z0-9+/=\s]*)"')
|
||||||
|
|
||||||
|
SCALARS = {
|
||||||
|
descriptor_pb2.FieldDescriptorProto.TYPE_DOUBLE: "double",
|
||||||
|
descriptor_pb2.FieldDescriptorProto.TYPE_FLOAT: "float",
|
||||||
|
descriptor_pb2.FieldDescriptorProto.TYPE_INT64: "int64",
|
||||||
|
descriptor_pb2.FieldDescriptorProto.TYPE_UINT64: "uint64",
|
||||||
|
descriptor_pb2.FieldDescriptorProto.TYPE_INT32: "int32",
|
||||||
|
descriptor_pb2.FieldDescriptorProto.TYPE_FIXED64: "fixed64",
|
||||||
|
descriptor_pb2.FieldDescriptorProto.TYPE_FIXED32: "fixed32",
|
||||||
|
descriptor_pb2.FieldDescriptorProto.TYPE_BOOL: "bool",
|
||||||
|
descriptor_pb2.FieldDescriptorProto.TYPE_STRING: "string",
|
||||||
|
descriptor_pb2.FieldDescriptorProto.TYPE_GROUP: "group",
|
||||||
|
descriptor_pb2.FieldDescriptorProto.TYPE_MESSAGE: "message",
|
||||||
|
descriptor_pb2.FieldDescriptorProto.TYPE_BYTES: "bytes",
|
||||||
|
descriptor_pb2.FieldDescriptorProto.TYPE_UINT32: "uint32",
|
||||||
|
descriptor_pb2.FieldDescriptorProto.TYPE_ENUM: "enum",
|
||||||
|
descriptor_pb2.FieldDescriptorProto.TYPE_SFIXED32: "sfixed32",
|
||||||
|
descriptor_pb2.FieldDescriptorProto.TYPE_SFIXED64: "sfixed64",
|
||||||
|
descriptor_pb2.FieldDescriptorProto.TYPE_SINT32: "sint32",
|
||||||
|
descriptor_pb2.FieldDescriptorProto.TYPE_SINT64: "sint64",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def is_within(child: Path, parent: Path) -> bool:
|
||||||
|
try:
|
||||||
|
child.relative_to(parent)
|
||||||
|
return True
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def validate_paths(source: Path, output: Path) -> tuple[Path, Path]:
|
||||||
|
source, output = source.resolve(), output.resolve()
|
||||||
|
if not source.is_dir():
|
||||||
|
raise ValueError(f"source is not a directory: {source}")
|
||||||
|
if source == output or is_within(output, source) or is_within(source, output):
|
||||||
|
raise ValueError("output must be outside, and not contain, source")
|
||||||
|
return source, output
|
||||||
|
|
||||||
|
|
||||||
|
def is_managed(output: Path) -> bool:
|
||||||
|
manifest = output / MANIFEST
|
||||||
|
if not manifest.is_file():
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
return json.loads(manifest.read_text(encoding="utf-8")).get("tool") == TOOL
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def stage_for(output: Path) -> Path:
|
||||||
|
if output.exists() and not is_managed(output):
|
||||||
|
raise FileExistsError(f"refusing to overwrite non-managed output directory: {output}")
|
||||||
|
output.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
return Path(tempfile.mkdtemp(prefix=f".{output.name}.", dir=output.parent))
|
||||||
|
|
||||||
|
|
||||||
|
def extract_descriptor(path: Path) -> descriptor_pb2.FileDescriptorProto | None:
|
||||||
|
text = path.read_text(encoding="utf-8-sig")
|
||||||
|
match = REFLECTION_RE.search(text)
|
||||||
|
if match is None:
|
||||||
|
return None
|
||||||
|
encoded = "".join(piece.group(1) for piece in STRING_RE.finditer(match.group("body")))
|
||||||
|
if not encoded:
|
||||||
|
raise ValueError(f"reflection contains no descriptor Base64: {path}")
|
||||||
|
descriptor = descriptor_pb2.FileDescriptorProto()
|
||||||
|
descriptor.ParseFromString(base64.b64decode(encoded, validate=True))
|
||||||
|
if not descriptor.name:
|
||||||
|
raise ValueError(f"descriptor has no source name: {path}")
|
||||||
|
return descriptor
|
||||||
|
|
||||||
|
|
||||||
|
def safe_descriptor_path(name: str) -> Path:
|
||||||
|
pure = PurePosixPath(name.replace("\\", "/"))
|
||||||
|
if pure.is_absolute() or not pure.parts or any(part in {"", ".", ".."} for part in pure.parts):
|
||||||
|
raise ValueError(f"unsafe descriptor path: {name!r}")
|
||||||
|
return Path(*pure.parts)
|
||||||
|
|
||||||
|
|
||||||
|
def quoted(value: str) -> str:
|
||||||
|
return json.dumps(value, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
def type_name(field: descriptor_pb2.FieldDescriptorProto) -> str:
|
||||||
|
scalar = SCALARS.get(field.type)
|
||||||
|
if scalar not in {"message", "enum", "group"}:
|
||||||
|
if scalar is None:
|
||||||
|
raise ValueError(f"unknown protobuf field type {field.type}")
|
||||||
|
return scalar
|
||||||
|
return field.type_name or scalar
|
||||||
|
|
||||||
|
|
||||||
|
def option_value(value) -> str:
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return "true" if value else "false"
|
||||||
|
if isinstance(value, str):
|
||||||
|
return quoted(value)
|
||||||
|
if hasattr(value, "name"):
|
||||||
|
return value.name
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def rendered_options(options, allowed: set[str] | None = None) -> tuple[list[str], bool]:
|
||||||
|
result = []
|
||||||
|
known = options.__class__()
|
||||||
|
for field, value in options.ListFields():
|
||||||
|
if field.is_extension or field.name in {"uninterpreted_option", "features"}:
|
||||||
|
continue
|
||||||
|
if allowed is not None and field.name not in allowed:
|
||||||
|
continue
|
||||||
|
if field.is_repeated or field.message_type is not None:
|
||||||
|
continue
|
||||||
|
setattr(known, field.name, value)
|
||||||
|
result.append(f"{field.name} = {option_value(value)}")
|
||||||
|
return result, known.SerializeToString() != options.SerializeToString()
|
||||||
|
|
||||||
|
|
||||||
|
def inline_options(options, allowed: set[str] | None = None) -> tuple[str, bool]:
|
||||||
|
values, incomplete = rendered_options(options, allowed)
|
||||||
|
return (" [" + ", ".join(values) + "]" if values else ""), incomplete
|
||||||
|
|
||||||
|
|
||||||
|
def render_enum(enum, indent: str, warnings: list[str], path: str) -> list[str]:
|
||||||
|
lines = [f"{indent}enum {enum.name} {{"]
|
||||||
|
opts, incomplete = rendered_options(enum.options)
|
||||||
|
for option in opts:
|
||||||
|
lines.append(f"{indent} option {option};")
|
||||||
|
if incomplete:
|
||||||
|
warnings.append(f"{path}: enum options retained only in descriptor set")
|
||||||
|
for reserved in enum.reserved_range:
|
||||||
|
end = reserved.end - 1
|
||||||
|
lines.append(f"{indent} reserved {reserved.start}{' to ' + str(end) if end != reserved.start else ''};")
|
||||||
|
if enum.reserved_name:
|
||||||
|
lines.append(f"{indent} reserved " + ", ".join(quoted(v) for v in enum.reserved_name) + ";")
|
||||||
|
for value in enum.value:
|
||||||
|
options, missing = inline_options(value.options)
|
||||||
|
if missing:
|
||||||
|
warnings.append(f"{path}.{value.name}: enum value options retained only in descriptor set")
|
||||||
|
lines.append(f"{indent} {value.name} = {value.number}{options};")
|
||||||
|
lines.append(f"{indent}}}")
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def map_entries(message) -> dict[str, object]:
|
||||||
|
return {nested.name: nested for nested in message.nested_type if nested.options.map_entry}
|
||||||
|
|
||||||
|
|
||||||
|
def render_field(field, syntax: str, indent: str, maps: dict[str, object], warnings: list[str], path: str) -> str:
|
||||||
|
target = field.type_name.rsplit(".", 1)[-1]
|
||||||
|
if field.label == field.LABEL_REPEATED and target in maps:
|
||||||
|
entry = maps[target]
|
||||||
|
if len(entry.field) == 2:
|
||||||
|
declaration = f"map<{type_name(entry.field[0])}, {type_name(entry.field[1])}>"
|
||||||
|
else:
|
||||||
|
declaration = f"repeated {type_name(field)}"
|
||||||
|
else:
|
||||||
|
label = ""
|
||||||
|
if field.label == field.LABEL_REPEATED:
|
||||||
|
label = "repeated "
|
||||||
|
elif syntax != "proto3" and field.label == field.LABEL_REQUIRED:
|
||||||
|
label = "required "
|
||||||
|
elif syntax != "proto3" or field.proto3_optional:
|
||||||
|
label = "optional "
|
||||||
|
declaration = label + type_name(field)
|
||||||
|
allowed = {"ctype", "packed", "jstype", "lazy", "deprecated", "weak", "unverified_lazy", "debug_redact", "retention"}
|
||||||
|
values, missing = rendered_options(field.options, allowed)
|
||||||
|
if field.default_value:
|
||||||
|
values.insert(0, f"default = {quoted(field.default_value) if field.type in (field.TYPE_STRING, field.TYPE_BYTES) else field.default_value}")
|
||||||
|
if field.json_name and field.json_name != field.name:
|
||||||
|
values.append(f"json_name = {quoted(field.json_name)}")
|
||||||
|
if missing:
|
||||||
|
warnings.append(f"{path}: field options retained only in descriptor set")
|
||||||
|
suffix = " [" + ", ".join(values) + "]" if values else ""
|
||||||
|
return f"{indent}{declaration} {field.name} = {field.number}{suffix};"
|
||||||
|
|
||||||
|
|
||||||
|
def render_message(message, syntax: str, indent: str, warnings: list[str], path: str) -> list[str]:
|
||||||
|
lines = [f"{indent}message {message.name} {{"]
|
||||||
|
opts, incomplete = rendered_options(message.options, {"message_set_wire_format", "no_standard_descriptor_accessor", "deprecated"})
|
||||||
|
for option in opts:
|
||||||
|
lines.append(f"{indent} option {option};")
|
||||||
|
if incomplete and not message.options.map_entry:
|
||||||
|
warnings.append(f"{path}: message options retained only in descriptor set")
|
||||||
|
for reserved in message.reserved_range:
|
||||||
|
end = reserved.end - 1
|
||||||
|
lines.append(f"{indent} reserved {reserved.start}{' to ' + str(end) if end != reserved.start else ''};")
|
||||||
|
if message.reserved_name:
|
||||||
|
lines.append(f"{indent} reserved " + ", ".join(quoted(v) for v in message.reserved_name) + ";")
|
||||||
|
for extension in message.extension_range:
|
||||||
|
end = "max" if extension.end >= 536870912 else str(extension.end - 1)
|
||||||
|
lines.append(f"{indent} extensions {extension.start} to {end};")
|
||||||
|
maps = map_entries(message)
|
||||||
|
synthetic = {field.oneof_index for field in message.field if field.proto3_optional}
|
||||||
|
regular_oneofs = {index for index in range(len(message.oneof_decl)) if index not in synthetic}
|
||||||
|
for field in message.field:
|
||||||
|
if field.HasField("oneof_index") and field.oneof_index in regular_oneofs:
|
||||||
|
continue
|
||||||
|
lines.append(render_field(field, syntax, indent + " ", maps, warnings, f"{path}.{field.name}"))
|
||||||
|
for index in sorted(regular_oneofs):
|
||||||
|
oneof = message.oneof_decl[index]
|
||||||
|
lines.append(f"{indent} oneof {oneof.name} {{")
|
||||||
|
for field in message.field:
|
||||||
|
if field.HasField("oneof_index") and field.oneof_index == index:
|
||||||
|
copy = descriptor_pb2.FieldDescriptorProto()
|
||||||
|
copy.CopyFrom(field)
|
||||||
|
copy.ClearField("oneof_index")
|
||||||
|
copy.label = copy.LABEL_OPTIONAL
|
||||||
|
lines.append(render_field(copy, "proto3", indent + " ", maps, warnings, f"{path}.{field.name}"))
|
||||||
|
lines.append(f"{indent} }}")
|
||||||
|
for enum in message.enum_type:
|
||||||
|
lines.extend(render_enum(enum, indent + " ", warnings, f"{path}.{enum.name}"))
|
||||||
|
for nested in message.nested_type:
|
||||||
|
if not nested.options.map_entry:
|
||||||
|
lines.extend(render_message(nested, syntax, indent + " ", warnings, f"{path}.{nested.name}"))
|
||||||
|
lines.append(f"{indent}}}")
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def render_extensions(fields, syntax: str, warnings: list[str], path: str) -> list[str]:
|
||||||
|
grouped: dict[str, list[object]] = {}
|
||||||
|
for field in fields:
|
||||||
|
grouped.setdefault(field.extendee, []).append(field)
|
||||||
|
lines = []
|
||||||
|
for extendee, entries in grouped.items():
|
||||||
|
lines.append(f"extend {extendee} {{")
|
||||||
|
for field in entries:
|
||||||
|
lines.append(render_field(field, syntax, " ", {}, warnings, f"{path}.{field.name}"))
|
||||||
|
lines.append("}")
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def render_file(descriptor: descriptor_pb2.FileDescriptorProto) -> tuple[str, list[str]]:
|
||||||
|
warnings: list[str] = []
|
||||||
|
syntax = descriptor.syntax or "proto2"
|
||||||
|
lines = [f'syntax = "{syntax}";', ""]
|
||||||
|
if descriptor.package:
|
||||||
|
lines += [f"package {descriptor.package};", ""]
|
||||||
|
public = set(descriptor.public_dependency)
|
||||||
|
weak = set(descriptor.weak_dependency)
|
||||||
|
for index, dependency in enumerate(descriptor.dependency):
|
||||||
|
qualifier = "public " if index in public else "weak " if index in weak else ""
|
||||||
|
lines.append(f"import {qualifier}{quoted(dependency)};")
|
||||||
|
if descriptor.dependency:
|
||||||
|
lines.append("")
|
||||||
|
options, incomplete = rendered_options(descriptor.options)
|
||||||
|
for option in options:
|
||||||
|
lines.append(f"option {option};")
|
||||||
|
if incomplete:
|
||||||
|
warnings.append(f"{descriptor.name}: file options retained only in descriptor set")
|
||||||
|
if options:
|
||||||
|
lines.append("")
|
||||||
|
for enum in descriptor.enum_type:
|
||||||
|
lines.extend(render_enum(enum, "", warnings, f"{descriptor.name}:{enum.name}"))
|
||||||
|
lines.append("")
|
||||||
|
for message in descriptor.message_type:
|
||||||
|
lines.extend(render_message(message, syntax, "", warnings, f"{descriptor.name}:{message.name}"))
|
||||||
|
lines.append("")
|
||||||
|
lines.extend(render_extensions(descriptor.extension, syntax, warnings, descriptor.name))
|
||||||
|
if descriptor.extension:
|
||||||
|
lines.append("")
|
||||||
|
for service in descriptor.service:
|
||||||
|
lines.append(f"service {service.name} {{")
|
||||||
|
for method in service.method:
|
||||||
|
client = "stream " if method.client_streaming else ""
|
||||||
|
server = "stream " if method.server_streaming else ""
|
||||||
|
lines.append(f" rpc {method.name} ({client}{method.input_type}) returns ({server}{method.output_type});")
|
||||||
|
lines += ["}", ""]
|
||||||
|
return "\n".join(lines).rstrip() + "\n", warnings
|
||||||
|
|
||||||
|
|
||||||
|
def reconstruct(source: Path, output: Path) -> dict:
|
||||||
|
source, output = validate_paths(source, output)
|
||||||
|
found: dict[str, tuple[descriptor_pb2.FileDescriptorProto, str]] = {}
|
||||||
|
scan_warnings: list[str] = []
|
||||||
|
for reflection in sorted(source.rglob("*Reflection.cs")):
|
||||||
|
if not reflection.is_file() or "proto" not in {part.casefold() for part in reflection.parts}:
|
||||||
|
continue
|
||||||
|
descriptor = extract_descriptor(reflection)
|
||||||
|
if descriptor is None:
|
||||||
|
scan_warnings.append(f"{reflection.relative_to(source).as_posix()}: no embedded descriptor")
|
||||||
|
continue
|
||||||
|
relative = reflection.relative_to(source).as_posix()
|
||||||
|
existing = found.get(descriptor.name)
|
||||||
|
if existing is not None:
|
||||||
|
if existing[0].SerializeToString() != descriptor.SerializeToString():
|
||||||
|
raise ValueError(f"conflicting descriptors named {descriptor.name!r}")
|
||||||
|
scan_warnings.append(f"{relative}: duplicate descriptor also in {existing[1]}")
|
||||||
|
continue
|
||||||
|
found[descriptor.name] = (descriptor, relative)
|
||||||
|
if not found:
|
||||||
|
raise ValueError("no embedded FileDescriptorProto values found")
|
||||||
|
stage = stage_for(output)
|
||||||
|
try:
|
||||||
|
descriptor_set = descriptor_pb2.FileDescriptorSet()
|
||||||
|
records = []
|
||||||
|
warnings = list(scan_warnings)
|
||||||
|
for name in sorted(found):
|
||||||
|
descriptor, reflection = found[name]
|
||||||
|
descriptor_set.file.add().CopyFrom(descriptor)
|
||||||
|
relative = safe_descriptor_path(name)
|
||||||
|
target = stage / relative
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
rendered, file_warnings = render_file(descriptor)
|
||||||
|
target.write_text(rendered, encoding="utf-8", newline="\n")
|
||||||
|
warnings.extend(file_warnings)
|
||||||
|
records.append({
|
||||||
|
"path": relative.as_posix(),
|
||||||
|
"package": descriptor.package,
|
||||||
|
"syntax": descriptor.syntax or "proto2",
|
||||||
|
"source_reflection": reflection,
|
||||||
|
"messages": len(descriptor.message_type),
|
||||||
|
"enums": len(descriptor.enum_type),
|
||||||
|
"dependencies": list(descriptor.dependency),
|
||||||
|
"descriptor_sha256": hashlib.sha256(descriptor.SerializeToString()).hexdigest(),
|
||||||
|
"proto_sha256": hashlib.sha256(rendered.encode("utf-8")).hexdigest(),
|
||||||
|
})
|
||||||
|
descriptor_bytes = descriptor_set.SerializeToString()
|
||||||
|
(stage / DESCRIPTOR_SET).write_bytes(descriptor_bytes)
|
||||||
|
manifest = {
|
||||||
|
"tool": TOOL,
|
||||||
|
"source": str(source),
|
||||||
|
"descriptor_set": DESCRIPTOR_SET,
|
||||||
|
"descriptor_set_sha256": hashlib.sha256(descriptor_bytes).hexdigest(),
|
||||||
|
"files": records,
|
||||||
|
"warnings": warnings,
|
||||||
|
"statistics": {
|
||||||
|
"proto_files": len(records),
|
||||||
|
"messages": sum(item["messages"] for item in records),
|
||||||
|
"enums": sum(item["enums"] for item in records),
|
||||||
|
"warnings": len(warnings),
|
||||||
|
},
|
||||||
|
"limitations": [
|
||||||
|
"Generated source comments are unavailable in FileDescriptorProto.",
|
||||||
|
"Options not representable by this renderer remain losslessly available in client-descriptors.pb.",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
(stage / MANIFEST).write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||||
|
if output.exists():
|
||||||
|
shutil.rmtree(output)
|
||||||
|
stage.replace(output)
|
||||||
|
return manifest
|
||||||
|
except Exception:
|
||||||
|
shutil.rmtree(stage, ignore_errors=True)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--source", required=True, type=Path, help="client Assembly-CSharp directory")
|
||||||
|
parser.add_argument("--output", required=True, type=Path, help="reconstructed Proto directory outside --source")
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = build_parser().parse_args()
|
||||||
|
try:
|
||||||
|
manifest = reconstruct(args.source, args.output)
|
||||||
|
except (OSError, ValueError, UnicodeError) as exc:
|
||||||
|
raise SystemExit(f"extract_client_proto: {exc}")
|
||||||
|
stats = manifest["statistics"]
|
||||||
|
print(f"wrote {args.output.resolve()} ({stats['proto_files']} .proto files, {stats['messages']} messages)")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Add the final costume-potential ledger to the pre-release collection save.
|
||||||
|
|
||||||
|
Dry-run by default. Applying requires stopped client/server, creates and
|
||||||
|
verifies a complete nine-file checkpoint, and atomically changes only
|
||||||
|
collection.json. No runtime compatibility for the unpublished old format is
|
||||||
|
kept in the Go server.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from datetime import datetime
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import save_checkpoint
|
||||||
|
from set_first_gacha_state import atomic_write_json
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_STATE = Path(__file__).resolve().parents[2] / "data" / "state"
|
||||||
|
|
||||||
|
|
||||||
|
def migrate(value: dict) -> tuple[dict, bool]:
|
||||||
|
if value.get("version") != "2.34.13" or not isinstance(value.get("costumes", []), list):
|
||||||
|
raise ValueError("collection.json is not the expected 2.34.13 save")
|
||||||
|
if "costume_potential" in value:
|
||||||
|
ledger = value["costume_potential"]
|
||||||
|
if not isinstance(ledger, dict) or any(
|
||||||
|
not isinstance(key, str) or not isinstance(nodes, list) or
|
||||||
|
any(not isinstance(node, int) or isinstance(node, bool) or node <= 0 for node in nodes)
|
||||||
|
for key, nodes in ledger.items()
|
||||||
|
):
|
||||||
|
raise ValueError("existing costume_potential ledger is malformed")
|
||||||
|
return value, False
|
||||||
|
for costume in value.get("costumes", []):
|
||||||
|
if "potential_id" in costume or "potential_ids" in costume:
|
||||||
|
raise ValueError("unexpected legacy potential data requires explicit review")
|
||||||
|
result = dict(value)
|
||||||
|
result["costume_potential"] = {}
|
||||||
|
return result, True
|
||||||
|
|
||||||
|
|
||||||
|
def run(state: Path, apply: bool) -> Path | None:
|
||||||
|
target = state / "collection.json"
|
||||||
|
with target.open(encoding="utf-8") as stream:
|
||||||
|
original = json.load(stream)
|
||||||
|
migrated, changed = migrate(original)
|
||||||
|
print(json.dumps({"changed": changed, "costume_count": len(original.get("costumes", [])), "active_costume_count": len(migrated.get("costume_potential", {}))}, indent=2))
|
||||||
|
if not changed or not apply:
|
||||||
|
return None
|
||||||
|
running = save_checkpoint.running_processes()
|
||||||
|
if running:
|
||||||
|
raise RuntimeError("client/server is still running:\n" + "\n".join(running))
|
||||||
|
before = save_checkpoint.inspect_files(state)
|
||||||
|
backup = state / "checkpoints" / f"{datetime.now():%Y%m%d-%H%M%S}-before-costume-potential-state"
|
||||||
|
save_checkpoint.create(state, backup, "before adding final costume potential ledger")
|
||||||
|
atomic_write_json(target, migrated)
|
||||||
|
with target.open(encoding="utf-8") as stream:
|
||||||
|
written = json.load(stream)
|
||||||
|
repeated, changed_again = migrate(written)
|
||||||
|
after = save_checkpoint.inspect_files(state)
|
||||||
|
if changed_again or repeated != migrated or any(before[name] != after[name] for name in save_checkpoint.STATE_FILES if name != "collection.json"):
|
||||||
|
raise OSError(f"costume potential migration failed verification; backup: {backup}")
|
||||||
|
save_checkpoint.verify(backup)
|
||||||
|
return backup
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--state", type=Path, default=DEFAULT_STATE)
|
||||||
|
parser.add_argument("--apply", action="store_true")
|
||||||
|
args = parser.parse_args()
|
||||||
|
try:
|
||||||
|
backup = run(args.state.resolve(), args.apply)
|
||||||
|
print(f"Migrated with complete verified backup: {backup}" if backup else "Dry-run/no change; use --apply with stopped processes when changed=true")
|
||||||
|
except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc:
|
||||||
|
parser.exit(1, f"migrate_costume_potential_state: {exc}\n")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Add the final per-instance equipment upgrade-attempt counter.
|
||||||
|
|
||||||
|
Dry-run by default. Applying requires stopped client/server, creates a verified
|
||||||
|
nine-file checkpoint, and atomically changes only equipment.json. Existing
|
||||||
|
equipment has never been successfully upgraded, so every instance starts at 0.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from datetime import datetime
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import save_checkpoint
|
||||||
|
from set_first_gacha_state import atomic_write_json
|
||||||
|
|
||||||
|
DEFAULT_STATE = Path(__file__).resolve().parents[2] / "data" / "state"
|
||||||
|
|
||||||
|
def migrate(value: dict) -> tuple[dict, list[int]]:
|
||||||
|
if value.get("version") != "2.34.13" or not isinstance(value.get("equipment"), list):
|
||||||
|
raise ValueError("equipment.json is not the expected 2.34.13 save")
|
||||||
|
result = dict(value)
|
||||||
|
result["equipment"] = []
|
||||||
|
changed = []
|
||||||
|
for original in value["equipment"]:
|
||||||
|
if not isinstance(original, dict) or not isinstance(original.get("inven_index"), int):
|
||||||
|
raise ValueError("invalid equipment instance")
|
||||||
|
item = dict(original)
|
||||||
|
if "upgrade_attempts" not in item:
|
||||||
|
item["upgrade_attempts"] = 0
|
||||||
|
changed.append(item["inven_index"])
|
||||||
|
elif not isinstance(item["upgrade_attempts"], int) or isinstance(item["upgrade_attempts"], bool) or item["upgrade_attempts"] < 0:
|
||||||
|
raise ValueError(f"equipment {item['inven_index']} has invalid upgrade_attempts")
|
||||||
|
result["equipment"].append(item)
|
||||||
|
return result, changed
|
||||||
|
|
||||||
|
def run(state: Path, apply: bool) -> Path | None:
|
||||||
|
target = state / "equipment.json"
|
||||||
|
with target.open(encoding="utf-8") as stream:
|
||||||
|
original = json.load(stream)
|
||||||
|
migrated, changed = migrate(original)
|
||||||
|
print(json.dumps({"equipment_count": len(migrated["equipment"]), "changed_count": len(changed), "changed_indices": changed}, indent=2))
|
||||||
|
if not changed or not apply:
|
||||||
|
return None
|
||||||
|
running = save_checkpoint.running_processes()
|
||||||
|
if running:
|
||||||
|
raise RuntimeError("client/server is still running:\n" + "\n".join(running))
|
||||||
|
before = save_checkpoint.inspect_files(state)
|
||||||
|
backup = state / "checkpoints" / f"{datetime.now():%Y%m%d-%H%M%S}-before-equipment-upgrade-attempts"
|
||||||
|
save_checkpoint.create(state, backup, "before adding equipment upgrade attempt counters")
|
||||||
|
atomic_write_json(target, migrated)
|
||||||
|
with target.open(encoding="utf-8") as stream:
|
||||||
|
written = json.load(stream)
|
||||||
|
repeated, remaining = migrate(written)
|
||||||
|
after = save_checkpoint.inspect_files(state)
|
||||||
|
if remaining or repeated != migrated or any(before[name] != after[name] for name in save_checkpoint.STATE_FILES if name != "equipment.json"):
|
||||||
|
raise OSError(f"equipment upgrade migration failed verification; backup: {backup}")
|
||||||
|
save_checkpoint.verify(backup)
|
||||||
|
return backup
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--state", type=Path, default=DEFAULT_STATE)
|
||||||
|
parser.add_argument("--apply", action="store_true")
|
||||||
|
args = parser.parse_args()
|
||||||
|
try:
|
||||||
|
backup = run(args.state.resolve(), args.apply)
|
||||||
|
print(f"Migrated with complete verified backup: {backup}" if backup else "Dry-run/no change; use --apply with stopped processes when changed_count>0")
|
||||||
|
except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc:
|
||||||
|
parser.exit(1, f"migrate_equipment_upgrade_attempts: {exc}\n")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Repair pre-release equipment saves with missing three-slot rank arrays.
|
||||||
|
|
||||||
|
The 2.34.13 client indexes EquipBaseInfo.Rank[0..2] even before enhancement.
|
||||||
|
Dry-run by default. Applying requires stopped client/server processes, verifies
|
||||||
|
and backs up all nine state files, and atomically updates equipment.json only.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from datetime import datetime
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import save_checkpoint
|
||||||
|
from set_first_gacha_state import atomic_write_json
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_STATE = Path(__file__).resolve().parents[2] / "data" / "state"
|
||||||
|
|
||||||
|
|
||||||
|
def repair(value: dict) -> tuple[dict, list[int]]:
|
||||||
|
if value.get("version") != "2.34.13":
|
||||||
|
raise ValueError("equipment.json must be a 2.34.13 save")
|
||||||
|
entries = value.get("equipment")
|
||||||
|
if not isinstance(entries, list):
|
||||||
|
raise ValueError("equipment must be an array")
|
||||||
|
result = dict(value)
|
||||||
|
result["equipment"] = []
|
||||||
|
updated = []
|
||||||
|
for item in entries:
|
||||||
|
if not isinstance(item, dict) or not isinstance(item.get("inven_index"), int):
|
||||||
|
raise ValueError("invalid equipment instance")
|
||||||
|
copy = dict(item)
|
||||||
|
if "rank" not in copy:
|
||||||
|
copy["rank"] = [0, 0, 0]
|
||||||
|
updated.append(copy["inven_index"])
|
||||||
|
elif not isinstance(copy["rank"], list) or len(copy["rank"]) != 3 or any(
|
||||||
|
not isinstance(rank, int) or isinstance(rank, bool) or rank < 0 or rank > 4
|
||||||
|
for rank in copy["rank"]
|
||||||
|
):
|
||||||
|
raise ValueError(f"equipment {copy['inven_index']} has an invalid rank array")
|
||||||
|
result["equipment"].append(copy)
|
||||||
|
return result, updated
|
||||||
|
|
||||||
|
|
||||||
|
def apply_state(state: Path, apply: bool) -> Path | None:
|
||||||
|
target = state / "equipment.json"
|
||||||
|
with target.open("r", encoding="utf-8") as stream:
|
||||||
|
original = json.load(stream)
|
||||||
|
repaired, updated = repair(original)
|
||||||
|
print(json.dumps({"equipment_count": len(repaired["equipment"]), "missing_rank_count": len(updated), "updated_indices": updated}, indent=2))
|
||||||
|
if not updated or not apply:
|
||||||
|
return None
|
||||||
|
running = save_checkpoint.running_processes()
|
||||||
|
if running:
|
||||||
|
raise RuntimeError("client/server is still running:\n" + "\n".join(running))
|
||||||
|
before = save_checkpoint.inspect_files(state)
|
||||||
|
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||||
|
backup = state / "checkpoints" / f"{stamp}-before-equipment-ranks"
|
||||||
|
save_checkpoint.create(state, backup, "before three-slot equipment rank repair")
|
||||||
|
atomic_write_json(target, repaired)
|
||||||
|
with target.open("r", encoding="utf-8") as stream:
|
||||||
|
written = json.load(stream)
|
||||||
|
_, remaining = repair(written)
|
||||||
|
after = save_checkpoint.inspect_files(state)
|
||||||
|
if remaining or any(before[name] != after[name] for name in save_checkpoint.STATE_FILES if name != "equipment.json"):
|
||||||
|
raise OSError(f"equipment repair failed verification; backup: {backup}")
|
||||||
|
return backup
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--state", type=Path, default=DEFAULT_STATE)
|
||||||
|
parser.add_argument("--apply", action="store_true")
|
||||||
|
args = parser.parse_args()
|
||||||
|
try:
|
||||||
|
backup = apply_state(args.state.resolve(), args.apply)
|
||||||
|
if backup:
|
||||||
|
print(f"updated equipment.json; verified nine-file backup: {backup}")
|
||||||
|
elif not args.apply:
|
||||||
|
print("dry run only; pass --apply after stopping game and server")
|
||||||
|
except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc:
|
||||||
|
parser.exit(1, f"repair_equipment_ranks: {exc}\n")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Undo the six rejected 2026-09-21 20:19–20:20 collection promotions.
|
||||||
|
|
||||||
|
The six /CharGrowth requests for collection character 920000054 (6510/1)
|
||||||
|
charged inventory and one gold payment before failing to persist a changed
|
||||||
|
character ID. This one-time repair only accepts the exact observed save and
|
||||||
|
six matching server errors, and removes the refund items those failed requests
|
||||||
|
created. Dry-run by default; applying requires stopped client/server, backs up
|
||||||
|
and verifies all nine account files, then writes only items.json/wallet.json.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from datetime import datetime
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import save_checkpoint
|
||||||
|
from set_first_gacha_state import atomic_write_json
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_STATE = Path(__file__).resolve().parents[2] / "data" / "state"
|
||||||
|
DEFAULT_LOG = Path(__file__).resolve().parents[2] / "logs" / "server-20260921-201837.err.log"
|
||||||
|
ERROR = "persist promoted collection character: player: collection character 920000054 not found"
|
||||||
|
IDENTITY = "char-promote:920000054:6510"
|
||||||
|
CONSUMED = {
|
||||||
|
900000038: (11, 93, 1),
|
||||||
|
900000041: (12, 86, 2),
|
||||||
|
900000042: (9, 94738, 753),
|
||||||
|
900000045: (14, 99973, 4),
|
||||||
|
900000046: (13, 99980, 3),
|
||||||
|
}
|
||||||
|
REFUNDS = {index: (8 if index % 2 else 7, 1 if index % 2 else 3) for index in range(900000049, 900000061)}
|
||||||
|
|
||||||
|
|
||||||
|
def repair(items: dict, wallet: dict, collection: dict, log: str) -> tuple[dict, dict]:
|
||||||
|
errors = [line for line in log.splitlines() if ERROR in line]
|
||||||
|
if len(errors) != 6 or any("path=/CharGrowth" not in line for line in errors):
|
||||||
|
raise ValueError(f"expected exactly six rejected promotion requests, got {len(errors)}")
|
||||||
|
if any(value.get("version") != "2.34.13" for value in (items, wallet, collection)):
|
||||||
|
raise ValueError("unexpected save version")
|
||||||
|
owner = [char for char in collection.get("characters", []) if char.get("inven_index") == 920000054]
|
||||||
|
if len(owner) != 1 or owner[0].get("id") != 6510 or owner[0].get("level") != 1:
|
||||||
|
raise ValueError("collection character no longer matches the rejected request")
|
||||||
|
if wallet.get("gold") != 56050 or wallet.get("spent", {}).get(IDENTITY) is not True:
|
||||||
|
raise ValueError("wallet differs from the observed failed payment")
|
||||||
|
inventory = {item.get("inven_index"): item for item in items.get("items", [])}
|
||||||
|
for index, (item_id, remaining, _) in CONSUMED.items():
|
||||||
|
item = inventory.get(index)
|
||||||
|
if not item or item.get("id") != item_id or item.get("type") != 8 or item.get("count") != remaining:
|
||||||
|
raise ValueError(f"item stack {index} differs from the observed failed requests")
|
||||||
|
for index, (item_id, count) in REFUNDS.items():
|
||||||
|
item = inventory.get(index)
|
||||||
|
if not item or item.get("id") != item_id or item.get("type") != 8 or item.get("count") != count:
|
||||||
|
raise ValueError(f"failed-request refund {index} was changed or used")
|
||||||
|
for indices in items.get("grant_items", {}).values():
|
||||||
|
if any(index in REFUNDS for index in indices):
|
||||||
|
raise ValueError("a failed-request refund has a separate grant reference")
|
||||||
|
|
||||||
|
corrected_items = dict(items)
|
||||||
|
corrected_items["items"] = []
|
||||||
|
for original in items["items"]:
|
||||||
|
index = original["inven_index"]
|
||||||
|
if index in REFUNDS:
|
||||||
|
continue
|
||||||
|
item = dict(original)
|
||||||
|
if index in CONSUMED:
|
||||||
|
item["count"] += 6 * CONSUMED[index][2]
|
||||||
|
corrected_items["items"].append(item)
|
||||||
|
corrected_wallet = dict(wallet)
|
||||||
|
corrected_wallet["gold"] = wallet["gold"] + 10000
|
||||||
|
corrected_wallet["spent"] = dict(wallet["spent"])
|
||||||
|
del corrected_wallet["spent"][IDENTITY]
|
||||||
|
return corrected_items, corrected_wallet
|
||||||
|
|
||||||
|
|
||||||
|
def run(state: Path, log_path: Path, apply: bool) -> Path | None:
|
||||||
|
before = save_checkpoint.inspect_files(state)
|
||||||
|
values = {}
|
||||||
|
for name in ("items", "wallet", "collection"):
|
||||||
|
with (state / f"{name}.json").open(encoding="utf-8") as stream:
|
||||||
|
values[name] = json.load(stream)
|
||||||
|
repaired_items, repaired_wallet = repair(values["items"], values["wallet"], values["collection"], log_path.read_text(encoding="utf-8"))
|
||||||
|
print(json.dumps({
|
||||||
|
"failed_requests": 6,
|
||||||
|
"character": "920000054 / 6510 / level 1 (unchanged)",
|
||||||
|
"restored_gold": 10000,
|
||||||
|
"restored_item_counts": {str(index): 6 * value[2] for index, value in CONSUMED.items()},
|
||||||
|
"removed_unearned_refund_indices": sorted(REFUNDS),
|
||||||
|
}, indent=2))
|
||||||
|
if not apply:
|
||||||
|
return None
|
||||||
|
running = save_checkpoint.running_processes()
|
||||||
|
if running:
|
||||||
|
raise RuntimeError("client/server is still running:\n" + "\n".join(running))
|
||||||
|
backup = state / "checkpoints" / f"{datetime.now():%Y%m%d-%H%M%S}-before-failed-collection-growth-repair"
|
||||||
|
save_checkpoint.create(state, backup, "before undoing six rejected collection CharGrowth requests")
|
||||||
|
atomic_write_json(state / "items.json", repaired_items)
|
||||||
|
atomic_write_json(state / "wallet.json", repaired_wallet)
|
||||||
|
after = save_checkpoint.inspect_files(state)
|
||||||
|
if any(before[name] != after[name] for name in save_checkpoint.STATE_FILES if name not in {"items.json", "wallet.json"}):
|
||||||
|
raise OSError(f"unrelated account state changed; backup: {backup}")
|
||||||
|
for name, expected in (("items.json", repaired_items), ("wallet.json", repaired_wallet)):
|
||||||
|
with (state / name).open(encoding="utf-8") as stream:
|
||||||
|
if json.load(stream) != expected:
|
||||||
|
raise OSError(f"failed verification of {name}; backup: {backup}")
|
||||||
|
save_checkpoint.verify(backup)
|
||||||
|
return backup
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--state", type=Path, default=DEFAULT_STATE)
|
||||||
|
parser.add_argument("--log", type=Path, default=DEFAULT_LOG)
|
||||||
|
parser.add_argument("--apply", action="store_true")
|
||||||
|
args = parser.parse_args()
|
||||||
|
try:
|
||||||
|
backup = run(args.state.resolve(), args.log.resolve(), args.apply)
|
||||||
|
print(f"Repaired with complete verified backup: {backup}" if backup else "Dry-run only; pass --apply after stopping client/server")
|
||||||
|
except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc:
|
||||||
|
parser.exit(1, f"repair_failed_collection_growth: {exc}\n")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Remove internal ResourceTable sentinels accidentally granted by dev mail.
|
||||||
|
|
||||||
|
Dry-run by default. Applying requires stopped client/server, creates a verified
|
||||||
|
nine-file checkpoint, and atomically changes only items.json. Historical mail
|
||||||
|
grant ledgers remain so the invalid attachment cannot be replayed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from datetime import datetime
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import save_checkpoint
|
||||||
|
from set_first_gacha_state import atomic_write_json
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_STATE = Path(__file__).resolve().parents[2] / "data" / "state"
|
||||||
|
INVALID_RESOURCE_IDS = {90045, 90046}
|
||||||
|
|
||||||
|
|
||||||
|
def repair(value: dict) -> tuple[dict, list[int]]:
|
||||||
|
if value.get("version") != "2.34.13" or not isinstance(value.get("items"), list):
|
||||||
|
raise ValueError("items.json is not the expected 2.34.13 save")
|
||||||
|
grant_items = value.get("grant_items")
|
||||||
|
granted = value.get("granted")
|
||||||
|
if not isinstance(grant_items, dict) or not isinstance(granted, dict):
|
||||||
|
raise ValueError("items.json grant ledgers are missing")
|
||||||
|
bad_indices: list[int] = []
|
||||||
|
for item in value["items"]:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
raise ValueError("invalid item instance")
|
||||||
|
if item.get("type") == 8 and item.get("id") in INVALID_RESOURCE_IDS:
|
||||||
|
index = item.get("inven_index")
|
||||||
|
if not isinstance(index, int) or index <= 0:
|
||||||
|
raise ValueError("invalid internal-resource inventory index")
|
||||||
|
owners = [
|
||||||
|
identity for identity, indices in grant_items.items()
|
||||||
|
if isinstance(indices, list) and index in indices
|
||||||
|
]
|
||||||
|
if len(owners) != 1 or not owners[0].startswith("mail:") or not granted.get(owners[0]):
|
||||||
|
raise ValueError(f"internal resource {index} is not an acknowledged mail grant")
|
||||||
|
bad_indices.append(index)
|
||||||
|
result = dict(value)
|
||||||
|
result["items"] = [item for item in value["items"] if item.get("inven_index") not in set(bad_indices)]
|
||||||
|
return result, bad_indices
|
||||||
|
|
||||||
|
|
||||||
|
def run(state: Path, apply: bool) -> Path | None:
|
||||||
|
target = state / "items.json"
|
||||||
|
with target.open(encoding="utf-8") as stream:
|
||||||
|
original = json.load(stream)
|
||||||
|
repaired, removed = repair(original)
|
||||||
|
print(json.dumps({"removed_count": len(removed), "removed_indices": removed}, indent=2))
|
||||||
|
if not removed or not apply:
|
||||||
|
return None
|
||||||
|
running = save_checkpoint.running_processes()
|
||||||
|
if running:
|
||||||
|
raise RuntimeError("client/server is still running:\n" + "\n".join(running))
|
||||||
|
before = save_checkpoint.inspect_files(state)
|
||||||
|
backup = state / "checkpoints" / f"{datetime.now():%Y%m%d-%H%M%S}-before-invalid-dev-mail-resource-repair"
|
||||||
|
save_checkpoint.create(state, backup, "before removing invalid developer-mail resource sentinels")
|
||||||
|
atomic_write_json(target, repaired)
|
||||||
|
with target.open(encoding="utf-8") as stream:
|
||||||
|
written = json.load(stream)
|
||||||
|
repeated, remaining = repair(written)
|
||||||
|
after = save_checkpoint.inspect_files(state)
|
||||||
|
if remaining or repeated != repaired or any(
|
||||||
|
before[name] != after[name] for name in save_checkpoint.STATE_FILES if name != "items.json"
|
||||||
|
):
|
||||||
|
raise OSError(f"invalid developer-mail resource repair failed verification; backup: {backup}")
|
||||||
|
save_checkpoint.verify(backup)
|
||||||
|
return backup
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--state", type=Path, default=DEFAULT_STATE)
|
||||||
|
parser.add_argument("--apply", action="store_true")
|
||||||
|
args = parser.parse_args()
|
||||||
|
try:
|
||||||
|
backup = run(args.state.resolve(), args.apply)
|
||||||
|
print(f"Repaired with complete verified backup: {backup}" if backup else "Dry-run/no change; use --apply after stopping the client/server")
|
||||||
|
except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc:
|
||||||
|
parser.exit(1, f"repair_invalid_dev_mail_resources: {exc}\n")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -84,7 +84,7 @@ def running_game_processes() -> list[str]:
|
|||||||
names = []
|
names = []
|
||||||
for line in result.stdout.splitlines():
|
for line in result.stdout.splitlines():
|
||||||
lowered = line.lower()
|
lowered = line.lower()
|
||||||
if "browndust2" in lowered or "bd2server.exe" in lowered:
|
if "browndust2" in lowered or "brown dust ii" in lowered or "bd2server.exe" in lowered:
|
||||||
names.append(line)
|
names.append(line)
|
||||||
return names
|
return names
|
||||||
|
|
||||||
|
|||||||
@@ -66,10 +66,18 @@ def running_processes() -> list[str]:
|
|||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
errors="replace",
|
errors="replace",
|
||||||
)
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
detail = (result.stderr or result.stdout).strip()
|
||||||
|
raise RuntimeError(
|
||||||
|
"cannot verify that the client/server are stopped; "
|
||||||
|
f"tasklist failed with exit code {result.returncode}: {detail}"
|
||||||
|
)
|
||||||
return [
|
return [
|
||||||
line
|
line
|
||||||
for line in result.stdout.splitlines()
|
for line in result.stdout.splitlines()
|
||||||
if "browndust2" in line.lower() or "bd2server.exe" in line.lower()
|
if "browndust2" in line.lower()
|
||||||
|
or "brown dust ii" in line.lower()
|
||||||
|
or "bd2server.exe" in line.lower()
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Set the explicit first-gacha completion marker in a development save.
|
||||||
|
|
||||||
|
This is a pre-release schema edit, not a compatibility migration. By default
|
||||||
|
it prints the proposed collection.json change. Applying requires stopped game
|
||||||
|
and server processes, creates a verified nine-file checkpoint, and atomically
|
||||||
|
replaces only collection.json.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from datetime import datetime
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import save_checkpoint
|
||||||
|
|
||||||
|
|
||||||
|
IDENTITY = "account:first-gacha-completed"
|
||||||
|
WORKSPACE_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
DEFAULT_STATE = WORKSPACE_ROOT / "data" / "state"
|
||||||
|
|
||||||
|
|
||||||
|
def updated_collection(value: dict, completed: bool) -> tuple[dict, bool]:
|
||||||
|
if value.get("version") != "2.34.13":
|
||||||
|
raise ValueError("collection.json is not a 2.34.13 save")
|
||||||
|
grants = value.get("grants")
|
||||||
|
if not isinstance(grants, dict):
|
||||||
|
raise ValueError("collection.json grants must be an object")
|
||||||
|
result = dict(value)
|
||||||
|
result["grants"] = dict(grants)
|
||||||
|
before = IDENTITY in grants
|
||||||
|
if completed:
|
||||||
|
existing = grants.get(IDENTITY)
|
||||||
|
if before and existing != {}:
|
||||||
|
raise ValueError("first-gacha marker exists with unexpected payload")
|
||||||
|
result["grants"][IDENTITY] = {}
|
||||||
|
else:
|
||||||
|
result["grants"].pop(IDENTITY, None)
|
||||||
|
return result, before != completed
|
||||||
|
|
||||||
|
|
||||||
|
def atomic_write_json(path: Path, value: dict) -> None:
|
||||||
|
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
||||||
|
try:
|
||||||
|
with temporary.open("w", encoding="utf-8", newline="\n") as stream:
|
||||||
|
json.dump(value, stream, ensure_ascii=False, separators=(",", ":"))
|
||||||
|
stream.write("\n")
|
||||||
|
stream.flush()
|
||||||
|
os.fsync(stream.fileno())
|
||||||
|
os.replace(temporary, path)
|
||||||
|
finally:
|
||||||
|
temporary.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_state(state: Path, completed: bool, apply: bool) -> Path | None:
|
||||||
|
collection_path = state / "collection.json"
|
||||||
|
with collection_path.open("r", encoding="utf-8") as stream:
|
||||||
|
current = json.load(stream)
|
||||||
|
updated, changed = updated_collection(current, completed)
|
||||||
|
print(json.dumps({
|
||||||
|
"identity": IDENTITY,
|
||||||
|
"before": IDENTITY in current["grants"],
|
||||||
|
"after": completed,
|
||||||
|
"changed": changed,
|
||||||
|
}, ensure_ascii=False, indent=2))
|
||||||
|
if not changed:
|
||||||
|
return None
|
||||||
|
if not apply:
|
||||||
|
print("dry run only; stop client/server and pass --apply", file=sys.stderr)
|
||||||
|
return None
|
||||||
|
running = save_checkpoint.running_processes()
|
||||||
|
if running:
|
||||||
|
raise RuntimeError("client/server is still running:\n" + "\n".join(running))
|
||||||
|
save_checkpoint.inspect_files(state)
|
||||||
|
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||||
|
backup = state / "checkpoints" / f"{stamp}-before-first-gacha-state"
|
||||||
|
save_checkpoint.create(state, backup, "before explicit first-gacha state edit")
|
||||||
|
atomic_write_json(collection_path, updated)
|
||||||
|
with collection_path.open("r", encoding="utf-8") as stream:
|
||||||
|
written = json.load(stream)
|
||||||
|
if (IDENTITY in written.get("grants", {})) != completed:
|
||||||
|
raise OSError(f"written first-gacha state failed verification; backup: {backup}")
|
||||||
|
save_checkpoint.inspect_files(state)
|
||||||
|
return backup
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--state", type=Path, default=DEFAULT_STATE)
|
||||||
|
selection = parser.add_mutually_exclusive_group(required=True)
|
||||||
|
selection.add_argument("--completed", action="store_true")
|
||||||
|
selection.add_argument("--not-completed", action="store_true")
|
||||||
|
parser.add_argument("--apply", action="store_true")
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = build_parser()
|
||||||
|
args = parser.parse_args()
|
||||||
|
try:
|
||||||
|
backup = apply_state(args.state.resolve(), args.completed, args.apply)
|
||||||
|
if backup is not None:
|
||||||
|
print(f"updated {args.state.resolve() / 'collection.json'}; backup: {backup}")
|
||||||
|
except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc:
|
||||||
|
parser.exit(1, f"set_first_gacha_state: {exc}\n")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -7,8 +7,10 @@ import sys
|
|||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
import zipfile
|
import zipfile
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
from Crypto.Cipher import AES
|
from Crypto.Cipher import AES
|
||||||
|
from google.protobuf import descriptor_pb2
|
||||||
|
|
||||||
|
|
||||||
TOOLS = Path(__file__).resolve().parents[1]
|
TOOLS = Path(__file__).resolve().parents[1]
|
||||||
@@ -17,6 +19,15 @@ sys.path.insert(0, str(TOOLS))
|
|||||||
import gamedata_db
|
import gamedata_db
|
||||||
import import_seed
|
import import_seed
|
||||||
import save_checkpoint
|
import save_checkpoint
|
||||||
|
import deobfuscate_client_source
|
||||||
|
import extract_client_proto
|
||||||
|
import set_first_gacha_state
|
||||||
|
import repair_equipment_ranks
|
||||||
|
import repair_failed_collection_growth
|
||||||
|
import migrate_costume_potential_state
|
||||||
|
import migrate_equipment_upgrade_attempts
|
||||||
|
import dev_mail_grant
|
||||||
|
import repair_invalid_dev_mail_resources
|
||||||
|
|
||||||
|
|
||||||
class Arguments:
|
class Arguments:
|
||||||
@@ -100,6 +111,93 @@ class ImportToolTests(unittest.TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class CheckpointToolTests(unittest.TestCase):
|
class CheckpointToolTests(unittest.TestCase):
|
||||||
|
def test_invalid_dev_mail_resource_repair_preserves_grant_ledger(self):
|
||||||
|
identity = "mail:13043056739:items"
|
||||||
|
original = {
|
||||||
|
"version": "2.34.13",
|
||||||
|
"items": [
|
||||||
|
{"inven_index": 10, "id": 90045, "type": 8, "count": 1},
|
||||||
|
{"inven_index": 11, "id": 127, "type": 8, "count": 99},
|
||||||
|
],
|
||||||
|
"granted": {identity: True},
|
||||||
|
"grant_items": {identity: [10]},
|
||||||
|
}
|
||||||
|
repaired, removed = repair_invalid_dev_mail_resources.repair(original)
|
||||||
|
self.assertEqual(removed, [10])
|
||||||
|
self.assertEqual([item["inven_index"] for item in repaired["items"]], [11])
|
||||||
|
self.assertEqual(repaired["granted"], original["granted"])
|
||||||
|
self.assertEqual(repaired["grant_items"], original["grant_items"])
|
||||||
|
repeated, removed = repair_invalid_dev_mail_resources.repair(repaired)
|
||||||
|
self.assertEqual(removed, [])
|
||||||
|
self.assertEqual(repeated, repaired)
|
||||||
|
|
||||||
|
def test_process_check_fails_closed_when_tasklist_is_unavailable(self):
|
||||||
|
failed = mock.Mock(returncode=1, stdout="", stderr="ERROR: Access denied")
|
||||||
|
with mock.patch.object(save_checkpoint.os, "name", "nt"), mock.patch.object(save_checkpoint.subprocess, "run", return_value=failed):
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "cannot verify"):
|
||||||
|
save_checkpoint.running_processes()
|
||||||
|
|
||||||
|
def test_equipment_upgrade_attempt_migration_is_explicit_and_idempotent(self):
|
||||||
|
original = {"version": "2.34.13", "equipment": [{"inven_index": 1, "id": 10010}]}
|
||||||
|
migrated, changed = migrate_equipment_upgrade_attempts.migrate(original)
|
||||||
|
self.assertEqual(changed, [1])
|
||||||
|
self.assertEqual(migrated["equipment"][0]["upgrade_attempts"], 0)
|
||||||
|
self.assertNotIn("upgrade_attempts", original["equipment"][0])
|
||||||
|
repeated, changed = migrate_equipment_upgrade_attempts.migrate(migrated)
|
||||||
|
self.assertEqual(changed, [])
|
||||||
|
self.assertEqual(repeated, migrated)
|
||||||
|
|
||||||
|
def test_costume_potential_state_migration_is_final_and_idempotent(self):
|
||||||
|
original = {"version": "2.34.13", "costumes": [{"inven_index": 1, "id": 1001}]}
|
||||||
|
migrated, changed = migrate_costume_potential_state.migrate(original)
|
||||||
|
self.assertTrue(changed)
|
||||||
|
self.assertEqual(migrated["costume_potential"], {})
|
||||||
|
self.assertNotIn("costume_potential", original)
|
||||||
|
repeated, changed = migrate_costume_potential_state.migrate(migrated)
|
||||||
|
self.assertFalse(changed)
|
||||||
|
self.assertEqual(repeated, migrated)
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
migrate_costume_potential_state.migrate({"version": "2.34.13", "costumes": [{"potential_id": 1}]})
|
||||||
|
|
||||||
|
def test_failed_collection_growth_repair_requires_exact_six_unchanged_requests(self):
|
||||||
|
module = repair_failed_collection_growth
|
||||||
|
items = {"version": "2.34.13", "grant_items": {}, "next_index": 900000061, "items": [
|
||||||
|
{"inven_index": index, "id": value[0], "type": 8, "count": value[1]}
|
||||||
|
for index, value in module.CONSUMED.items()
|
||||||
|
] + [
|
||||||
|
{"inven_index": index, "id": value[0], "type": 8, "count": value[1]}
|
||||||
|
for index, value in module.REFUNDS.items()
|
||||||
|
]}
|
||||||
|
wallet = {"version": "2.34.13", "gold": 56050, "spent": {module.IDENTITY: True}}
|
||||||
|
collection = {"version": "2.34.13", "characters": [
|
||||||
|
{"inven_index": 920000054, "id": 6510, "level": 1},
|
||||||
|
]}
|
||||||
|
line = f"WARN session packet rejected path=/CharGrowth error={module.ERROR}\n"
|
||||||
|
corrected_items, corrected_wallet = module.repair(items, wallet, collection, line * 6)
|
||||||
|
self.assertEqual(corrected_wallet["gold"], 66050)
|
||||||
|
self.assertNotIn(module.IDENTITY, corrected_wallet["spent"])
|
||||||
|
self.assertEqual(len(corrected_items["items"]), len(module.CONSUMED))
|
||||||
|
self.assertEqual(next(item["count"] for item in corrected_items["items"] if item["inven_index"] == 900000042), 99256)
|
||||||
|
self.assertEqual(items["items"][2]["count"], 94738)
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
module.repair(items, wallet, collection, line * 5)
|
||||||
|
|
||||||
|
def test_equipment_rank_repair_only_fills_missing_arrays(self):
|
||||||
|
original = {"version": "2.34.13", "equipment": [
|
||||||
|
{"inven_index": 1, "id": 10010},
|
||||||
|
{"inven_index": 2, "id": 943619, "rank": [0, 2, 0]},
|
||||||
|
]}
|
||||||
|
repaired, indices = repair_equipment_ranks.repair(original)
|
||||||
|
self.assertEqual(indices, [1])
|
||||||
|
self.assertEqual(repaired["equipment"][0]["rank"], [0, 0, 0])
|
||||||
|
self.assertEqual(repaired["equipment"][1]["rank"], [0, 2, 0])
|
||||||
|
self.assertNotIn("rank", original["equipment"][0])
|
||||||
|
repeated, indices = repair_equipment_ranks.repair(repaired)
|
||||||
|
self.assertEqual(indices, [])
|
||||||
|
self.assertEqual(repeated, repaired)
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
repair_equipment_ranks.repair({"version": "2.34.13", "equipment": [{"inven_index": 3, "rank": [1, 2]}]})
|
||||||
|
|
||||||
def test_checkpoint_hash_manifest(self):
|
def test_checkpoint_hash_manifest(self):
|
||||||
with tempfile.TemporaryDirectory() as temporary:
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
root = Path(temporary)
|
root = Path(temporary)
|
||||||
@@ -111,6 +209,221 @@ class CheckpointToolTests(unittest.TestCase):
|
|||||||
created = save_checkpoint.create(state, target, "test")
|
created = save_checkpoint.create(state, target, "test")
|
||||||
self.assertEqual(save_checkpoint.verify(target), created)
|
self.assertEqual(save_checkpoint.verify(target), created)
|
||||||
|
|
||||||
|
def test_first_gacha_state_edit_is_explicit_and_idempotent(self):
|
||||||
|
original = {"version": "2.34.13", "grants": {"draw": {}}}
|
||||||
|
completed, changed = set_first_gacha_state.updated_collection(original, True)
|
||||||
|
self.assertTrue(changed)
|
||||||
|
self.assertEqual(completed["grants"][set_first_gacha_state.IDENTITY], {})
|
||||||
|
repeated, changed = set_first_gacha_state.updated_collection(completed, True)
|
||||||
|
self.assertFalse(changed)
|
||||||
|
pending, changed = set_first_gacha_state.updated_collection(repeated, False)
|
||||||
|
self.assertTrue(changed)
|
||||||
|
self.assertNotIn(set_first_gacha_state.IDENTITY, pending["grants"])
|
||||||
|
|
||||||
|
|
||||||
|
class DevelopmentMailGrantToolTests(unittest.TestCase):
|
||||||
|
def test_deterministic_random_box_is_replaced_by_direct_material(self):
|
||||||
|
items = [
|
||||||
|
{"id": 400131, "element_type": 9, "name": "装备制作所需材料", "category": "随机箱"},
|
||||||
|
{"id": 127, "element_type": 8, "name": "<未找到本地化文本 #32127>", "category": "资源"},
|
||||||
|
{"id": 999, "element_type": 9, "name": "真正随机箱", "category": "随机箱"},
|
||||||
|
]
|
||||||
|
mapped = dev_mail_grant.map_fixed_boxes_to_direct_items(
|
||||||
|
items,
|
||||||
|
{400131: (8, 127, 1)},
|
||||||
|
{400131: {"女神之泪": 1}},
|
||||||
|
)
|
||||||
|
self.assertNotIn((9, 400131), {(item["element_type"], item["id"]) for item in mapped})
|
||||||
|
self.assertNotIn((9, 999), {(item["element_type"], item["id"]) for item in mapped})
|
||||||
|
material = next(item for item in mapped if item["element_type"] == 8 and item["id"] == 127)
|
||||||
|
self.assertEqual(material["name"], "女神之泪")
|
||||||
|
self.assertIn("无需开箱", material["details"])
|
||||||
|
self.assertIn("400131", material["details"])
|
||||||
|
|
||||||
|
def test_internal_lost_resource_is_not_mail_safe(self):
|
||||||
|
self.assertFalse(dev_mail_grant._safe_direct_mail_item({
|
||||||
|
"id": 90045, "element_type": 8, "name": "金币遗失物品",
|
||||||
|
"source_table": "ResourceTable", "resource_type": 2,
|
||||||
|
}))
|
||||||
|
|
||||||
|
def test_gold_currency_mail_uses_type_four_id_zero_and_requested_count(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
root = Path(temporary)
|
||||||
|
source = root / "mail.json"
|
||||||
|
output = root / "generated.json"
|
||||||
|
source.write_text(json.dumps({
|
||||||
|
"version": "2.34.13", "mails": [], "mail_count": 1, "max_mail_id": 0,
|
||||||
|
}), encoding="utf-8")
|
||||||
|
gold = {"id": 0, "element_type": 4, "name": "金币"}
|
||||||
|
store = dev_mail_grant.MailGrantStore(source, output, [gold], 365)
|
||||||
|
result = store.grant({"item_id": 0, "element_type": 4, "count": 123456789})
|
||||||
|
self.assertEqual(result["mail"]["reward_types"], [4])
|
||||||
|
self.assertEqual(result["mail"]["reward_ids"], [0])
|
||||||
|
self.assertEqual(result["mail"]["reward_counts"], [123456789])
|
||||||
|
|
||||||
|
def test_packed_varints_accepts_repeated_and_packed_fields(self):
|
||||||
|
self.assertEqual(dev_mail_grant.packed_varints({4: [3, b"\x80\x01\x02"]}, 4), [3, 128, 2])
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
dev_mail_grant.packed_varints({4: [b"\x80"]}, 4)
|
||||||
|
|
||||||
|
def test_item_picker_is_collapsible_and_scrolls_its_list(self):
|
||||||
|
self.assertIn('<details id=item-picker>', dev_mail_grant.PAGE)
|
||||||
|
self.assertIn('<div class=item-list>', dev_mail_grant.PAGE)
|
||||||
|
self.assertIn('max-height:min(40vh,28rem)', dev_mail_grant.PAGE)
|
||||||
|
self.assertIn('overflow:auto', dev_mail_grant.PAGE)
|
||||||
|
self.assertIn("$('item-picker').open=false", dev_mail_grant.PAGE)
|
||||||
|
|
||||||
|
def test_picker_search_includes_random_box_aliases(self):
|
||||||
|
self.assertIn("x.category+' '+(x.details||'')", dev_mail_grant.PAGE)
|
||||||
|
|
||||||
|
def test_grant_writes_complete_seed_without_state_mutation(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
root = Path(temporary)
|
||||||
|
source = root / "source-mail.json"
|
||||||
|
output = root / "generated-mail.json"
|
||||||
|
source.write_text(json.dumps({
|
||||||
|
"version": "2.34.13",
|
||||||
|
"mails": [{
|
||||||
|
"mail_id": 100, "mail_type": 2, "title": "base", "body": "base",
|
||||||
|
"expires_at": 200, "reward_types": [8], "reward_ids": [7],
|
||||||
|
"reward_counts": [1], "sent_at": 100,
|
||||||
|
}],
|
||||||
|
"mail_count": 2, "max_mail_id": 100,
|
||||||
|
}), encoding="utf-8")
|
||||||
|
original_source = source.read_text(encoding="utf-8")
|
||||||
|
store = dev_mail_grant.MailGrantStore(source, output, [{
|
||||||
|
"id": 9, "element_type": 8, "name": "slime",
|
||||||
|
}], 365)
|
||||||
|
self.assertTrue(output.is_file())
|
||||||
|
self.assertEqual(json.loads(output.read_text(encoding="utf-8"))["mail_count"], 2)
|
||||||
|
result = store.grant({"item_id": 9, "element_type": 8, "count": 123, "title": "test", "body": "body"})
|
||||||
|
written = json.loads(output.read_text(encoding="utf-8"))
|
||||||
|
self.assertEqual(source.read_text(encoding="utf-8"), original_source)
|
||||||
|
self.assertEqual(written["mail_count"], 3)
|
||||||
|
self.assertEqual(written["max_mail_id"], 101)
|
||||||
|
self.assertEqual(written["mails"][-1]["reward_types"], [8])
|
||||||
|
self.assertEqual(written["mails"][-1]["reward_ids"], [9])
|
||||||
|
self.assertEqual(written["mails"][-1]["reward_counts"], [123])
|
||||||
|
self.assertFalse(result["restart_required"])
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
store.grant({"item_id": 999, "element_type": 8, "count": 1})
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
store.grant({"item_id": 9, "element_type": 8, "count": dev_mail_grant.MAX_INT32 + 1})
|
||||||
|
|
||||||
|
|
||||||
|
class ClientSourceToolTests(unittest.TestCase):
|
||||||
|
def test_deobfuscates_code_not_comments_or_literals_and_records_collisions(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
root = Path(temporary)
|
||||||
|
source = root / "Assembly-CSharp"
|
||||||
|
source.mkdir()
|
||||||
|
mapping = root / "translation.obfuscate"
|
||||||
|
mapping.write_text(
|
||||||
|
"#ReverseOrder\n"
|
||||||
|
"α⇨Net.Player/User-Info\n"
|
||||||
|
"β⇨Other.User Info\n"
|
||||||
|
"γ⇨class\n"
|
||||||
|
"δ⇨Meaning.D\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
(source / "α.cs").write_text(
|
||||||
|
"// α β γ δ\n"
|
||||||
|
"class α { string v = \"α β γ δ\"; char x = 'α'; α f; β g; γ h; δ i; }\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
(source / "asset.bin").write_bytes(b"\x00a")
|
||||||
|
output = root / "mirror"
|
||||||
|
manifest = deobfuscate_client_source.deobfuscate(source, mapping, output)
|
||||||
|
text = (output / "User_Info.cs").read_text(encoding="utf-8")
|
||||||
|
self.assertIn("// α β γ δ", text)
|
||||||
|
self.assertIn('"α β γ δ"', text)
|
||||||
|
self.assertIn("char x = 'α'", text)
|
||||||
|
self.assertIn("class User_Info", text)
|
||||||
|
self.assertIn("User_Info__from_u03B2 g", text)
|
||||||
|
self.assertIn("_class h", text)
|
||||||
|
self.assertIn("D i", text)
|
||||||
|
self.assertEqual((output / "asset.bin").read_bytes(), b"\x00a")
|
||||||
|
self.assertEqual(manifest["statistics"]["identifier_replacements"], 5)
|
||||||
|
self.assertTrue((output / deobfuscate_client_source.MANIFEST).is_file())
|
||||||
|
|
||||||
|
def test_deobfuscator_skips_ambiguous_scoped_symbol(self):
|
||||||
|
entries = [
|
||||||
|
{"line": 1, "source": "α", "meaning": "One.Value"},
|
||||||
|
{"line": 2, "source": "α", "meaning": "Two.Value"},
|
||||||
|
]
|
||||||
|
replacements, report, warnings = deobfuscate_client_source.build_replacements(entries)
|
||||||
|
self.assertNotIn("α", replacements)
|
||||||
|
self.assertEqual(report, [])
|
||||||
|
self.assertTrue(any("ambiguous" in warning for warning in warnings))
|
||||||
|
|
||||||
|
def test_deobfuscator_refuses_output_inside_source_or_unmanaged_output(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
root = Path(temporary)
|
||||||
|
source = root / "source"
|
||||||
|
source.mkdir()
|
||||||
|
mapping = root / "map"
|
||||||
|
mapping.write_text("a⇨Name\n", encoding="utf-8")
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
deobfuscate_client_source.deobfuscate(source, mapping, source / "out")
|
||||||
|
output = root / "output"
|
||||||
|
output.mkdir()
|
||||||
|
(output / "someone.txt").write_text("keep", encoding="utf-8")
|
||||||
|
with self.assertRaises(FileExistsError):
|
||||||
|
deobfuscate_client_source.deobfuscate(source, mapping, output)
|
||||||
|
|
||||||
|
def test_reconstructs_proto_and_lossless_descriptor_set(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
root = Path(temporary)
|
||||||
|
source = root / "Assembly-CSharp"
|
||||||
|
net = source / "Proto" / "Net"
|
||||||
|
net.mkdir(parents=True)
|
||||||
|
descriptor = descriptor_pb2.FileDescriptorProto(
|
||||||
|
name="Request/Login.proto", package="proto.net", syntax="proto3"
|
||||||
|
)
|
||||||
|
message = descriptor.message_type.add(name="LoginRequest")
|
||||||
|
message.field.add(
|
||||||
|
name="seq", number=1,
|
||||||
|
label=descriptor_pb2.FieldDescriptorProto.LABEL_OPTIONAL,
|
||||||
|
type=descriptor_pb2.FieldDescriptorProto.TYPE_INT32,
|
||||||
|
)
|
||||||
|
encoded = base64.b64encode(descriptor.SerializeToString()).decode("ascii")
|
||||||
|
(net / "LoginRequestReflection.cs").write_text(
|
||||||
|
"private static FileDescriptor descriptor = "
|
||||||
|
"FileDescriptor.FromGeneratedCode(Convert.FromBase64String("
|
||||||
|
f"string.Concat(new string[] {{ \"{encoded}\" }})), new FileDescriptor[0], info);",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
output = root / "proto-view"
|
||||||
|
manifest = extract_client_proto.reconstruct(source, output)
|
||||||
|
proto = (output / "Request" / "Login.proto").read_text(encoding="utf-8")
|
||||||
|
self.assertIn('syntax = "proto3";', proto)
|
||||||
|
self.assertIn("package proto.net;", proto)
|
||||||
|
self.assertIn("message LoginRequest", proto)
|
||||||
|
self.assertIn("int32 seq = 1;", proto)
|
||||||
|
saved = descriptor_pb2.FileDescriptorSet()
|
||||||
|
saved.ParseFromString((output / extract_client_proto.DESCRIPTOR_SET).read_bytes())
|
||||||
|
self.assertEqual(saved.file[0], descriptor)
|
||||||
|
self.assertEqual(manifest["statistics"]["proto_files"], 1)
|
||||||
|
|
||||||
|
def test_proto_extractor_rejects_unmanaged_or_nested_outputs(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
root = Path(temporary)
|
||||||
|
source = root / "source"
|
||||||
|
(source / "Proto" / "Net").mkdir(parents=True)
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
extract_client_proto.reconstruct(source, source / "view")
|
||||||
|
descriptor = descriptor_pb2.FileDescriptorProto(name="X.proto", syntax="proto3")
|
||||||
|
encoded = base64.b64encode(descriptor.SerializeToString()).decode("ascii")
|
||||||
|
(source / "Proto" / "Net" / "XReflection.cs").write_text(
|
||||||
|
"Convert.FromBase64String(string.Concat(new string[] {"
|
||||||
|
f"\"{encoded}\"" + "})), new FileDescriptor[0], info);",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
output = root / "view"
|
||||||
|
output.mkdir()
|
||||||
|
with self.assertRaises(FileExistsError):
|
||||||
|
extract_client_proto.reconstruct(source, output)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user