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:
2026-09-21 22:57:53 +08:00
parent a7c065eaa5
commit f55a71ac52
47 changed files with 6237 additions and 97 deletions
+45 -5
View File
@@ -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,
+37 -12
View File
@@ -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")
+43
View File
@@ -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)
}
}
+8 -1
View File
@@ -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})
} }
+3
View File
@@ -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)
+17
View File
@@ -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 == "" {
+175
View File
@@ -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)
}
}
+1 -1
View File
@@ -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
+67
View File
@@ -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)
}
}
+260
View File
@@ -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)
}
}
+353
View File
@@ -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(&current); 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
}
+179
View File
@@ -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")
}
}
+153
View File
@@ -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
}
+36
View File
@@ -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)
}
}
+87 -3
View File
@@ -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 {
+98 -2
View File
@@ -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)
+155 -4
View File
@@ -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...)
+117
View File
@@ -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 {
+164
View File
@@ -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
View File
@@ -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()
+421
View File
@@ -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)
}
}
+122 -2
View File
@@ -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
+226
View File
@@ -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
View File
@@ -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
+57
View File
@@ -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)
}
}
+4 -2
View File
@@ -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)
+30
View File
@@ -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()
+3 -3
View File
@@ -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
View File
@@ -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
+4
View File
@@ -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)
+59
View File
@@ -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` 键:
+372
View File
@@ -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())
+490
View File
@@ -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())
+398
View File
@@ -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())
+90
View File
@@ -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())
+1 -1
View File
@@ -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
+9 -1
View File
@@ -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()
] ]
+114
View File
@@ -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())
+313
View File
@@ -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()