feat: expand growth, equipment, and mail
- add character promotion and costume potential flows - add equipment management and enhancement - harden development mail and save tools
This commit is contained in:
@@ -143,10 +143,6 @@ func serve(args []string) error {
|
||||
if err != nil {
|
||||
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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
type CostumePotentialCost struct{ Type, ID, Count uint64 }
|
||||
|
||||
type CostumePotentialNode struct {
|
||||
ID uint64
|
||||
ConditionGrade uint64
|
||||
Prerequisites []uint64
|
||||
Costs []CostumePotentialCost
|
||||
}
|
||||
|
||||
type CostumePotentialDesign struct {
|
||||
Nodes map[uint64]map[uint64]CostumePotentialNode
|
||||
CostumeUnique map[uint64]uint64
|
||||
CharacterGrade map[uint64]uint64
|
||||
CharacterUnique map[uint64]uint64
|
||||
}
|
||||
|
||||
func LoadCostumePotentialDesign(root, version string) (*CostumePotentialDesign, error) {
|
||||
plain, err := ReadQuestDatabase(root, version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dir, err := os.MkdirTemp("", "bd2-costume-potential-")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
path := filepath.Join(dir, "common.db")
|
||||
if err := os.WriteFile(path, plain, 0o600); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer db.Close()
|
||||
return loadCostumePotentialDesign(db)
|
||||
}
|
||||
|
||||
func loadCostumePotentialDesign(db *sql.DB) (*CostumePotentialDesign, error) {
|
||||
d := &CostumePotentialDesign{Nodes: map[uint64]map[uint64]CostumePotentialNode{}, CostumeUnique: map[uint64]uint64{}, CharacterGrade: map[uint64]uint64{}, CharacterUnique: map[uint64]uint64{}}
|
||||
rows, err := db.Query("SELECT groupId,id,ProtoBuf FROM CostumeNodeTable ORDER BY groupId,id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var groupID, id uint64
|
||||
var proto []byte
|
||||
if err := rows.Scan(&groupID, &id, &proto); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
counts, _ := packedInts(proto, 1)
|
||||
ids, _ := packedInts(proto, 2)
|
||||
types, _ := packedInts(proto, 3)
|
||||
if len(counts) == 0 || len(counts) != len(ids) || len(counts) != len(types) {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: invalid costume potential costs %d/%d", groupID, id)
|
||||
}
|
||||
grade, _ := packedInts(proto, 17)
|
||||
conditions, _ := packedInts(proto, 18)
|
||||
node := CostumePotentialNode{ID: id, Prerequisites: append([]uint64(nil), conditions...)}
|
||||
if len(grade) == 1 {
|
||||
node.ConditionGrade = grade[0]
|
||||
} else if len(grade) > 1 {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: invalid costume potential grade %d/%d", groupID, id)
|
||||
}
|
||||
for i := range counts {
|
||||
if counts[i] == 0 || types[i] == 0 || (types[i] != 4 && ids[i] == 0) {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: invalid costume potential cost %d/%d", groupID, id)
|
||||
}
|
||||
node.Costs = append(node.Costs, CostumePotentialCost{Type: types[i], ID: ids[i], Count: counts[i]})
|
||||
}
|
||||
if d.Nodes[groupID] == nil {
|
||||
d.Nodes[groupID] = map[uint64]CostumePotentialNode{}
|
||||
}
|
||||
d.Nodes[groupID][id] = node
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err = db.Query("SELECT id,ProtoBuf FROM CostumeTable")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var id uint64
|
||||
var proto []byte
|
||||
if err := rows.Scan(&id, &proto); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
unique, _ := packedInts(proto, 27)
|
||||
if len(unique) == 1 {
|
||||
d.CostumeUnique[id] = unique[0]
|
||||
}
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err = db.Query("SELECT id,ProtoBuf FROM CharTable")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var id uint64
|
||||
var proto []byte
|
||||
if err := rows.Scan(&id, &proto); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
grade, _ := packedInts(proto, 10)
|
||||
unique, _ := packedInts(proto, 20)
|
||||
if len(grade) == 1 && len(unique) == 1 {
|
||||
d.CharacterGrade[id] = grade[0]
|
||||
d.CharacterUnique[id] = unique[0]
|
||||
}
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(d.Nodes) == 0 {
|
||||
return nil, fmt.Errorf("gamedata: CostumeNodeTable is empty")
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func (d *CostumePotentialDesign) Validate(costumeID, characterID, grade uint64, active, requested []uint64) ([]CostumePotentialCost, error) {
|
||||
if d == nil || costumeID == 0 || characterID == 0 || len(requested) == 0 {
|
||||
return nil, fmt.Errorf("gamedata: invalid costume potential request")
|
||||
}
|
||||
if d.CostumeUnique[costumeID] == 0 || d.CostumeUnique[costumeID] != d.CharacterUnique[characterID] {
|
||||
return nil, fmt.Errorf("gamedata: costume %d does not belong to character %d", costumeID, characterID)
|
||||
}
|
||||
if grade == 0 {
|
||||
grade = d.CharacterGrade[characterID]
|
||||
}
|
||||
known := make(map[uint64]bool, len(active)+len(requested))
|
||||
for _, id := range active {
|
||||
known[id] = true
|
||||
}
|
||||
for _, id := range requested {
|
||||
if id == 0 || known[id] {
|
||||
return nil, fmt.Errorf("gamedata: duplicate costume potential node %d", id)
|
||||
}
|
||||
known[id] = true
|
||||
}
|
||||
var costs []CostumePotentialCost
|
||||
for _, id := range requested {
|
||||
node, exists := d.Nodes[costumeID][id]
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("gamedata: costume %d has no potential node %d", costumeID, id)
|
||||
}
|
||||
if node.ConditionGrade > grade {
|
||||
return nil, fmt.Errorf("gamedata: potential node %d requires growth grade %d", id, node.ConditionGrade)
|
||||
}
|
||||
for _, prerequisite := range node.Prerequisites {
|
||||
if !known[prerequisite] {
|
||||
return nil, fmt.Errorf("gamedata: potential node %d requires node %d", id, prerequisite)
|
||||
}
|
||||
}
|
||||
costs = append(costs, node.Costs...)
|
||||
}
|
||||
return costs, nil
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCostumePotentialValidatePrerequisitesAndAggregate(t *testing.T) {
|
||||
d := &CostumePotentialDesign{
|
||||
Nodes: map[uint64]map[uint64]CostumePotentialNode{100: {
|
||||
1: {ID: 1, Costs: []CostumePotentialCost{{Type: 4, Count: 10}}},
|
||||
2: {ID: 2, ConditionGrade: 2, Prerequisites: []uint64{1}, Costs: []CostumePotentialCost{{Type: 8, ID: 7, Count: 3}}},
|
||||
}},
|
||||
CostumeUnique: map[uint64]uint64{100: 9}, CharacterGrade: map[uint64]uint64{200: 2}, CharacterUnique: map[uint64]uint64{200: 9},
|
||||
}
|
||||
costs, err := d.Validate(100, 200, 0, nil, []uint64{1, 2})
|
||||
if err != nil || len(costs) != 2 {
|
||||
t.Fatalf("valid chain costs=%+v err=%v", costs, err)
|
||||
}
|
||||
if _, err := d.Validate(100, 200, 0, nil, []uint64{2}); err == nil {
|
||||
t.Fatal("missing prerequisite accepted")
|
||||
}
|
||||
if _, err := d.Validate(100, 201, 0, nil, []uint64{1}); err == nil {
|
||||
t.Fatal("wrong character accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCostumePotentialAgainstInstalledVersion23413(t *testing.T) {
|
||||
root := os.Getenv("BD2_TEST_GAMEDATA_ROOT")
|
||||
if root == "" {
|
||||
t.Skip("set BD2_TEST_GAMEDATA_ROOT for installed GameData integration test")
|
||||
}
|
||||
design, err := LoadCostumePotentialDesign(root, "20260910162539")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
nodes := make([]uint64, 0, len(design.Nodes[65103]))
|
||||
for id := range design.Nodes[65103] {
|
||||
nodes = append(nodes, id)
|
||||
}
|
||||
costs, err := design.Validate(65103, 6514, 0, nil, nodes)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(nodes) != 17 || len(costs) < 17 {
|
||||
t.Fatalf("installed costume 65103 nodes=%d costs=%d", len(nodes), len(costs))
|
||||
}
|
||||
questCosts, err := design.Validate(3501, 354, 0, nil, []uint64{1})
|
||||
if err != nil || len(questCosts) != 1 || questCosts[0].Type != 4 || questCosts[0].Count == 0 {
|
||||
t.Fatalf("installed quest reward costume 3501 first node costs=%+v err=%v", questCosts, err)
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,7 @@ func ReadDatabase(root, version, logical string) ([]byte, error) {
|
||||
|
||||
// questDatabaseEntry is the member in the 2.34.13 GameData archive that
|
||||
// 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
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// LoadEquipmentSlots reads EquipmentTable.SlotType for every installed item.
|
||||
// SlotType is a proto3 enum, so an absent field is the valid first slot (0).
|
||||
func LoadEquipmentSlots(root, version string) (map[uint64]uint64, error) {
|
||||
plain, err := ReadQuestDatabase(root, version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dir, err := os.MkdirTemp("", "bd2-equipment-slots-")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
path := filepath.Join(dir, "common.db")
|
||||
if err := os.WriteFile(path, plain, 0o600); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer db.Close()
|
||||
return loadEquipmentSlots(db)
|
||||
}
|
||||
|
||||
func loadEquipmentSlots(db *sql.DB) (map[uint64]uint64, error) {
|
||||
rows, err := db.Query("SELECT id,ProtoBuf FROM EquipmentTable")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
slots := make(map[uint64]uint64)
|
||||
for rows.Next() {
|
||||
var id uint64
|
||||
var proto []byte
|
||||
if err := rows.Scan(&id, &proto); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
values, err := packedInts(proto, 20)
|
||||
if err != nil || len(values) > 1 {
|
||||
return nil, fmt.Errorf("gamedata: equipment %d malformed slot: %v", id, err)
|
||||
}
|
||||
var slot uint64
|
||||
if len(values) == 1 {
|
||||
slot = values[0]
|
||||
}
|
||||
if slot > 4 {
|
||||
return nil, fmt.Errorf("gamedata: equipment %d invalid slot %d", id, slot)
|
||||
}
|
||||
slots[id] = slot
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(slots) == 0 {
|
||||
return nil, fmt.Errorf("gamedata: EquipmentTable is empty")
|
||||
}
|
||||
return slots, nil
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadEquipmentSlotsKeepsProtoDefaultSlotZero(t *testing.T) {
|
||||
db, err := sql.Open("sqlite", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
if _, err := db.Exec("CREATE TABLE EquipmentTable (id INTEGER PRIMARY KEY, ProtoBuf BLOB)"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.Exec("INSERT INTO EquipmentTable VALUES (?,?),(?,?)", 10010, []byte{}, 943619, testVarintField(nil, 20, 4)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
slots, err := loadEquipmentSlots(db)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(slots) != 2 || slots[10010] != 0 || slots[943619] != 4 {
|
||||
t.Fatalf("slots=%v", slots)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
type EquipmentUpgradeLevel struct {
|
||||
Level, GrowthPoint uint64
|
||||
Costs []PromotionCost
|
||||
SuccessRatio float64
|
||||
}
|
||||
|
||||
type EquipmentUpgradeDesign struct {
|
||||
MaxLevel map[uint64]uint64
|
||||
Group map[uint64]uint64
|
||||
RankGroup map[uint64]uint64
|
||||
Levels map[[2]uint64]EquipmentUpgradeLevel
|
||||
RankRatio map[[2]uint64][]float64
|
||||
roll func(float64) (bool, error)
|
||||
rankRoll func([]float64) (uint64, error)
|
||||
}
|
||||
|
||||
func LoadEquipmentUpgradeDesign(root, version string) (*EquipmentUpgradeDesign, error) {
|
||||
plain, err := ReadQuestDatabase(root, version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dir, err := os.MkdirTemp("", "bd2-equipment-upgrade-")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
path := filepath.Join(dir, "common.db")
|
||||
if err := os.WriteFile(path, plain, 0o600); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer db.Close()
|
||||
return loadEquipmentUpgradeDesign(db)
|
||||
}
|
||||
|
||||
func loadEquipmentUpgradeDesign(db *sql.DB) (*EquipmentUpgradeDesign, error) {
|
||||
d := &EquipmentUpgradeDesign{
|
||||
MaxLevel: map[uint64]uint64{}, Group: map[uint64]uint64{}, RankGroup: map[uint64]uint64{},
|
||||
Levels: map[[2]uint64]EquipmentUpgradeLevel{}, RankRatio: map[[2]uint64][]float64{},
|
||||
roll: cryptoRatioRoll, rankRoll: cryptoRankRoll,
|
||||
}
|
||||
groupMaximum := map[uint64]uint64{}
|
||||
rankGroups := map[uint64]bool{}
|
||||
rows, err := db.Query("SELECT id,ProtoBuf FROM EquipmentTable")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var id uint64
|
||||
var proto []byte
|
||||
if err := rows.Scan(&id, &proto); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
groups, _ := packedInts(proto, 4)
|
||||
maximum, _ := packedInts(proto, 13)
|
||||
rankGroup, _ := packedInts(proto, 19)
|
||||
if len(groups) != 1 || len(maximum) != 1 || len(rankGroup) != 1 || maximum[0] == 0 || rankGroup[0] == 0 {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: equipment %d invalid upgrade design", id)
|
||||
}
|
||||
d.Group[id], d.MaxLevel[id], d.RankGroup[id] = groups[0], maximum[0], rankGroup[0]
|
||||
rankGroups[rankGroup[0]] = true
|
||||
if prior, found := groupMaximum[groups[0]]; found && prior != maximum[0] {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: equipment growth group %d has inconsistent maxima", groups[0])
|
||||
}
|
||||
groupMaximum[groups[0]] = maximum[0]
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err = db.Query("SELECT groupId,id,ProtoBuf FROM EquipmentGrowthTable")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var group, level uint64
|
||||
var proto []byte
|
||||
if err := rows.Scan(&group, &level, &proto); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
maximum, referenced := groupMaximum[group]
|
||||
if !referenced {
|
||||
continue
|
||||
}
|
||||
// EquipmentGrowthTable includes the terminal maximum-level row, which
|
||||
// intentionally has no next-upgrade cost or success ratio.
|
||||
if level >= maximum {
|
||||
continue
|
||||
}
|
||||
counts, _ := packedInts(proto, 7)
|
||||
ids, _ := packedInts(proto, 8)
|
||||
types, _ := packedInts(proto, 9)
|
||||
point, _ := packedInts(proto, 5)
|
||||
ratio, present, err := fixed64Double(proto, 10)
|
||||
if err != nil || !present || ratio < 0 || ratio > 1 || len(point) != 1 || len(counts) == 0 || len(counts) != len(ids) || len(counts) != len(types) {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: equipment growth %d/%d malformed", group, level)
|
||||
}
|
||||
entry := EquipmentUpgradeLevel{Level: level, GrowthPoint: point[0], SuccessRatio: ratio}
|
||||
for i := range counts {
|
||||
if counts[i] == 0 || (types[i] != 4 && types[i] != 8) || (types[i] == 4 && ids[i] != 0) || (types[i] == 8 && ids[i] == 0) {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: equipment growth %d/%d invalid cost", group, level)
|
||||
}
|
||||
entry.Costs = append(entry.Costs, PromotionCost{Type: types[i], ID: ids[i], Count: counts[i]})
|
||||
}
|
||||
d.Levels[[2]uint64{group, level}] = entry
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err = db.Query("SELECT groupId,id,ProtoBuf FROM EquipmentRankTable")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var group, slot uint64
|
||||
var proto []byte
|
||||
if err := rows.Scan(&group, &slot, &proto); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
if !rankGroups[group] {
|
||||
continue
|
||||
}
|
||||
ratios, err := fixed32Floats(proto, 4)
|
||||
if err != nil || slot < 1 || slot > 3 || len(ratios) != 4 {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: equipment rank %d/%d malformed", group, slot)
|
||||
}
|
||||
var total float64
|
||||
for _, ratio := range ratios {
|
||||
if ratio < 0 || ratio > 1 || math.IsNaN(ratio) {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: equipment rank %d/%d invalid ratio", group, slot)
|
||||
}
|
||||
total += ratio
|
||||
}
|
||||
if math.Abs(total-1) > 1e-5 {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: equipment rank %d/%d ratio total %.8f", group, slot, total)
|
||||
}
|
||||
d.RankRatio[[2]uint64{group, slot}] = append([]float64(nil), ratios...)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for group := range rankGroups {
|
||||
for slot := uint64(1); slot <= 3; slot++ {
|
||||
if len(d.RankRatio[[2]uint64{group, slot}]) != 4 {
|
||||
return nil, fmt.Errorf("gamedata: missing equipment rank %d/%d", group, slot)
|
||||
}
|
||||
}
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func (d *EquipmentUpgradeDesign) Level(equipmentID, level uint64) (EquipmentUpgradeLevel, uint64, error) {
|
||||
if d == nil || equipmentID == 0 {
|
||||
return EquipmentUpgradeLevel{}, 0, fmt.Errorf("gamedata: invalid equipment upgrade request")
|
||||
}
|
||||
maximum, exists := d.MaxLevel[equipmentID]
|
||||
if !exists {
|
||||
return EquipmentUpgradeLevel{}, 0, fmt.Errorf("gamedata: unknown equipment %d", equipmentID)
|
||||
}
|
||||
if level >= maximum {
|
||||
return EquipmentUpgradeLevel{}, maximum, fmt.Errorf("gamedata: equipment %d already at maximum level %d", equipmentID, maximum)
|
||||
}
|
||||
entry, exists := d.Levels[[2]uint64{d.Group[equipmentID], level}]
|
||||
if !exists {
|
||||
return EquipmentUpgradeLevel{}, maximum, fmt.Errorf("gamedata: missing equipment growth %d/%d", d.Group[equipmentID], level)
|
||||
}
|
||||
return entry, maximum, nil
|
||||
}
|
||||
|
||||
func (d *EquipmentUpgradeDesign) Roll(ratio float64) (bool, error) {
|
||||
if d == nil {
|
||||
return false, fmt.Errorf("gamedata: equipment upgrade roller unavailable")
|
||||
}
|
||||
if d.roll == nil {
|
||||
return cryptoRatioRoll(ratio)
|
||||
}
|
||||
return d.roll(ratio)
|
||||
}
|
||||
|
||||
// RollRank returns the official C/B/A/S grade number (1..4) for the slot
|
||||
// unlocked at +3, +6 or +9. The distributions come from EquipmentRankTable.
|
||||
func (d *EquipmentUpgradeDesign) RollRank(equipmentID, slot uint64) (uint64, error) {
|
||||
if d == nil || slot < 1 || slot > 3 {
|
||||
return 0, fmt.Errorf("gamedata: invalid equipment rank request")
|
||||
}
|
||||
group, exists := d.RankGroup[equipmentID]
|
||||
if !exists {
|
||||
return 0, fmt.Errorf("gamedata: unknown equipment rank design %d", equipmentID)
|
||||
}
|
||||
ratios := d.RankRatio[[2]uint64{group, slot}]
|
||||
if len(ratios) != 4 {
|
||||
return 0, fmt.Errorf("gamedata: missing equipment rank %d/%d", group, slot)
|
||||
}
|
||||
if d.rankRoll != nil {
|
||||
return d.rankRoll(ratios)
|
||||
}
|
||||
return cryptoRankRoll(ratios)
|
||||
}
|
||||
|
||||
func cryptoRatioRoll(ratio float64) (bool, error) {
|
||||
if ratio <= 0 {
|
||||
return false, nil
|
||||
}
|
||||
if ratio >= 1 {
|
||||
return true, nil
|
||||
}
|
||||
value, err := cryptoUnitFloat()
|
||||
return value < ratio && !math.IsNaN(ratio), err
|
||||
}
|
||||
|
||||
func cryptoRankRoll(ratios []float64) (uint64, error) {
|
||||
if len(ratios) != 4 {
|
||||
return 0, fmt.Errorf("gamedata: invalid equipment rank ratios")
|
||||
}
|
||||
value, err := cryptoUnitFloat()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var cumulative float64
|
||||
for i, ratio := range ratios {
|
||||
cumulative += ratio
|
||||
if value < cumulative || i == len(ratios)-1 {
|
||||
return uint64(i + 1), nil
|
||||
}
|
||||
}
|
||||
return 0, fmt.Errorf("gamedata: equipment rank roll failed")
|
||||
}
|
||||
|
||||
func cryptoUnitFloat() (float64, error) {
|
||||
var raw [8]byte
|
||||
if _, err := rand.Read(raw[:]); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
value := binary.LittleEndian.Uint64(raw[:]) >> 11
|
||||
return float64(value) / float64(uint64(1)<<53), nil
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/binary"
|
||||
"math"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"bd2server/internal/wire"
|
||||
)
|
||||
|
||||
func TestEquipmentUpgradeDesignReadsCostsAndRatio(t *testing.T) {
|
||||
db, err := sql.Open("sqlite", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
if _, err := db.Exec("CREATE TABLE EquipmentTable (id INTEGER PRIMARY KEY, ProtoBuf BLOB); CREATE TABLE EquipmentGrowthTable (groupId INTEGER,id INTEGER,ProtoBuf BLOB,PRIMARY KEY(groupId,id)); CREATE TABLE EquipmentRankTable (groupId INTEGER,id INTEGER,ProtoBuf BLOB,PRIMARY KEY(groupId,id))"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
equipment := testVarintField(testVarintField(testVarintField(nil, 4, 954), 13, 9), 19, 904)
|
||||
growth := testVarintField(testVarintField(nil, 4, 954), 5, 670)
|
||||
growth = testPackedField(growth, 7, 960)
|
||||
growth = testPackedField(growth, 8, 0)
|
||||
growth = testPackedField(growth, 9, 4)
|
||||
growth = wire.AppendDouble(growth, 10, 0.7)
|
||||
if _, err := db.Exec("INSERT INTO EquipmentTable VALUES (?,?)", 943035, equipment); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.Exec("INSERT INTO EquipmentGrowthTable VALUES (?,?,?)", 954, 0, growth); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var ratio []byte
|
||||
for _, value := range []float32{1, 0, 0, 0} {
|
||||
var raw [4]byte
|
||||
binary.LittleEndian.PutUint32(raw[:], math.Float32bits(value))
|
||||
ratio = append(ratio, raw[:]...)
|
||||
}
|
||||
for slot := 1; slot <= 3; slot++ {
|
||||
if _, err := db.Exec("INSERT INTO EquipmentRankTable VALUES (?,?,?)", 904, slot, wire.AppendBytes(nil, 4, ratio)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
design, err := loadEquipmentUpgradeDesign(db)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
level, maximum, err := design.Level(943035, 0)
|
||||
if err != nil || maximum != 9 || level.GrowthPoint != 670 || len(level.Costs) != 1 || level.Costs[0] != (PromotionCost{Type: 4, Count: 960}) || math.Abs(level.SuccessRatio-0.7) > 1e-12 {
|
||||
t.Fatalf("level=%+v maximum=%d err=%v", level, maximum, err)
|
||||
}
|
||||
if rank, err := design.RollRank(943035, 1); err != nil || rank != 1 {
|
||||
t.Fatalf("rank=%d err=%v", rank, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEquipmentUpgradeAgainstInstalledVersion23413(t *testing.T) {
|
||||
root := os.Getenv("BD2_TEST_GAMEDATA_ROOT")
|
||||
if root == "" {
|
||||
t.Skip("set BD2_TEST_GAMEDATA_ROOT for installed GameData integration test")
|
||||
}
|
||||
design, err := LoadEquipmentUpgradeDesign(root, "20260910162539")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
level, maximum, err := design.Level(943035, 0)
|
||||
if err != nil || maximum != 9 || len(level.Costs) != 1 || level.Costs[0] != (PromotionCost{Type: 4, Count: 960}) || level.SuccessRatio != 1 {
|
||||
t.Fatalf("installed level=%+v maximum=%d err=%v", level, maximum, err)
|
||||
}
|
||||
ratios := design.RankRatio[[2]uint64{904, 1}]
|
||||
if design.RankGroup[943035] != 904 || len(ratios) != 4 || math.Abs(ratios[0]-0.45) > 1e-6 || math.Abs(ratios[3]-0.01) > 1e-6 {
|
||||
t.Fatalf("installed rank group=%d ratios=%v", design.RankGroup[943035], ratios)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// PromotionCost is one exact CharGrowthTable.ClassupItem{Type,Id,Count} row.
|
||||
// Currency (type 4) has no inventory index; the other rows are owned items.
|
||||
type PromotionCost struct{ Type, ID, Count uint64 }
|
||||
|
||||
// PromotionGrowthResult is the authoritative result of one CharGrowth request
|
||||
// that can cross one or more class-up boundaries. Costs contains the exact
|
||||
// cumulative class-up costs selected by the request's aggregate gold amount.
|
||||
type PromotionGrowthResult struct {
|
||||
CharacterID uint64
|
||||
Level uint64
|
||||
Exp uint64
|
||||
Costs []PromotionCost
|
||||
Refunds []GrowthMaterial
|
||||
}
|
||||
|
||||
// CharacterPromotion reads the installed character and growth tables. At a
|
||||
// stage cap the client sends its class-up materials through CharGrowth, then
|
||||
// detects a promotion by comparing the response character ID to the old ID.
|
||||
func CharacterPromotion(root, version string, charID int, level, exp uint64) (uint64, []PromotionCost, error) {
|
||||
plain, err := ReadQuestDatabase(root, version)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
dir, err := os.MkdirTemp("", "bd2-promotion-")
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
path := filepath.Join(dir, "common.db")
|
||||
if err := os.WriteFile(path, plain, 0o600); err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro")
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
defer db.Close()
|
||||
return characterPromotion(db, charID, level, exp)
|
||||
}
|
||||
|
||||
// CharacterGrowthPromotions applies a client's combined class-up/growth
|
||||
// request. The UI aggregates gold and class-up resources for every boundary
|
||||
// crossed by the selected target level, while ordinary EXP resources remain
|
||||
// in the same request. Gold therefore identifies an exact prefix of the
|
||||
// CharTable.NextCharId chain; each prefix cost is then subtracted from the
|
||||
// submitted resources and only the remainder contributes EXP.
|
||||
func CharacterGrowthPromotions(root, version string, charID int, level, exp uint64, submitted []PromotionCost) (PromotionGrowthResult, error) {
|
||||
plain, err := ReadQuestDatabase(root, version)
|
||||
if err != nil {
|
||||
return PromotionGrowthResult{}, err
|
||||
}
|
||||
dir, err := os.MkdirTemp("", "bd2-promotion-growth-")
|
||||
if err != nil {
|
||||
return PromotionGrowthResult{}, err
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
path := filepath.Join(dir, "common.db")
|
||||
if err := os.WriteFile(path, plain, 0o600); err != nil {
|
||||
return PromotionGrowthResult{}, err
|
||||
}
|
||||
db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro")
|
||||
if err != nil {
|
||||
return PromotionGrowthResult{}, err
|
||||
}
|
||||
defer db.Close()
|
||||
return characterGrowthPromotions(db, charID, level, exp, submitted)
|
||||
}
|
||||
|
||||
type promotionStage struct {
|
||||
fromID, nextID uint64
|
||||
maximum uint64
|
||||
costs []PromotionCost
|
||||
}
|
||||
|
||||
func characterGrowthPromotions(db *sql.DB, charID int, level, exp uint64, submitted []PromotionCost) (PromotionGrowthResult, error) {
|
||||
if charID <= 0 || level == 0 || len(submitted) == 0 {
|
||||
return PromotionGrowthResult{}, fmt.Errorf("gamedata: invalid combined promotion input")
|
||||
}
|
||||
requested := make(map[[2]uint64]uint64, len(submitted))
|
||||
var requestedGold uint64
|
||||
for _, cost := range submitted {
|
||||
if cost.Count == 0 || (cost.Type != 4 && (cost.Type != 8 || cost.ID == 0)) {
|
||||
return PromotionGrowthResult{}, fmt.Errorf("gamedata: invalid submitted promotion material")
|
||||
}
|
||||
key := [2]uint64{cost.Type, cost.ID}
|
||||
if cost.Count > ^uint64(0)-requested[key] {
|
||||
return PromotionGrowthResult{}, fmt.Errorf("gamedata: submitted promotion material overflow")
|
||||
}
|
||||
requested[key] += cost.Count
|
||||
if cost.Type == 4 {
|
||||
if cost.ID != 0 || requestedGold != 0 {
|
||||
return PromotionGrowthResult{}, fmt.Errorf("gamedata: invalid submitted promotion currency")
|
||||
}
|
||||
requestedGold = cost.Count
|
||||
}
|
||||
}
|
||||
if requestedGold == 0 {
|
||||
return PromotionGrowthResult{}, fmt.Errorf("gamedata: combined promotion has no gold")
|
||||
}
|
||||
|
||||
currentID := uint64(charID)
|
||||
var stages []promotionStage
|
||||
var cumulative []PromotionCost
|
||||
var cumulativeGold uint64
|
||||
for len(stages) < 32 {
|
||||
nextID, costs, maximum, err := promotionDefinition(db, currentID)
|
||||
if err != nil {
|
||||
return PromotionGrowthResult{}, err
|
||||
}
|
||||
stages = append(stages, promotionStage{fromID: currentID, nextID: nextID, maximum: maximum, costs: costs})
|
||||
cumulative = append(cumulative, costs...)
|
||||
for _, cost := range costs {
|
||||
if cost.Type == 4 {
|
||||
if cost.Count > ^uint64(0)-cumulativeGold {
|
||||
return PromotionGrowthResult{}, fmt.Errorf("gamedata: cumulative promotion gold overflow")
|
||||
}
|
||||
cumulativeGold += cost.Count
|
||||
}
|
||||
}
|
||||
if cumulativeGold == requestedGold {
|
||||
break
|
||||
}
|
||||
if cumulativeGold > requestedGold {
|
||||
return PromotionGrowthResult{}, fmt.Errorf("gamedata: submitted gold %d does not match a promotion prefix", requestedGold)
|
||||
}
|
||||
currentID = nextID
|
||||
}
|
||||
if cumulativeGold != requestedGold {
|
||||
return PromotionGrowthResult{}, fmt.Errorf("gamedata: submitted gold %d exceeds the promotion chain", requestedGold)
|
||||
}
|
||||
|
||||
remaining := make(map[[2]uint64]uint64, len(requested))
|
||||
for key, count := range requested {
|
||||
remaining[key] = count
|
||||
}
|
||||
for _, cost := range cumulative {
|
||||
key := [2]uint64{cost.Type, cost.ID}
|
||||
if remaining[key] < cost.Count {
|
||||
return PromotionGrowthResult{}, fmt.Errorf("gamedata: missing cumulative promotion cost %d/%d x%d", cost.Type, cost.ID, cost.Count-remaining[key])
|
||||
}
|
||||
remaining[key] -= cost.Count
|
||||
}
|
||||
if remaining[[2]uint64{4, 0}] != 0 {
|
||||
return PromotionGrowthResult{}, fmt.Errorf("gamedata: unexpected promotion gold remainder")
|
||||
}
|
||||
|
||||
var gained uint64
|
||||
for key, count := range remaining {
|
||||
if count == 0 {
|
||||
continue
|
||||
}
|
||||
if key[0] != 8 || key[1] == 0 {
|
||||
return PromotionGrowthResult{}, fmt.Errorf("gamedata: unexpected combined promotion material %d/%d x%d", key[0], key[1], count)
|
||||
}
|
||||
var proto []byte
|
||||
if err := db.QueryRow("SELECT ProtoBuf FROM ResourceTable WHERE id=?", key[1]).Scan(&proto); err != nil {
|
||||
return PromotionGrowthResult{}, fmt.Errorf("gamedata: promotion growth resource %d: %w", key[1], err)
|
||||
}
|
||||
values, err := packedInts(proto, 9)
|
||||
if err != nil || len(values) != 1 || values[0] == 0 {
|
||||
return PromotionGrowthResult{}, fmt.Errorf("gamedata: resource %d is not an experience material", key[1])
|
||||
}
|
||||
if count > (^uint64(0)-gained)/values[0] {
|
||||
return PromotionGrowthResult{}, fmt.Errorf("gamedata: combined promotion experience overflow")
|
||||
}
|
||||
gained += count * values[0]
|
||||
}
|
||||
|
||||
currentID = uint64(charID)
|
||||
currentLevel := level
|
||||
currentExp := exp
|
||||
for _, stage := range stages {
|
||||
if currentID != stage.fromID {
|
||||
return PromotionGrowthResult{}, fmt.Errorf("gamedata: invalid promotion stage sequence")
|
||||
}
|
||||
definition, err := growthDefinition(db, currentID)
|
||||
if err != nil {
|
||||
return PromotionGrowthResult{}, err
|
||||
}
|
||||
currentLevel, currentExp, gained, err = applyGrowthExperience(db, definition, currentLevel, currentExp, gained)
|
||||
if err != nil {
|
||||
return PromotionGrowthResult{}, err
|
||||
}
|
||||
if currentLevel != stage.maximum {
|
||||
return PromotionGrowthResult{}, fmt.Errorf("gamedata: insufficient experience to reach promotion cap %d", definition.maximum)
|
||||
}
|
||||
currentID = stage.nextID
|
||||
}
|
||||
finalDefinition, err := growthDefinition(db, currentID)
|
||||
if err != nil {
|
||||
return PromotionGrowthResult{}, err
|
||||
}
|
||||
currentLevel, currentExp, gained, err = applyGrowthExperience(db, finalDefinition, currentLevel, currentExp, gained)
|
||||
if err != nil {
|
||||
return PromotionGrowthResult{}, err
|
||||
}
|
||||
refunds, err := refundGrowthResources(db, gained)
|
||||
if err != nil {
|
||||
return PromotionGrowthResult{}, err
|
||||
}
|
||||
return PromotionGrowthResult{CharacterID: currentID, Level: currentLevel, Exp: currentExp, Costs: cumulative, Refunds: refunds}, nil
|
||||
}
|
||||
|
||||
type characterGrowthDefinition struct {
|
||||
group, maximum uint64
|
||||
}
|
||||
|
||||
func growthDefinition(db *sql.DB, charID uint64) (characterGrowthDefinition, error) {
|
||||
var charProto []byte
|
||||
if err := db.QueryRow("SELECT ProtoBuf FROM CharTable WHERE id=?", charID).Scan(&charProto); err != nil {
|
||||
return characterGrowthDefinition{}, fmt.Errorf("gamedata: character %d: %w", charID, err)
|
||||
}
|
||||
growthIDs, _ := packedInts(charProto, 1)
|
||||
if len(growthIDs) != 1 {
|
||||
return characterGrowthDefinition{}, fmt.Errorf("gamedata: character %d has no growth definition", charID)
|
||||
}
|
||||
var growthProto []byte
|
||||
if err := db.QueryRow("SELECT ProtoBuf FROM CharGrowthTable WHERE id=?", growthIDs[0]).Scan(&growthProto); err != nil {
|
||||
return characterGrowthDefinition{}, err
|
||||
}
|
||||
groups, _ := packedInts(growthProto, 1)
|
||||
maximum, _ := packedInts(growthProto, 9)
|
||||
if len(groups) != 1 || len(maximum) != 1 {
|
||||
return characterGrowthDefinition{}, fmt.Errorf("gamedata: invalid growth definition %d", growthIDs[0])
|
||||
}
|
||||
return characterGrowthDefinition{group: groups[0], maximum: maximum[0]}, nil
|
||||
}
|
||||
|
||||
func promotionDefinition(db *sql.DB, charID uint64) (uint64, []PromotionCost, uint64, error) {
|
||||
definition, err := growthDefinition(db, charID)
|
||||
if err != nil {
|
||||
return 0, nil, 0, err
|
||||
}
|
||||
var charProto []byte
|
||||
if err := db.QueryRow("SELECT ProtoBuf FROM CharTable WHERE id=?", charID).Scan(&charProto); err != nil {
|
||||
return 0, nil, 0, err
|
||||
}
|
||||
next, _ := packedInts(charProto, 15)
|
||||
if len(next) != 1 || next[0] == 0 {
|
||||
return 0, nil, 0, fmt.Errorf("gamedata: character %d has no next stage", charID)
|
||||
}
|
||||
growthIDs, _ := packedInts(charProto, 1)
|
||||
var growthProto []byte
|
||||
if err := db.QueryRow("SELECT ProtoBuf FROM CharGrowthTable WHERE id=?", growthIDs[0]).Scan(&growthProto); err != nil {
|
||||
return 0, nil, 0, err
|
||||
}
|
||||
types, _ := packedInts(growthProto, 4)
|
||||
ids, _ := packedInts(growthProto, 3)
|
||||
counts, _ := packedInts(growthProto, 2)
|
||||
if len(types) == 0 || len(types) != len(ids) || len(types) != len(counts) {
|
||||
return 0, nil, 0, fmt.Errorf("gamedata: invalid promotion costs for character %d", charID)
|
||||
}
|
||||
costs := make([]PromotionCost, len(types))
|
||||
for i := range costs {
|
||||
costs[i] = PromotionCost{Type: types[i], ID: ids[i], Count: counts[i]}
|
||||
}
|
||||
return next[0], costs, definition.maximum, nil
|
||||
}
|
||||
|
||||
func applyGrowthExperience(db *sql.DB, definition characterGrowthDefinition, level, exp, gained uint64) (uint64, uint64, uint64, error) {
|
||||
if level > definition.maximum {
|
||||
return 0, 0, 0, fmt.Errorf("gamedata: level %d exceeds growth maximum %d", level, definition.maximum)
|
||||
}
|
||||
for gained > 0 && level < definition.maximum {
|
||||
var proto []byte
|
||||
if err := db.QueryRow("SELECT ProtoBuf FROM CharLevelTable WHERE GroupId=? AND id=?", definition.group, level).Scan(&proto); err != nil {
|
||||
return 0, 0, 0, err
|
||||
}
|
||||
required, _ := packedInts(proto, 8)
|
||||
if len(required) != 1 || required[0] == 0 || exp >= required[0] {
|
||||
return 0, 0, 0, fmt.Errorf("gamedata: invalid level curve group %d level %d", definition.group, level)
|
||||
}
|
||||
need := required[0] - exp
|
||||
if gained < need {
|
||||
exp += gained
|
||||
gained = 0
|
||||
break
|
||||
}
|
||||
gained -= need
|
||||
level++
|
||||
exp = 0
|
||||
}
|
||||
if level == definition.maximum {
|
||||
exp = 0
|
||||
}
|
||||
return level, exp, gained, nil
|
||||
}
|
||||
|
||||
func characterPromotion(db *sql.DB, charID int, level, exp uint64) (uint64, []PromotionCost, error) {
|
||||
if charID <= 0 || level == 0 {
|
||||
return 0, nil, fmt.Errorf("gamedata: invalid promotion character or level")
|
||||
}
|
||||
var current []byte
|
||||
if err := db.QueryRow("SELECT ProtoBuf FROM CharTable WHERE id=?", charID).Scan(¤t); err != nil {
|
||||
return 0, nil, fmt.Errorf("gamedata: promotion character %d: %w", charID, err)
|
||||
}
|
||||
next, err := packedInts(current, 15)
|
||||
if err != nil || len(next) != 1 || next[0] == 0 {
|
||||
return 0, nil, fmt.Errorf("gamedata: character %d has no next stage", charID)
|
||||
}
|
||||
growthID, err := packedInts(current, 1)
|
||||
if err != nil || len(growthID) != 1 {
|
||||
return 0, nil, fmt.Errorf("gamedata: character %d has no growth definition", charID)
|
||||
}
|
||||
var growth []byte
|
||||
if err := db.QueryRow("SELECT ProtoBuf FROM CharGrowthTable WHERE id=?", growthID[0]).Scan(&growth); err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
maximum, _ := packedInts(growth, 9)
|
||||
group, _ := packedInts(growth, 1)
|
||||
if len(maximum) != 1 || len(group) != 1 || level != maximum[0] {
|
||||
return 0, nil, fmt.Errorf("gamedata: character %d has not reached its promotion level", charID)
|
||||
}
|
||||
var curve []byte
|
||||
if err := db.QueryRow("SELECT ProtoBuf FROM CharLevelTable WHERE GroupId=? AND id=?", group[0], level).Scan(&curve); err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
required, _ := packedInts(curve, 8)
|
||||
if len(required) != 0 && exp < required[0] {
|
||||
return 0, nil, fmt.Errorf("gamedata: character %d has not reached its promotion experience", charID)
|
||||
}
|
||||
var nextProto []byte
|
||||
if err := db.QueryRow("SELECT ProtoBuf FROM CharTable WHERE id=?", next[0]).Scan(&nextProto); err != nil {
|
||||
return 0, nil, fmt.Errorf("gamedata: next character %d: %w", next[0], err)
|
||||
}
|
||||
nextGrowth, _ := packedInts(nextProto, 1)
|
||||
if len(nextGrowth) != 1 || nextGrowth[0] == growthID[0] {
|
||||
return 0, nil, fmt.Errorf("gamedata: invalid promotion %d -> %d", charID, next[0])
|
||||
}
|
||||
types, _ := packedInts(growth, 4)
|
||||
ids, _ := packedInts(growth, 3)
|
||||
counts, _ := packedInts(growth, 2)
|
||||
if len(types) == 0 || len(types) != len(ids) || len(types) != len(counts) {
|
||||
return 0, nil, fmt.Errorf("gamedata: invalid promotion costs for character %d", charID)
|
||||
}
|
||||
costs := make([]PromotionCost, len(types))
|
||||
for i := range costs {
|
||||
if counts[i] == 0 || (types[i] != 4 && ids[i] == 0) {
|
||||
return 0, nil, fmt.Errorf("gamedata: invalid promotion cost for character %d", charID)
|
||||
}
|
||||
costs[i] = PromotionCost{Type: types[i], ID: ids[i], Count: counts[i]}
|
||||
}
|
||||
return next[0], costs, nil
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/binary"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCharacterGrowthPromotionsAgainstInstalledVersion23413(t *testing.T) {
|
||||
root := os.Getenv("BD2_TEST_GAMEDATA_ROOT")
|
||||
if root == "" {
|
||||
t.Skip("set BD2_TEST_GAMEDATA_ROOT for installed GameData integration test")
|
||||
}
|
||||
submitted := []PromotionCost{
|
||||
{Type: 8, ID: 9, Count: 753},
|
||||
{Type: 8, ID: 11, Count: 1},
|
||||
{Type: 8, ID: 12, Count: 2},
|
||||
{Type: 8, ID: 13, Count: 3},
|
||||
{Type: 8, ID: 14, Count: 4},
|
||||
{Type: 4, Count: 10000},
|
||||
}
|
||||
result, err := CharacterGrowthPromotions(root, "20260910162539", 6510, 1, 0, submitted)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.CharacterID != 6514 || result.Level != 100 || result.Exp != 0 || len(result.Costs) != 8 {
|
||||
t.Fatalf("installed 2.34.13 combined promotion=%+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func testVarintField(dst []byte, field int, value uint64) []byte {
|
||||
dst = binary.AppendUvarint(dst, uint64(field<<3))
|
||||
return binary.AppendUvarint(dst, value)
|
||||
}
|
||||
|
||||
func testPackedField(dst []byte, field int, values ...uint64) []byte {
|
||||
var packed []byte
|
||||
for _, value := range values {
|
||||
packed = binary.AppendUvarint(packed, value)
|
||||
}
|
||||
dst = binary.AppendUvarint(dst, uint64(field<<3|2))
|
||||
dst = binary.AppendUvarint(dst, uint64(len(packed)))
|
||||
return append(dst, packed...)
|
||||
}
|
||||
|
||||
func TestCharacterPromotionUsesCharAndGrowthTables(t *testing.T) {
|
||||
db, err := sql.Open("sqlite", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
for _, schema := range []string{
|
||||
"CREATE TABLE CharTable (id INTEGER PRIMARY KEY, ProtoBuf BLOB)",
|
||||
"CREATE TABLE CharGrowthTable (id INTEGER PRIMARY KEY, ProtoBuf BLOB)",
|
||||
"CREATE TABLE CharLevelTable (GroupId INTEGER, id INTEGER, ProtoBuf BLOB, PRIMARY KEY(GroupId,id))",
|
||||
} {
|
||||
if _, err := db.Exec(schema); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
current := testVarintField(nil, 1, 101)
|
||||
current = testVarintField(current, 15, 351)
|
||||
next := testVarintField(nil, 1, 102)
|
||||
growth := testVarintField(nil, 1, 101)
|
||||
growth = testPackedField(growth, 2, 1, 1000)
|
||||
growth = testPackedField(growth, 3, 11, 0)
|
||||
growth = testPackedField(growth, 4, 8, 4)
|
||||
growth = testVarintField(growth, 9, 20)
|
||||
if _, err := db.Exec("INSERT INTO CharTable VALUES (?,?),(?,?)", 350, current, 351, next); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.Exec("INSERT INTO CharGrowthTable VALUES (?,?)", 101, growth); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.Exec("INSERT INTO CharLevelTable VALUES (?,?,?)", 101, 20, []byte{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
nextID, costs, err := characterPromotion(db, 350, 20, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if nextID != 351 || len(costs) != 2 || costs[0] != (PromotionCost{Type: 8, ID: 11, Count: 1}) || costs[1] != (PromotionCost{Type: 4, Count: 1000}) {
|
||||
t.Fatalf("promotion next=%d costs=%+v", nextID, costs)
|
||||
}
|
||||
if _, _, err := characterPromotion(db, 350, 19, 0); err == nil {
|
||||
t.Fatal("promotion before the stage cap was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCharacterGrowthPromotionsCrossesTwoStagesWithCumulativeCosts(t *testing.T) {
|
||||
db, err := sql.Open("sqlite", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
for _, schema := range []string{
|
||||
"CREATE TABLE CharTable (id INTEGER PRIMARY KEY, ProtoBuf BLOB)",
|
||||
"CREATE TABLE CharGrowthTable (id INTEGER PRIMARY KEY, ProtoBuf BLOB)",
|
||||
"CREATE TABLE CharLevelTable (GroupId INTEGER, id INTEGER, ProtoBuf BLOB, PRIMARY KEY(GroupId,id))",
|
||||
"CREATE TABLE ResourceTable (id INTEGER PRIMARY KEY, ProtoBuf BLOB)",
|
||||
} {
|
||||
if _, err := db.Exec(schema); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, row := range []struct {
|
||||
id, growth, next uint64
|
||||
}{{352, 103, 353}, {353, 104, 354}, {354, 105, 0}} {
|
||||
proto := testVarintField(nil, 1, row.growth)
|
||||
if row.next != 0 {
|
||||
proto = testVarintField(proto, 15, row.next)
|
||||
}
|
||||
if _, err := db.Exec("INSERT INTO CharTable VALUES (?,?)", row.id, proto); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
growth103 := testVarintField(nil, 1, 103)
|
||||
growth103 = testPackedField(growth103, 2, 3, 3000)
|
||||
growth103 = testPackedField(growth103, 3, 13, 0)
|
||||
growth103 = testPackedField(growth103, 4, 8, 4)
|
||||
growth103 = testVarintField(growth103, 9, 60)
|
||||
growth104 := testVarintField(nil, 1, 104)
|
||||
growth104 = testPackedField(growth104, 2, 4, 4000)
|
||||
growth104 = testPackedField(growth104, 3, 14, 0)
|
||||
growth104 = testPackedField(growth104, 4, 8, 4)
|
||||
growth104 = testVarintField(growth104, 9, 80)
|
||||
growth105 := testVarintField(testVarintField(nil, 1, 105), 9, 100)
|
||||
for _, row := range []struct {
|
||||
id uint64
|
||||
proto []byte
|
||||
}{{103, growth103}, {104, growth104}, {105, growth105}} {
|
||||
if _, err := db.Exec("INSERT INTO CharGrowthTable VALUES (?,?)", row.id, row.proto); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, curve := range []struct{ group, first, maximum uint64 }{{103, 1, 60}, {104, 60, 80}, {105, 80, 100}} {
|
||||
for level := curve.first; level < curve.maximum; level++ {
|
||||
proto := testVarintField(nil, 8, 10)
|
||||
if _, err := db.Exec("INSERT INTO CharLevelTable VALUES (?,?,?)", curve.group, level, proto); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
resource := testVarintField(nil, 9, 10)
|
||||
if _, err := db.Exec("INSERT INTO ResourceTable VALUES (?,?)", 9, resource); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
submitted := []PromotionCost{
|
||||
{Type: 8, ID: 9, Count: 40},
|
||||
{Type: 8, ID: 13, Count: 3},
|
||||
{Type: 8, ID: 14, Count: 4},
|
||||
{Type: 4, Count: 7000},
|
||||
}
|
||||
result, err := characterGrowthPromotions(db, 352, 60, 0, submitted)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.CharacterID != 354 || result.Level != 100 || result.Exp != 0 || len(result.Refunds) != 0 {
|
||||
t.Fatalf("combined result=%+v", result)
|
||||
}
|
||||
if len(result.Costs) != 4 {
|
||||
t.Fatalf("cumulative costs=%+v", result.Costs)
|
||||
}
|
||||
fromLevelOne := append([]PromotionCost(nil), submitted...)
|
||||
fromLevelOne[0].Count = 99
|
||||
result, err = characterGrowthPromotions(db, 352, 1, 0, fromLevelOne)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.CharacterID != 354 || result.Level != 100 || result.Exp != 0 {
|
||||
t.Fatalf("level-one combined result=%+v", result)
|
||||
}
|
||||
bad := append([]PromotionCost(nil), submitted...)
|
||||
bad[len(bad)-1].Count = 6000
|
||||
if _, err := characterGrowthPromotions(db, 352, 60, 0, bad); err == nil {
|
||||
t.Fatal("non-prefix aggregate gold was accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// RandomBoxDesign contains only boxes whose GameData reward graph is wholly
|
||||
// deterministic. The client asks the server to open every RandomBox, but an
|
||||
// offline server must not invent an outcome for weighted boxes. Deterministic
|
||||
// material boxes (including the engraving-scroll and essence boxes) are safe
|
||||
// to resolve directly from the authoritative RewardGroupTable.
|
||||
type RandomBoxDesign struct {
|
||||
rewards map[uint64][]BattleReward
|
||||
}
|
||||
|
||||
// LoadRandomBoxDesign reads RandomBoxTable -> RewardGroupTable from the
|
||||
// installed common GameData. A box is deliberately omitted if its group is
|
||||
// empty, weighted, malformed, cyclic, or resolves to a non-ItemDBInfo reward;
|
||||
// callers then fail closed when a client tries to open it.
|
||||
func LoadRandomBoxDesign(root, version string) (*RandomBoxDesign, error) {
|
||||
plain, err := ReadQuestDatabase(root, version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dir, err := os.MkdirTemp("", "bd2-random-box-")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
path := filepath.Join(dir, "common.db")
|
||||
if err := os.WriteFile(path, plain, 0o600); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
groups := map[uint64][]BattleReward{}
|
||||
rows, err := db.Query("SELECT id, ProtoBuf FROM RewardGroupTable")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var id uint64
|
||||
var raw []byte
|
||||
if err := rows.Scan(&id, &raw); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
ids, err := packedInts(raw, 5)
|
||||
if err != nil {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: random box reward group %d item IDs: %w", id, err)
|
||||
}
|
||||
types, err := packedInts(raw, 6)
|
||||
if err != nil {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: random box reward group %d item types: %w", id, err)
|
||||
}
|
||||
counts, err := packedInts(raw, 4)
|
||||
if err != nil {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: random box reward group %d item counts: %w", id, err)
|
||||
}
|
||||
ratios, err := packedInts(raw, 8)
|
||||
if err != nil {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: random box reward group %d ratios: %w", id, err)
|
||||
}
|
||||
// A single reward is deterministic. Ratios are presentation/probability
|
||||
// metadata in that case; a multi-entry group is intentionally not used.
|
||||
if len(ids) != 1 || len(types) != 1 || len(counts) != 1 || ids[0] == 0 || !itemDBInfoRewardType(types[0]) || counts[0] == 0 || (len(ratios) != 0 && len(ratios) != 1) {
|
||||
continue
|
||||
}
|
||||
groups[id] = []BattleReward{{Type: types[0], ID: ids[0], Count: counts[0]}}
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
design := &RandomBoxDesign{rewards: map[uint64][]BattleReward{}}
|
||||
rows, err = db.Query("SELECT id, ProtoBuf FROM RandomBoxTable")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var rowID uint64
|
||||
var raw []byte
|
||||
if err := rows.Scan(&rowID, &raw); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
ids, err := packedInts(raw, 4)
|
||||
if err != nil || len(ids) != 1 || ids[0] != rowID {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: malformed random box %d", rowID)
|
||||
}
|
||||
groupIDs, err := packedInts(raw, 9)
|
||||
if err != nil || len(groupIDs) != 1 || groupIDs[0] == 0 {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: random box %d reward group: %w", rowID, err)
|
||||
}
|
||||
if rewards, ok := groups[groupIDs[0]]; ok {
|
||||
design.rewards[rowID] = append([]BattleReward(nil), rewards...)
|
||||
}
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return design, nil
|
||||
}
|
||||
|
||||
// itemDBInfoRewardType is the ItemDBInfo part of DataManager.GetItemInfo for
|
||||
// the static tables this server persists in Inventory. Equipment, characters,
|
||||
// costumes and currency each have a different domain store/response field, so
|
||||
// a RandomBox resolving to one of them is intentionally not opened here.
|
||||
func itemDBInfoRewardType(itemType uint64) bool {
|
||||
switch itemType {
|
||||
case 5, 7, 8, 9, 13, 14, 17, 27, 29:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Open returns the exact aggregate for count opens. It never samples a
|
||||
// weighted group, which avoids silently fabricating a random result.
|
||||
func (d *RandomBoxDesign) Open(boxID, count uint64) ([]BattleReward, error) {
|
||||
if d == nil || count == 0 {
|
||||
return nil, errors.New("gamedata: invalid random box open")
|
||||
}
|
||||
rewards, ok := d.rewards[boxID]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("gamedata: random box %d is not deterministic ItemDBInfo loot", boxID)
|
||||
}
|
||||
result := make([]BattleReward, len(rewards))
|
||||
for i, reward := range rewards {
|
||||
if reward.Count > ^uint64(0)/count {
|
||||
return nil, errors.New("gamedata: random box reward count overflow")
|
||||
}
|
||||
result[i] = reward
|
||||
result[i].Count *= count
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRandomBoxOpenAggregatesOnlyKnownDeterministicReward(t *testing.T) {
|
||||
design := &RandomBoxDesign{rewards: map[uint64][]BattleReward{
|
||||
7: {{Type: 8, ID: 704, Count: 1}},
|
||||
}}
|
||||
got, err := design.Open(7, 100000)
|
||||
if err != nil || len(got) != 1 || got[0] != (BattleReward{Type: 8, ID: 704, Count: 100000}) {
|
||||
t.Fatalf("Open deterministic box = %+v, %v", got, err)
|
||||
}
|
||||
if _, err := design.Open(8, 1); err == nil {
|
||||
t.Fatal("accepted an unknown or weighted box")
|
||||
}
|
||||
}
|
||||
|
||||
// This locks the actual 2.34.13 row behind the installed GameData opt-in,
|
||||
// rather than replacing GameData lookup with a hand-written item mapping.
|
||||
func TestLoadRandomBoxDesignInstalledEngravingEssence(t *testing.T) {
|
||||
root := os.Getenv("BD2_TEST_GAMEDATA_ROOT")
|
||||
if root == "" {
|
||||
t.Skip("set BD2_TEST_GAMEDATA_ROOT for installed GameData integration test")
|
||||
}
|
||||
design, err := LoadRandomBoxDesign(root, "20260910162539")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := design.Open(433302, 100000) // Essence of Perseverance box.
|
||||
if err != nil || len(got) != 1 || got[0] != (BattleReward{Type: 8, ID: 704, Count: 100000}) {
|
||||
t.Fatalf("433302 deterministic contents = %+v, %v", got, err)
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,24 @@ import (
|
||||
const packetCode = 131
|
||||
const 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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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...)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
package player
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"bd2server/internal/gamedata"
|
||||
"bd2server/internal/wire"
|
||||
)
|
||||
|
||||
type CostumePotentialService struct {
|
||||
design *gamedata.CostumePotentialDesign
|
||||
collection *CollectionStore
|
||||
characters *CharacterStore
|
||||
inventory *Inventory
|
||||
wallet *Wallet
|
||||
}
|
||||
|
||||
func NewCostumePotentialService(design *gamedata.CostumePotentialDesign, collection *CollectionStore, characters *CharacterStore, inventory *Inventory, wallet *Wallet) (*CostumePotentialService, error) {
|
||||
if design == nil || collection == nil || characters == nil || inventory == nil || wallet == nil {
|
||||
return nil, errors.New("player: incomplete costume potential service")
|
||||
}
|
||||
return &CostumePotentialService{design: design, collection: collection, characters: characters, inventory: inventory, wallet: wallet}, nil
|
||||
}
|
||||
|
||||
func (s *CostumePotentialService) Handle(path string, request []byte) (int, []byte, bool, error) {
|
||||
if path != "/CostumeNodeActivation" {
|
||||
return 0, nil, false, nil
|
||||
}
|
||||
seq, found, err := wire.Varint(request, 1)
|
||||
if err != nil || !found || seq == 0 {
|
||||
return 0, nil, true, errors.New("player: CostumeNodeActivation missing sequence")
|
||||
}
|
||||
characterIndex, found, err := wire.Varint(request, 2)
|
||||
if err != nil || !found || characterIndex == 0 {
|
||||
return 0, nil, true, errors.New("player: CostumeNodeActivation missing character")
|
||||
}
|
||||
costumeIndex, found, err := wire.Varint(request, 3)
|
||||
if err != nil || !found || costumeIndex == 0 {
|
||||
return 0, nil, true, errors.New("player: CostumeNodeActivation missing costume")
|
||||
}
|
||||
var nodes []uint64
|
||||
var materials []Item
|
||||
err = wire.Walk(request, func(field wire.Field) error {
|
||||
switch field.Number {
|
||||
case 4:
|
||||
if field.Type != 0 && field.Type != 2 {
|
||||
return errors.New("player: invalid potential node field")
|
||||
}
|
||||
for data := field.Value; len(data) != 0; {
|
||||
id, count := binary.Uvarint(data)
|
||||
if count <= 0 || id == 0 {
|
||||
return errors.New("player: invalid potential node ID")
|
||||
}
|
||||
nodes = append(nodes, id)
|
||||
data = data[count:]
|
||||
}
|
||||
case 5:
|
||||
if field.Type != 2 {
|
||||
return errors.New("player: invalid potential material field")
|
||||
}
|
||||
var item Item
|
||||
if err := decodeVarints(field.Value, map[int]*uint64{1: &item.InvenIndex, 2: &item.ID, 3: &item.Type, 4: &item.Count, 5: &item.KeepFlag, 6: &item.TimeValue, 9: &item.SortID, 10: &item.UseCount}); err != nil {
|
||||
return err
|
||||
}
|
||||
if item.Type == 0 || item.Count == 0 || (item.Type != 4 && (item.InvenIndex == 0 || item.ID == 0)) {
|
||||
return errors.New("player: incomplete potential material")
|
||||
}
|
||||
materials = append(materials, item)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return 0, nil, true, err
|
||||
}
|
||||
if len(nodes) == 0 || len(materials) == 0 {
|
||||
return 0, nil, true, errors.New("player: CostumeNodeActivation has no nodes or materials")
|
||||
}
|
||||
character, found := s.characters.Find(characterIndex)
|
||||
if !found {
|
||||
return 0, nil, true, fmt.Errorf("player: unknown potential character %d", characterIndex)
|
||||
}
|
||||
costume, found := s.collection.CostumeByIndex(costumeIndex)
|
||||
if !found || costume.UseChar != characterIndex {
|
||||
return 0, nil, true, fmt.Errorf("player: potential costume %d is not owned by character %d", costumeIndex, characterIndex)
|
||||
}
|
||||
if err := s.collection.ValidateCostumePotentialActivation(costumeIndex, nodes); err != nil {
|
||||
return 0, nil, true, err
|
||||
}
|
||||
costs, err := s.design.Validate(costume.ID, character.ID, 0, costume.PotentialIDs, nodes)
|
||||
if err != nil {
|
||||
return 0, nil, true, fmt.Errorf("player: validate costume potential GameData: %w", err)
|
||||
}
|
||||
want := make(map[[2]uint64]uint64)
|
||||
for _, cost := range costs {
|
||||
key := [2]uint64{cost.Type, cost.ID}
|
||||
if cost.Count > ^uint64(0)-want[key] {
|
||||
return 0, nil, true, errors.New("player: costume potential cost overflow")
|
||||
}
|
||||
want[key] += cost.Count
|
||||
}
|
||||
got := make(map[[2]uint64]uint64)
|
||||
var items []Item
|
||||
var gold uint64
|
||||
for _, material := range materials {
|
||||
key := [2]uint64{material.Type, material.ID}
|
||||
if material.Type == 4 {
|
||||
if material.InvenIndex != 0 || material.ID != 0 || gold != 0 {
|
||||
return 0, nil, true, errors.New("player: invalid costume potential currency")
|
||||
}
|
||||
gold = material.Count
|
||||
} else {
|
||||
if material.Type != 8 {
|
||||
return 0, nil, true, fmt.Errorf("player: unsupported costume potential material type %d", material.Type)
|
||||
}
|
||||
items = append(items, material)
|
||||
}
|
||||
if material.Count > ^uint64(0)-got[key] {
|
||||
return 0, nil, true, errors.New("player: submitted costume potential material overflow")
|
||||
}
|
||||
got[key] += material.Count
|
||||
}
|
||||
if len(got) != len(want) {
|
||||
return 0, nil, true, fmt.Errorf("player: costume potential material kinds mismatch: request=%v GameData=%v", got, want)
|
||||
}
|
||||
for key, count := range want {
|
||||
if got[key] != count {
|
||||
return 0, nil, true, fmt.Errorf("player: costume potential material %d/%d=%d want %d", key[0], key[1], got[key], count)
|
||||
}
|
||||
}
|
||||
if len(items) != 0 {
|
||||
if err := s.inventory.CanConsume(items); err != nil {
|
||||
return 0, nil, true, fmt.Errorf("player: validate costume potential items: %w", err)
|
||||
}
|
||||
}
|
||||
if gold != 0 && !s.wallet.CanSpendGold(gold) {
|
||||
return 0, nil, true, errors.New("player: insufficient gold for costume potential")
|
||||
}
|
||||
sortedNodes := append([]uint64(nil), nodes...)
|
||||
sort.Slice(sortedNodes, func(i, j int) bool { return sortedNodes[i] < sortedNodes[j] })
|
||||
parts := make([]string, len(sortedNodes))
|
||||
for i, id := range sortedNodes {
|
||||
parts[i] = strconv.FormatUint(id, 10)
|
||||
}
|
||||
identity := "costume-potential:" + strconv.FormatUint(costumeIndex, 10) + ":" + strings.Join(parts, ",")
|
||||
if gold != 0 {
|
||||
if _, err := s.wallet.SpendGoldOnce(identity, gold); err != nil {
|
||||
return 0, nil, true, fmt.Errorf("player: spend costume potential gold: %w", err)
|
||||
}
|
||||
}
|
||||
if len(items) != 0 {
|
||||
if err := s.inventory.Consume(items); err != nil {
|
||||
return 0, nil, true, fmt.Errorf("player: consume costume potential items: %w", err)
|
||||
}
|
||||
}
|
||||
if err := s.collection.ActivateCostumePotential(costumeIndex, nodes); err != nil {
|
||||
return 0, nil, true, fmt.Errorf("player: persist costume potential: %w", err)
|
||||
}
|
||||
return 261, nil, true, nil
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package player
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"bd2server/internal/gamedata"
|
||||
"bd2server/internal/wire"
|
||||
)
|
||||
|
||||
func TestCostumeNodeActivationSupportsSingleAndOneClickSets(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
inventory, err := OpenInventory(filepath.Join(dir, "items.json"), &Starter{Version: "2.34.13"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
materials, err := inventory.GrantOnce("potential", []gamedata.BattleReward{{Type: 8, ID: 114, Count: 5}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wallet, err := OpenWallet(filepath.Join(dir, "wallet.json"), Currency{Gold: 300})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
characters, err := OpenCharacterStore(filepath.Join(dir, "characters.json"), []Character{{InvenIndex: 77, ID: 6514, Level: 100}}, inventory, "", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
collection, err := OpenCollectionStore(filepath.Join(dir, "collection.json"), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
collection.data.Costumes = []Costume{{InvenIndex: 88, ID: 65103, UseChar: 77}}
|
||||
if err := collection.commit(collection.data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
design := &gamedata.CostumePotentialDesign{
|
||||
Nodes: map[uint64]map[uint64]gamedata.CostumePotentialNode{65103: {
|
||||
1: {ID: 1, Costs: []gamedata.CostumePotentialCost{{Type: 4, Count: 100}}},
|
||||
2: {ID: 2, Prerequisites: []uint64{1}, Costs: []gamedata.CostumePotentialCost{{Type: 8, ID: 114, Count: 2}}},
|
||||
3: {ID: 3, Prerequisites: []uint64{2}, Costs: []gamedata.CostumePotentialCost{{Type: 8, ID: 114, Count: 3}, {Type: 4, Count: 200}}},
|
||||
}},
|
||||
CostumeUnique: map[uint64]uint64{65103: 651},
|
||||
CharacterGrade: map[uint64]uint64{6514: 5},
|
||||
CharacterUnique: map[uint64]uint64{6514: 651},
|
||||
}
|
||||
service, err := NewCostumePotentialService(design, collection, characters, inventory, wallet)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 77), 3, 88)
|
||||
request = wire.AppendVarint(request, 4, 1)
|
||||
request = wire.AppendBytes(request, 5, ItemWire(Item{Type: 4, Count: 100}))
|
||||
code, response, handled, err := service.Handle("/CostumeNodeActivation", request)
|
||||
if err != nil || !handled || code != 261 || len(response) != 0 {
|
||||
t.Fatalf("single activation code=%d handled=%v response=%x err=%v", code, handled, response, err)
|
||||
}
|
||||
oneClick := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, 2), 2, 77), 3, 88)
|
||||
oneClick = wire.AppendVarint(wire.AppendVarint(oneClick, 4, 2), 4, 3)
|
||||
item := materials[0]
|
||||
oneClick = wire.AppendBytes(oneClick, 5, ItemWire(item))
|
||||
oneClick = wire.AppendBytes(oneClick, 5, ItemWire(Item{Type: 4, Count: 200}))
|
||||
code, _, handled, err = service.Handle("/CostumeNodeActivation", oneClick)
|
||||
if err != nil || !handled || code != 261 {
|
||||
t.Fatalf("one-click activation code=%d handled=%v err=%v", code, handled, err)
|
||||
}
|
||||
if wallet.Snapshot().Gold != 0 {
|
||||
t.Fatalf("gold=%d", wallet.Snapshot().Gold)
|
||||
}
|
||||
costume, found := collection.CostumeByIndex(88)
|
||||
if !found || len(costume.PotentialIDs) != 3 || costume.PotentialIDs[0] != 1 || costume.PotentialIDs[2] != 3 {
|
||||
t.Fatalf("activated costume=%+v found=%v", costume, found)
|
||||
}
|
||||
var encoded []uint64
|
||||
if err := wire.Walk(CostumeWire(costume), func(field wire.Field) error {
|
||||
if field.Number == 8 {
|
||||
v, _ := binary.Uvarint(field.Value)
|
||||
encoded = append(encoded, v)
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(encoded) != 3 {
|
||||
t.Fatalf("wire potential IDs=%v", encoded)
|
||||
}
|
||||
if _, _, _, err := service.Handle("/CostumeNodeActivation", oneClick); err == nil {
|
||||
t.Fatal("already active nodes accepted")
|
||||
}
|
||||
if wallet.Snapshot().Gold != 0 {
|
||||
t.Fatal("duplicate request charged wallet")
|
||||
}
|
||||
}
|
||||
+669
-16
@@ -6,25 +6,31 @@ import (
|
||||
"fmt"
|
||||
"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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user