feat: add verified gacha flows and native client studio

This commit is contained in:
2026-09-30 23:49:00 +08:00
parent 78467e13ea
commit f445cbc9ac
66 changed files with 2387 additions and 2351 deletions
+170
View File
@@ -0,0 +1,170 @@
package gacha
import (
"errors"
"fmt"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/player"
"bd2server/internal/server/wire"
)
const firstGachaResponseDigest = "first-gacha-confirm-v1"
type firstGachaPreview struct {
sequence uint64
rewards []gamedata.FirstGachaReward
equipment map[int]player.Equipment
response []byte
}
func (s *Service) previewFirstGacha(request []byte) (int, []byte, bool, error) {
if s.first == nil {
return 175, nil, true, errors.New("gacha: first gacha is not configured")
}
if s.FirstGachaCompleted() {
return 175, nil, true, errors.New("gacha: first gacha already completed")
}
for _, field := range []int{3, 4} {
value, _, err := wire.Varint(request, field)
if err != nil || value != 0 {
return 175, nil, true, errors.New("gacha: first gacha preview cannot use a cash product")
}
}
seq, found, err := wire.Varint(request, 1)
if err != nil || !found || seq == 0 {
return 175, nil, true, errors.New("gacha: invalid first gacha preview sequence")
}
session := s.loginIdentity()
s.firstMu.Lock()
defer s.firstMu.Unlock()
if previous, found := s.firstPreviews[session]; found && previous.sequence == seq {
return 175, append([]byte(nil), previous.response...), true, nil
}
rewards, err := s.first.Roll()
if err != nil {
return 175, nil, true, err
}
preview := firstGachaPreview{sequence: seq, rewards: append([]gamedata.FirstGachaReward(nil), rewards...), equipment: make(map[int]player.Equipment)}
var bundle []byte
for sortID, reward := range rewards {
switch reward.Type {
case 11:
costume := wire.AppendVarint(nil, 2, reward.ID)
if sortID != 0 {
costume = wire.AppendVarint(costume, 6, uint64(sortID))
}
bundle = wire.AppendBytes(bundle, 3, costume)
case 10:
entry, err := firstGachaEquipment(s.first.EquipmentCatalog(), reward.ID, uint64(sortID))
if err != nil {
return 175, nil, true, err
}
preview.equipment[sortID] = entry
bundle = wire.AppendBytes(bundle, 4, player.EquipmentWire(entry))
default:
return 175, nil, true, fmt.Errorf("gacha: unsupported first reward type %d", reward.Type)
}
}
preview.response = wire.AppendBytes(nil, 1, bundle)
s.firstPreviews[session] = preview
if s.onPreview != nil {
if err := s.onPreview(); err != nil {
delete(s.firstPreviews, session)
return 175, nil, true, fmt.Errorf("gacha: update first preview mission: %w", err)
}
}
return 175, append([]byte(nil), preview.response...), true, nil
}
func firstGachaEquipment(catalog *gamedata.EquipmentGachaCatalog, id, sortID uint64) (player.Equipment, error) {
if catalog == nil {
return player.Equipment{}, errors.New("gacha: first gacha equipment catalog missing")
}
main, sub, private, err := catalog.RollOptions(id)
if err != nil {
return player.Equipment{}, err
}
entry := player.Equipment{ID: id, SortID: sortID, Rank: []uint64{0, 0, 0}}
for _, option := range main {
entry.MainOption = append(entry.MainOption, player.EquipmentOption{GroupID: option.GroupID, ID: option.ID})
}
for _, option := range sub {
entry.SubOption = append(entry.SubOption, player.EquipmentOption{GroupID: option.GroupID, ID: option.ID})
}
if private != nil {
entry.PrivateOption = &player.EquipmentOption{GroupID: private.GroupID, ID: private.ID}
}
return entry, nil
}
func (s *Service) confirmFirstGacha(_ []byte, seq, buyType uint64, tickets []player.Item, explicitIdentity string) (int, []byte, bool, error) {
if s.first == nil || s.equipmentInventory == nil {
return 146, nil, true, errors.New("gacha: first gacha runtime is not attached")
}
if buyType != 1 || len(tickets) != 0 {
return 146, nil, true, errors.New("gacha: first gacha requires normal free confirmation")
}
identity := explicitIdentity
if identity == "" {
identity = s.requestIdentity(s.first.GachaID, seq)
}
responseIdentity := identity + ":response"
if response, found, err := s.collection.GachaBatchResponse(responseIdentity, firstGachaResponseDigest); found || err != nil {
return 146, response, true, err
}
if s.FirstGachaCompleted() {
return 146, nil, true, errors.New("gacha: first gacha already completed")
}
session := s.loginIdentity()
s.firstMu.Lock()
preview, found := s.firstPreviews[session]
s.firstMu.Unlock()
if !found || len(preview.rewards) != s.first.Count {
return 146, nil, true, errors.New("gacha: first gacha confirmation requires a preview")
}
var costumeIDs, costumeSortIDs []uint64
var equipment []player.Equipment
for sortID, reward := range preview.rewards {
switch reward.Type {
case 11:
costumeIDs = append(costumeIDs, reward.ID)
costumeSortIDs = append(costumeSortIDs, uint64(sortID))
case 10:
candidate, ok := preview.equipment[sortID]
if !ok || candidate.ID != reward.ID {
return 146, nil, true, fmt.Errorf("gacha: first preview missing equipment slot %d", sortID)
}
saved, err := s.equipmentInventory.GrantGeneratedOnce(fmt.Sprintf("%s:first-equip:%d", identity, sortID), candidate)
if err != nil {
return 146, nil, true, err
}
equipment = append(equipment, saved)
default:
return 146, nil, true, fmt.Errorf("gacha: unsupported first reward type %d", reward.Type)
}
}
grant, err := s.collection.GrantRegularPurchase(identity, costumeIDs, s.first.CostumeCatalog(), player.GachaPurchase{
Group: s.first.Group, BuyType: 1, RewardCount: uint64(s.first.Count), RewardSortIDs: costumeSortIDs,
})
if err != nil {
return 146, nil, true, err
}
if err := s.creditOverflow(identity, grant); err != nil {
return 146, nil, true, err
}
bundle := s.rewardBundle(grant)
for _, entry := range equipment {
bundle = wire.AppendBytes(bundle, 4, player.EquipmentWire(entry))
}
response := wire.AppendBytes(nil, 1, bundle)
if err := s.collection.RecordGachaBatch(responseIdentity, firstGachaResponseDigest, response); err != nil {
return 146, nil, true, err
}
if s.onPreview != nil {
if err := s.onPreview(); err != nil {
return 146, nil, true, fmt.Errorf("gacha: update first gacha mission: %w", err)
}
}
return 146, response, true, nil
}
@@ -0,0 +1,150 @@
package gacha
import (
"bytes"
"testing"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/player"
"bd2server/internal/server/stateio"
"bd2server/internal/server/wire"
)
func TestFirstGachaPreviewConfirmAndReplay(t *testing.T) {
characters := make(map[uint64]gamedata.CharacterDesign)
equipmentDesign := make(map[uint64]gamedata.EquipmentDesign)
program := &gamedata.FirstGachaRewardGroup{ID: 20, DropCount: 1, DropType: 1}
for i := uint64(0); i < 5; i++ {
costumeID := 61001 + i
equipmentID := 71001 + i
characters[costumeID] = fixtureCharacter(6100+i, 100+i)
equipmentDesign[equipmentID] = gamedata.EquipmentDesign{ID: equipmentID, Grade: 4}
program.Entries = append(program.Entries,
gamedata.FirstGachaRewardEntry{ItemType: 11, ItemID: costumeID, Count: 1, Weight: 1},
gamedata.FirstGachaRewardEntry{ItemType: 10, ItemID: equipmentID, Count: 1, Weight: 1})
}
first, err := gamedata.NewFirstGachaDesign(gamedata.GachaGroupDesign{ID: 2, GachaSubType: 3, TenTimeGachaID: 20}, 20, 10, program, characters, equipmentDesign)
if err != nil {
t.Fatal(err)
}
infinite, err := gamedata.NewInfiniteGachaDesign(10, []uint64{61001}, characters)
if err != nil {
t.Fatal(err)
}
regular, err := gamedata.NewRegularGachaCatalog(map[uint64]gamedata.RegularGacha{
1: {ID: 1, Count: 1, PriceType: 3, Price: 200, Pool: []gamedata.WeightedCostume{{ID: 61001, Weight: 1}}},
}, characters)
if err != nil {
t.Fatal(err)
}
storage := stateio.NewMemory()
collection, err := player.OpenCollectionStore(storage, nil)
if err != nil {
t.Fatal(err)
}
wallet, err := player.OpenWallet(storage, player.Currency{})
if err != nil {
t.Fatal(err)
}
equipment, err := player.OpenEquipmentInventory(storage)
if err != nil {
t.Fatal(err)
}
service, err := NewService(infinite, regular, collection, wallet)
if err != nil {
t.Fatal(err)
}
if err := service.AttachFirstGacha(first); err != nil {
t.Fatal(err)
}
service.AttachEquipmentGacha(first.EquipmentCatalog(), equipment)
service.BeginSession("first-session")
previewRequest := wire.AppendVarint(wire.AppendVarint(nil, 1, 11), 2, 20)
code, preview, handled, err := service.Handle("/GachaBuyPreview", previewRequest)
if err != nil || !handled || code != 175 {
t.Fatalf("preview code=%d handled=%v err=%v", code, handled, err)
}
bundle, found, err := wire.Bytes(preview, 1)
if err != nil || !found || countFields(bundle, 3) != 5 || countFields(bundle, 4) != 5 {
t.Fatalf("preview costumes=%d equipment=%d found=%v err=%v", countFields(bundle, 3), countFields(bundle, 4), found, err)
}
if len(collection.Costumes()) != 0 || len(equipment.All()) != 0 || service.FirstGachaCompleted() {
t.Fatal("preview changed authoritative ownership")
}
_, replayPreview, _, err := service.Handle("/GachaBuyPreview", previewRequest)
if err != nil || !bytes.Equal(preview, replayPreview) {
t.Fatalf("same preview request was not idempotent: err=%v", err)
}
buyRequest := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, 12), 2, 20), 3, 1)
code, response, handled, err := service.Handle("/GachaBuy", buyRequest)
if err != nil || !handled || code != 146 {
t.Fatalf("confirm code=%d handled=%v err=%v", code, handled, err)
}
bundle, found, err = wire.Bytes(response, 1)
if err != nil || !found || countFields(bundle, 3) != 5 || countFields(bundle, 4) != 5 {
t.Fatalf("confirm costumes=%d equipment=%d found=%v err=%v", countFields(bundle, 3), countFields(bundle, 4), found, err)
}
if !service.FirstGachaCompleted() || len(collection.Costumes()) != 5 || len(equipment.All()) != 5 {
t.Fatalf("confirmed state completed=%v costumes=%d equipment=%d", service.FirstGachaCompleted(), len(collection.Costumes()), len(equipment.All()))
}
if user := collection.GachaUser(2); user.TotalBuyCount != 10 || user.Point != 0 {
t.Fatalf("first gacha accounting=%+v", user)
}
for i, costume := range collection.Costumes() {
if costume.SortID != uint64(i*2) {
t.Fatalf("costume %d sort=%d want=%d", costume.ID, costume.SortID, i*2)
}
}
_, replay, _, err := service.Handle("/GachaBuy", buyRequest)
if err != nil || !bytes.Equal(response, replay) || len(collection.Costumes()) != 5 || len(equipment.All()) != 5 {
t.Fatalf("confirm replay mutated state: costumes=%d equipment=%d err=%v", len(collection.Costumes()), len(equipment.All()), err)
}
service.BeginSession("second-session")
if _, _, _, err := service.Handle("/GachaBuyPreview", wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 20)); err == nil {
t.Fatal("completed first gacha reopened in a new session")
}
}
func TestFirstGachaRejectsConfirmationWithoutPreview(t *testing.T) {
// The full happy-path test proves attachment. This assertion locks down the
// security boundary: a forged buy cannot skip the server-owned reroll.
service := newFirstGachaTestService(t)
request := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 20), 3, 1)
if _, _, handled, err := service.Handle("/GachaBuy", request); err == nil || !handled {
t.Fatalf("direct confirmation handled=%v err=%v", handled, err)
}
}
func newFirstGachaTestService(t *testing.T) *Service {
t.Helper()
characters := map[uint64]gamedata.CharacterDesign{61001: fixtureCharacter(6100, 100)}
program := &gamedata.FirstGachaRewardGroup{ID: 20, DropCount: 10, Entries: []gamedata.FirstGachaRewardEntry{{ItemType: 11, ItemID: 61001, Count: 1, Weight: 1}}}
first, err := gamedata.NewFirstGachaDesign(gamedata.GachaGroupDesign{ID: 2, GachaSubType: 3, TenTimeGachaID: 20}, 20, 10, program, characters, nil)
if err != nil {
t.Fatal(err)
}
infinite, err := gamedata.NewInfiniteGachaDesign(10, []uint64{61001}, characters)
if err != nil {
t.Fatal(err)
}
regular, err := gamedata.NewRegularGachaCatalog(map[uint64]gamedata.RegularGacha{1: {ID: 1, Count: 1, PriceType: 3, Price: 1, Pool: []gamedata.WeightedCostume{{ID: 61001, Weight: 1}}}}, characters)
if err != nil {
t.Fatal(err)
}
storage := stateio.NewMemory()
collection, _ := player.OpenCollectionStore(storage, nil)
wallet, _ := player.OpenWallet(storage, player.Currency{})
equipment, _ := player.OpenEquipmentInventory(storage)
service, err := NewService(infinite, regular, collection, wallet)
if err != nil {
t.Fatal(err)
}
if err := service.AttachFirstGacha(first); err != nil {
t.Fatal(err)
}
service.AttachEquipmentGacha(first.EquipmentCatalog(), equipment)
service.BeginSession("first-no-preview")
return service
}
+205 -15
View File
@@ -1,9 +1,10 @@
// Package gacha implements the local, non-payment version of the official
// infinite reroll product. Static pools come from verified GameData;
// only the latest preview and confirmed ownership are persisted locally.
// Package gacha implements server-authoritative draws from verified GameData,
// including the starter reroll and scheduled daily-free batches.
package gacha
import (
"crypto/sha256"
"encoding/binary"
"errors"
"fmt"
"sync"
@@ -34,6 +35,7 @@ const (
type Service struct {
design *gamedata.InfiniteGachaDesign
first *gamedata.FirstGachaDesign
regular *gamedata.RegularGachaCatalog
collection *player.CollectionStore
wallet *player.Wallet
@@ -45,6 +47,8 @@ type Service struct {
previewEventIndex uint64
sessionMu sync.RWMutex
sessionID string
firstMu sync.Mutex
firstPreviews map[string]firstGachaPreview
now func() time.Time
}
@@ -71,6 +75,17 @@ func (s *Service) AttachEquipmentGacha(catalog *gamedata.EquipmentGachaCatalog,
s.equipmentCatalog, s.equipmentInventory = catalog, inventory
}
func (s *Service) AttachFirstGacha(design *gamedata.FirstGachaDesign) error {
if design == nil || design.GachaID == 0 || design.Count == 0 || design.Group.GachaSubType != 3 {
return errors.New("gacha: invalid first gacha design")
}
s.first = design
s.firstMu.Lock()
s.firstPreviews = make(map[string]firstGachaPreview)
s.firstMu.Unlock()
return nil
}
// 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.
@@ -86,6 +101,11 @@ func (s *Service) BeginSession(id string) {
s.sessionMu.Lock()
s.sessionID = id
s.sessionMu.Unlock()
s.firstMu.Lock()
if s.firstPreviews != nil {
s.firstPreviews = make(map[string]firstGachaPreview)
}
s.firstMu.Unlock()
}
func NewService(design *gamedata.InfiniteGachaDesign, regular *gamedata.RegularGachaCatalog, collection *player.CollectionStore, wallet *player.Wallet) (*Service, error) {
@@ -96,7 +116,7 @@ func NewService(design *gamedata.InfiniteGachaDesign, regular *gamedata.RegularG
}
func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) {
if path != "/GachaInfo" && path != "/GachaBuy" && path != "/GachaPointExchange" && path != "/GachaPointManualExchange" && path != "/GachaBuyPreview" && path != "/GachaBuyPreviewLock" && path != "/GachaSelectionSave" && path != "/CashShopPurchaseCountInfo" && path != "/CashShopBuy" {
if path != "/GachaInfo" && path != "/GachaBuy" && path != "/GachaMultiBuy" && path != "/GachaPointExchange" && path != "/GachaPointManualExchange" && path != "/GachaBuyPreview" && path != "/GachaBuyPreviewLock" && path != "/GachaSelectionSave" && path != "/CashShopPurchaseCountInfo" && path != "/CashShopBuy" {
return 0, nil, false, nil
}
seq, found, err := wire.Varint(request, 1)
@@ -112,6 +132,8 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error)
return s.lockPreview(request)
case "/GachaBuy":
return s.buy(request, seq)
case "/GachaMultiBuy":
return s.multiBuy(request, seq)
case "/GachaPointExchange":
return s.pointExchange(request, seq)
case "/GachaPointManualExchange":
@@ -238,13 +260,149 @@ func (s *Service) lockPreview(request []byte) (int, []byte, bool, error) {
if err := s.collection.LockPreview(eventIndex); err != nil {
return 175, nil, true, err
}
// 2.34.13 has no separate PacketCodeTypeProto member for the lock RPC.
// no separate PacketCodeTypeProto member for the lock RPC.
// It is a sub-operation of GachaBuyPreview and the client callback only
// parses the empty response, so it uses the parent packet code.
return 175, nil, true, nil
}
// multiBuy is the client's AllGacha daily-free macro, not a paid multi-pull.
// Every item runs within the transport's one account transaction. 146 follows
// GachaBuy; its exact MultiBuy envelope awaits a 2.35.10 official capture.
func (s *Service) multiBuy(request []byte, seq uint64) (int, []byte, bool, error) {
const maxBatchSize = 256
var ids []uint64
appendID := func(id uint64) error {
if id == 0 || id > 1<<31-1 || len(ids) >= maxBatchSize {
return errors.New("gacha: invalid daily free batch ID or size")
}
ids = append(ids, id)
return nil
}
if err := wire.Walk(request, func(field wire.Field) error {
if field.Number != 2 {
return nil
}
switch field.Type {
case 0:
id, n := binary.Uvarint(field.Value)
if n <= 0 {
return wire.ErrMalformed
}
return appendID(id)
case 2:
for packed := field.Value; len(packed) != 0; {
id, n := binary.Uvarint(packed)
if n <= 0 {
return wire.ErrMalformed
}
if err := appendID(id); err != nil {
return err
}
packed = packed[n:]
}
return nil
default:
return errors.New("gacha: invalid daily free batch ID wire type")
}
}); err != nil {
return 146, nil, true, err
}
if len(ids) == 0 {
return 146, nil, true, errors.New("gacha: empty daily free batch")
}
identity := fmt.Sprintf("multi-gacha:session:%s:seq:%d", s.loginIdentity(), seq)
var canonical []byte
for _, id := range ids {
canonical = binary.AppendUvarint(canonical, id)
}
digest := fmt.Sprintf("%x", sha256.Sum256(canonical))
if response, found, err := s.collection.GachaBatchResponse(identity, digest); found || err != nil {
return 146, response, true, err
}
// Validate the complete batch before a single allowance or reward changes.
// Counts are accumulated per group so repeated IDs cannot evade the limit.
day := dailyResetKey(s.now())
requested := make(map[uint64]uint64)
for _, id := range ids {
groupID, allowance := uint64(0), uint64(0)
if design, found := s.regular.Gacha(id); found {
group, grouped := s.regular.GroupForGacha(id)
if grouped && group.OneTimeGachaID == id && design.Count == 1 {
groupID, allowance = group.ID, design.FreeCountDay
}
} else if s.equipmentCatalog != nil {
if design, found := s.equipmentCatalog.Gacha(id); found && !design.TicketOnly && design.Count == 1 {
if s.equipmentInventory == nil {
return 146, nil, true, errors.New("gacha: equipment inventory not attached")
}
if group, grouped := s.equipmentCatalog.GroupForGacha(id); grouped && group.OneTimeGachaID == id {
groupID, allowance = group.ID, design.FreeCountDay
}
}
}
if allowance == 0 || !s.dailyFreeScheduleOpen(groupID) {
return 146, nil, true, fmt.Errorf("gacha: unavailable daily free gacha %d", id)
}
requested[groupID]++
used := s.collection.GachaDailyCount(day, groupID, 0)
if used >= allowance || requested[groupID] > allowance-used {
return 146, nil, true, fmt.Errorf("gacha: daily free batch exceeds allowance group=%d", groupID)
}
}
var response []byte
for slot, id := range ids {
subrequest := wire.AppendVarint(wire.AppendVarint(nil, 1, seq), 2, id)
slotIdentity := fmt.Sprintf("%s:slot:%d:id:%d", identity, slot, id)
_, single, _, err := s.buyWithIdentity(subrequest, seq, slotIdentity)
if err != nil {
return 146, nil, true, fmt.Errorf("gacha: daily free batch slot %d: %w", slot, err)
}
result := wire.AppendVarint(nil, 1, id)
if err := wire.Walk(single, func(field wire.Field) error {
// Single-buy fields 1..5 correspond to multi-result fields 2..6.
switch field.Type {
case 0:
value, n := binary.Uvarint(field.Value)
if n <= 0 {
return wire.ErrMalformed
}
result = wire.AppendVarint(result, field.Number+1, value)
case 2:
result = wire.AppendBytes(result, field.Number+1, field.Value)
default:
return errors.New("gacha: unexpected single-buy response wire type")
}
return nil
}); err != nil {
return 146, nil, true, err
}
response = wire.AppendBytes(response, 1, result)
}
if err := s.collection.RecordGachaBatch(identity, digest, response); err != nil {
return 146, nil, true, err
}
return 146, response, true, nil
}
func (s *Service) dailyFreeScheduleOpen(groupID uint64) bool {
if s.schedule == nil {
return false
}
now := s.now().UnixMilli()
for _, entry := range s.schedule.Schedules {
if entry.GroupID == groupID && !entry.FreeCountBonus && now >= 0 && uint64(now) >= entry.StartTime && uint64(now) <= entry.EndTime {
return true
}
}
return false
}
func (s *Service) buy(request []byte, seq uint64) (int, []byte, bool, error) {
return s.buyWithIdentity(request, seq, "")
}
func (s *Service) buyWithIdentity(request []byte, seq uint64, identity string) (int, []byte, bool, error) {
id, found, err := wire.Varint(request, 2)
if err != nil || !found || id == 0 {
return 146, nil, true, errors.New("gacha: missing gacha id")
@@ -287,10 +445,13 @@ func (s *Service) buy(request []byte, seq uint64) (int, []byte, bool, error) {
}); err != nil {
return 146, nil, true, err
}
if s.first != nil && id == s.first.GachaID {
return s.confirmFirstGacha(request, seq, buyType, tickets, identity)
}
design, ok := s.regular.Gacha(id)
if !ok && s.equipmentCatalog != nil {
if equipment, found := s.equipmentCatalog.Gacha(id); found {
return s.buyEquipment(seq, buyType, tickets, equipment)
return s.buyEquipmentWithIdentity(seq, buyType, tickets, equipment, identity)
}
}
if !ok {
@@ -324,7 +485,9 @@ func (s *Service) buy(request []byte, seq uint64) (int, []byte, bool, error) {
return 146, nil, true, fmt.Errorf("gacha: ticket %d is not valid for gacha %d", ticket.ID, id)
}
}
identity := s.requestIdentity(id, seq)
if identity == "" {
identity = s.requestIdentity(id, seq)
}
grant, already := s.collection.Grant(identity)
if isMoonrise && !already {
if _, completed := s.collection.Grant(moonriseDrawGrant); completed {
@@ -530,10 +693,15 @@ func (s *Service) stepForGacha(gachaID uint64) (uint64, gamedata.GachaStepDesign
// ticket-only draws have no schedule group and persist only their idempotency
// marker plus the generated equipment instances.
func (s *Service) buyEquipment(seq, buyType uint64, tickets []player.Item, design gamedata.EquipmentGacha) (int, []byte, bool, error) {
return s.buyEquipmentWithIdentity(seq, buyType, tickets, design, "")
}
func (s *Service) buyEquipmentWithIdentity(seq, buyType uint64, tickets []player.Item, design gamedata.EquipmentGacha, identity string) (int, []byte, bool, error) {
if s.equipmentInventory == nil {
return 146, nil, true, errors.New("gacha: equipment inventory not attached")
}
if buyType != 1 || (!design.TicketOnly && design.PriceType != 3) {
isDailyFree := buyType == 0 && !design.TicketOnly && design.Count == 1 && design.FreeCountDay != 0
if (buyType != 1 && !isDailyFree) || (!design.TicketOnly && design.PriceType != 3) {
return 146, nil, true, fmt.Errorf("gacha: unsupported equipment buy type %d", buyType)
}
if design.TicketOnly && len(tickets) == 0 {
@@ -555,7 +723,12 @@ func (s *Service) buyEquipment(seq, buyType uint64, tickets []player.Item, desig
if !hasGroup && !design.TicketOnly {
return 146, nil, true, fmt.Errorf("gacha: equipment gacha %d has no group", design.ID)
}
identity := s.requestIdentity(design.ID, seq)
if isDailyFree && len(tickets) != 0 {
return 146, nil, true, errors.New("gacha: daily free equipment draw cannot use tickets")
}
if identity == "" {
identity = s.requestIdentity(design.ID, seq)
}
grant, already := s.collection.Grant(identity)
var entries []player.Equipment
if already {
@@ -567,6 +740,9 @@ func (s *Service) buyEquipment(seq, buyType uint64, tickets []player.Item, desig
entries = append(entries, entry)
}
} else {
if isDailyFree && s.collection.GachaDailyCount(dailyResetKey(s.now()), group.ID, 0) >= design.FreeCountDay {
return 146, nil, true, fmt.Errorf("gacha: daily equipment draw exhausted group=%d", group.ID)
}
var ticketCount uint64
for _, ticket := range tickets {
ticketCount += ticket.Count
@@ -582,7 +758,7 @@ func (s *Service) buyEquipment(seq, buyType uint64, tickets []player.Item, desig
return 146, nil, true, err
}
}
if remain := uint64(design.Count) - ticketCount; remain != 0 {
if remain := uint64(design.Count) - ticketCount; remain != 0 && !isDailyFree {
if design.TicketOnly {
return 146, nil, true, errors.New("gacha: ticket-only equipment draw cannot use diamonds")
}
@@ -628,7 +804,11 @@ func (s *Service) buyEquipment(seq, buyType uint64, tickets []player.Item, desig
if design.TicketOnly {
grant, err = s.collection.GrantEquipmentDraw(identity)
} else {
grant, err = s.collection.GrantEquipmentPurchase(identity, uint64(len(entries)), player.GachaPurchase{Group: gamedata.GachaGroupDesign{ID: group.ID, PointCount: group.PointCount}, BuyType: buyType, Fixed: fixedStates})
purchase := player.GachaPurchase{Group: gamedata.GachaGroupDesign{ID: group.ID, PointCount: group.PointCount}, BuyType: buyType, Fixed: fixedStates}
if isDailyFree {
purchase.DailyKey, purchase.DailyLimit = dailyResetKey(s.now()), design.FreeCountDay
}
grant, err = s.collection.GrantEquipmentPurchase(identity, uint64(len(entries)), purchase)
}
if err != nil {
return 146, nil, true, err
@@ -759,11 +939,17 @@ func (s *Service) requestIdentity(gachaID, seq uint64) string {
}
func (s *Service) preview(request []byte) (int, []byte, bool, error) {
id, found, err := wire.Varint(request, 2)
if err != nil || !found {
return 0, nil, true, fmt.Errorf("gacha: unsupported preview id %d", id)
}
if s.first != nil && id == s.first.GachaID {
return s.previewFirstGacha(request)
}
if _, bought := s.collection.Grant(infiniteGrant); bought {
return 175, nil, true, errors.New("gacha: infinite product already purchased")
}
id, found, err := wire.Varint(request, 2)
if err != nil || !found || id != gamedata.InfiniteGachaID {
if id != gamedata.InfiniteGachaID {
return 0, nil, true, fmt.Errorf("gacha: unsupported preview id %d", id)
}
group, found, err := wire.Varint(request, 3)
@@ -899,7 +1085,11 @@ func (s *Service) rewardBundle(grant player.CollectionGrant) []byte {
bundle = wire.AppendBytes(bundle, 3, player.CostumeWire(costume))
}
}
for sortID, costumeID := range grant.ViewCostumeIDs {
for position, costumeID := range grant.ViewCostumeIDs {
sortID := uint64(position)
if len(grant.ViewCostumeSortIDs) == len(grant.ViewCostumeIDs) {
sortID = grant.ViewCostumeSortIDs[position]
}
view := wire.AppendVarint(nil, 2, costumeID)
view = wire.AppendVarint(view, 3, 11)
view = wire.AppendVarint(view, 4, 1)
@@ -909,7 +1099,7 @@ func (s *Service) rewardBundle(grant player.CollectionGrant) []byte {
charView = wire.AppendVarint(charView, 3, 6)
charView = wire.AppendVarint(charView, 4, 1)
if sortID != 0 {
charView = wire.AppendVarint(charView, 6, uint64(sortID))
charView = wire.AppendVarint(charView, 6, sortID)
}
bundle = wire.AppendBytes(bundle, 6, charView)
}
+238 -2
View File
@@ -1,10 +1,15 @@
package gacha
import (
"bytes"
"encoding/binary"
"errors"
"os"
"path/filepath"
"testing"
"time"
"bd2server/internal/server/accountstate"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/player"
"bd2server/internal/server/stateio"
@@ -16,7 +21,7 @@ func TestTicketOnlyEquipmentDrawUsesGameDataAndNoScheduleAccounting(t *testing.T
if root == "" {
t.Skip("set BD2_TEST_GAMEDATA_ROOT for installed GameData integration test")
}
const version = "20260910162539"
const version = "20260923193640"
infinite, err := gamedata.LoadInfiniteGacha(root, version)
if err != nil {
t.Fatal(err)
@@ -546,13 +551,244 @@ func TestUnverifiedGachaRPCsRemainUnhandled(t *testing.T) {
t.Fatal(err)
}
request := wire.AppendVarint(nil, 1, 1)
for _, path := range []string{"/GachaMultiBuy", "/GachaLog"} {
for _, path := range []string{"/GachaLog"} {
if code, response, handled, err := service.Handle(path, request); err != nil || handled || code != 0 || response != nil {
t.Fatalf("%s code=%d bytes=%x handled=%v err=%v", path, code, response, handled, err)
}
}
}
func newMultiBuyTestService(t *testing.T, storage stateio.Store) *Service {
t.Helper()
character := fixtureCharacter(6090, 253)
characters := map[uint64]gamedata.CharacterDesign{60901: character}
design, err := gamedata.NewInfiniteGachaDesign(10, []uint64{60901}, characters)
if err != nil {
t.Fatal(err)
}
regular, err := gamedata.NewRegularGachaCatalog(map[uint64]gamedata.RegularGacha{
11: {ID: 11, Count: 1, FreeCountDay: 2, PriceType: 3, Price: 200, Pool: []gamedata.WeightedCostume{{ID: 60901, Weight: 1}}},
12: {ID: 12, Count: 1, FreeCountDay: 1, PriceType: 3, Price: 200, Pool: []gamedata.WeightedCostume{{ID: 60901, Weight: 1}}},
13: {ID: 13, Count: 10, PriceType: 3, Price: 2000, Pool: []gamedata.WeightedCostume{{ID: 60901, Weight: 1}}},
}, characters)
if err != nil {
t.Fatal(err)
}
for _, group := range []gamedata.GachaGroupDesign{{ID: 1, OneTimeGachaID: 11, TenTimeGachaID: 13, PointCount: 1}, {ID: 2, OneTimeGachaID: 12, PointCount: 1}} {
if err := regular.AddGroupDesign(group, gamedata.GachaFixedDesign{}); err != nil {
t.Fatal(err)
}
}
collection, err := player.OpenCollectionStore(storage, nil)
if err != nil {
t.Fatal(err)
}
wallet, err := player.OpenWallet(storage, player.Currency{FreeJewelry: 1000})
if err != nil {
t.Fatal(err)
}
service, err := NewService(design, regular, collection, wallet)
if err != nil {
t.Fatal(err)
}
service.BeginSession("multi-login")
service.now = func() time.Time { return time.Date(2026, 9, 30, 12, 0, 0, 0, time.UTC) }
service.schedule = &ScheduleSeed{Schedules: []ScheduleWindow{{GroupID: 1, StartTime: 1, EndTime: 2000000000000}, {GroupID: 2, StartTime: 1, EndTime: 2000000000000}}}
return service
}
func multiBuyRequest(seq uint64, packed bool, ids ...uint64) []byte {
request := wire.AppendVarint(nil, 1, seq)
var values []byte
for _, id := range ids {
if packed {
values = binary.AppendUvarint(values, id)
} else {
request = wire.AppendVarint(request, 2, id)
}
}
if packed {
request = wire.AppendBytes(request, 2, values)
}
return request
}
func TestGachaMultiBuyPreservesOrderRepeatedIDsAndRestartReplay(t *testing.T) {
storage := stateio.NewMemory()
service := newMultiBuyTestService(t, storage)
request := multiBuyRequest(10, true, 12, 11, 11)
code, response, handled, err := service.Handle("/GachaMultiBuy", request)
if err != nil || code != 146 || !handled {
t.Fatalf("multi code=%d handled=%v err=%v", code, handled, err)
}
var gotIDs []uint64
if err := wire.Walk(response, func(field wire.Field) error {
if field.Number != 1 || field.Type != 2 {
return errors.New("invalid result")
}
id, _, err := wire.Varint(field.Value, 1)
if err != nil {
return err
}
gotIDs = append(gotIDs, id)
bundle, found, err := wire.Bytes(field.Value, 2)
if err != nil || !found || (countFields(bundle, 3)+countFields(bundle, 9)) != 1 {
return errors.New("missing costume/upgrade")
}
point, _, err := wire.Varint(field.Value, 3)
if err != nil || point != 1 {
return errors.New("missing point")
}
return nil
}); err != nil {
t.Fatal(err)
}
if len(gotIDs) != 3 || gotIDs[0] != 12 || gotIDs[1] != 11 || gotIDs[2] != 11 {
t.Fatalf("order=%v", gotIDs)
}
if service.wallet.Snapshot().FreeJewelry != 1000 {
t.Fatal("free batch charged currency")
}
if owned := service.collection.Costumes(); len(owned) != 1 || owned[0].Level != 2 {
t.Fatalf("owned=%+v", owned)
}
if service.collection.GachaDailyCount("2026-09-30", 1, 0) != 2 || service.collection.GachaUser(1).Point != 2 {
t.Fatal("repeated IDs did not make independent draws")
}
service = newMultiBuyTestService(t, storage)
_, replay, _, err := service.Handle("/GachaMultiBuy", multiBuyRequest(10, false, 12, 11, 11))
if err != nil || !bytes.Equal(response, replay) {
t.Fatalf("restart/unpacked replay differs: %v", err)
}
if service.collection.Costumes()[0].Level != 2 || service.collection.GachaUser(1).Point != 2 {
t.Fatal("replay mutated rewards")
}
if _, _, _, err := service.Handle("/GachaMultiBuy", multiBuyRequest(10, true, 11, 12, 11)); err == nil {
t.Fatal("same sequence changed ordered request")
}
service.BeginSession("next-login")
if _, _, _, err := service.Handle("/GachaMultiBuy", request); err == nil {
t.Fatal("new login bypassed exhausted daily limits")
}
service.now = func() time.Time { return time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC) }
if _, _, _, err := service.Handle("/GachaMultiBuy", request); err != nil {
t.Fatalf("next UTC reset did not restore allowance: %v", err)
}
}
func TestGachaMultiBuyInvalidOrExcessBatchDoesNotGrantAnySlot(t *testing.T) {
for name, ids := range map[string][]uint64{"unknown": {11, 999}, "excess": {11, 11, 11}, "ten-pull": {11, 13}, "zero": {11, 0}, "negative-int32": {11, ^uint64(0)}, "empty": {}} {
t.Run(name, func(t *testing.T) {
service := newMultiBuyTestService(t, stateio.NewMemory())
code, _, handled, err := service.Handle("/GachaMultiBuy", multiBuyRequest(1, true, ids...))
if code != 146 || !handled || err == nil {
t.Fatalf("code=%d handled=%v err=%v", code, handled, err)
}
if len(service.collection.Costumes()) != 0 || service.collection.GachaDailyCount("2026-09-30", 1, 0) != 0 || service.collection.GachaUser(1).Point != 0 {
t.Fatal("invalid batch partly granted")
}
})
}
service := newMultiBuyTestService(t, stateio.NewMemory())
service.schedule.Schedules[1].EndTime = 2
if _, _, _, err := service.Handle("/GachaMultiBuy", multiBuyRequest(1, true, 11, 12)); err == nil || len(service.collection.Costumes()) != 0 {
t.Fatal("closed schedule partly granted")
}
}
func TestGachaMultiBuyFailureRollsBackWholeSQLiteOperation(t *testing.T) {
path := filepath.Join(t.TempDir(), "state.db")
repo, err := accountstate.Open(path)
if err != nil {
t.Fatal(err)
}
defer repo.Close()
service := newMultiBuyTestService(t, repo)
if err := service.collection.EnsurePersisted(); err != nil {
t.Fatal(err)
}
service.AttachPreviewMission(func() error { return errors.New("injected mission failure after reward") })
operation, err := repo.BeginOperation()
if err != nil {
t.Fatal(err)
}
if _, _, _, err := service.Handle("/GachaMultiBuy", multiBuyRequest(1, true, 11, 12)); err == nil {
t.Fatal("expected failure")
}
// A dirty rollback intentionally fail-stops domain memory. Reopen below
// verifies the disk state and prevents stale in-memory objects being reused.
if err := operation.Rollback(); err == nil || repo.Check() == nil {
t.Fatal("dirty rollback must fail-stop the current repository")
}
if err := repo.Close(); err != nil {
t.Fatal(err)
}
repo, err = accountstate.Open(path)
if err != nil {
t.Fatal(err)
}
defer repo.Close()
reopened := newMultiBuyTestService(t, repo)
if len(reopened.collection.Costumes()) != 0 || reopened.collection.GachaDailyCount("2026-09-30", 1, 0) != 0 || reopened.collection.GachaUser(1).Point != 0 {
t.Fatal("failed request persisted a partial batch")
}
if _, found, _ := reopened.collection.GachaBatchResponse("multi-gacha:session:multi-login:seq:1", "ignored"); found {
t.Fatal("failed request stored completion")
}
}
func TestGachaMultiBuyCurrent23510CostumeAndEquipmentPools(t *testing.T) {
root := filepath.Join("..", "..", "..", "..", "data", "resources", "GameData")
const version = "20260923193640"
if _, err := os.Stat(filepath.Join(root, version, "release", "common-dbdata.bin")); os.IsNotExist(err) {
t.Skip("installed 2.35.10 GameData archive is unavailable")
}
storage := stateio.NewMemory()
service := newMultiBuyTestService(t, storage)
regular, err := gamedata.LoadRegularCostumeGachaGroups(root, version, []uint64{71, 166, 205, 207}, nil)
if err != nil {
t.Fatal(err)
}
equipmentCatalog, err := gamedata.LoadEquipmentGachaGroups(root, version, []uint64{72, 153, 206, 208})
if err != nil {
t.Fatal(err)
}
equipment, err := player.OpenEquipmentInventory(storage)
if err != nil {
t.Fatal(err)
}
service.regular = regular
service.AttachEquipmentGacha(equipmentCatalog, equipment)
service.schedule.Schedules = nil
for _, groupID := range []uint64{71, 166, 205, 207, 72, 153, 206, 208} {
service.schedule.Schedules = append(service.schedule.Schedules, ScheduleWindow{GroupID: groupID, StartTime: 1, EndTime: 2000000000000})
}
ids := []uint64{10100040, 20100034, 10100113, 20100070, 10100145, 20100082, 10100146, 20100083}
request := multiBuyRequest(99, true, ids...)
code, response, handled, err := service.Handle("/GachaMultiBuy", request)
if err != nil || !handled || code != 146 {
t.Fatalf("installed mixed batch: code=%d handled=%v err=%v", code, handled, err)
}
if countFields(response, 1) != 8 || len(equipment.All()) != 4 {
t.Fatalf("results=%d equipment=%d", countFields(response, 1), len(equipment.All()))
}
if service.wallet.Snapshot().FreeJewelry != 1000 {
t.Fatal("installed free batch spent diamonds")
}
for _, groupID := range []uint64{71, 166, 205, 207, 72, 153, 206, 208} {
if service.collection.GachaDailyCount("2026-09-30", groupID, 0) != 1 || service.collection.GachaUser(groupID).Point != 1 {
t.Fatalf("missing daily allowance/point group=%d", groupID)
}
}
if states := service.collection.GachaFixedStates(); len(states) != 4 {
t.Fatalf("missing four costume/equipment fixed types: %+v", states)
}
_, replay, _, err := service.Handle("/GachaMultiBuy", request)
if err != nil || !bytes.Equal(response, replay) || len(equipment.All()) != 4 {
t.Fatalf("installed replay changed equipment/rewards: %v", err)
}
}
func TestCompletedStepUpPersistsAndRemainsVisible(t *testing.T) {
const stepUpGroupID = 29
stepUpGachaIDs := []uint64{8100118, 8100119, 8100120, 8100121}