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
+29 -9
View File
@@ -39,10 +39,13 @@ type CollectionGrant struct {
Upgrades []CostumeUpgrade `json:"upgrades,omitempty"`
Exchanges []CostumeExchange `json:"exchanges,omitempty"`
ViewCostumeIDs []uint64 `json:"view_costume_ids"`
ViewCostumeSortIDs []uint64 `json:"view_costume_sort_ids,omitempty"`
GachaGroupID uint64 `json:"gacha_group_id,omitempty"`
GachaPoint uint64 `json:"gacha_point,omitempty"`
GachaFixed []GachaFixedState `json:"gacha_fixed,omitempty"`
SelectionApplySortIDs []uint64 `json:"selection_apply_sort_ids,omitempty"`
GachaRequestDigest string `json:"gacha_request_digest,omitempty"`
GachaResponse []byte `json:"gacha_response,omitempty"`
}
type GachaUserState struct {
@@ -79,6 +82,10 @@ type GachaPurchase struct {
SelectionApplySortIDs []uint64
StepUpGroupID uint64
StepUpStep uint64
RewardCount uint64
// RewardSortIDs preserves positions from mixed reward programs. Ordinary
// costume-only gachas leave it empty and use their slice positions.
RewardSortIDs []uint64
}
type GachaSelection struct {
@@ -245,9 +252,9 @@ func (s *CollectionStore) EnsurePersisted() error {
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 &&
len(grant.ViewCostumeIDs) == 0 && len(grant.ViewCostumeSortIDs) == 0 && grant.GachaGroupID == 0 &&
grant.GachaPoint == 0 && len(grant.GachaFixed) == 0 &&
len(grant.SelectionApplySortIDs) == 0
len(grant.SelectionApplySortIDs) == 0 && grant.GachaRequestDigest == "" && len(grant.GachaResponse) == 0
}
// BindBaseCharacters attaches the authoritative base roster and rejects a
@@ -513,7 +520,7 @@ func (s *CollectionStore) GrantRegularPurchase(identity string, costumeIDs []uin
if identity == "" || len(costumeIDs) == 0 || design == nil || purchase.Group.ID == 0 {
return CollectionGrant{}, errors.New("player: invalid regular gacha purchase")
}
return s.grantCostumes(identity, costumeIDs, design.Character, func(next *collectionSnapshot, grant *CollectionGrant) error {
return s.grantCostumesSorted(identity, costumeIDs, purchase.RewardSortIDs, design.Character, func(next *collectionSnapshot, grant *CollectionGrant) error {
if err := applyStepUpProgress(next, purchase.StepUpGroupID, purchase.StepUpStep); err != nil {
return err
}
@@ -579,15 +586,26 @@ func (s *CollectionStore) GrantEquipmentDraw(identity string) (CollectionGrant,
}
func (s *CollectionStore) grantCostumes(identity string, costumeIDs []uint64, character func(uint64) (gamedata.CharacterDesign, bool), mutate func(*collectionSnapshot, *CollectionGrant) error) (CollectionGrant, error) {
return s.grantCostumesSorted(identity, costumeIDs, nil, character, mutate)
}
func (s *CollectionStore) grantCostumesSorted(identity string, costumeIDs, sortIDs []uint64, character func(uint64) (gamedata.CharacterDesign, bool), mutate func(*collectionSnapshot, *CollectionGrant) error) (CollectionGrant, error) {
if len(sortIDs) != 0 && len(sortIDs) != len(costumeIDs) {
return CollectionGrant{}, errors.New("player: costume reward sort count mismatch")
}
s.mu.Lock()
defer s.mu.Unlock()
if grant, ok := s.data.Grants[identity]; ok {
return cloneGrant(grant), nil
}
next := cloneCollection(s.data)
grant := CollectionGrant{ViewCostumeIDs: append([]uint64(nil), costumeIDs...)}
grant := CollectionGrant{ViewCostumeIDs: append([]uint64(nil), costumeIDs...), ViewCostumeSortIDs: append([]uint64(nil), sortIDs...)}
now := uint64(time.Now().UnixMilli())
for sortIndex, costumeID := range costumeIDs {
sortID := uint64(sortIndex)
if len(sortIDs) != 0 {
sortID = sortIDs[sortIndex]
}
if costumeID == 0 {
return CollectionGrant{}, errors.New("player: preview has invalid costume")
}
@@ -603,9 +621,9 @@ func (s *CollectionStore) grantCostumes(identity string, costumeIDs []uint64, ch
before := next.Costumes[position].Level
if before < maxLevel {
next.Costumes[position].Level++
grant.Upgrades = append(grant.Upgrades, CostumeUpgrade{InvenIndex: next.Costumes[position].InvenIndex, CostumeID: costumeID, Before: before, After: next.Costumes[position].Level, SortID: uint64(sortIndex)})
grant.Upgrades = append(grant.Upgrades, CostumeUpgrade{InvenIndex: next.Costumes[position].InvenIndex, CostumeID: costumeID, Before: before, After: next.Costumes[position].Level, SortID: sortID})
} else {
exchange, err := costumeOverflowExchange(costumeID, next.Costumes[position].InvenIndex, uint64(sortIndex), characterDesign)
exchange, err := costumeOverflowExchange(costumeID, next.Costumes[position].InvenIndex, sortID, characterDesign)
if err != nil {
return CollectionGrant{}, err
}
@@ -622,9 +640,9 @@ func (s *CollectionStore) grantCostumes(identity string, costumeIDs []uint64, ch
}
if before < maxLevel {
next.BaseCostumeLevels[key] = before + 1
grant.Upgrades = append(grant.Upgrades, CostumeUpgrade{InvenIndex: s.base[base].InvenIndex, CostumeID: costumeID, Before: before, After: before + 1, SortID: uint64(sortIndex)})
grant.Upgrades = append(grant.Upgrades, CostumeUpgrade{InvenIndex: s.base[base].InvenIndex, CostumeID: costumeID, Before: before, After: before + 1, SortID: sortID})
} else {
exchange, err := costumeOverflowExchange(costumeID, s.base[base].InvenIndex, uint64(sortIndex), characterDesign)
exchange, err := costumeOverflowExchange(costumeID, s.base[base].InvenIndex, sortID, characterDesign)
if err != nil {
return CollectionGrant{}, err
}
@@ -645,7 +663,7 @@ func (s *CollectionStore) grantCostumes(identity string, costumeIDs []uint64, ch
next.Characters = append(next.Characters, ownedCharacter)
grant.CharacterIndices = append(grant.CharacterIndices, characterIndex)
}
costume := Costume{InvenIndex: costumeIndex, ID: costumeID, UseChar: characterIndex, SortID: uint64(sortIndex), TimeValue: now}
costume := Costume{InvenIndex: costumeIndex, ID: costumeID, UseChar: characterIndex, SortID: sortID, TimeValue: now}
next.Costumes = append(next.Costumes, costume)
grant.CostumeIndices = append(grant.CostumeIndices, costumeIndex)
}
@@ -912,11 +930,13 @@ func findCharacterByID(base, collection []Character, id uint64) (uint64, bool) {
}
func cloneGrant(grant CollectionGrant) CollectionGrant {
grant.GachaResponse = append([]byte(nil), grant.GachaResponse...)
grant.CharacterIndices = append([]uint64(nil), grant.CharacterIndices...)
grant.CostumeIndices = append([]uint64(nil), grant.CostumeIndices...)
grant.Upgrades = append([]CostumeUpgrade(nil), grant.Upgrades...)
grant.Exchanges = append([]CostumeExchange(nil), grant.Exchanges...)
grant.ViewCostumeIDs = append([]uint64(nil), grant.ViewCostumeIDs...)
grant.ViewCostumeSortIDs = append([]uint64(nil), grant.ViewCostumeSortIDs...)
grant.GachaFixed = append([]GachaFixedState(nil), grant.GachaFixed...)
grant.SelectionApplySortIDs = append([]uint64(nil), grant.SelectionApplySortIDs...)
return grant
+37
View File
@@ -0,0 +1,37 @@
package player
import "errors"
// GachaBatchResponse returns the immutable result of a completed batch. The
// digest binds the full ordered request, including repeated gacha IDs.
func (s *CollectionStore) GachaBatchResponse(identity, digest string) ([]byte, bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
grant, found := s.data.Grants[identity]
if !found {
return nil, false, nil
}
if grant.GachaRequestDigest != digest || len(grant.GachaResponse) == 0 {
return nil, true, errors.New("player: gacha batch request identity conflict")
}
return append([]byte(nil), grant.GachaResponse...), true, nil
}
// RecordGachaBatch participates in the caller's account operation alongside
// every reward, daily allowance and fixed-pity update in the batch.
func (s *CollectionStore) RecordGachaBatch(identity, digest string, response []byte) error {
if identity == "" || digest == "" || len(response) == 0 {
return errors.New("player: invalid gacha batch result")
}
s.mu.Lock()
defer s.mu.Unlock()
if prior, found := s.data.Grants[identity]; found {
if prior.GachaRequestDigest != digest {
return errors.New("player: gacha batch request identity conflict")
}
return nil
}
next := cloneCollection(s.data)
next.Grants[identity] = CollectionGrant{GachaRequestDigest: digest, GachaResponse: append([]byte(nil), response...)}
return s.commit(next)
}
+8 -4
View File
@@ -59,21 +59,25 @@ func applyGachaPurchase(next *collectionSnapshot, identity string, costumeIDs []
}
next.GachaApplied[prefix+identity] = true
}
point := purchase.Group.PointCount * uint64(len(costumeIDs))
rewardCount := uint64(len(costumeIDs))
if purchase.RewardCount != 0 {
rewardCount = purchase.RewardCount
}
point := purchase.Group.PointCount * rewardCount
user.Point += point
switch purchase.BuyType {
case 1: // GB_NORMAL
user.TotalBuyCount += uint64(len(costumeIDs))
user.TotalBuyCount += rewardCount
case 2: // GB_CASH outside the date-scoped daily discount
if purchase.DailyLimit == 0 {
if len(costumeIDs) == 1 {
if rewardCount == 1 {
user.OneCashPickCount++
} else {
user.TenCashPickCount++
}
}
case 3: // GB_CASH_CONTENT_TICKET
user.TotalBuyCount += uint64(len(costumeIDs))
user.TotalBuyCount += rewardCount
}
next.GachaUsers[groupKey] = user
grant.GachaGroupID = purchase.Group.ID
+1 -1
View File
@@ -48,7 +48,7 @@ func TestUseRandomBoxPersistsExactStackAndRewardFromInstalledGameData(t *testing
if root == "" {
t.Skip("set BD2_TEST_GAMEDATA_ROOT for installed GameData integration test")
}
design, err := gamedata.LoadRandomBoxDesign(root, "20260910162539")
design, err := gamedata.LoadRandomBoxDesign(root, "20260923193640")
if err != nil {
t.Fatal(err)
}
+32 -24
View File
@@ -5,7 +5,6 @@ import (
"path/filepath"
"testing"
"bd2server/internal/server/fixture"
"bd2server/internal/server/wire"
)
@@ -25,32 +24,41 @@ func TestStarterCanAnswerWithoutCapture(t *testing.T) {
}
}
func TestStarterMatchesInitialAccountSample(t *testing.T) {
root := filepath.Join("..", "..", "..", "..", "data", "capture", "2.34.13", "20260920-003254")
set, err := fixture.Load(root)
if err != nil {
t.Skipf("optional capture unavailable: %v", err)
func TestStarterProtocolEncodingAndRoundTrip(t *testing.T) {
seed := &Starter{
Version: "2.35.10",
Items: []Item{{InvenIndex: 1, ID: 2, Type: 8, Count: 3}},
Costumes: []Costume{{InvenIndex: 4, ID: 5, UseChar: 6}},
Characters: []Character{{InvenIndex: 6, ID: 7, HP: 8, Level: 1, UseCostume: 4, ConnectPotentialCostume: 5}},
FieldCharControlDeckType: 2,
}
key, err := set.SessionKey()
path := filepath.Join(t.TempDir(), "starter.json")
if err := seed.Write(path); err != nil {
t.Fatal(err)
}
loaded, err := Load(path)
if err != nil {
t.Fatal(err)
}
batch, err := set.BatchResponse(1)
if err != nil {
t.Fatal(err)
}
_, recorded, err := fixture.DecodeBatch(batch, []byte(key))
if err != nil {
t.Fatal(err)
}
seed, err := Load(filepath.Join("..", "..", "..", "seed", "v2_35_10", "starter_player.json"))
if err != nil {
t.Fatal(err)
}
for _, path := range []string{"/ItemInfo", "/CostumeInfo", "/CharInfo"} {
_, proto, ok, err := seed.Handle(path, wire.AppendVarint(nil, 1, 900001))
if err != nil || !ok || !bytes.Equal(proto, recorded[path]) {
t.Errorf("%s parsed player starter differs from source: got=%x want=%x err=%v", path, proto, recorded[path], err)
}
for _, test := range []struct {
path string
code int
proto []byte
}{
{"/ItemInfo", 21, []byte{0x0a, 8, 0x08, 1, 0x10, 2, 0x18, 8, 0x20, 3}},
{"/CostumeInfo", 40, []byte{0x0a, 6, 0x08, 4, 0x10, 5, 0x20, 6}},
{"/CharInfo", 9, []byte{0x0a, 12, 0x08, 6, 0x10, 7, 0x18, 8, 0x20, 1, 0x38, 4, 0x68, 5, 0x10, 2}},
} {
t.Run(test.path, func(t *testing.T) {
code, proto, handled, err := loaded.Handle(test.path, wire.AppendVarint(nil, 1, 900001))
if err != nil || !handled || code != test.code || !bytes.Equal(proto, test.proto) {
t.Fatalf("code=%d proto=%x handled=%v err=%v", code, proto, handled, err)
}
for _, request := range [][]byte{nil, {0x08, 0}, {0x08, 0x80}} {
if _, _, handled, err := loaded.Handle(test.path, request); !handled || err == nil {
t.Fatal("invalid request accepted")
}
}
})
}
}