fix(server): correct pass purchase level bonuses, costs and reward thresholds
This commit is contained in:
@@ -692,8 +692,10 @@ func (s *Service) handle(path string, b []byte, identity string) ([]byte, error)
|
||||
var claims []string
|
||||
var threshold uint64
|
||||
for _, lv := range s.design.PassLevels[d.LevelGroup] {
|
||||
eligible := p.Exp >= threshold
|
||||
// NextNeedExp advances from this level to the next; level 1 starts at 0.
|
||||
threshold += lv.NeedExp
|
||||
if p.Exp < threshold || !all && lv.ID != id {
|
||||
if !eligible || !all && lv.ID != id {
|
||||
continue
|
||||
}
|
||||
for typ := uint64(0); typ <= 1; typ++ {
|
||||
@@ -766,44 +768,51 @@ func (s *Service) handle(path string, b []byte, identity string) ([]byte, error)
|
||||
if chosen == nil {
|
||||
return nil, errors.New("eventtasks: unknown pass purchase")
|
||||
}
|
||||
var level, threshold uint64
|
||||
for _, lv := range s.design.PassLevels[s.design.Passes[v.ID].LevelGroup] {
|
||||
threshold += lv.NeedExp
|
||||
if p.Exp >= threshold {
|
||||
level = lv.ID
|
||||
d := s.design.Passes[v.ID]
|
||||
if d.NewbieStep > s.state.NewbieStep {
|
||||
return nil, fmt.Errorf("eventtasks: guide pass step locked pass=%d type=%d", v.ID, typ)
|
||||
}
|
||||
levels := s.design.PassLevels[d.LevelGroup]
|
||||
if len(levels) == 0 {
|
||||
return nil, fmt.Errorf("eventtasks: missing pass levels pass=%d type=%d", v.ID, typ)
|
||||
}
|
||||
starts := make([]uint64, len(levels))
|
||||
levelIndex := 0
|
||||
for i := 1; i < len(levels); i++ {
|
||||
starts[i] = starts[i-1] + levels[i-1].NeedExp
|
||||
if p.Exp >= starts[i] {
|
||||
levelIndex = i
|
||||
}
|
||||
}
|
||||
if level < chosen.UnlockLevel {
|
||||
return nil, errors.New("eventtasks: pass purchase level locked")
|
||||
}
|
||||
// Private-server cash products follow the existing CashShopBuy policy:
|
||||
// no external payment is charged. Premium activation is limited once per
|
||||
// scheduled pass by durable Premium state. A supplied entitlement checker
|
||||
// can restrict this policy for installations that integrate payments.
|
||||
if chosen.CashID != 0 && s.authorizeCash != nil && !s.authorizeCash(v.ID, typ) {
|
||||
return nil, errors.New("eventtasks: cash entitlement required")
|
||||
if typ == 0 && levelIndex == len(levels)-1 {
|
||||
return nil, fmt.Errorf("eventtasks: pass already at max level pass=%d type=%d", v.ID, typ)
|
||||
}
|
||||
if p.Premium && (typ == 1 || typ == 3) {
|
||||
return nil, errors.New("eventtasks: premium already active")
|
||||
return nil, fmt.Errorf("eventtasks: premium already active pass=%d type=%d", v.ID, typ)
|
||||
}
|
||||
// CashShopBuy charges paid diamonds and grants the pass ticket first.
|
||||
// Activation consumes that purchase entitlement and the design ticket.
|
||||
if chosen.CashID != 0 && (s.authorizeCash == nil || !s.authorizeCash(v.ID, typ)) {
|
||||
return nil, fmt.Errorf("eventtasks: cash entitlement required pass=%d type=%d sku=%d/%d/%d", v.ID, typ, chosen.CashGroup, chosen.CashID, chosen.CashSales)
|
||||
}
|
||||
var costs []gamedata.Reward
|
||||
if chosen.Cost.Count > 0 {
|
||||
costs = append(costs, chosen.Cost)
|
||||
cost := chosen.Cost
|
||||
if typ == 0 {
|
||||
// PassRootUI.GetLevelPassBuyWeight uses the current level.
|
||||
cost.Count *= levels[levelIndex].ID
|
||||
}
|
||||
costs = append(costs, cost)
|
||||
}
|
||||
if _, e = s.economy.Apply(identity, costs, chosen.Rewards); e != nil {
|
||||
return nil, e
|
||||
return nil, fmt.Errorf("eventtasks: pass payment failed pass=%d type=%d: %w", v.ID, typ, e)
|
||||
}
|
||||
if typ == 0 {
|
||||
levels := s.design.PassLevels[s.design.Passes[v.ID].LevelGroup]
|
||||
var threshold uint64
|
||||
for _, lv := range levels {
|
||||
threshold += lv.NeedExp
|
||||
if threshold > p.Exp {
|
||||
p.Exp = threshold
|
||||
break
|
||||
}
|
||||
}
|
||||
} else if typ == 1 || typ == 3 {
|
||||
if chosen.LevelsGranted > 0 {
|
||||
target := uint64(levelIndex) + min(chosen.LevelsGranted, uint64(len(levels)-1-levelIndex))
|
||||
// Preserve progress within the current level and discard surplus at MAX.
|
||||
p.Exp = min(starts[target]+p.Exp-starts[levelIndex], starts[len(levels)-1])
|
||||
}
|
||||
if typ == 1 || typ == 3 {
|
||||
p.Premium = true
|
||||
}
|
||||
return wire.AppendVarint(nil, 1, p.Exp), nil
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"bd2server/internal/server/stateio"
|
||||
"bd2server/internal/server/wire"
|
||||
"bytes"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -13,12 +15,15 @@ import (
|
||||
type economyStub struct {
|
||||
calls int
|
||||
rewards []gamedata.Reward
|
||||
costs []gamedata.Reward
|
||||
err error
|
||||
}
|
||||
|
||||
func (e *economyStub) Apply(_ string, _ []gamedata.Reward, r []gamedata.Reward) ([]byte, error) {
|
||||
func (e *economyStub) Apply(_ string, c []gamedata.Reward, r []gamedata.Reward) ([]byte, error) {
|
||||
e.calls++
|
||||
e.costs = append(e.costs, c...)
|
||||
e.rewards = append(e.rewards, r...)
|
||||
return []byte{10, 0}, nil
|
||||
return []byte{10, 0}, e.err
|
||||
}
|
||||
func setup(t *testing.T) (*Service, *economyStub, stateio.Store) {
|
||||
t.Helper()
|
||||
@@ -154,6 +159,12 @@ func TestAttendanceNextGroupAndRepeatedChainReward(t *testing.T) {
|
||||
func TestPrivateServerPremiumCashPassOnceAndRestartReplay(t *testing.T) {
|
||||
s, e, store := setup(t)
|
||||
s.design.PassBuys = map[uint64][]gamedata.EventPassBuy{8: {{ID: 1, Type: 1, CashID: 42, Rewards: []gamedata.Reward{{Type: 4, Count: 10}}}}}
|
||||
authorizations := 0
|
||||
authorize := func(id, typ uint64) bool {
|
||||
authorizations++
|
||||
return id == 8 && typ == 1 && authorizations == 1
|
||||
}
|
||||
s.AttachCashAuthorization(authorize)
|
||||
request := wire.AppendVarint(req(1), 2, 8)
|
||||
request = wire.AppendVarint(request, 3, 1)
|
||||
_, reply, _, err := s.Handle("/PassBuy", request)
|
||||
@@ -169,6 +180,7 @@ func TestPrivateServerPremiumCashPassOnceAndRestartReplay(t *testing.T) {
|
||||
}
|
||||
reopened.now = s.now
|
||||
reopened.SetSession("test")
|
||||
reopened.AttachCashAuthorization(authorize)
|
||||
_, again, _, err := reopened.Handle("/PassBuy", request)
|
||||
if err != nil || !bytes.Equal(reply, again) || e.calls != 1 {
|
||||
t.Fatal("cash pass replay duplicated reward")
|
||||
@@ -178,4 +190,121 @@ func TestPrivateServerPremiumCashPassOnceAndRestartReplay(t *testing.T) {
|
||||
if _, _, _, err = reopened.Handle("/PassBuy", duplicate); err == nil || e.calls != 1 {
|
||||
t.Fatal("premium cash purchase granted twice")
|
||||
}
|
||||
if authorizations != 1 {
|
||||
t.Fatal("replay or duplicate consumed another cash entitlement")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPassPurchasesGrantLevelsInsteadOfRequiringThem(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
typ, exp, grant, wantExp uint64
|
||||
baseCost, wantCost uint64
|
||||
cash bool
|
||||
}{
|
||||
{"first level upgrade", 0, 0, 1, 20, 50, 50, false},
|
||||
{"level weighted price", 0, 25, 1, 55, 50, 100, false},
|
||||
{"three level upgrade", 0, 0, 3, 90, 150, 150, false},
|
||||
{"premium from level one", 1, 0, 3, 90, 1000, 1000, false},
|
||||
{"premium surplus capped", 1, 55, 3, 90, 1000, 1000, false},
|
||||
{"cash premium grants levels", 3, 0, 3, 90, 1, 1, true},
|
||||
{"cash premium at max", 3, 90, 3, 90, 1, 1, true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
s, e, _ := setup(t)
|
||||
s.design.PassLevels[8] = []gamedata.EventPassLevel{{ID: 1, NeedExp: 20}, {ID: 2, NeedExp: 30}, {ID: 3, NeedExp: 40}, {ID: 4}}
|
||||
buy := gamedata.EventPassBuy{ID: 1, Type: tc.typ, LevelsGranted: tc.grant, Cost: gamedata.Reward{Type: 3, Count: tc.baseCost}}
|
||||
authorizations := 0
|
||||
if tc.cash {
|
||||
buy.CashID = 42
|
||||
buy.Cost = gamedata.Reward{Type: 19, ID: 77, Count: 1}
|
||||
s.AttachCashAuthorization(func(id, typ uint64) bool {
|
||||
authorizations++
|
||||
return id == 8 && typ == 3
|
||||
})
|
||||
}
|
||||
s.design.PassBuys = map[uint64][]gamedata.EventPassBuy{8: {buy}}
|
||||
p := s.pass(s.registry.List()[2])
|
||||
p.Exp = tc.exp
|
||||
request := wire.AppendVarint(req(1), 2, 8)
|
||||
request = wire.AppendVarint(request, 3, tc.typ)
|
||||
_, reply, _, err := s.Handle("/PassBuy", request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if scalar(reply, 1) != tc.wantExp || p.Exp != tc.wantExp || p.Premium != (tc.typ == 1 || tc.typ == 3) {
|
||||
t.Fatalf("wrong pass state %+v reply=%x", p, reply)
|
||||
}
|
||||
if e.calls != 1 || len(e.costs) != 1 || e.costs[0].Count != tc.wantCost || e.costs[0].Type != buy.Cost.Type {
|
||||
t.Fatalf("wrong purchase charge %+v", e.costs)
|
||||
}
|
||||
_, replay, _, err := s.Handle("/PassBuy", request)
|
||||
if err != nil || !bytes.Equal(reply, replay) || e.calls != 1 || tc.cash && authorizations != 1 {
|
||||
t.Fatal("retry repeated payment or activation")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPassPurchaseRejectsWithoutChangingProgress(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, wantError string
|
||||
typ, exp uint64
|
||||
cash, denied bool
|
||||
paymentError bool
|
||||
}{
|
||||
{"level purchase at max", "pass already at max level", 0, 20, false, false, false},
|
||||
{"cash checker missing", "cash entitlement required", 3, 0, true, false, false},
|
||||
{"cash purchase missing", "cash entitlement required", 3, 0, true, true, false},
|
||||
{"insufficient diamonds", "pass payment failed pass=8 type=1", 1, 0, false, false, true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
s, e, _ := setup(t)
|
||||
s.design.PassLevels[8] = []gamedata.EventPassLevel{{ID: 1, NeedExp: 20}, {ID: 2}}
|
||||
buy := gamedata.EventPassBuy{ID: 1, Type: tc.typ, LevelsGranted: 3, Cost: gamedata.Reward{Type: 3, Count: 1000}}
|
||||
if tc.cash {
|
||||
buy.CashID = 42
|
||||
}
|
||||
if tc.denied {
|
||||
s.AttachCashAuthorization(func(uint64, uint64) bool { return false })
|
||||
}
|
||||
if tc.paymentError {
|
||||
e.err = errors.New("insufficient currency")
|
||||
}
|
||||
s.design.PassBuys = map[uint64][]gamedata.EventPassBuy{8: {buy}}
|
||||
s.pass(s.registry.List()[2]).Exp = tc.exp
|
||||
request := wire.AppendVarint(req(1), 2, 8)
|
||||
request = wire.AppendVarint(request, 3, tc.typ)
|
||||
if _, _, _, err := s.Handle("/PassBuy", request); err == nil || !strings.Contains(err.Error(), tc.wantError) {
|
||||
t.Fatalf("expected %q, got %v", tc.wantError, err)
|
||||
}
|
||||
p := s.pass(s.registry.List()[2])
|
||||
if p.Exp != tc.exp || p.Premium || len(s.state.Receipts) != 0 {
|
||||
t.Fatalf("failed purchase changed state %+v", p)
|
||||
}
|
||||
if !tc.paymentError && e.calls != 0 {
|
||||
t.Fatal("ineligible purchase reached payment")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPassRewardsUseLevelStartExperience(t *testing.T) {
|
||||
for _, tc := range []struct{ exp, wantClaims uint64 }{{0, 1}, {19, 1}, {20, 2}, {50, 3}} {
|
||||
s, e, _ := setup(t)
|
||||
s.design.PassLevels[8] = []gamedata.EventPassLevel{
|
||||
{ID: 1, NeedExp: 20, Basic: gamedata.Reward{Type: 3, Count: 1}},
|
||||
{ID: 2, NeedExp: 30, Basic: gamedata.Reward{Type: 3, Count: 2}},
|
||||
{ID: 3, Basic: gamedata.Reward{Type: 3, Count: 3}},
|
||||
}
|
||||
s.pass(s.registry.List()[2]).Exp = tc.exp
|
||||
request := wire.AppendVarint(req(1), 2, 1)
|
||||
request = wire.AppendVarint(request, 3, 8)
|
||||
if _, _, _, err := s.Handle("/PassReward", request); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if uint64(len(e.rewards)) != tc.wantClaims {
|
||||
t.Fatalf("exp=%d claimed %+v; wanted %d", tc.exp, e.rewards, tc.wantClaims)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,9 +30,12 @@ type EventPassLevel struct {
|
||||
Basic, Premium Reward
|
||||
}
|
||||
type EventPassBuy struct {
|
||||
ID, Type, UnlockLevel, CashGroup, CashID, CashSales uint64
|
||||
Cost Reward
|
||||
Rewards []Reward
|
||||
ID, Type, CashGroup, CashID, CashSales uint64
|
||||
// PassBuyTable.UnlockLevel is the number of levels granted by a purchase.
|
||||
// LocalText 2403 displays it as "Pass Level +{0}", not a prerequisite.
|
||||
LevelsGranted uint64
|
||||
Cost Reward
|
||||
Rewards []Reward
|
||||
}
|
||||
type EventTasksDesign struct {
|
||||
Attendance map[uint64]EventAttendance
|
||||
@@ -201,7 +204,7 @@ func loadEventTasksDesign(db *sql.DB) (*EventTasksDesign, error) {
|
||||
if e := each("PassBuyTable", func(p []byte) error {
|
||||
r := EventPassBuy{}
|
||||
var g uint64
|
||||
if e := read(p, map[int]*uint64{7: &g, 8: &r.ID, 12: &r.Type, 13: &r.UnlockLevel, 4: &r.CashGroup, 5: &r.CashID, 6: &r.CashSales}); e != nil {
|
||||
if e := read(p, map[int]*uint64{7: &g, 8: &r.ID, 12: &r.Type, 13: &r.LevelsGranted, 4: &r.CashGroup, 5: &r.CashID, 6: &r.CashSales}); e != nil {
|
||||
return e
|
||||
}
|
||||
var e error
|
||||
|
||||
@@ -24,4 +24,8 @@ func TestInstalledEventTasks23510(t *testing.T) {
|
||||
if d.MissionGroups[51].Type != 3 || len(d.MissionGroups[51].Groups) != 7 {
|
||||
t.Fatal("daily-open groups lost")
|
||||
}
|
||||
buys := d.PassBuys[140]
|
||||
if len(buys) != 2 || buys[0].Type != 0 || buys[0].LevelsGranted != 1 || buys[1].Type != 1 || buys[1].LevelsGranted != 3 {
|
||||
t.Fatalf("pass level purchase bonuses lost: %+v", buys)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user