fix(server): grant login attendance rewards once and combine reward receipts atomically

This commit is contained in:
2026-10-05 13:56:22 +08:00
parent eab6b514e8
commit 90cbd6871b
7 changed files with 569 additions and 40 deletions
+47 -1
View File
@@ -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)
}
+69 -24
View File
@@ -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)
}
}
}