fix(server): separate pass reward claims and implement cumulative purchase bonuses
This commit is contained in:
@@ -786,6 +786,14 @@ func serve(args []string) (serveErr error) {
|
|||||||
if err := cashService.AttachShopSeed(serverConfig); err != nil {
|
if err := cashService.AttachShopSeed(serverConfig); err != nil {
|
||||||
return fmt.Errorf("attach cash product availability: %w", err)
|
return fmt.Errorf("attach cash product availability: %w", err)
|
||||||
}
|
}
|
||||||
|
cashBonusDesign, err := gamedata.LoadCashBonusCatalog(gameData, *gameDataVersion)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("load cash bonus design: %w", err)
|
||||||
|
}
|
||||||
|
cashBonuses, err := commerce.NewCashBonuses(gameplayStore, cashEconomy, cashService, cashBonusDesign, cashDesign.Packages)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("load cash bonus claims: %w", err)
|
||||||
|
}
|
||||||
cashService.AttachLegacyCounts(gachaService)
|
cashService.AttachLegacyCounts(gachaService)
|
||||||
cashService.AttachDelegate(func(key gamedata.CashProductKey, request []byte) ([]byte, bool, error) {
|
cashService.AttachDelegate(func(key gamedata.CashProductKey, request []byte) ([]byte, bool, error) {
|
||||||
known := key.GroupID == infiniteGacha.ProductGroupID && key.ProductID == infiniteGacha.ProductID && key.SaleGroup == infiniteGacha.SaleGroup
|
known := key.GroupID == infiniteGacha.ProductGroupID && key.ProductID == infiniteGacha.ProductID && key.SaleGroup == infiniteGacha.SaleGroup
|
||||||
@@ -1034,6 +1042,7 @@ func serve(args []string) (serveErr error) {
|
|||||||
eventTasksService.AttachGameplayProvider(worldService.GameplayAchievementProvider(achievementCounterDesign, achievementGrades))
|
eventTasksService.AttachGameplayProvider(worldService.GameplayAchievementProvider(achievementCounterDesign, achievementGrades))
|
||||||
game, err := session.NewServerWithProgress(login, progressState,
|
game, err := session.NewServerWithProgress(login, progressState,
|
||||||
cashService,
|
cashService,
|
||||||
|
cashBonuses,
|
||||||
clearPackages,
|
clearPackages,
|
||||||
commerce.PackInfoHandler{World: worldService, Claims: clearPackages},
|
commerce.PackInfoHandler{World: worldService, Claims: clearPackages},
|
||||||
commerce.AttendanceHandler{Events: eventTasksService, Economy: cashEconomy, LoginPasses: loginPasses, Store: gameplayStore},
|
commerce.AttendanceHandler{Events: eventTasksService, Economy: cashEconomy, LoginPasses: loginPasses, Store: gameplayStore},
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
package commerce
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bd2server/internal/server/gamedata"
|
||||||
|
"bd2server/internal/server/stateio"
|
||||||
|
"bd2server/internal/server/wire"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"sort"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
type bonusGroup struct{ ProductGroup, ContentsGroup uint64 }
|
||||||
|
type bonusPurchases interface {
|
||||||
|
LifetimePurchaseTotal([]gamedata.CashProductKey) (uint64, error)
|
||||||
|
}
|
||||||
|
type bonusState struct {
|
||||||
|
Claims map[string]bool `json:"claims"`
|
||||||
|
Receipts map[string]purchaseReceipt `json:"receipts"`
|
||||||
|
}
|
||||||
|
type CashBonuses struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
store stateio.Store
|
||||||
|
economy Economy
|
||||||
|
counts bonusPurchases
|
||||||
|
groups map[bonusGroup][]gamedata.CashProductKey
|
||||||
|
rewards map[bonusGroup][]gamedata.CashBonusReward
|
||||||
|
ordered []bonusGroup
|
||||||
|
claimIDs map[string]bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func bonusClaimID(group bonusGroup, id uint64) string {
|
||||||
|
return fmt.Sprintf("%d:%d:%d", group.ProductGroup, group.ContentsGroup, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCashBonuses(store stateio.Store, economy Economy, counts bonusPurchases, design *gamedata.CashBonusCatalog, packages []gamedata.CashPackageDesign) (*CashBonuses, error) {
|
||||||
|
if store == nil || economy == nil || counts == nil || design == nil {
|
||||||
|
return nil, fmt.Errorf("commerce: missing cash bonus dependency")
|
||||||
|
}
|
||||||
|
s := &CashBonuses{store: store, economy: economy, counts: counts, groups: map[bonusGroup][]gamedata.CashProductKey{}, rewards: map[bonusGroup][]gamedata.CashBonusReward{}, claimIDs: map[string]bool{}}
|
||||||
|
seen := map[gamedata.CashProductKey]bool{}
|
||||||
|
for _, p := range packages {
|
||||||
|
// EPackages.BonusBundleGroup=8; unrelated packages can reuse a contents ID.
|
||||||
|
if p.PackageType != 8 || len(design.Groups[p.ContentsGroupID]) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
k := gamedata.CashProductKey{GroupID: p.GroupID, ProductID: p.ID, SaleGroup: p.SaleGroup}
|
||||||
|
if seen[k] {
|
||||||
|
return nil, fmt.Errorf("commerce: duplicate cash bonus product %+v", k)
|
||||||
|
}
|
||||||
|
seen[k] = true
|
||||||
|
group := bonusGroup{p.GroupID, p.ContentsGroupID}
|
||||||
|
s.groups[group] = append(s.groups[group], k)
|
||||||
|
s.rewards[group] = design.Groups[p.ContentsGroupID]
|
||||||
|
}
|
||||||
|
for group, rows := range s.rewards {
|
||||||
|
s.ordered = append(s.ordered, group)
|
||||||
|
for _, row := range rows {
|
||||||
|
s.claimIDs[bonusClaimID(group, row.ID)] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(s.ordered, func(i, j int) bool {
|
||||||
|
if s.ordered[i].ProductGroup != s.ordered[j].ProductGroup {
|
||||||
|
return s.ordered[i].ProductGroup < s.ordered[j].ProductGroup
|
||||||
|
}
|
||||||
|
return s.ordered[i].ContentsGroup < s.ordered[j].ContentsGroup
|
||||||
|
})
|
||||||
|
_, err := s.load()
|
||||||
|
return s, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CashBonuses) load() (bonusState, error) {
|
||||||
|
v := bonusState{Claims: map[string]bool{}, Receipts: map[string]purchaseReceipt{}}
|
||||||
|
raw, err := s.store.Load("commerce_cash_bonuses")
|
||||||
|
if err != nil || raw == nil {
|
||||||
|
return v, err
|
||||||
|
}
|
||||||
|
if err = stateio.RequireExactJSONObject(raw, "claims", "receipts"); err != nil {
|
||||||
|
return v, err
|
||||||
|
}
|
||||||
|
if err = json.Unmarshal(raw, &v); err != nil {
|
||||||
|
return v, err
|
||||||
|
}
|
||||||
|
if v.Claims == nil || v.Receipts == nil {
|
||||||
|
return v, fmt.Errorf("commerce: malformed cash bonus state")
|
||||||
|
}
|
||||||
|
for id, claimed := range v.Claims {
|
||||||
|
if !claimed || !s.claimIDs[id] {
|
||||||
|
return v, fmt.Errorf("commerce: invalid cash bonus claim %s", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for id, r := range v.Receipts {
|
||||||
|
digest, err := hex.DecodeString(r.Digest)
|
||||||
|
if id == "" || err != nil || len(digest) != sha256.Size || len(r.Response) == 0 {
|
||||||
|
return v, fmt.Errorf("commerce: invalid cash bonus receipt")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CashBonuses) rewarded(v bonusState, group bonusGroup, field int) []byte {
|
||||||
|
var out []byte
|
||||||
|
for _, row := range s.rewards[group] {
|
||||||
|
if v.Claims[bonusClaimID(group, row.ID)] {
|
||||||
|
out = wire.AppendVarint(out, field, row.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CashBonuses) Handle(path string, request []byte) (int, []byte, bool, error) {
|
||||||
|
return s.HandleSession(path, request, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CashBonuses) HandleSession(path string, request []byte, session string) (int, []byte, bool, error) {
|
||||||
|
code := 588
|
||||||
|
if path == "/CashBonusReward" {
|
||||||
|
code = 589
|
||||||
|
} else if path != "/CashBonusInfo" {
|
||||||
|
return 0, nil, false, nil
|
||||||
|
}
|
||||||
|
seq, present, err := wire.Varint(request, 1)
|
||||||
|
if err != nil || !present || seq == 0 || seq > math.MaxInt32 {
|
||||||
|
return code, nil, true, fmt.Errorf("commerce: invalid cash bonus sequence")
|
||||||
|
}
|
||||||
|
if err = wire.Walk(request, func(wire.Field) error { return nil }); err != nil {
|
||||||
|
return code, nil, true, err
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
v, err := s.load()
|
||||||
|
if err != nil {
|
||||||
|
return code, nil, true, err
|
||||||
|
}
|
||||||
|
if path == "/CashBonusInfo" {
|
||||||
|
var out []byte
|
||||||
|
for _, group := range s.ordered {
|
||||||
|
n, err := s.counts.LifetimePurchaseTotal(s.groups[group])
|
||||||
|
if err != nil {
|
||||||
|
return code, nil, true, err
|
||||||
|
}
|
||||||
|
// Preserve the native empty response for groups with no purchases.
|
||||||
|
if n == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b := wire.AppendVarint(nil, 1, group.ProductGroup)
|
||||||
|
b = wire.AppendVarint(b, 2, group.ContentsGroup)
|
||||||
|
b = wire.AppendVarint(b, 3, n)
|
||||||
|
b = append(b, s.rewarded(v, group, 4)...)
|
||||||
|
out = wire.AppendBytes(out, 1, b)
|
||||||
|
}
|
||||||
|
return code, out, true, nil
|
||||||
|
}
|
||||||
|
if session == "" {
|
||||||
|
return code, nil, true, fmt.Errorf("commerce: cash bonus session unavailable")
|
||||||
|
}
|
||||||
|
identity := fmt.Sprintf("cash-bonus:%x:%d", sha256.Sum256([]byte(session)), seq)
|
||||||
|
digest := fmt.Sprintf("%x", sha256.Sum256(request))
|
||||||
|
if receipt, ok := v.Receipts[identity]; ok {
|
||||||
|
if receipt.Digest != digest {
|
||||||
|
return code, nil, true, fmt.Errorf("commerce: conflicting cash bonus replay")
|
||||||
|
}
|
||||||
|
return code, append([]byte(nil), receipt.Response...), true, nil
|
||||||
|
}
|
||||||
|
groupID, _, err := wire.Varint(request, 2)
|
||||||
|
if err != nil {
|
||||||
|
return code, nil, true, err
|
||||||
|
}
|
||||||
|
contents, _, err := wire.Varint(request, 3)
|
||||||
|
if err != nil {
|
||||||
|
return code, nil, true, err
|
||||||
|
}
|
||||||
|
id, _, err := wire.Varint(request, 4)
|
||||||
|
if err != nil {
|
||||||
|
return code, nil, true, err
|
||||||
|
}
|
||||||
|
group := bonusGroup{groupID, contents}
|
||||||
|
var chosen *gamedata.CashBonusReward
|
||||||
|
for _, row := range s.rewards[group] {
|
||||||
|
if row.ID == id {
|
||||||
|
copy := row
|
||||||
|
chosen = ©
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if chosen == nil {
|
||||||
|
return code, nil, true, fmt.Errorf("commerce: unknown cash bonus group=%d contents=%d bonus=%d", groupID, contents, id)
|
||||||
|
}
|
||||||
|
n, err := s.counts.LifetimePurchaseTotal(s.groups[group])
|
||||||
|
if err != nil {
|
||||||
|
return code, nil, true, err
|
||||||
|
}
|
||||||
|
if n < chosen.RequireCount {
|
||||||
|
return code, nil, true, fmt.Errorf("commerce: cash bonus purchase threshold not reached group=%d contents=%d bonus=%d count=%d require=%d", groupID, contents, id, n, chosen.RequireCount)
|
||||||
|
}
|
||||||
|
claimID := bonusClaimID(group, id)
|
||||||
|
var bundle []byte
|
||||||
|
if !v.Claims[claimID] {
|
||||||
|
bundle, err = s.economy.Apply("cash-bonus-grant:"+claimID, nil, []gamedata.Reward{chosen.Reward})
|
||||||
|
if err != nil {
|
||||||
|
return code, nil, true, err
|
||||||
|
}
|
||||||
|
v.Claims[claimID] = true
|
||||||
|
}
|
||||||
|
response := wire.AppendBytes(nil, 1, bundle)
|
||||||
|
response = append(response, s.rewarded(v, group, 2)...)
|
||||||
|
v.Receipts[identity] = purchaseReceipt{Digest: digest, Response: response}
|
||||||
|
raw, err := json.Marshal(v)
|
||||||
|
if err == nil {
|
||||||
|
err = s.store.Save("commerce_cash_bonuses", raw)
|
||||||
|
}
|
||||||
|
return code, response, true, err
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
package commerce
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bd2server/internal/server/gamedata"
|
||||||
|
"bd2server/internal/server/wire"
|
||||||
|
"bytes"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func bonusRequest(seq, group, contents, bonus uint64) []byte {
|
||||||
|
b := wire.AppendVarint(nil, 1, seq)
|
||||||
|
b = wire.AppendVarint(b, 2, group)
|
||||||
|
b = wire.AppendVarint(b, 3, contents)
|
||||||
|
return wire.AppendVarint(b, 4, bonus)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCashBonusUsesCommittedSeriesPurchasesAndNativeClaimProtocol(t *testing.T) {
|
||||||
|
shop, eco, store := serviceFixture(t, 1)
|
||||||
|
packages := []gamedata.CashPackageDesign{
|
||||||
|
{GroupID: 2, ID: 1, PackageType: 8, ContentsGroupID: 7},
|
||||||
|
{GroupID: 2, ID: 1, SaleGroup: 1, PackageType: 8, ContentsGroupID: 7},
|
||||||
|
// Another package kind can reuse contents=7 but must not count.
|
||||||
|
{GroupID: 1, ID: 1, PackageType: 3, ContentsGroupID: 7},
|
||||||
|
}
|
||||||
|
design := &gamedata.CashBonusCatalog{Groups: map[uint64][]gamedata.CashBonusReward{
|
||||||
|
7: {{ID: 1, RequireCount: 2, Reward: gamedata.Reward{Type: 8, ID: 1000, Count: 2}}, {ID: 2, RequireCount: 3, Reward: gamedata.Reward{Type: 8, ID: 1000, Count: 15}}},
|
||||||
|
}}
|
||||||
|
s, err := NewCashBonuses(store, eco, shop, design, packages)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
infoRequest := wire.AppendVarint(nil, 1, 1)
|
||||||
|
if code, info, handled, err := s.Handle("/CashBonusInfo", infoRequest); err != nil || code != 588 || !handled || len(info) != 0 {
|
||||||
|
t.Fatal("new account bonus info must be empty", code, info, err)
|
||||||
|
}
|
||||||
|
claim := bonusRequest(1, 2, 7, 1)
|
||||||
|
if _, _, _, err := s.HandleSession("/CashBonusReward", claim, "s"); err == nil || eco.calls != 0 {
|
||||||
|
t.Fatal("unearned bonus granted")
|
||||||
|
}
|
||||||
|
for seq, sale := range []uint64{0, 1} {
|
||||||
|
if _, _, _, err := shop.HandleSession("/CashShopBuy", buyRequest(uint64(seq+1), 2, 1, sale, ""), "s"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Normal recharge is not a member of the bonus series.
|
||||||
|
if _, _, _, err := shop.HandleSession("/CashShopBuy", buyRequest(3, 1, 1, 0, ""), "s"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, info, _, err := s.Handle("/CashBonusInfo", infoRequest)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
row, present, _ := wire.Bytes(info, 1)
|
||||||
|
count, _, _ := wire.Varint(row, 3)
|
||||||
|
if !present || count != 2 {
|
||||||
|
t.Fatal("incorrect committed series purchase count", count)
|
||||||
|
}
|
||||||
|
calls := eco.calls
|
||||||
|
code, reply, handled, err := s.HandleSession("/CashBonusReward", claim, "s")
|
||||||
|
if err != nil || code != 589 || !handled || eco.calls != calls+1 || len(eco.rewards) != 1 || eco.rewards[0].Count != 2 || len(eco.costs) != 0 {
|
||||||
|
t.Fatal("earned bonus not granted through native protocol", code, err, eco.rewards)
|
||||||
|
}
|
||||||
|
if id, _, _ := wire.Varint(reply, 2); id != 1 {
|
||||||
|
t.Fatal("native rewarded ID list missing")
|
||||||
|
}
|
||||||
|
reopened, err := NewCashBonuses(store, eco, shop, design, packages)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, replay, _, err := reopened.HandleSession("/CashBonusReward", claim, "s")
|
||||||
|
if err != nil || !bytes.Equal(reply, replay) || eco.calls != calls+1 {
|
||||||
|
t.Fatal("restart replay duplicated bonus", err)
|
||||||
|
}
|
||||||
|
_, noOp, _, err := reopened.HandleSession("/CashBonusReward", bonusRequest(2, 2, 7, 1), "s")
|
||||||
|
bundle, present, _ := wire.Bytes(noOp, 1)
|
||||||
|
if err != nil || !present || len(bundle) != 0 || eco.calls != calls+1 {
|
||||||
|
t.Fatal("fresh duplicate must return empty grant with claimed IDs", err)
|
||||||
|
}
|
||||||
|
for _, request := range [][]byte{bonusRequest(1, 2, 7, 2), bonusRequest(4, 1, 7, 1), bonusRequest(5, 2, 7, 99), bonusRequest(6, 2, 7, 2)} {
|
||||||
|
if _, _, _, err := reopened.HandleSession("/CashBonusReward", request, "s"); err == nil || eco.calls != calls+1 {
|
||||||
|
t.Fatal("conflicting replay, wrong series or unmet bonus accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, info, _, err = reopened.Handle("/CashBonusInfo", infoRequest)
|
||||||
|
row, _, _ = wire.Bytes(info, 1)
|
||||||
|
if id, _, _ := wire.Varint(row, 4); err != nil || id != 1 {
|
||||||
|
t.Fatal("claimed IDs missing after restart", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCashBonusFailedGrantDoesNotMarkClaimed(t *testing.T) {
|
||||||
|
shop, eco, store := serviceFixture(t, 0)
|
||||||
|
if _, _, _, err := shop.HandleSession("/CashShopBuy", buyRequest(1, 2, 1, 0, ""), "s"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
design := &gamedata.CashBonusCatalog{Groups: map[uint64][]gamedata.CashBonusReward{7: {{ID: 1, RequireCount: 1, Reward: gamedata.Reward{Type: 8, ID: 1000, Count: 2}}}}}
|
||||||
|
s, err := NewCashBonuses(store, eco, shop, design, []gamedata.CashPackageDesign{{GroupID: 2, ID: 1, PackageType: 8, ContentsGroupID: 7}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
eco.fail = true
|
||||||
|
request := bonusRequest(1, 2, 7, 1)
|
||||||
|
if _, _, _, err := s.HandleSession("/CashBonusReward", request, "s"); err == nil {
|
||||||
|
t.Fatal("failed grant accepted")
|
||||||
|
}
|
||||||
|
v, err := s.load()
|
||||||
|
if err != nil || len(v.Claims) != 0 || len(v.Receipts) != 0 {
|
||||||
|
t.Fatal("failed grant marked bonus claimed", err)
|
||||||
|
}
|
||||||
|
eco.fail = false
|
||||||
|
if _, _, _, err := s.HandleSession("/CashBonusReward", request, "s"); err != nil {
|
||||||
|
t.Fatal("failed grant could not be retried", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
package commerce
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bd2server/internal/server/calendar"
|
||||||
|
"bd2server/internal/server/events"
|
||||||
|
"bd2server/internal/server/gameconfig"
|
||||||
|
"bd2server/internal/server/gamedata"
|
||||||
|
"bd2server/internal/server/hunting"
|
||||||
|
"bd2server/internal/server/player"
|
||||||
|
"bd2server/internal/server/readonly"
|
||||||
|
"bd2server/internal/server/stateio"
|
||||||
|
"bd2server/internal/server/wire"
|
||||||
|
"bytes"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestInstalledCashProductRewardCoverage23510(t *testing.T) {
|
||||||
|
root := os.Getenv("BD2_REAL_GAMEDATA")
|
||||||
|
if root == "" {
|
||||||
|
t.Skip("BD2_REAL_GAMEDATA not configured")
|
||||||
|
}
|
||||||
|
catalog, err := gamedata.LoadCashCatalog(root, "20260923193640")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
resolver, err := gamedata.LoadCashRewardResolver(root, "20260923193640")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
entitlements, err := gamedata.LoadCashEntitlementDesign(root, "20260923193640")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Infinite draw products grant the user's confirmed preview, rather than
|
||||||
|
// the placeholder cash box. Validate their dedicated design and route.
|
||||||
|
db, release, err := gamedata.OpenDatabase(root, "20260923193640", "common")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rowsDB, err := db.Query("SELECT id,ProtoBuf FROM GachaGroupTable")
|
||||||
|
if err != nil {
|
||||||
|
release()
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
type drawGroup struct{ id, gacha uint64 }
|
||||||
|
var groups []drawGroup
|
||||||
|
for rowsDB.Next() {
|
||||||
|
var id uint64
|
||||||
|
var raw []byte
|
||||||
|
if err := rowsDB.Scan(&id, &raw); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
typ, _, _ := wire.Varint(raw, 17)
|
||||||
|
sub, _, _ := wire.Varint(raw, 16)
|
||||||
|
if typ == 1 && sub == 5 {
|
||||||
|
gacha, _, _ := wire.Varint(raw, 33)
|
||||||
|
groups = append(groups, drawGroup{id, gacha})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := rowsDB.Err(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rowsDB.Close()
|
||||||
|
release()
|
||||||
|
policy, err := NewCatalog("2.35.10", catalog, gameconfig.Default().Purchases)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
shop, err := NewService(policy, stateio.NewMemory(), &purchaseEconomy{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
seed, err := readonly.Load(filepath.Join("..", "..", "..", "seed", "v2_35_10", "readonly.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
calendars, err := calendar.LoadDirectory(filepath.Join("..", "..", "..", "..", "schedules"), "2.35.10", "20260923193640")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
seed, err = calendars.ApplyReadonly(seed)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
shop.SetClock(func() time.Time { return time.Date(2026, 10, 5, 5, 0, 0, 0, time.UTC) }, 0)
|
||||||
|
if err := shop.AttachShopSeed(seed); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
graph, err := gamedata.LoadRewardGraph(root, "20260923193640")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
costumes, err := gamedata.LoadRewardCostumeCatalog(root, "20260923193640")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
equipment, err := gamedata.LoadRewardEquipmentCatalog(root, "20260923193640")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
skins, err := gamedata.LoadPrestigeSkins(root, "20260923193640")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ownedDesign, err := gamedata.LoadOwnedEventItemDesign(root, "20260923193640")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
newEconomy := func() (*EntitlementEconomy, *player.Wallet) {
|
||||||
|
store := stateio.NewMemory()
|
||||||
|
items, err := player.OpenInventory(store, &player.Starter{Version: "2.35.10"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
wallet, err := player.OpenWallet(store, player.Currency{Jewelry: 100000000, FreeJewelry: 100000000, Gold: 100000000})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
collection, err := player.OpenCollectionStore(store, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ownedEquipment, err := player.OpenEquipmentInventory(store)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, persist := range []func() error{items.EnsurePersisted, wallet.EnsurePersisted, collection.EnsurePersisted, ownedEquipment.EnsurePersisted} {
|
||||||
|
if err := persist(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
base, err := events.NewEconomy(store, items, wallet, collection, ownedEquipment, costumes, equipment, graph, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
base.AttachPrestigeSkins(skins)
|
||||||
|
base.AttachOwnedItemDesign(ownedDesign)
|
||||||
|
ap, err := hunting.Open(store, root, "20260923193640", items, wallet, func() (int, error) { return 21, nil }, 0, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
base.AttachHuntingAP(ap)
|
||||||
|
e, err := NewEntitlementEconomy(store, base, resolver, items, entitlements)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return e, wallet
|
||||||
|
}
|
||||||
|
delegated := map[gamedata.CashProductKey]bool{}
|
||||||
|
retired := map[gamedata.CashProductKey]bool{}
|
||||||
|
for _, group := range groups {
|
||||||
|
d, err := gamedata.LoadInfiniteGachaForSchedules(root, "20260923193640", []uint64{group.id})
|
||||||
|
if err != nil && strings.Contains(err.Error(), "infinite cash product missing") {
|
||||||
|
// Early infinite draws retain placeholder gold/count=0 boxes in this
|
||||||
|
// version. They are historical timed products, not grantable purchases.
|
||||||
|
found := false
|
||||||
|
for _, p := range catalog.Products {
|
||||||
|
if p.Key.ProductID != group.gacha {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
found = true
|
||||||
|
if p.TimeLimitType == 0 || shop.IsAvailable(p.Key) {
|
||||||
|
t.Fatalf("retired infinite product %+v is purchasable without a valid preview design", p.Key)
|
||||||
|
}
|
||||||
|
retired[p.Key] = true
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("infinite group=%d has no matching retired product", group.id)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err != nil || d == nil || d.Count == 0 {
|
||||||
|
t.Fatalf("infinite draw group=%d cannot grant confirmed preview: %v", group.id, err)
|
||||||
|
}
|
||||||
|
delegated[gamedata.CashProductKey{GroupID: d.ProductGroupID, ProductID: d.ProductID, SaleGroup: d.SaleGroup}] = true
|
||||||
|
}
|
||||||
|
cash, recharge, delegateCount, retiredCount := 0, 0, 0, 0
|
||||||
|
for _, p := range catalog.Products {
|
||||||
|
if p.PriceType != 1 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cash++
|
||||||
|
if p.Recharge {
|
||||||
|
recharge++
|
||||||
|
}
|
||||||
|
if delegated[p.Key] {
|
||||||
|
delegateCount++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if retired[p.Key] {
|
||||||
|
retiredCount++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if p.RandomBoxID == 0 {
|
||||||
|
t.Errorf("cash product %+v has no reward box", p.Key)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, box := range []uint64{p.RandomBoxID, p.BonusRandomBoxID} {
|
||||||
|
if box == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
leaves, err := resolver.ResolveGranted([]gamedata.BattleReward{{Type: 9, ID: box, Count: 1}})
|
||||||
|
if err != nil || len(leaves) == 0 {
|
||||||
|
t.Errorf("cash product %+v box=%d cannot grant rewards: %v", p.Key, box, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, r := range leaves {
|
||||||
|
if r.Type == 19 && entitlements.TicketTypes[r.ID] == 0 || r.Type == 62 && !entitlements.AvatarSets[r.ID] {
|
||||||
|
t.Errorf("cash product %+v has unknown entitlement %+v", p.Key, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Exercise the same grant and debit modules used by CashShopBuy against
|
||||||
|
// isolated in-memory accounts; no real save or authenticated API is touched.
|
||||||
|
q, err := policy.Quote(p.Key, 1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var costs []gamedata.Reward
|
||||||
|
if q.Cost > 0 {
|
||||||
|
costs = []gamedata.Reward{{Type: q.ItemType, Count: q.Cost}}
|
||||||
|
}
|
||||||
|
rewards := []gamedata.Reward{{Type: 9, ID: p.RandomBoxID, Count: 1}}
|
||||||
|
if p.BonusRandomBoxID != 0 {
|
||||||
|
rewards = append(rewards, gamedata.Reward{Type: 9, ID: p.BonusRandomBoxID, Count: 1})
|
||||||
|
}
|
||||||
|
e, wallet := newEconomy()
|
||||||
|
bundle, err := e.Apply("installed-audit", costs, rewards)
|
||||||
|
if err != nil || len(bundle) == 0 {
|
||||||
|
t.Errorf("cash product %+v failed real debit/grant: %v", p.Key, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
balance := wallet.Snapshot()
|
||||||
|
replay, err := e.Apply("installed-audit", costs, rewards)
|
||||||
|
if err != nil || !bytes.Equal(replay, bundle) || wallet.Snapshot() != balance {
|
||||||
|
t.Errorf("cash product %+v replay repeated debit/grant: %v", p.Key, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cash != 614 || recharge != 14 || delegateCount != 3 || retiredCount != 5 {
|
||||||
|
t.Fatalf("cash coverage changed: cash=%d recharge=%d delegated=%d retired=%d", cash, recharge, delegateCount, retiredCount)
|
||||||
|
}
|
||||||
|
bonus, err := gamedata.LoadCashBonusCatalog(root, "20260923193640")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rows := bonus.Groups[1]
|
||||||
|
if len(rows) != 3 || rows[0].RequireCount != 2 || rows[0].Reward.Count != 2 || rows[1].RequireCount != 6 || rows[1].Reward.Count != 15 || rows[2].RequireCount != 8 || rows[2].Reward.Count != 30 {
|
||||||
|
t.Fatalf("cash bonus thresholds or rewards changed: %+v", rows)
|
||||||
|
}
|
||||||
|
t.Logf("validated %d cash rows: %d recharge variants, %d preview designs, %d unavailable historical draws; checked other reward boxes and typed ticket/skin entitlements", cash, recharge, delegateCount, retiredCount)
|
||||||
|
}
|
||||||
@@ -651,6 +651,26 @@ func (s *Service) purchaseCountDBInfos() ([][]byte, error) {
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
func (s *Service) PurchaseCountDBInfos() [][]byte { v, _ := s.purchaseCountDBInfos(); return v }
|
func (s *Service) PurchaseCountDBInfos() [][]byte { v, _ := s.purchaseCountDBInfos(); return v }
|
||||||
|
|
||||||
|
// LifetimePurchaseTotal derives cumulative bonuses from committed purchases,
|
||||||
|
// including previous reset periods, without keeping a second purchase counter.
|
||||||
|
func (s *Service) LifetimePurchaseTotal(keys []gamedata.CashProductKey) (uint64, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
v, err := s.load()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
var total uint64
|
||||||
|
for _, k := range keys {
|
||||||
|
n := v.Counts[skuKey(k)].Lifetime
|
||||||
|
if n > math.MaxInt32-total {
|
||||||
|
return 0, fmt.Errorf("commerce: bonus purchase count overflow")
|
||||||
|
}
|
||||||
|
total += n
|
||||||
|
}
|
||||||
|
return total, nil
|
||||||
|
}
|
||||||
func (s *Service) HasPurchased(k gamedata.CashProductKey) bool {
|
func (s *Service) HasPurchased(k gamedata.CashProductKey) bool {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
package eventtasks
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bd2server/internal/server/gamedata"
|
||||||
|
"bd2server/internal/server/wire"
|
||||||
|
"bytes"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func passClaimRequest(seq, rewardType uint64, all bool, level uint64) []byte {
|
||||||
|
b := req(seq)
|
||||||
|
if all {
|
||||||
|
b = wire.AppendVarint(b, 2, 1)
|
||||||
|
}
|
||||||
|
b = wire.AppendVarint(b, 3, 8)
|
||||||
|
if level != 0 {
|
||||||
|
b = wire.AppendVarint(b, 4, level)
|
||||||
|
}
|
||||||
|
if rewardType != 0 {
|
||||||
|
b = wire.AppendVarint(b, 5, rewardType)
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func claimPass(t *testing.T, s *Service, request []byte) []byte {
|
||||||
|
t.Helper()
|
||||||
|
code, response, handled, err := s.Handle("/PassReward", request)
|
||||||
|
if err != nil || code != 126 || !handled {
|
||||||
|
t.Fatalf("pass claim failed: code=%d handled=%t err=%v", code, handled, err)
|
||||||
|
}
|
||||||
|
if _, present, err := wire.Bytes(response, 1); err != nil || !present {
|
||||||
|
t.Fatalf("native receiver requires a non-null reward bundle: %x, %v", response, err)
|
||||||
|
}
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPassRewardNativeOneClickAfterPurchaseAndRestart(t *testing.T) {
|
||||||
|
s, e, store := setup(t)
|
||||||
|
s.design.PassLevels[8] = []gamedata.EventPassLevel{
|
||||||
|
{ID: 1, NeedExp: 20, Basic: gamedata.Reward{Type: 3, Count: 1}, Premium: gamedata.Reward{Type: 4, Count: 10}},
|
||||||
|
{ID: 2, NeedExp: 30, Basic: gamedata.Reward{Type: 3, Count: 2}, Premium: gamedata.Reward{Type: 4, Count: 20}},
|
||||||
|
{ID: 3, NeedExp: 40, Basic: gamedata.Reward{Type: 3, Count: 3}, Premium: gamedata.Reward{Type: 4, Count: 30}},
|
||||||
|
{ID: 4, Basic: gamedata.Reward{Type: 3, Count: 4}, Premium: gamedata.Reward{Type: 4, Count: 40}},
|
||||||
|
}
|
||||||
|
s.design.PassBuys = map[uint64][]gamedata.EventPassBuy{8: {{ID: 1, Type: 1, LevelsGranted: 3, Cost: gamedata.Reward{Type: 3, Count: 1000}}}}
|
||||||
|
buy := wire.AppendVarint(req(1), 2, 8)
|
||||||
|
buy = wire.AppendVarint(buy, 3, 1)
|
||||||
|
if _, _, _, err := s.Handle("/PassBuy", buy); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PassRootUI.ReceiveAllReward sends BASIC first, then PREMIUM_1 in its callback.
|
||||||
|
basic := passClaimRequest(2, 0, true, 0)
|
||||||
|
basicReply := claimPass(t, s, basic)
|
||||||
|
for _, lv := range s.design.PassLevels[8] {
|
||||||
|
p := s.pass(s.registry.List()[2])
|
||||||
|
if !p.Claimed[key(lv.ID, 0)] || p.Claimed[key(lv.ID, 1)] {
|
||||||
|
t.Fatalf("basic phase incorrectly claimed premium at level %d", lv.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(e.rewards) != 4 || e.rewards[0].Type != 3 || e.rewards[3].Count != 4 {
|
||||||
|
t.Fatalf("basic phase granted wrong rewards: %+v", e.rewards)
|
||||||
|
}
|
||||||
|
reopened, err := Open(store, s.design, s.registry, e)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
reopened.now = s.now
|
||||||
|
reopened.SetSession("test")
|
||||||
|
if replay := claimPass(t, reopened, basic); !bytes.Equal(replay, basicReply) || e.calls != 2 {
|
||||||
|
t.Fatal("restart replay repeated basic grants")
|
||||||
|
}
|
||||||
|
premium := passClaimRequest(3, 1, true, 0)
|
||||||
|
premiumReply := claimPass(t, reopened, premium)
|
||||||
|
if len(e.rewards) != 8 || e.rewards[4].Type != 4 || e.rewards[7].Count != 40 || e.calls != 3 {
|
||||||
|
t.Fatalf("premium phase granted wrong rewards: %+v", e.rewards)
|
||||||
|
}
|
||||||
|
for _, lv := range reopened.design.PassLevels[8] {
|
||||||
|
p := reopened.pass(reopened.registry.List()[2])
|
||||||
|
if !p.Claimed[key(lv.ID, 0)] || !p.Claimed[key(lv.ID, 1)] {
|
||||||
|
t.Fatalf("claim state incomplete at level %d", lv.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if replay := claimPass(t, reopened, premium); !bytes.Equal(replay, premiumReply) || e.calls != 3 {
|
||||||
|
t.Fatal("premium replay repeated grants")
|
||||||
|
}
|
||||||
|
// A new one-click request after claiming must complete without granting again.
|
||||||
|
for typ := uint64(0); typ <= 1; typ++ {
|
||||||
|
response := claimPass(t, reopened, passClaimRequest(4+typ, typ, true, 0))
|
||||||
|
bundle, _, _ := wire.Bytes(response, 1)
|
||||||
|
if len(bundle) != 0 || e.calls != 3 || len(e.rewards) != 8 {
|
||||||
|
t.Fatal("already-claimed all request repeated rewards")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPassRewardBasicAlreadyClaimedBeforePremiumPurchase(t *testing.T) {
|
||||||
|
s, e, _ := setup(t)
|
||||||
|
s.design.PassLevels[8] = []gamedata.EventPassLevel{{ID: 1, Basic: gamedata.Reward{Type: 3, Count: 5}, Premium: gamedata.Reward{Type: 4, Count: 50}}}
|
||||||
|
claimPass(t, s, passClaimRequest(1, 0, true, 0))
|
||||||
|
s.design.PassBuys = map[uint64][]gamedata.EventPassBuy{8: {{ID: 1, Type: 1, Cost: gamedata.Reward{Type: 3, Count: 1000}}}}
|
||||||
|
buy := wire.AppendVarint(req(2), 2, 8)
|
||||||
|
buy = wire.AppendVarint(buy, 3, 1)
|
||||||
|
if _, _, _, err := s.Handle("/PassBuy", buy); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
claimPass(t, s, passClaimRequest(3, 0, true, 0))
|
||||||
|
if len(e.rewards) != 1 || e.calls != 2 {
|
||||||
|
t.Fatal("empty basic phase granted again")
|
||||||
|
}
|
||||||
|
claimPass(t, s, passClaimRequest(4, 1, true, 0))
|
||||||
|
if len(e.rewards) != 2 || e.rewards[1].Type != 4 || e.rewards[1].Count != 50 || e.calls != 3 {
|
||||||
|
t.Fatalf("empty basic phase prevented premium claim: %+v", e.rewards)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPassRewardSingleClaimsAndLockedLevels(t *testing.T) {
|
||||||
|
s, e, _ := setup(t)
|
||||||
|
s.design.PassLevels[8] = []gamedata.EventPassLevel{
|
||||||
|
{ID: 1, NeedExp: 20, Basic: gamedata.Reward{Type: 3, Count: 5}, Premium: gamedata.Reward{Type: 4, Count: 50}},
|
||||||
|
{ID: 2, Basic: gamedata.Reward{Type: 3, Count: 6}, Premium: gamedata.Reward{Type: 4, Count: 60}},
|
||||||
|
}
|
||||||
|
if _, _, _, err := s.Handle("/PassReward", passClaimRequest(1, 1, true, 0)); err == nil || e.calls != 0 {
|
||||||
|
t.Fatal("premium reward granted without purchase")
|
||||||
|
}
|
||||||
|
claimPass(t, s, passClaimRequest(2, 0, false, 1))
|
||||||
|
if len(e.rewards) != 1 || e.rewards[0].Count != 5 {
|
||||||
|
t.Fatal("single basic reward incorrect")
|
||||||
|
}
|
||||||
|
for _, request := range [][]byte{passClaimRequest(3, 0, false, 1), passClaimRequest(4, 0, false, 2), passClaimRequest(5, 0, false, 99), passClaimRequest(6, 2, true, 0)} {
|
||||||
|
if _, _, _, err := s.Handle("/PassReward", request); err == nil || e.calls != 1 {
|
||||||
|
t.Fatal("duplicate, locked or fabricated reward accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.pass(s.registry.List()[2]).Premium = true
|
||||||
|
claimPass(t, s, passClaimRequest(7, 1, false, 1))
|
||||||
|
if len(e.rewards) != 2 || e.rewards[1].Count != 50 || s.pass(s.registry.List()[2]).Claimed[key(2, 1)] {
|
||||||
|
t.Fatal("single premium claim changed another level")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPassRewardKeepsCurrentNewbieStepInEveryResponse(t *testing.T) {
|
||||||
|
s, _, _ := setup(t)
|
||||||
|
d := s.design.Passes[8]
|
||||||
|
d.NewbieStep = 1
|
||||||
|
s.design.Passes[8] = d
|
||||||
|
s.state.NewbieStep = 1
|
||||||
|
s.design.PassLevels[8] = []gamedata.EventPassLevel{{ID: 1, NeedExp: 20, Basic: gamedata.Reward{Type: 3, Count: 5}}, {ID: 2, Basic: gamedata.Reward{Type: 3, Count: 6}}}
|
||||||
|
response := claimPass(t, s, passClaimRequest(1, 0, true, 0))
|
||||||
|
if scalar(response, 2) != 1 || s.state.NewbieStep != 1 {
|
||||||
|
t.Fatal("partial claim resets native guide step")
|
||||||
|
}
|
||||||
|
response = claimPass(t, s, passClaimRequest(2, 0, true, 0))
|
||||||
|
if scalar(response, 2) != 1 {
|
||||||
|
t.Fatal("empty phase resets native guide step")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -687,7 +687,10 @@ func (s *Service) handle(path string, b []byte, identity string) ([]byte, error)
|
|||||||
}
|
}
|
||||||
all, id, rt := scalar(b, 2) != 0, scalar(b, 4), scalar(b, 5)
|
all, id, rt := scalar(b, 2) != 0, scalar(b, 4), scalar(b, 5)
|
||||||
if rt > 1 {
|
if rt > 1 {
|
||||||
return nil, errors.New("eventtasks: invalid pass reward kind")
|
return nil, fmt.Errorf("eventtasks: invalid pass reward kind pass=%d level=%d reward_type=%d all=%t", v.ID, id, rt, all)
|
||||||
|
}
|
||||||
|
if rt == 1 && !p.Premium {
|
||||||
|
return nil, fmt.Errorf("eventtasks: premium pass required pass=%d level=%d reward_type=%d all=%t", v.ID, id, rt, all)
|
||||||
}
|
}
|
||||||
var rewards []gamedata.Reward
|
var rewards []gamedata.Reward
|
||||||
var claims []string
|
var claims []string
|
||||||
@@ -699,34 +702,36 @@ func (s *Service) handle(path string, b []byte, identity string) ([]byte, error)
|
|||||||
if !eligible || !all && lv.ID != id {
|
if !eligible || !all && lv.ID != id {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for typ := uint64(0); typ <= 1; typ++ {
|
// IsAll selects levels within RewardType. The native one-click flow
|
||||||
if !all && typ != rt || typ == 1 && !p.Premium {
|
// sends an all-basic request followed by a separate all-premium one.
|
||||||
continue
|
ck := key(lv.ID, rt)
|
||||||
}
|
if p.Claimed[ck] {
|
||||||
ck := key(lv.ID, typ)
|
continue
|
||||||
if p.Claimed[ck] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
r := lv.Basic
|
|
||||||
if typ == 1 {
|
|
||||||
r = lv.Premium
|
|
||||||
}
|
|
||||||
if r.Type > 0 && r.Count > 0 {
|
|
||||||
rewards = append(rewards, r)
|
|
||||||
}
|
|
||||||
claims = append(claims, ck)
|
|
||||||
}
|
}
|
||||||
|
r := lv.Basic
|
||||||
|
if rt == 1 {
|
||||||
|
r = lv.Premium
|
||||||
|
}
|
||||||
|
if r.Type > 0 && r.Count > 0 {
|
||||||
|
rewards = append(rewards, r)
|
||||||
|
}
|
||||||
|
claims = append(claims, ck)
|
||||||
}
|
}
|
||||||
if len(claims) == 0 {
|
if len(claims) == 0 && !all {
|
||||||
return nil, errors.New("eventtasks: pass reward not eligible")
|
return nil, fmt.Errorf("eventtasks: pass reward not eligible pass=%d level=%d reward_type=%d all=%t exp=%d premium=%t", v.ID, id, rt, all, p.Exp, p.Premium)
|
||||||
}
|
}
|
||||||
bundle, e := s.economy.Apply(identity, nil, rewards)
|
var bundle []byte
|
||||||
if e != nil {
|
if len(claims) > 0 {
|
||||||
return nil, e
|
bundle, e = s.economy.Apply(identity, nil, rewards)
|
||||||
|
if e != nil {
|
||||||
|
return nil, fmt.Errorf("eventtasks: pass reward grant failed pass=%d level=%d reward_type=%d all=%t: %w", v.ID, id, rt, all, e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for _, k := range claims {
|
for _, k := range claims {
|
||||||
p.Claimed[k] = true
|
p.Claimed[k] = true
|
||||||
}
|
}
|
||||||
|
// An already-claimed all-basic phase must still succeed so the client
|
||||||
|
// can proceed to all-premium. Include a non-null empty reward bundle.
|
||||||
out := wire.AppendBytes(nil, 1, bundle)
|
out := wire.AppendBytes(nil, 1, bundle)
|
||||||
if d.NewbieStep > 0 {
|
if d.NewbieStep > 0 {
|
||||||
complete := true
|
complete := true
|
||||||
@@ -747,8 +752,9 @@ func (s *Service) handle(path string, b []byte, identity string) ([]byte, error)
|
|||||||
}
|
}
|
||||||
if complete && s.state.NewbieStep == d.NewbieStep {
|
if complete && s.state.NewbieStep == d.NewbieStep {
|
||||||
s.state.NewbieStep++
|
s.state.NewbieStep++
|
||||||
out = wire.AppendVarint(out, 2, s.state.NewbieStep)
|
|
||||||
}
|
}
|
||||||
|
// The native receiver assigns this field even on an incomplete step.
|
||||||
|
out = wire.AppendVarint(out, 2, s.state.NewbieStep)
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
case "/PassBuy":
|
case "/PassBuy":
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ var ErrInvalidRequest = errors.New("feature: invalid protobuf request")
|
|||||||
var emptyResponses = map[string]int{
|
var emptyResponses = map[string]int{
|
||||||
"/AvatarShopWishListInfo": 470,
|
"/AvatarShopWishListInfo": 470,
|
||||||
"/CafeteriaInfo": 351,
|
"/CafeteriaInfo": 351,
|
||||||
"/CashBonusInfo": 588,
|
|
||||||
"/CharPartnerInfo": 65,
|
"/CharPartnerInfo": 65,
|
||||||
"/CommunityRewardInfo": 289,
|
"/CommunityRewardInfo": 289,
|
||||||
"/DailyStoryInfo": 538,
|
"/DailyStoryInfo": 538,
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package gamedata
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CashBonusReward struct {
|
||||||
|
ID, RequireCount uint64
|
||||||
|
Reward Reward
|
||||||
|
}
|
||||||
|
|
||||||
|
type CashBonusCatalog struct{ Groups map[uint64][]CashBonusReward }
|
||||||
|
|
||||||
|
func LoadCashBonusCatalog(root, version string) (*CashBonusCatalog, error) {
|
||||||
|
db, done, err := openStatDatabase(root, version)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer done()
|
||||||
|
d := &CashBonusCatalog{Groups: map[uint64][]CashBonusReward{}}
|
||||||
|
err = readCashMetadata(db, "CashBonusTable", func(raw []byte) error {
|
||||||
|
v, err := cashScalars(raw, 1, 2, 3, 6, 5, 4)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if v[0] == 0 || v[1] == 0 || v[2] == 0 || v[3] == 0 || v[5] == 0 {
|
||||||
|
return fmt.Errorf("gamedata: invalid cash bonus")
|
||||||
|
}
|
||||||
|
d.Groups[v[0]] = append(d.Groups[v[0]], CashBonusReward{v[1], v[2], Reward{Type: v[3], ID: v[4], Count: v[5]}})
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for group, rows := range d.Groups {
|
||||||
|
sort.Slice(rows, func(i, j int) bool { return rows[i].ID < rows[j].ID })
|
||||||
|
for i, row := range rows {
|
||||||
|
if i > 0 && row.ID == rows[i-1].ID {
|
||||||
|
return nil, fmt.Errorf("gamedata: duplicate cash bonus group=%d id=%d", group, row.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
d.Groups[group] = rows
|
||||||
|
}
|
||||||
|
return d, nil
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user