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 {
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))
if err != nil {
return fmt.Errorf("load starter deck: %w", err)
@@ -159,6 +155,13 @@ func serve(args []string) error {
if err != nil {
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()
if err != nil {
return fmt.Errorf("read account seed currency: %w", err)
@@ -180,6 +183,9 @@ func serve(args []string) error {
if err != nil {
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)
if err != nil {
return fmt.Errorf("load mission GameData: %w", err)
@@ -201,6 +207,20 @@ func serve(args []string) error {
if err != nil {
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)
if err != nil {
return fmt.Errorf("load owned collection: %w", err)
@@ -217,6 +237,9 @@ func serve(args []string) error {
if err != nil {
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.AttachEquipmentGacha(equipmentGacha, ownedEquipment)
gachaService.AttachPreviewMission(func() error {
@@ -230,6 +253,11 @@ func serve(args []string) error {
if err := collection.BindBaseCharacters(worldService.CharacterService().RawAll()); err != nil {
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 {
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 {
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 {
return missionService.CompleteMission(gamedata.MissionKey{GroupType: 0, GroupID: 1, ID: 113})
})
@@ -263,6 +302,7 @@ func serve(args []string) error {
deckStateStore,
ownedItems,
ownedEquipment,
costumePotentialService,
starter,
mailService,
gachaService,
+37 -12
View File
@@ -21,18 +21,24 @@ type Service struct {
round uint64
monster uint64
deck uint64
pack int
initialBlue [][]byte
gameDataRoot string
gameDataVersion string
inventory *player.Inventory
currentPack func() (int, error)
loadRewards func(string, string, int, uint64) ([]gamedata.BattleReward, error)
buffs func() ([]gamedata.PictorialBuffStat, error)
onTutorialWin func() error
}
func (s *Service) AttachTutorialWin(callback func() error) { s.onTutorialWin = callback }
func NewService(gameDataRoot, gameDataVersion string, inventory *player.Inventory) *Service {
return &Service{gameDataRoot: gameDataRoot, gameDataVersion: gameDataVersion, inventory: inventory}
func NewService(gameDataRoot, gameDataVersion string, inventory *player.Inventory, currentPack func() (int, error)) *Service {
return &Service{
gameDataRoot: gameDataRoot, gameDataVersion: gameDataVersion,
inventory: inventory, currentPack: currentPack, loadRewards: gamedata.BattleDeckRewards,
}
}
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 {
return 0, nil, true, errors.New("battle: missing battle mode")
}
s.entered, s.index, s.round, s.initialBlue = true, 0, 0, nil
s.monster, _, _ = wire.Varint(request, 3)
s.deck = deck
slog.Info("team trace: battle entered", "monster", s.monster, "enemyDeck", deck, "mode", mode)
packID := 0
if s.currentPack != nil {
packID, err = s.currentPack()
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)
if s.buffs != nil {
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.
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
case "/BattleRetry":
if !s.entered {
@@ -170,11 +188,18 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error)
}
rewardBundle := false
if result == 1 && s.inventory != nil && s.monster != 0 && s.gameDataRoot != "" {
rewards, rewardErr := gamedata.BattleDeckRewards(s.gameDataRoot, s.gameDataVersion, 21, s.deck)
if rewardErr != nil {
return 0, nil, true, fmt.Errorf("battle: monster %d deck %d rewards: %w", s.monster, s.deck, rewardErr)
if s.pack <= 0 {
return 0, nil, true, errors.New("battle: victory has no locked pack")
}
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 {
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)
}
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
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
}
panic("unreachable")
+43
View File
@@ -1,9 +1,11 @@
package battle
import (
"path/filepath"
"testing"
"bd2server/internal/gamedata"
"bd2server/internal/player"
"bd2server/internal/wire"
)
@@ -120,3 +122,44 @@ func TestBattleEnterUsesSamePictorialSnapshotAsAllCharRefresh(t *testing.T) {
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
}
// 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
// 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,
@@ -408,7 +415,7 @@ func (s *Service) buyEquipment(seq, buyType uint64, tickets []player.Item, desig
if err != nil {
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 {
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 {
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)
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)
+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) {
root := os.Getenv("BD2_REAL_GAMEDATA")
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
// 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"
// 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 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
// literal title/body (ordinary system mail), or a TemplateID and Sender (the
// localized/template-driven mail form). Reward fields are parallel arrays:
@@ -169,12 +187,19 @@ type stateSnapshot struct {
type Service struct {
mu sync.Mutex
Starter *Starter
seedPath string
seedStamp fileStamp
path string
inventory *player.Inventory
wallet *player.Wallet
state stateSnapshot
}
type fileStamp struct {
size int64
modTime int64
}
func OpenService(path string, starter *Starter, inventory *player.Inventory, wallet *player.Wallet) (*Service, error) {
if path == "" || starter == nil || inventory == nil || wallet == nil {
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
}
// 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) {
if path != "/MailInfo" && path != "/MailOpen" {
return 0, nil, false, nil
@@ -217,12 +261,46 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error)
s.mu.Lock()
defer s.mu.Unlock()
if path == "/MailInfo" {
if err := s.reloadSeedIfChanged(); err != nil {
return 0, nil, true, err
}
return packetCode, s.info(), true, nil
}
response, err := s.open(request)
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 {
var result []byte
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(currency, 4, reward.Count)
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 {
return nil, errors.New("mail: invalid item reward")
}
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 {
+98 -2
View File
@@ -2,6 +2,7 @@ package mail
import (
"bytes"
"os"
"path/filepath"
"testing"
@@ -30,7 +31,7 @@ func TestStarterAnswersMailInfoWithoutCapture(t *testing.T) {
func TestMailOpenGrantsItemsAndCurrencyAndPersists(t *testing.T) {
dir := t.TempDir()
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}},
}}
starter := &player.Starter{Version: "2.34.13"}
@@ -38,7 +39,7 @@ func TestMailOpenGrantsItemsAndCurrencyAndPersists(t *testing.T) {
if err != nil {
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 {
t.Fatal(err)
}
@@ -55,6 +56,9 @@ func TestMailOpenGrantsItemsAndCurrencyAndPersists(t *testing.T) {
if wallet.Snapshot().FreeJewelry != 70 {
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 {
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) {
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)
+155 -4
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
@@ -82,6 +83,11 @@ type GachaSelection struct {
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 {
Version string `json:"version"`
NextCharacterIndex uint64 `json:"next_character_index"`
@@ -92,6 +98,7 @@ type collectionSnapshot struct {
Characters []Character `json:"characters,omitempty"`
Costumes []Costume `json:"costumes,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"`
StepUpProgress map[string]uint64 `json:"step_up_progress,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{
Version: "2.34.13", NextCharacterIndex: 920000001, NextCostumeIndex: 930000001,
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{},
}}
b, err := os.ReadFile(s.path)
@@ -136,6 +144,9 @@ func OpenCollectionStore(path string, base []Costume) (*CollectionStore, error)
if s.data.BaseCostumeLevels == nil {
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 {
s.data.GachaSelections = map[string][]GachaSelection{}
}
@@ -154,12 +165,23 @@ func OpenCollectionStore(path string, base []Costume) (*CollectionStore, error)
if s.data.GachaPointExchange == nil {
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 {
return nil, err
}
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
// and starter stores. Collection characters must share those instances: a new
// 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
}
// 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
// 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.
@@ -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 {
applyGachaPurchase(next, identity, costumeIDs, purchase, grant)
if purchase.Group.GachaSubType == 3 {
next.Grants[FirstGachaCompletedIdentity] = CollectionGrant{}
}
return nil
})
}
@@ -639,12 +687,49 @@ func (s *CollectionStore) FindCharacter(index uint64) (Character, bool) {
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()
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)
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
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 {
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) {
@@ -671,6 +811,13 @@ func (s *CollectionStore) Grant(identity string) (CollectionGrant, bool) {
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) {
for _, character := range s.Characters() {
if character.InvenIndex == index {
@@ -732,6 +879,10 @@ func cloneCollection(in collectionSnapshot) collectionSnapshot {
for k, v := range in.BaseCostumeLevels {
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))
for k, v := range in.GachaSelections {
out.GachaSelections[k] = append([]GachaSelection(nil), v...)
+117
View File
@@ -7,6 +7,74 @@ import (
"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) {
path := filepath.Join(t.TempDir(), "collection.json")
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) {
store, err := OpenCollectionStore(filepath.Join(t.TempDir(), "collection.json"), 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"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"unicode/utf8"
"bd2server/internal/gamedata"
"bd2server/internal/wire"
)
// Equipment is one server-owned equipment instance. The immutable definition
// (name, icon, slot and base stats) remains in the client's local GameData.
type Equipment struct {
InvenIndex uint64 `json:"inven_index"`
ID uint64 `json:"id"`
Level uint64 `json:"level"`
UseChar uint64 `json:"use_char,omitempty"`
KeepFlag uint64 `json:"keep_flag,omitempty"`
LockFlag uint64 `json:"lock_flag,omitempty"`
SortID uint64 `json:"sort_id,omitempty"`
MainOption []EquipmentOption `json:"main_option,omitempty"`
SubOption []EquipmentOption `json:"sub_option,omitempty"`
PrivateOption *EquipmentOption `json:"private_option,omitempty"`
Rank []uint64 `json:"rank,omitempty"`
InvenIndex uint64 `json:"inven_index"`
ID uint64 `json:"id"`
Level uint64 `json:"level"`
UseChar uint64 `json:"use_char,omitempty"`
KeepFlag uint64 `json:"keep_flag,omitempty"`
LockFlag uint64 `json:"lock_flag,omitempty"`
SortID uint64 `json:"sort_id,omitempty"`
Mark string `json:"mark,omitempty"`
MainOption []EquipmentOption `json:"main_option,omitempty"`
SubOption []EquipmentOption `json:"sub_option,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
@@ -48,6 +54,36 @@ type EquipmentInventory struct {
path string
owned equipmentSnapshot
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 {
@@ -71,12 +107,28 @@ func OpenEquipmentInventory(path string) (*EquipmentInventory, error) {
if err != nil {
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 {
return nil, fmt.Errorf("player: decode equipment: %w", err)
}
if s.owned.Version != "2.34.13" || s.owned.NextIndex < 910000001 || s.owned.Granted == nil {
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
}
@@ -102,7 +154,7 @@ func (s *EquipmentInventory) GrantOnce(identity string, equipmentID uint64) (Equ
// GrantGeneratedOnce saves an independently generated gacha instance. Retry
// calls return the original rolls rather than creating a second copy.
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")
}
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")
}
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
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)}
@@ -171,9 +231,7 @@ func EquipmentWire(entry Equipment) []byte {
base = wire.AppendBytes(base, 5, equipmentOptionWire(*entry.PrivateOption))
}
for _, rank := range entry.Rank {
if rank != 0 {
base = wire.AppendVarint(base, 6, rank)
}
base = wire.AppendVarint(base, 6, rank)
}
var out []byte
out = wire.AppendVarint(out, 1, entry.InvenIndex)
@@ -190,6 +248,9 @@ func EquipmentWire(entry Equipment) []byte {
if entry.SortID != 0 {
out = wire.AppendVarint(out, 7, entry.SortID)
}
if entry.Mark != "" {
out = wire.AppendBytes(out, 8, []byte(entry.Mark))
}
return out
}
@@ -199,7 +260,7 @@ func equipmentOptionWire(option EquipmentOption) []byte {
}
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
}
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" {
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()
defer s.mu.Unlock()
var response []byte
@@ -217,6 +296,580 @@ func (s *EquipmentInventory) Handle(path string, request []byte) (int, []byte, b
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 {
s.mu.Lock()
defer s.mu.Unlock()
+421
View File
@@ -1,12 +1,298 @@
package player
import (
"encoding/binary"
"path/filepath"
"testing"
"bd2server/internal/gamedata"
"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) {
path := filepath.Join(t.TempDir(), "equipment.json")
store, err := OpenEquipmentInventory(path)
@@ -43,6 +329,19 @@ func TestEquipmentGrantPersistsAndIsIdempotent(t *testing.T) {
if index != first.InvenIndex || id != 10010 {
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) {
@@ -101,3 +400,125 @@ func TestEquipmentUsePersistsCharacterBinding(t *testing.T) {
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"
"os"
"path/filepath"
"strconv"
"sync"
"bd2server/internal/gamedata"
@@ -29,7 +30,19 @@ type CharacterStore struct {
gameDataVersion string
grow func(Character, []gamedata.GrowthMaterial) (uint64, uint64, []gamedata.GrowthMaterial, error)
collection *CollectionStore
wallet *Wallet
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
@@ -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) {
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)
if errors.Is(err, os.ErrNotExist) {
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 {
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")
}
materials = append(materials, item)
@@ -247,6 +263,16 @@ func (s *CharacterStore) Handle(path string, request []byte) (int, []byte, bool,
if position < 0 && !fromCollection {
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))
for i, material := range materials {
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)
}
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)
}
} else {
@@ -295,6 +321,100 @@ func (s *CharacterStore) Handle(path string, request []byte) (int, []byte, bool,
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
// whose TalentSkillTable.ClassType is 14. The story character 6010 has
// ValueList[0]=10000 at every talent level (100%). The authoritative maximum
+226
View File
@@ -3,6 +3,7 @@ package player
import (
"bytes"
"encoding/binary"
"fmt"
"os"
"path/filepath"
"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) {
dir := t.TempDir()
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
// seed. GrantOnce uses a battle identity to prevent double-credit on retries.
type Inventory struct {
mu sync.Mutex
path string
starter *Starter
owned ownedSnapshot
mu sync.Mutex
path string
starter *Starter
randomBoxes *gamedata.RandomBoxDesign
owned ownedSnapshot
}
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) {
if path != "/ItemInfo" {
if path != "/ItemInfo" && path != "/UseRandomBox" {
return 0, nil, false, nil
}
if path == "/UseRandomBox" {
return s.useRandomBox(request)
}
s.mu.Lock()
defer s.mu.Unlock()
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)
}
// 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 {
s.mu.Lock()
defer s.mu.Unlock()
@@ -75,6 +156,33 @@ func (s *Inventory) All() []Item {
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) {
if 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] {
return nil, nil
}
next := ownedSnapshot{Version: s.owned.Version, NextIndex: s.owned.NextIndex,
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...)
}
next := cloneOwnedSnapshot(s.owned)
newItems := make([]Item, 0, len(rewards))
for _, r := range rewards {
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.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)
if err != nil {
return nil, err
return err
}
dir := filepath.Dir(s.path)
if err := os.MkdirAll(dir, 0o700); err != nil {
return nil, err
return err
}
f, err := os.CreateTemp(dir, ".inventory-*.tmp")
if err != nil {
return nil, err
return err
}
defer os.Remove(f.Name())
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 {
err = closeErr
}
if err != nil {
return nil, err
if err == nil {
err = os.Rename(f.Name(), s.path)
}
if err := os.Rename(f.Name(), s.path); err != nil {
return nil, err
}
s.owned = next
return newItems, nil
return err
}
// 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.
// 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 {
_, err := s.ConsumeAndRefund(requested, nil)
return err
+57
View File
@@ -1,6 +1,7 @@
package player
import (
"os"
"path/filepath"
"testing"
@@ -41,3 +42,59 @@ func TestBattleRewardPersistsWithoutDuplicate(t *testing.T) {
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"`
UseChar uint64 `json:"use_char,omitempty"`
SortID uint64 `json:"sort_id,omitempty"`
PotentialID uint64 `json:"potential_id,omitempty"`
PotentialIDs []uint64 `json:"-"`
DesignID uint64 `json:"design_id,omitempty"`
BurstLevel uint64 `json:"burst_level,omitempty"`
TimeValue uint64 `json:"time_value,omitempty"`
@@ -177,7 +177,9 @@ func CostumeWire(entry Costume) []byte {
costume = wire.AppendBytes(costume, 5, book)
}
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, 10, entry.BurstLevel)
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
}
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 {
s.mu.Lock()
defer s.mu.Unlock()
+3 -3
View File
@@ -149,9 +149,9 @@ func OpenStore(path string) (*Store, error) {
s.cleared[questKey(packID, questID)] = struct{}{}
}
if legacy {
// Install the unambiguous pack+quest schema immediately. Pack 21 and 22
// both number quests from one, so waiting until the first pack22 update
// would risk overwriting valid pack21 progress.
// Install the unambiguous pack+quest schema immediately. Adjacent story
// packs reuse quest numbers, so delaying migration until a later-pack
// update would risk overwriting valid earlier-pack progress.
if err := s.commit(s.position, s.tutorials, s.quests, s.cleared); err != nil {
return nil, fmt.Errorf("progress: migrate legacy save: %w", err)
}
+65 -21
View File
@@ -11,6 +11,7 @@ import (
"log/slog"
"os"
"sort"
"sync"
"bd2server/internal/deck"
"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)
}
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 {
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) 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 {
seed Seed
state *progress.Store
starter *player.Starter
equipment *player.EquipmentInventory
inventory *player.Inventory
wallet *player.Wallet
characters *player.CharacterStore
collection *player.CollectionStore
decks *deck.Store
quests map[int]gamedata.QuestDesign
transition gamedata.PackTransition
packs map[int]map[int]gamedata.QuestDesign
transitions map[int]gamedata.PackTransition
seed Seed
state *progress.Store
starter *player.Starter
equipment *player.EquipmentInventory
inventory *player.Inventory
wallet *player.Wallet
characters *player.CharacterStore
collection *player.CollectionStore
decks *deck.Store
quests map[int]gamedata.QuestDesign
transition gamedata.PackTransition
packs map[int]map[int]gamedata.QuestDesign
transitions map[int]gamedata.PackTransition
activePackMu sync.RWMutex
activePack int
}
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 {
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
case "/PackInGameInfo":
pack, err := requestPack(request)
@@ -148,6 +186,7 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error)
if !s.packUnlocked(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))
return 5, s.packInfoFor(pack), true, nil
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 {
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)
return 18, s.clearResponse(pack, quest, design.Rewards[0], items, questEquipment), true, nil
default:
@@ -223,8 +267,8 @@ func (s *Service) transitionFor(packID int) gamedata.PackTransition {
}
// packUnlocked follows the static story chain and requires every preceding
// pack to be complete. This accepts pack22 after pack21 quest38 without
// accidentally exposing arbitrary tables from the shared GameData database.
// pack to be complete. This accepts the configured next story pack only after
// its predecessor is complete, without exposing arbitrary GameData tables.
func (s *Service) packUnlocked(packID int) bool {
current := s.seed.PackID
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
// 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 {
return s.packInfoFor(s.seed.PackID)
}
@@ -410,8 +454,8 @@ func (s *Service) packInfoFor(packID int) []byte {
}
out = wire.AppendString(out, 4, position)
// The remaining starter-only records were observed in the official
// pack21 response. They represent reputation, hunting-ground, statue, and
// reward state, not generic pack defaults, so a newly entered pack22 must
// starter-pack response. They represent reputation, hunting-ground, statue,
// and reward state, not generic defaults, so a newly entered later pack must
// not inherit them.
if packID != s.seed.PackID {
visit := wire.AppendVarint(nil, 5, uint64(packID))
@@ -653,7 +697,7 @@ func (s *Service) PictorialCostumes() []player.Costume {
if s.collection != nil {
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)
}
return result
+4
View File
@@ -252,6 +252,10 @@ func TestPack22InitializesWithIndependentQuestIdentity(t *testing.T) {
if err != nil || !handled || code != 5 {
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)
if err != nil || !found {
t.Fatalf("pack22 active quest missing: %v", err)