fix(server): grant login attendance rewards once and combine reward receipts atomically
This commit is contained in:
@@ -47,6 +47,13 @@ func (h AttendanceHandler) HandleSession(path string, request []byte, session st
|
||||
if err != nil || !handled {
|
||||
return code, response, handled, err
|
||||
}
|
||||
// The event handler may already have granted ordinary attendance rewards.
|
||||
// The client accepts exactly one reward envelope, so combine every grant in
|
||||
// execution order under this wrapper's replay identity.
|
||||
response, eventBundle, err := takeAttendanceRewardEnvelope(response)
|
||||
if err != nil {
|
||||
return code, nil, true, err
|
||||
}
|
||||
key := fmt.Sprintf("commerce_attendance:%x:%d", sha256.Sum256([]byte(session)), seq)
|
||||
digest := fmt.Sprintf("%x", sha256.Sum256(request))
|
||||
var receipt attendanceReceipt
|
||||
@@ -71,11 +78,15 @@ func (h AttendanceHandler) HandleSession(path string, request []byte, session st
|
||||
}
|
||||
} else {
|
||||
receipt.Digest = digest
|
||||
receipt.Bundle, err = h.Economy.ClaimSubscriptions(key)
|
||||
subscriptionBundle, err := h.Economy.ClaimSubscriptions(key)
|
||||
if err != nil {
|
||||
return code, nil, true, err
|
||||
}
|
||||
// Preserve events -> login pass -> subscription execution order and all
|
||||
// repeated reward entries in the single client envelope.
|
||||
receipt.Bundle = append(receipt.Bundle, eventBundle...)
|
||||
receipt.Bundle = append(receipt.Bundle, loginBundle...)
|
||||
receipt.Bundle = append(receipt.Bundle, subscriptionBundle...)
|
||||
raw, err := json.Marshal(receipt)
|
||||
if err != nil {
|
||||
return code, nil, true, err
|
||||
@@ -94,3 +105,38 @@ func (h AttendanceHandler) HandleSession(path string, request []byte, session st
|
||||
}
|
||||
return code, response, true, nil
|
||||
}
|
||||
|
||||
// Strip the child envelope before adding the combined one. Keep native and
|
||||
// unrelated unknown fields byte-for-byte; reject ambiguous child envelopes
|
||||
// rather than returning a response the client would silently ignore.
|
||||
func takeAttendanceRewardEnvelope(response []byte) ([]byte, []byte, error) {
|
||||
var native, bundle []byte
|
||||
var receipt string
|
||||
var hasBundle, hasReceipt bool
|
||||
err := wire.Walk(response, func(f wire.Field) error {
|
||||
switch f.Number {
|
||||
case 1001:
|
||||
if f.Type != 2 || hasBundle {
|
||||
return fmt.Errorf("commerce: ambiguous attendance reward bundle")
|
||||
}
|
||||
hasBundle = true
|
||||
bundle = append([]byte(nil), f.Value...)
|
||||
case 1002:
|
||||
if f.Type != 2 || hasReceipt {
|
||||
return fmt.Errorf("commerce: ambiguous attendance reward receipt")
|
||||
}
|
||||
hasReceipt = true
|
||||
receipt = string(f.Value)
|
||||
default:
|
||||
native = append(native, response[f.Start:f.End]...)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if hasBundle != hasReceipt || hasReceipt && (receipt == "" || len(receipt) > 1024) {
|
||||
return nil, nil, fmt.Errorf("commerce: incomplete attendance reward envelope")
|
||||
}
|
||||
return native, bundle, nil
|
||||
}
|
||||
|
||||
@@ -5,7 +5,10 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"bd2server/internal/server/events"
|
||||
"bd2server/internal/server/eventtasks"
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/player"
|
||||
"bd2server/internal/server/wire"
|
||||
)
|
||||
|
||||
@@ -15,6 +18,123 @@ func (originalAttendance) Handle(path string, _ []byte) (int, []byte, bool, erro
|
||||
return 0, wire.AppendVarint(nil, 55, 123), path == "/Attendance", nil
|
||||
}
|
||||
|
||||
type attendanceBalances struct {
|
||||
balance uint64
|
||||
calls int
|
||||
}
|
||||
|
||||
func (e *attendanceBalances) Apply(_ string, _ []gamedata.Reward, rewards []gamedata.Reward) ([]byte, error) {
|
||||
e.calls++
|
||||
var bundle []byte
|
||||
for _, r := range rewards {
|
||||
e.balance += r.Count
|
||||
bundle = wire.AppendBytes(bundle, 1, player.ItemWire(player.Item{Type: r.Type, ID: r.ID, Count: r.Count}))
|
||||
}
|
||||
return bundle, nil
|
||||
}
|
||||
|
||||
func TestAttendanceCombinesOrdinaryLoginPassAndSubscriptionInGrantOrder(t *testing.T) {
|
||||
e, items, _, _, now := entitlementFixture(t)
|
||||
// eventtasks uses the production clock; align the commerce fixture with it
|
||||
// while buying the subscription on the previous reset day.
|
||||
*now = time.Now().UTC().Add(-24 * time.Hour)
|
||||
balances := &attendanceBalances{}
|
||||
e.base = balances
|
||||
if _, err := e.Apply("subscription", nil, []gamedata.Reward{{Type: 19, ID: 38, Count: 1}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
*now = now.Add(24 * time.Hour)
|
||||
registry := events.NewRegistry()
|
||||
if err := registry.Replace([]events.Schedule{{UID: 17, Type: 0, ID: 1, Start: 1, End: now.Add(time.Hour).UnixMilli()}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
design := &gamedata.EventTasksDesign{
|
||||
Attendance: map[uint64]gamedata.EventAttendance{1: {ID: 1, Group: 10}},
|
||||
AttendanceRewards: map[uint64][]gamedata.EventAttendanceReward{10: {{Group: 10, ID: 1, Day: 1, Basic: gamedata.Reward{Type: 3, Count: 2}}}},
|
||||
}
|
||||
tasks, err := eventtasks.Open(e.store, design, registry, e)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tasks.SetSession("combined-session")
|
||||
passes, err := NewLoginPasses(e.store, &gamedata.LoginPassCatalog{Groups: map[uint64][]gamedata.LoginPassReward{
|
||||
20: {{ID: 1, TicketID: 77, Free: gamedata.Reward{Type: 3, Count: 11}}},
|
||||
}}, e, items, func(uint64) bool { return true })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
passes.SetClock(func() time.Time { return *now }, 0)
|
||||
h := AttendanceHandler{Events: tasks, Economy: e, LoginPasses: passes, Store: e.store}
|
||||
request := wire.AppendVarint(nil, 1, 1)
|
||||
_, response, _, err := h.HandleSession("/Attendance", request, "combined-session")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if balances.calls != 4 || balances.balance != 19 {
|
||||
t.Fatalf("wrong combined grant: calls=%d balance=%d", balances.calls, balances.balance)
|
||||
}
|
||||
counts := map[int]int{}
|
||||
if err := wire.Walk(response, func(f wire.Field) error { counts[f.Number]++; return nil }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if counts[1001] != 1 || counts[1002] != 1 || counts[5] != 1 || counts[6] != 1 {
|
||||
t.Fatalf("client requires a single combined envelope and native notices: %v", counts)
|
||||
}
|
||||
bundle, _, _ := wire.Bytes(response, 1001)
|
||||
var snapshots []uint64
|
||||
if err := wire.Walk(bundle, func(f wire.Field) error {
|
||||
if f.Number == 1 {
|
||||
count, _, err := wire.Varint(f.Value, 4)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
snapshots = append(snapshots, count)
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(snapshots) != 3 || snapshots[0] != 2 || snapshots[1] != 11 || snapshots[2] != 5 {
|
||||
t.Fatalf("combined reward entries lost or reordered: %v", snapshots)
|
||||
}
|
||||
_, replay, _, err := h.HandleSession("/Attendance", request, "combined-session")
|
||||
if err != nil || !bytes.Equal(response, replay) || balances.calls != 4 {
|
||||
t.Fatal("combined retry changed rewards", err)
|
||||
}
|
||||
_, next, _, err := h.HandleSession("/Attendance", wire.AppendVarint(nil, 1, 2), "combined-session")
|
||||
if err != nil || balances.calls != 4 {
|
||||
t.Fatal("fresh request granted again", err)
|
||||
}
|
||||
if _, ok, _ := wire.Bytes(next, 1001); ok {
|
||||
t.Fatal("fresh request replays old reward envelope")
|
||||
}
|
||||
if _, ok, _ := wire.Bytes(next, 5); ok {
|
||||
t.Fatal("fresh request replays old attendance stamps")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttendanceRejectsAmbiguousChildRewardEnvelope(t *testing.T) {
|
||||
native := wire.AppendVarint(nil, 55, 123)
|
||||
bundle := wire.AppendBytes(native, 1001, wire.AppendVarint(nil, 7, 1))
|
||||
valid := wire.AppendString(bundle, 1002, "event-receipt")
|
||||
stripped, rewards, err := takeAttendanceRewardEnvelope(valid)
|
||||
if err != nil || !bytes.Equal(stripped, native) || len(rewards) == 0 {
|
||||
t.Fatal("valid envelope was not preserved", err)
|
||||
}
|
||||
for _, malformed := range [][]byte{
|
||||
bundle,
|
||||
wire.AppendString(native, 1002, "event-receipt"),
|
||||
wire.AppendBytes(valid, 1001, nil),
|
||||
wire.AppendString(valid, 1002, "second"),
|
||||
wire.AppendString(bundle, 1002, ""),
|
||||
wire.AppendVarint(native, 1001, 1),
|
||||
} {
|
||||
if _, _, err := takeAttendanceRewardEnvelope(malformed); err == nil {
|
||||
t.Fatalf("ambiguous envelope accepted: %x", malformed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttendanceExtensionReplaysRewardAndPreservesNativeResponse(t *testing.T) {
|
||||
e, _, base, _, now := entitlementFixture(t)
|
||||
if _, err := e.Apply("subscription", nil, []gamedata.Reward{{Type: 19, ID: 38, Count: 1}}); err != nil {
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
package eventtasks
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/events"
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/stateio"
|
||||
"bd2server/internal/server/wire"
|
||||
"bytes"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func claimEntries(b []byte) int {
|
||||
n := 0
|
||||
_ = wire.Walk(b, func(f wire.Field) error {
|
||||
if f.Number == 5 {
|
||||
n++
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return n
|
||||
}
|
||||
func TestAutomaticAttendanceLoginMarkerReplayAndRestart(t *testing.T) {
|
||||
s, eco, store := setup(t)
|
||||
if _, b, _, err := s.Handle("/LoginEvent", req(1)); err != nil || claimEntries(b) != 0 || eco.calls != 0 {
|
||||
t.Fatal("login event silently granted", err)
|
||||
}
|
||||
_, response, _, err := s.Handle("/Attendance", req(2))
|
||||
if err != nil || eco.calls != 1 || claimEntries(response) != 1 || s.state.Attendance["1"].Count != 1 {
|
||||
t.Fatal("shared marker prevented actual grant", err)
|
||||
}
|
||||
reopened, err := Open(store, s.design, s.registry, eco)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reopened.now = s.now
|
||||
reopened.SetSession("test")
|
||||
_, replay, _, err := reopened.Handle("/Attendance", req(2))
|
||||
if err != nil || !bytes.Equal(response, replay) || eco.calls != 1 {
|
||||
t.Fatal("durable replay changed", err)
|
||||
}
|
||||
reopened.SetSession("reconnect")
|
||||
_, response, _, err = reopened.Handle("/Attendance", req(1))
|
||||
if err != nil || claimEntries(response) != 0 || eco.calls != 1 {
|
||||
t.Fatal("history returned as new reward", err)
|
||||
}
|
||||
if _, present, _ := wire.Bytes(response, 1001); present {
|
||||
t.Fatal("empty grant had bundle")
|
||||
}
|
||||
}
|
||||
func TestAutomaticLimitAttendanceUsesScheduleDateAndSkipsInactive(t *testing.T) {
|
||||
s, eco, _ := setup(t)
|
||||
now := s.now()
|
||||
reg := events.NewRegistry()
|
||||
rows := []events.Schedule{
|
||||
{UID: 10, Type: 1, ID: 7, Start: now.Add(-48 * time.Hour).UnixMilli(), End: now.Add(48 * time.Hour).UnixMilli()},
|
||||
{UID: 11, Type: 1, ID: 8, Start: now.Add(time.Hour).UnixMilli(), End: now.Add(48 * time.Hour).UnixMilli()},
|
||||
{UID: 12, Type: 1, ID: 9, Start: now.Add(-48 * time.Hour).UnixMilli(), End: now.UnixMilli()},
|
||||
}
|
||||
if err := reg.Replace(rows); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.registry = reg
|
||||
s.design.LimitRewards = map[[2]uint64]uint64{{7, 1}: 100, {7, 2}: 200, {7, 3}: 300, {7, 4}: 400, {8, 1}: 500, {9, 3}: 600}
|
||||
s.state.LoginDays[now.Add(-24*time.Hour).Format("2006-01-02")] = now.Add(-24 * time.Hour).UnixMilli()
|
||||
_, response, _, err := s.Handle("/Attendance", req(1))
|
||||
if err != nil || claimEntries(response) != 1 || eco.calls != 1 || len(eco.rewards) != 1 || eco.rewards[0].ID != 300 {
|
||||
t.Fatal("wrong calendar day granted", eco.rewards, err)
|
||||
}
|
||||
if len(s.state.Attendance) != 1 {
|
||||
t.Fatal("inactive calendar mutated")
|
||||
}
|
||||
now = now.Add(24 * time.Hour)
|
||||
s.now = func() time.Time { return now }
|
||||
_, response, _, err = s.Handle("/Attendance", req(2))
|
||||
if err != nil || claimEntries(response) != 1 || eco.rewards[len(eco.rewards)-1].ID != 400 {
|
||||
t.Fatal("next calendar day missing", err)
|
||||
}
|
||||
}
|
||||
func TestAutomaticAttendanceUIDZeroSeparateDesignsAndSingleSettlement(t *testing.T) {
|
||||
s, eco, _ := setup(t)
|
||||
now := s.now()
|
||||
reg := events.NewRegistry()
|
||||
if err := reg.Replace([]events.Schedule{{Type: 0, ID: 1, Start: now.Add(-time.Hour).UnixMilli(), End: now.Add(time.Hour).UnixMilli()}, {Type: 0, ID: 2, Start: now.Add(-time.Hour).UnixMilli(), End: now.Add(time.Hour).UnixMilli()}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.registry = reg
|
||||
s.design.Attendance[2] = gamedata.EventAttendance{ID: 2, Group: 2}
|
||||
s.design.AttendanceRewards[2] = []gamedata.EventAttendanceReward{{ID: 1, Group: 2, Day: 1, Basic: gamedata.Reward{Type: 4, Count: 20}}, {ID: 2, Group: 2, Day: 2, Basic: gamedata.Reward{Type: 4, Count: 30}}}
|
||||
_, response, _, err := s.Handle("/Attendance", req(1))
|
||||
if err != nil || eco.calls != 1 || len(eco.rewards) != 2 || claimEntries(response) != 2 || len(s.state.Attendance) != 2 {
|
||||
t.Fatal("public zero identities collided", err)
|
||||
}
|
||||
}
|
||||
func TestAutomaticAttendanceEconomyFailureRestoresProgressAndClaims(t *testing.T) {
|
||||
s, eco, _ := setup(t)
|
||||
eco.err = errors.New("settlement failure")
|
||||
if _, _, _, err := s.Handle("/Attendance", req(1)); err == nil {
|
||||
t.Fatal("failed settlement accepted")
|
||||
}
|
||||
if len(s.state.Attendance) != 0 || len(s.state.LoginDays) != 0 || len(s.state.Receipts) != 0 {
|
||||
t.Fatal("failed settlement persisted markers")
|
||||
}
|
||||
eco.err = nil
|
||||
_, response, _, err := s.Handle("/Attendance", req(1))
|
||||
if err != nil || claimEntries(response) != 1 || !s.state.Attendance["1"].Obtained["1/1"] {
|
||||
t.Fatal("retry lost eligibility", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The account transaction restores economic state; this store exercises the
|
||||
// domain restoration that must also drop newly inserted map entries.
|
||||
type attendanceFailStore struct{ stateio.Store }
|
||||
|
||||
func (s attendanceFailStore) Save(string, []byte) error { return errors.New("save failure") }
|
||||
func TestAutomaticAttendanceSaveFailureRestoresState(t *testing.T) {
|
||||
s, _, store := setup(t)
|
||||
s.store = attendanceFailStore{store}
|
||||
if _, _, _, err := s.Handle("/Attendance", req(1)); err == nil {
|
||||
t.Fatal("failed save accepted")
|
||||
}
|
||||
if len(s.state.Attendance) != 0 || len(s.state.LoginDays) != 0 || len(s.state.Receipts) != 0 {
|
||||
t.Fatal("failed save retained new state")
|
||||
}
|
||||
persisted, err := store.Load("eventtasks")
|
||||
if err != nil || len(persisted) != 0 {
|
||||
t.Fatal("failed save persisted attendance", err)
|
||||
}
|
||||
}
|
||||
@@ -13,22 +13,18 @@ func TestAttendancePremiumRequiresOwnedTicketAndDoesNotDuplicate(t *testing.T) {
|
||||
s.design.Attendance[1] = gamedata.EventAttendance{ID: 1, Group: 1, Ticket: 77}
|
||||
s.design.AttendanceRewards[1] = []gamedata.EventAttendanceReward{{ID: 1, Group: 1, Day: 1, Basic: gamedata.Reward{Type: 4, Count: 100}, Premium: gamedata.Reward{Type: 4, Count: 100}}}
|
||||
s.AttachAttendancePremium(func(ticket uint64) bool { return paid && ticket == 77 })
|
||||
if _, _, _, err := s.Handle("/Attendance", req(1)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
claim := wire.AppendVarint(req(2), 2, 1)
|
||||
claim = wire.AppendVarint(claim, 3, 1)
|
||||
claim = wire.AppendVarint(claim, 4, 1)
|
||||
code, response, handled, err := s.Handle("/EventReward", claim)
|
||||
if err != nil || code != 151 || !handled {
|
||||
claim := req(1)
|
||||
code, response, handled, err := s.Handle("/Attendance", claim)
|
||||
if err != nil || code != 0 || !handled {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
bundle, ok, err := wire.Bytes(response, 1001)
|
||||
if err != nil || !ok || !bytes.Equal(bundle, []byte{10, 0}) {
|
||||
t.Fatal("actual bundle absent", bundle, err)
|
||||
}
|
||||
receipt, ok, err := wire.Bytes(response, 1002)
|
||||
if err != nil || !ok || string(receipt) != "test:/EventReward:2" {
|
||||
if err != nil || !ok || string(receipt) != "test:/Attendance:1" {
|
||||
t.Fatal("durable receipt absent", string(receipt), err)
|
||||
}
|
||||
want := 1
|
||||
@@ -38,7 +34,7 @@ func TestAttendancePremiumRequiresOwnedTicketAndDoesNotDuplicate(t *testing.T) {
|
||||
if len(eco.rewards) != want {
|
||||
t.Fatal("premium ticket ignored", paid, eco.rewards)
|
||||
}
|
||||
if _, replay, _, err := s.Handle("/EventReward", claim); err != nil || eco.calls != 1 || !bytes.Equal(replay, response) {
|
||||
if _, replay, _, err := s.Handle("/Attendance", claim); err != nil || eco.calls != 1 || !bytes.Equal(replay, response) {
|
||||
t.Fatal("claim replay duplicated", err)
|
||||
}
|
||||
reopened, err := Open(store, s.design, s.registry, eco)
|
||||
@@ -47,7 +43,7 @@ func TestAttendancePremiumRequiresOwnedTicketAndDoesNotDuplicate(t *testing.T) {
|
||||
}
|
||||
reopened.now = s.now
|
||||
reopened.SetSession("test")
|
||||
_, replay, _, err := reopened.Handle("/EventReward", claim)
|
||||
_, replay, _, err := reopened.Handle("/Attendance", claim)
|
||||
if err != nil || eco.calls != 1 || !bytes.Equal(replay, response) {
|
||||
t.Fatal("restart lost reward envelope", err)
|
||||
}
|
||||
|
||||
@@ -258,6 +258,40 @@ func (s *Service) attendance(v events.Schedule) *attendance {
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// attendanceRewards is shared by automatic and explicit claims. Obtained is
|
||||
// the current cycle ledger; History is the client-visible historical ledger.
|
||||
func (s *Service) attendanceRewards(v events.Schedule, a *attendance, group, id uint64) []gamedata.Reward {
|
||||
if a.Obtained[key(group, id)] {
|
||||
return nil
|
||||
}
|
||||
if v.Type == 0 {
|
||||
if group != a.Group {
|
||||
return nil
|
||||
}
|
||||
for _, r := range s.design.AttendanceRewards[group] {
|
||||
if r.ID != id || r.Day > a.Count {
|
||||
continue
|
||||
}
|
||||
var rewards []gamedata.Reward
|
||||
if r.Basic.Count > 0 {
|
||||
rewards = append(rewards, r.Basic)
|
||||
}
|
||||
if ticket := s.design.Attendance[v.ID].Ticket; ticket != 0 && s.attendancePremium != nil && s.attendancePremium(ticket) && r.Premium.Count > 0 {
|
||||
rewards = append(rewards, r.Premium)
|
||||
}
|
||||
return rewards
|
||||
}
|
||||
} else if v.Type == 1 && group == v.ID && id > 0 {
|
||||
box, ok := s.design.LimitRewards[[2]uint64{group, id}]
|
||||
date := time.UnixMilli(v.Start).UTC().AddDate(0, 0, int(id)-1).Format("2006-01-02")
|
||||
_, logged := s.state.LoginDays[date]
|
||||
if ok && box > 0 && logged && date == s.day() {
|
||||
return []gamedata.Reward{{Type: 9, ID: box, Count: 1}}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (s *Service) pass(v events.Schedule) *pass {
|
||||
k := scheduleKey(v)
|
||||
p := s.state.Passes[k]
|
||||
@@ -343,11 +377,13 @@ func (s *Service) Handle(path string, req []byte) (int, []byte, bool, error) {
|
||||
before, _ := json.Marshal(s.state)
|
||||
out, err := s.handle(path, req, rk)
|
||||
if err != nil {
|
||||
s.state = snapshot{}
|
||||
_ = json.Unmarshal(before, &s.state)
|
||||
return code, nil, true, err
|
||||
}
|
||||
s.state.Receipts[rk] = receipt{ds, code, out}
|
||||
if err = s.save(); err != nil {
|
||||
s.state = snapshot{}
|
||||
_ = json.Unmarshal(before, &s.state)
|
||||
return code, nil, true, err
|
||||
}
|
||||
@@ -362,6 +398,7 @@ func (s *Service) handle(path string, b []byte, identity string) ([]byte, error)
|
||||
s.state.LoginDays[today] = s.now().UnixMilli()
|
||||
}
|
||||
var out []byte
|
||||
var granted []gamedata.Reward
|
||||
for _, v := range s.registry.List() {
|
||||
if !s.active(v) {
|
||||
continue
|
||||
@@ -430,15 +467,43 @@ func (s *Service) handle(path string, b []byte, identity string) ([]byte, error)
|
||||
}
|
||||
out = wire.AppendBytes(out, 2, x)
|
||||
}
|
||||
for claim := range a.Obtained {
|
||||
var group, id uint64
|
||||
_, _ = fmt.Sscanf(claim, "%d/%d", &group, &id)
|
||||
group := a.Group
|
||||
var ids []uint64
|
||||
if v.Type == 0 {
|
||||
for _, r := range s.design.AttendanceRewards[group] {
|
||||
ids = append(ids, r.ID)
|
||||
}
|
||||
} else {
|
||||
group = v.ID
|
||||
for k := range s.design.LimitRewards {
|
||||
if k[0] == group {
|
||||
ids = append(ids, k[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
|
||||
for _, id := range ids {
|
||||
rewards := s.attendanceRewards(v, a, group, id)
|
||||
if len(rewards) == 0 {
|
||||
continue
|
||||
}
|
||||
granted = append(granted, rewards...)
|
||||
a.Obtained[key(group, id)] = true
|
||||
a.History[key(group, id)] = true
|
||||
x := wire.AppendVarint(nil, 1, v.UID)
|
||||
x = wire.AppendVarint(x, 2, group)
|
||||
x = wire.AppendVarint(x, 3, id)
|
||||
out = wire.AppendBytes(out, 5, x)
|
||||
}
|
||||
}
|
||||
if len(granted) > 0 {
|
||||
bundle, err := s.economy.Apply(identity, nil, granted)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = wire.AppendBytes(out, 1001, bundle)
|
||||
out = wire.AppendString(out, 1002, identity)
|
||||
}
|
||||
return out, nil
|
||||
case "/AttendanceInfo":
|
||||
var out []byte
|
||||
@@ -494,27 +559,7 @@ func (s *Service) handle(path string, b []byte, identity string) ([]byte, error)
|
||||
if a.Obtained[ck] {
|
||||
return nil, errors.New("eventtasks: attendance already received")
|
||||
}
|
||||
var rewards []gamedata.Reward
|
||||
if v.Type == 0 {
|
||||
if g != a.Group {
|
||||
return nil, errors.New("eventtasks: wrong attendance group")
|
||||
}
|
||||
for _, r := range s.design.AttendanceRewards[g] {
|
||||
if r.ID == id && r.Day <= a.Count {
|
||||
rewards = append(rewards, r.Basic)
|
||||
if ticket := s.design.Attendance[v.ID].Ticket; ticket != 0 && s.attendancePremium != nil && s.attendancePremium(ticket) && r.Premium.Count > 0 {
|
||||
rewards = append(rewards, r.Premium)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
box, ok := s.design.LimitRewards[[2]uint64{g, id}]
|
||||
date := time.UnixMilli(v.Start).UTC().AddDate(0, 0, int(id)-1).Format("2006-01-02")
|
||||
_, logged := s.state.LoginDays[date]
|
||||
if ok && g == v.ID && logged && date == s.day() {
|
||||
rewards = []gamedata.Reward{{Type: 9, ID: box, Count: 1}}
|
||||
}
|
||||
}
|
||||
rewards := s.attendanceRewards(v, a, g, id)
|
||||
if len(rewards) == 0 {
|
||||
return nil, errors.New("eventtasks: attendance day unavailable")
|
||||
}
|
||||
|
||||
@@ -108,8 +108,8 @@ func TestAttendanceDailyCounterAndClaimEligibility(t *testing.T) {
|
||||
claim = wire.AppendVarint(claim, 2, 1)
|
||||
claim = wire.AppendVarint(claim, 3, 1)
|
||||
claim = wire.AppendVarint(claim, 4, 1)
|
||||
if _, _, _, err := s.Handle("/EventReward", claim); err != nil {
|
||||
t.Fatal(err)
|
||||
if _, _, _, err := s.Handle("/EventReward", claim); err == nil {
|
||||
t.Fatal("automatic reward claimed again")
|
||||
}
|
||||
if e.calls != 1 {
|
||||
t.Fatal("attendance reward missing")
|
||||
@@ -145,8 +145,8 @@ func TestAttendanceNextGroupAndRepeatedChainReward(t *testing.T) {
|
||||
claim := wire.AppendVarint(req(seq), 2, 1)
|
||||
claim = wire.AppendVarint(claim, 3, group)
|
||||
claim = wire.AppendVarint(claim, 4, 1)
|
||||
if _, _, _, err := s.Handle("/EventReward", claim); err != nil {
|
||||
t.Fatal(err)
|
||||
if _, _, _, err := s.Handle("/EventReward", claim); err == nil {
|
||||
t.Fatal("automatic cycle reward claimed again")
|
||||
}
|
||||
seq++
|
||||
today = today.Add(24 * time.Hour)
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
package session
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"bd2server/internal/server/accountstate"
|
||||
"bd2server/internal/server/cryptox"
|
||||
"bd2server/internal/server/events"
|
||||
"bd2server/internal/server/eventtasks"
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/player"
|
||||
"bd2server/internal/server/protocol"
|
||||
"bd2server/internal/server/wire"
|
||||
)
|
||||
|
||||
// This adapter writes the real normalized wallet and inventory grant ledgers.
|
||||
type attendanceTransactionEconomy struct {
|
||||
wallet *player.Wallet
|
||||
inventory *player.Inventory
|
||||
calls int
|
||||
}
|
||||
|
||||
func (e *attendanceTransactionEconomy) Apply(identity string, costs, rewards []gamedata.Reward) ([]byte, error) {
|
||||
if len(costs) != 0 {
|
||||
return nil, fmt.Errorf("unexpected attendance costs")
|
||||
}
|
||||
e.calls++
|
||||
if _, err := e.wallet.GrantQuestOnce(identity, rewards); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var items []gamedata.BattleReward
|
||||
for _, r := range rewards {
|
||||
if r.Type == 9 || r.Type == 8 {
|
||||
items = append(items, gamedata.BattleReward{Type: r.Type, ID: r.ID, Count: r.Count})
|
||||
}
|
||||
}
|
||||
if _, err := e.inventory.GrantOnce(identity, items); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return wire.AppendBytes(nil, 1, nil), nil
|
||||
}
|
||||
|
||||
func TestAttendanceBatchRollsBackAndSuccessfulRetryGrantsOnce(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "state.db")
|
||||
now := time.Now().UTC()
|
||||
registry := events.NewRegistry()
|
||||
if err := registry.Replace([]events.Schedule{
|
||||
{UID: 1, Type: 0, ID: 1, Start: now.Add(-24 * time.Hour).UnixMilli(), End: now.Add(48 * time.Hour).UnixMilli()},
|
||||
{UID: 2, Type: 1, ID: 2, Start: now.Truncate(24 * time.Hour).UnixMilli(), End: now.Add(48 * time.Hour).UnixMilli()},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
design := &gamedata.EventTasksDesign{
|
||||
Attendance: map[uint64]gamedata.EventAttendance{1: {ID: 1, Group: 1}},
|
||||
AttendanceRewards: map[uint64][]gamedata.EventAttendanceReward{1: {{ID: 1, Group: 1, Day: 1, Basic: gamedata.Reward{Type: 4, Count: 100}}}},
|
||||
LimitRewards: map[[2]uint64]uint64{{2, 1}: 987},
|
||||
}
|
||||
open := func() (*accountstate.Repository, *player.Wallet, *player.Inventory, *eventtasks.Service, *attendanceTransactionEconomy) {
|
||||
t.Helper()
|
||||
repo, err := accountstate.Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wallet, err := player.OpenWallet(repo, player.Currency{Gold: 12})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
inv, err := player.OpenInventory(repo, &player.Starter{Version: "2.35.10"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
eco := &attendanceTransactionEconomy{wallet: wallet, inventory: inv}
|
||||
svc, err := eventtasks.Open(repo, design, registry, eco)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return repo, wallet, inv, svc, eco
|
||||
}
|
||||
makeServer := func(repo *accountstate.Repository, svc *eventtasks.Service) *Server {
|
||||
t.Helper()
|
||||
server, err := NewServer(fakeLogin{}, svc, &mutatingDomain{store: repo})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := server.AttachStateStore(repo); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return server
|
||||
}
|
||||
batch := func(server *Server, cookie string, seq uint64, fail bool) error {
|
||||
t.Helper()
|
||||
requests := []protocol.BatchRequest{{Path: "/Attendance", RequestData: base64.StdEncoding.EncodeToString(wire.AppendVarint(nil, 1, seq))}}
|
||||
if fail {
|
||||
requests = append(requests, protocol.BatchRequest{Path: "/InjectedFailure", RequestData: base64.StdEncoding.EncodeToString(wire.AppendVarint(nil, 1, seq+1))})
|
||||
}
|
||||
plain, err := json.Marshal(requests)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, err := cryptox.EncryptBase64(plain, server.KeyForTest())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = server.DispatchRaw("/BatchRequest", []byte(body), "s="+cookie)
|
||||
return err
|
||||
}
|
||||
repo, wallet, inv, svc, eco := open()
|
||||
if err := wallet.EnsurePersisted(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.EnsurePersisted(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := svc.SetNewbieStep(0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before, err := repo.Load("eventtasks")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := makeServer(repo, svc)
|
||||
session := login(t, server)
|
||||
if err := batch(server, session.Cookie, 2, true); err == nil {
|
||||
t.Fatal("failed attendance batch accepted")
|
||||
}
|
||||
if eco.calls != 1 {
|
||||
t.Fatalf("attendance did not reach real economy before failure: calls=%d", eco.calls)
|
||||
}
|
||||
if err := repo.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Rollback fences the old domain objects; reopen every repository/domain.
|
||||
repo, wallet, inv, svc, eco = open()
|
||||
after, err := repo.Load("eventtasks")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(after) != string(before) {
|
||||
t.Fatalf("attendance progress, claim markers or receipts survived rollback: %s", after)
|
||||
}
|
||||
if wallet.Snapshot().Gold != 12 || len(inv.All()) != 0 {
|
||||
t.Fatalf("rewards survived rollback: wallet=%+v items=%+v", wallet.Snapshot(), inv.All())
|
||||
}
|
||||
server = makeServer(repo, svc)
|
||||
session = login(t, server)
|
||||
if err := batch(server, session.Cookie, 2, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := batch(server, session.Cookie, 3, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if eco.calls != 1 {
|
||||
t.Fatalf("new sequence duplicated attendance reward: calls=%d", eco.calls)
|
||||
}
|
||||
if err := repo.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repo, wallet, inv, _, _ = open()
|
||||
defer repo.Close()
|
||||
items := inv.All()
|
||||
if wallet.Snapshot().Gold != 112 || len(items) != 1 || items[0].Type != 9 || items[0].ID != 987 || items[0].Count != 1 {
|
||||
t.Fatalf("successful attendance not persisted exactly once: wallet=%+v items=%+v", wallet.Snapshot(), items)
|
||||
}
|
||||
var state struct {
|
||||
Attendance map[string]struct {
|
||||
Count uint64
|
||||
LastDay string
|
||||
Obtained, History map[string]bool
|
||||
}
|
||||
LoginDays map[string]int64
|
||||
}
|
||||
saved, err := repo.Load("eventtasks")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := json.Unmarshal(saved, &state); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(state.Attendance) != 2 || len(state.LoginDays) != 1 {
|
||||
t.Fatalf("successful attendance progress missing: %s", saved)
|
||||
}
|
||||
for uid, a := range state.Attendance {
|
||||
if a.Count != 1 || a.LastDay == "" || len(a.Obtained) != 1 || len(a.History) != 1 {
|
||||
t.Fatalf("claim markers missing for %s: %+v", uid, a)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user