fix(server): deliver attendance rewards through idempotent mail and notify new messages

This commit is contained in:
2026-10-05 14:18:34 +08:00
parent 9ece9d9afa
commit 8d657bf694
12 changed files with 705 additions and 140 deletions
+7
View File
@@ -824,6 +824,10 @@ func serve(args []string) (serveErr error) {
if err != nil {
return fmt.Errorf("load event tasks state: %w", err)
}
if err := mailService.AttachAttendanceRewardEconomy(eventEconomy); err != nil {
return fmt.Errorf("attach attendance mail rewards: %w", err)
}
eventTasksService.AttachAttendanceMail(mailService)
newbieStep, _, err := wire.Varint(login.UserInfo, 39)
if err != nil {
return err
@@ -1091,6 +1095,9 @@ func serve(args []string) (serveErr error) {
if err := game.AttachResponseObserver(eventTasksService); err != nil {
return err
}
if err := game.AttachResponseObserver(mailService); err != nil {
return fmt.Errorf("attach new mail notifications: %w", err)
}
if authService != nil {
if err := game.AttachLoginAuthenticator(authService); err != nil {
return err
+40 -5
View File
@@ -8,6 +8,7 @@ import (
"bd2server/internal/server/events"
"bd2server/internal/server/eventtasks"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/mail"
"bd2server/internal/server/player"
"bd2server/internal/server/wire"
)
@@ -33,7 +34,7 @@ func (e *attendanceBalances) Apply(_ string, _ []gamedata.Reward, rewards []game
return bundle, nil
}
func TestAttendanceCombinesOrdinaryLoginPassAndSubscriptionInGrantOrder(t *testing.T) {
func TestAttendanceMailsOrdinaryRewardsAndCombinesLoginPassAndSubscription(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.
@@ -57,6 +58,18 @@ func TestAttendanceCombinesOrdinaryLoginPassAndSubscriptionInGrantOrder(t *testi
t.Fatal(err)
}
tasks.SetSession("combined-session")
mailWallet, err := player.OpenWallet(e.store, player.Currency{})
if err != nil {
t.Fatal(err)
}
mailbox, err := mail.OpenService(e.store, &mail.Starter{Version: "2.35.10", MailCount: 1}, items, mailWallet)
if err != nil {
t.Fatal(err)
}
if err := mailbox.AttachAttendanceRewardEconomy(e); err != nil {
t.Fatal(err)
}
tasks.AttachAttendanceMail(mailbox)
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 })
@@ -70,7 +83,7 @@ func TestAttendanceCombinesOrdinaryLoginPassAndSubscriptionInGrantOrder(t *testi
if err != nil {
t.Fatal(err)
}
if balances.calls != 4 || balances.balance != 19 {
if balances.calls != 3 || balances.balance != 17 {
t.Fatalf("wrong combined grant: calls=%d balance=%d", balances.calls, balances.balance)
}
counts := map[int]int{}
@@ -94,15 +107,15 @@ func TestAttendanceCombinesOrdinaryLoginPassAndSubscriptionInGrantOrder(t *testi
}); err != nil {
t.Fatal(err)
}
if len(snapshots) != 3 || snapshots[0] != 2 || snapshots[1] != 11 || snapshots[2] != 5 {
if len(snapshots) != 2 || snapshots[0] != 11 || snapshots[1] != 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 {
if err != nil || !bytes.Equal(response, replay) || balances.calls != 3 {
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 {
if err != nil || balances.calls != 3 {
t.Fatal("fresh request granted again", err)
}
if _, ok, _ := wire.Bytes(next, 1001); ok {
@@ -111,6 +124,28 @@ func TestAttendanceCombinesOrdinaryLoginPassAndSubscriptionInGrantOrder(t *testi
if _, ok, _ := wire.Bytes(next, 5); ok {
t.Fatal("fresh request replays old attendance stamps")
}
_, mailInfo, _, err := mailbox.Handle("/MailInfo", wire.AppendVarint(nil, 1, 3))
if err != nil {
t.Fatal(err)
}
mailCount := 0
var mailID uint64
if err := wire.Walk(mailInfo, func(f wire.Field) error {
if f.Number == 1 {
mailCount++
mailID, _, _ = wire.Varint(f.Value, 1)
}
return nil
}); err != nil || mailCount != 1 {
t.Fatalf("ordinary attendance did not issue exactly one mail: count=%d err=%v", mailCount, err)
}
open := wire.AppendVarint(wire.AppendVarint(nil, 1, 4), 2, mailID)
if _, _, _, err := mailbox.Handle("/MailOpen", open); err != nil {
t.Fatal(err)
}
if balances.calls != 4 || balances.balance != 19 {
t.Fatalf("ordinary reward must arrive only after opening mail: calls=%d balance=%d", balances.calls, balances.balance)
}
}
func TestAttendanceRejectsAmbiguousChildRewardEnvelope(t *testing.T) {
@@ -23,26 +23,28 @@ func claimEntries(b []byte) int {
}
func TestAutomaticAttendanceLoginMarkerReplayAndRestart(t *testing.T) {
s, eco, store := setup(t)
m := s.attendanceMail.(*attendanceMailStub)
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 {
if err != nil || m.calls != 1 || eco.calls != 0 || 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.AttachAttendanceMail(m)
reopened.now = s.now
reopened.SetSession("test")
_, replay, _, err := reopened.Handle("/Attendance", req(2))
if err != nil || !bytes.Equal(response, replay) || eco.calls != 1 {
if err != nil || !bytes.Equal(response, replay) || m.calls != 1 || eco.calls != 0 {
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 {
if err != nil || claimEntries(response) != 0 || m.calls != 1 || eco.calls != 0 {
t.Fatal("history returned as new reward", err)
}
if _, present, _ := wire.Bytes(response, 1001); present {
@@ -51,6 +53,7 @@ func TestAutomaticAttendanceLoginMarkerReplayAndRestart(t *testing.T) {
}
func TestAutomaticLimitAttendanceUsesScheduleDateAndSkipsInactive(t *testing.T) {
s, eco, _ := setup(t)
m := s.attendanceMail.(*attendanceMailStub)
now := s.now()
reg := events.NewRegistry()
rows := []events.Schedule{
@@ -65,8 +68,8 @@ func TestAutomaticLimitAttendanceUsesScheduleDateAndSkipsInactive(t *testing.T)
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 err != nil || claimEntries(response) != 1 || m.calls != 1 || eco.calls != 0 || len(m.rewards) != 1 || m.rewards[0].ID != 300 {
t.Fatal("wrong calendar day granted", m.rewards, err)
}
if len(s.state.Attendance) != 1 {
t.Fatal("inactive calendar mutated")
@@ -74,12 +77,13 @@ func TestAutomaticLimitAttendanceUsesScheduleDateAndSkipsInactive(t *testing.T)
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 {
if err != nil || claimEntries(response) != 1 || m.rewards[len(m.rewards)-1].ID != 400 {
t.Fatal("next calendar day missing", err)
}
}
func TestAutomaticAttendanceUIDZeroSeparateDesignsAndSingleSettlement(t *testing.T) {
s, eco, _ := setup(t)
m := s.attendanceMail.(*attendanceMailStub)
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 {
@@ -89,27 +93,28 @@ func TestAutomaticAttendanceUIDZeroSeparateDesignsAndSingleSettlement(t *testing
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 {
if err != nil || m.calls != 1 || eco.calls != 0 || len(m.rewards) != 2 || claimEntries(response) != 2 || len(s.state.Attendance) != 2 {
t.Fatal("public zero identities collided", err)
}
}
func TestAutomaticAttendanceEconomyFailureRestoresProgressAndClaims(t *testing.T) {
func TestAutomaticAttendanceMailFailureRestoresProgressAndClaims(t *testing.T) {
s, eco, _ := setup(t)
eco.err = errors.New("settlement failure")
m := s.attendanceMail.(*attendanceMailStub)
m.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
m.err = nil
_, response, _, err := s.Handle("/Attendance", req(1))
if err != nil || claimEntries(response) != 1 || !s.state.Attendance["1"].Obtained["1/1"] {
if err != nil || eco.calls != 0 || m.calls != 1 || 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
// The account transaction restores mailbox state; this store exercises the
// domain restoration that must also drop newly inserted map entries.
type attendanceFailStore struct{ stateio.Store }
@@ -128,3 +133,43 @@ func TestAutomaticAttendanceSaveFailureRestoresState(t *testing.T) {
t.Fatal("failed save persisted attendance", err)
}
}
func TestAttendancePreviouslyGrantedRewardsAreNotMailedAgain(t *testing.T) {
s, eco, _ := setup(t)
m := s.attendanceMail.(*attendanceMailStub)
a := s.attendance(s.registry.List()[0])
a.Group, a.Count, a.LastDay = 1, 1, s.day()
a.Obtained["1/1"], a.History["1/1"] = true, true
_, response, _, err := s.Handle("/Attendance", req(1))
if err != nil || claimEntries(response) != 0 || m.calls != 0 || eco.calls != 0 {
t.Fatal("existing direct reward mailed again", err)
}
}
func TestExplicitAttendanceClaimUsesMailboxAndSharedLedger(t *testing.T) {
s, eco, _ := setup(t)
m := s.attendanceMail.(*attendanceMailStub)
if _, _, _, err := s.Handle("/LoginEvent", 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)
_, response, _, err := s.Handle("/EventReward", claim)
if err != nil || len(response) != 0 || m.calls != 1 || eco.calls != 0 || m.identity != "test:/EventReward:2" {
t.Fatal("explicit claim bypassed mailbox", err)
}
_, response, _, err = s.Handle("/Attendance", req(3))
if err != nil || claimEntries(response) != 0 || m.calls != 1 || eco.calls != 0 {
t.Fatal("auto duplicated explicit mail", err)
}
}
func TestAttendanceMissingMailboxDoesNotGrantOrPersist(t *testing.T) {
s, eco, _ := setup(t)
s.AttachAttendanceMail(nil)
if _, _, _, err := s.Handle("/Attendance", req(1)); err == nil {
t.Fatal("missing mailbox accepted")
}
if eco.calls != 0 || len(s.state.Attendance) != 0 || len(s.state.LoginDays) != 0 || len(s.state.Receipts) != 0 {
t.Fatal("missing mailbox granted or retained state")
}
}
@@ -10,6 +10,7 @@ import (
func TestAttendancePremiumRequiresOwnedTicketAndDoesNotDuplicate(t *testing.T) {
for _, paid := range []bool{false, true} {
s, eco, store := setup(t)
m := s.attendanceMail.(*attendanceMailStub)
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 })
@@ -19,32 +20,32 @@ func TestAttendancePremiumRequiresOwnedTicketAndDoesNotDuplicate(t *testing.T) {
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)
if _, present, _ := wire.Bytes(response, 1001); present {
t.Fatal("attendance directly granted rewards")
}
receipt, ok, err := wire.Bytes(response, 1002)
if err != nil || !ok || string(receipt) != "test:/Attendance:1" {
t.Fatal("durable receipt absent", string(receipt), err)
if m.identity != "test:/Attendance:1" || m.title == "" || m.body == "" || !m.sentAt.Equal(s.now()) {
t.Fatal("mail identity or content absent")
}
want := 1
if paid {
want = 2
}
if len(eco.rewards) != want {
t.Fatal("premium ticket ignored", paid, eco.rewards)
if len(m.rewards) != want {
t.Fatal("premium ticket ignored", paid, m.rewards)
}
if _, replay, _, err := s.Handle("/Attendance", claim); err != nil || eco.calls != 1 || !bytes.Equal(replay, response) {
if _, replay, _, err := s.Handle("/Attendance", claim); err != nil || m.calls != 1 || eco.calls != 0 || !bytes.Equal(replay, response) {
t.Fatal("claim replay duplicated", err)
}
reopened, err := Open(store, s.design, s.registry, eco)
if err != nil {
t.Fatal(err)
}
reopened.AttachAttendanceMail(m)
reopened.now = s.now
reopened.SetSession("test")
_, replay, _, err := reopened.Handle("/Attendance", claim)
if err != nil || eco.calls != 1 || !bytes.Equal(replay, response) {
if err != nil || m.calls != 1 || eco.calls != 0 || !bytes.Equal(replay, response) {
t.Fatal("restart lost reward envelope", err)
}
}
+21 -13
View File
@@ -24,6 +24,13 @@ import (
type Economy interface {
Apply(string, []gamedata.Reward, []gamedata.Reward) ([]byte, error)
}
// AttendanceMailIssuer delivers attendance attachments inside the parent
// account transaction. Its durable identity prevents duplicate mail on replay.
type AttendanceMailIssuer interface {
IssueAttachmentsOnce(identity, title, body string, rewards []gamedata.Reward, sentAt time.Time) error
}
type attendance struct {
Group, Count uint64
LastDay string
@@ -69,6 +76,7 @@ type Service struct {
unlocked func(uint64, uint64) bool
authorizeCash func(uint64, uint64) bool
attendancePremium func(uint64) bool
attendanceMail AttendanceMailIssuer
associated func(events.Schedule) uint64
}
@@ -135,6 +143,15 @@ func (s *Service) AttachCashAuthorization(f func(uint64, uint64) bool) {
func (s *Service) AttachAttendancePremium(f func(uint64) bool) { s.attendancePremium = f }
func (s *Service) AttachAssociatedMissionGroup(f func(events.Schedule) uint64) { s.associated = f }
func (s *Service) AttachAttendanceMail(issuer AttendanceMailIssuer) { s.attendanceMail = issuer }
func (s *Service) mailAttendance(identity string, rewards []gamedata.Reward) error {
if s.attendanceMail == nil {
return errors.New("eventtasks: attendance mailbox unavailable")
}
return s.attendanceMail.IssueAttachmentsOnce(identity, "Attendance Rewards", "Your attendance rewards are ready. Please claim the attachments in your mailbox.", rewards, s.now())
}
func (s *Service) SetNewbieStep(step uint64) error {
s.mu.Lock()
defer s.mu.Unlock()
@@ -497,12 +514,9 @@ func (s *Service) handle(path string, b []byte, identity string) ([]byte, error)
}
}
if len(granted) > 0 {
bundle, err := s.economy.Apply(identity, nil, granted)
if err != nil {
if err := s.mailAttendance(identity, granted); err != nil {
return nil, err
}
out = wire.AppendBytes(out, 1001, bundle)
out = wire.AppendString(out, 1002, identity)
}
return out, nil
case "/AttendanceInfo":
@@ -563,18 +577,12 @@ func (s *Service) handle(path string, b []byte, identity string) ([]byte, error)
if len(rewards) == 0 {
return nil, errors.New("eventtasks: attendance day unavailable")
}
bundle, e := s.economy.Apply(identity, nil, rewards)
if e != nil {
return nil, e
if err := s.mailAttendance(identity, rewards); err != nil {
return nil, err
}
a.Obtained[ck] = true
a.History[ck] = true
// Frozen EventRewardResponse has no known fields. The owned-client plugin
// applies this actual grant once using the durable request receipt; Handle
// persists the complete response for replay before the account commits.
out := wire.AppendBytes(nil, 1001, bundle)
out = wire.AppendString(out, 1002, identity)
return out, nil
return nil, nil
case "/EventMissionInfo":
var out []byte
for _, v := range s.taskSchedules() {
+33 -4
View File
@@ -25,6 +25,32 @@ func (e *economyStub) Apply(_ string, c []gamedata.Reward, r []gamedata.Reward)
e.rewards = append(e.rewards, r...)
return []byte{10, 0}, e.err
}
type attendanceMailStub struct {
calls int
rewards []gamedata.Reward
identity, title, body string
sentAt time.Time
err error
issued map[string]bool
}
func (m *attendanceMailStub) IssueAttachmentsOnce(identity, title, body string, rewards []gamedata.Reward, sentAt time.Time) error {
if m.err != nil {
return m.err
}
if m.issued == nil {
m.issued = map[string]bool{}
}
if m.issued[identity] {
return nil
}
m.issued[identity] = true
m.calls++
m.rewards = append(m.rewards, rewards...)
m.identity, m.title, m.body, m.sentAt = identity, title, body, sentAt
return nil
}
func setup(t *testing.T) (*Service, *economyStub, stateio.Store) {
t.Helper()
now := time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC)
@@ -41,6 +67,7 @@ func setup(t *testing.T) (*Service, *economyStub, stateio.Store) {
}
s.now = func() time.Time { return now }
s.SetSession("test")
s.AttachAttendanceMail(&attendanceMailStub{})
return s, e, store
}
func req(seq uint64) []byte { return wire.AppendVarint(nil, 1, seq) }
@@ -94,7 +121,8 @@ func TestMissionRejectsFabricatedProgressAndReplaySurvivesRestart(t *testing.T)
}
}
func TestAttendanceDailyCounterAndClaimEligibility(t *testing.T) {
s, e, _ := setup(t)
s, eco, _ := setup(t)
m := s.attendanceMail.(*attendanceMailStub)
if _, _, _, err := s.Handle("/Attendance", req(1)); err != nil {
t.Fatal(err)
}
@@ -111,7 +139,7 @@ func TestAttendanceDailyCounterAndClaimEligibility(t *testing.T) {
if _, _, _, err := s.Handle("/EventReward", claim); err == nil {
t.Fatal("automatic reward claimed again")
}
if e.calls != 1 {
if eco.calls != 0 || m.calls != 1 {
t.Fatal("attendance reward missing")
}
again := wire.AppendVarint(nil, 1, 4)
@@ -122,7 +150,8 @@ func TestAttendanceDailyCounterAndClaimEligibility(t *testing.T) {
}
func TestAttendanceNextGroupAndRepeatedChainReward(t *testing.T) {
s, e, _ := setup(t)
s, eco, _ := setup(t)
m := s.attendanceMail.(*attendanceMailStub)
s.design.Attendance[1] = gamedata.EventAttendance{ID: 1, Group: 20}
s.design.AttendanceGroups = map[[2]uint64]gamedata.EventAttendanceGroup{{20, 5}: {Group: 20, ID: 5, Next: 6}, {20, 6}: {Group: 20, ID: 6, Next: 5}}
s.design.AttendanceRewards = map[uint64][]gamedata.EventAttendanceReward{5: {{Group: 5, ID: 1, Day: 1, Basic: gamedata.Reward{Type: 4, Count: 5}}}, 6: {{Group: 6, ID: 1, Day: 1, Basic: gamedata.Reward{Type: 4, Count: 6}}}}
@@ -151,7 +180,7 @@ func TestAttendanceNextGroupAndRepeatedChainReward(t *testing.T) {
seq++
today = today.Add(24 * time.Hour)
}
if e.calls != 3 || len(s.state.Attendance["1"].History) != 2 {
if eco.calls != 0 || m.calls != 3 || len(s.state.Attendance["1"].History) != 2 {
t.Fatal("repeated cycle reward or retained history missing")
}
}
+90
View File
@@ -0,0 +1,90 @@
package mail
import (
"errors"
"fmt"
"math"
"reflect"
"strings"
"time"
"bd2server/internal/server/gamedata"
)
// AttendanceRewardEconomy resolves attachment wrappers only when claimed and
// applies every reward to its owning domain under the account transaction.
type AttendanceRewardEconomy interface {
Apply(string, []gamedata.Reward, []gamedata.Reward) ([]byte, error)
}
func (s *Service) AttachAttendanceRewardEconomy(e AttendanceRewardEconomy) error {
if s == nil || e == nil {
return errors.New("mail: invalid attendance reward economy")
}
s.mu.Lock()
defer s.mu.Unlock()
s.attendanceEconomy = e
return nil
}
// IssueAttachmentsOnce implements the eventtasks attendance issuer. No wallet,
// inventory mutation or random reward selection occurs during mail creation.
func (s *Service) IssueAttachmentsOnce(identity, title, body string, rewards []gamedata.Reward, sentAt time.Time) error {
if s == nil {
return errors.New("mail: attendance issuer unavailable")
}
s.mu.Lock()
defer s.mu.Unlock()
if s.attendanceEconomy == nil {
return errors.New("mail: attendance reward economy unavailable")
}
if strings.TrimSpace(identity) == "" || identity != strings.TrimSpace(identity) || len(identity) > 450 || strings.TrimSpace(title) == "" || len(title) > 2000 || strings.TrimSpace(body) == "" || len(body) > 20000 || sentAt.UnixMilli() <= 0 || sentAt.UnixMilli() > math.MaxInt64-int64(30*24*time.Hour/time.Millisecond) {
return errors.New("mail: invalid attendance mail")
}
if err := s.validateAttendanceAttachments(rewards); err != nil {
return err
}
key := "attendance:" + identity
if id, exists := s.issued[key]; exists {
previous := s.dynamic[id]
if previous.Title != title || previous.Body != body || !reflect.DeepEqual(mailAttachments(previous), rewards) {
return errors.New("mail: attendance identity reused with different attachments")
}
return nil
}
return s.enqueueCompensations([]compensation{{identity: key, title: title, body: body, rewards: rewards, sentAt: sentAt}})
}
func (s *Service) isAttendanceMail(id uint64) bool {
for identity, mailID := range s.issued {
if mailID == id && strings.HasPrefix(identity, "attendance:") {
return true
}
}
return false
}
func mailAttachments(entry MailDBInfo) []gamedata.Reward {
rewards := make([]gamedata.Reward, len(entry.RewardTypes))
for i, typ := range entry.RewardTypes {
rewards[i] = gamedata.Reward{Type: typ, ID: entry.RewardIDs[i], Count: entry.RewardCounts[i]}
}
return rewards
}
func (s *Service) validateAttendanceAttachments(rewards []gamedata.Reward) error {
if len(rewards) == 0 {
return errors.New("mail: empty attendance attachments")
}
currencies := map[uint64]bool{2: true, 3: true, 4: true, 12: true, 15: true, 16: true, 18: true, 20: true, 21: true, 22: true, 23: true, 24: true, 30: true, 31: true, 32: true, 33: true, 43: true, 44: true, 60: true, 68: true}
items := map[uint64]bool{5: true, 7: true, 8: true, 9: true, 10: true, 11: true, 13: true, 14: true, 17: true, 19: true, 25: true, 26: true, 27: true, 29: true, 34: true, 45: true, 46: true, 47: true, 49: true}
for _, r := range rewards {
if r.Count == 0 || r.Count > math.MaxInt32 || r.ID > math.MaxInt32 || (!currencies[r.Type] && !items[r.Type]) || (currencies[r.Type] && r.ID != 0) || (items[r.Type] && r.ID == 0) {
return fmt.Errorf("mail: invalid attendance reward %d:%d:%d", r.Type, r.ID, r.Count)
}
if r.Type == 19 && !s.supportedItemDBInfoReward(r) {
return errors.New("mail: unsupported attendance content ticket")
}
}
return nil
}
+156
View File
@@ -0,0 +1,156 @@
package mail
import (
"bytes"
"testing"
"time"
"bd2server/internal/server/events"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/player"
"bd2server/internal/server/stateio"
"bd2server/internal/server/wire"
)
type attendanceDesign struct{ selections int }
func (*attendanceDesign) Character(uint64) (gamedata.CharacterDesign, bool) {
return gamedata.CharacterDesign{}, false
}
func (*attendanceDesign) RollOptions(uint64) ([]gamedata.EquipmentOptionChoice, []gamedata.EquipmentOptionChoice, *gamedata.EquipmentOptionChoice, error) {
return nil, nil, nil, nil
}
func (d *attendanceDesign) Resolve(r []gamedata.BattleReward) ([]gamedata.BattleReward, error) {
return d.ResolveGranted(r)
}
func (d *attendanceDesign) ResolveGranted(r []gamedata.BattleReward) ([]gamedata.BattleReward, error) {
var out []gamedata.BattleReward
for _, reward := range r {
if reward.Type == 9 && reward.ID == 100 {
d.selections++
out = append(out, gamedata.BattleReward{Type: 4, Count: 10 * reward.Count}, gamedata.BattleReward{Type: 8, ID: 1000, Count: 2 * reward.Count}, gamedata.BattleReward{Type: 9, ID: 200, Count: reward.Count})
} else {
out = append(out, reward)
}
}
return out, nil
}
func attendanceMailFixture(t *testing.T, store stateio.Store, seed *Starter, d *attendanceDesign) (*Service, *player.Inventory, *player.Wallet) {
t.Helper()
items, err := player.OpenInventory(store, &player.Starter{Version: "2.35.10"})
if err != nil {
t.Fatal(err)
}
wallet, err := player.OpenWallet(store, player.Currency{})
if err != nil {
t.Fatal(err)
}
collection, err := player.OpenCollectionStore(store, nil)
if err != nil {
t.Fatal(err)
}
equipment, err := player.OpenEquipmentInventory(store)
if err != nil {
t.Fatal(err)
}
for _, persist := range []func() error{items.EnsurePersisted, wallet.EnsurePersisted, collection.EnsurePersisted, equipment.EnsurePersisted} {
if err := persist(); err != nil {
t.Fatal(err)
}
}
economy, err := events.NewEconomy(store, items, wallet, collection, equipment, d, d, d, nil)
if err != nil {
t.Fatal(err)
}
service, err := OpenService(store, seed, items, wallet)
if err != nil {
t.Fatal(err)
}
if err := service.AttachAttendanceRewardEconomy(economy); err != nil {
t.Fatal(err)
}
return service, items, wallet
}
func TestAttendanceMailDefersRewardsAndReplaysAcrossRestart(t *testing.T) {
store := stateio.NewMemory()
seed := &Starter{Version: "2.35.10", MailCount: 1, MaxMailID: 100}
design := &attendanceDesign{}
s, items, wallet := attendanceMailFixture(t, store, seed, design)
now := time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC)
s.now = func() time.Time { return now }
reward := []gamedata.Reward{{Type: 9, ID: 100, Count: 1}}
if err := s.IssueAttachmentsOnce("daily:1", "签到奖励", "请领取", reward, now); err != nil {
t.Fatal(err)
}
if err := s.IssueAttachmentsOnce("daily:1", "签到奖励", "请领取", reward, now.Add(time.Minute)); err != nil {
t.Fatal(err)
}
if err := s.IssueAttachmentsOnce("daily:1", "签到奖励", "请领取", []gamedata.Reward{{Type: 4, Count: 999}}, now); err == nil {
t.Fatal("identity accepted changed attachments")
}
if len(s.dynamic) != 1 || wallet.Snapshot().Gold != 0 || len(items.All()) != 0 || design.selections != 0 {
t.Fatal("issuing mail granted rewards")
}
if err := s.IssueAttachmentsOnce("daily:2", "签到奖励", "请领取", reward, now); err != nil {
t.Fatal(err)
}
if s.issued["attendance:daily:2"] != 102 {
t.Fatal("mail ID did not increase")
}
request := wire.AppendVarint(nil, 1, 1)
request = wire.AppendVarint(request, 2, 101)
request = wire.AppendVarint(request, 2, 102)
_, response, _, err := s.Handle("/MailOpen", request)
if err != nil {
t.Fatal(err)
}
if wallet.Snapshot().Gold != 20 || design.selections != 2 || len(items.All()) != 4 || len(s.history) != 2 {
t.Fatalf("claim missing domains/history: gold=%d selections=%d items=%d", wallet.Snapshot().Gold, design.selections, len(items.All()))
}
for _, item := range items.All() {
if item.Type == 9 && item.ID != 200 {
t.Fatal("OPEN wrapper stored instead of DIRECT child")
}
}
reopened, items, wallet := attendanceMailFixture(t, store, seed, design)
reopened.now = s.now
_, replay, _, err := reopened.Handle("/MailOpen", request)
if err != nil || !bytes.Equal(response, replay) || design.selections != 2 || wallet.Snapshot().Gold != 20 || len(items.All()) != 4 {
t.Fatal("restart replay changed or repeated grants", err)
}
if err := reopened.IssueAttachmentsOnce("expired", "签到奖励", "请领取", reward, now.Add(-31*24*time.Hour)); err != nil {
t.Fatal(err)
}
expired := wire.AppendVarint(nil, 1, 2)
expired = wire.AppendVarint(expired, 2, 103)
if _, _, _, err := reopened.Handle("/MailOpen", expired); err == nil || design.selections != 2 || len(reopened.history) != 2 {
t.Fatal("expired mail granted")
}
}
func TestAttendanceEconomyDoesNotRegrantExistingOpenedMail(t *testing.T) {
store := stateio.NewMemory()
seed := &Starter{Version: "2.35.10", MailCount: 1, MaxMailID: 100}
design := &attendanceDesign{}
s, _, wallet := attendanceMailFixture(t, store, seed, design)
now := time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC)
s.now = func() time.Time { return now }
if err := s.EnqueueCompensation("existing:grant", "奖励", "请领取", []gamedata.Reward{{Type: 4, Count: 7}}, now); err != nil {
t.Fatal(err)
}
request := wire.AppendVarint(nil, 1, 1)
request = wire.AppendVarint(request, 2, 101)
if _, _, _, err := s.Handle("/MailOpen", request); err != nil {
t.Fatal(err)
}
reopened, _, wallet := attendanceMailFixture(t, store, seed, design)
reopened.now = s.now
if _, _, _, err := reopened.Handle("/MailOpen", request); err != nil {
t.Fatal(err)
}
if wallet.Snapshot().Gold != 7 || design.selections != 0 {
t.Fatal("existing opened mail routed to new attendance economy")
}
}
+100 -76
View File
@@ -239,22 +239,24 @@ type stateSnapshot struct {
// delivery. Starter is immutable source data; mutable rows live in the player
// state alongside the bounded core snapshot.
type Service struct {
contentTickets *gamedata.GachaContentTicketDesign
mu sync.Mutex
Starter *Starter
seedPath string
seedStamp fileStamp
grantSpoolPath string
storage stateio.AtomicEntryStore
inventory *player.Inventory
wallet *player.Wallet
collection *player.CollectionStore
costumeDesign player.CostumeDesignSource
state stateSnapshot
dynamic map[uint64]MailDBInfo
issued map[string]uint64
history map[uint64]MailDBInfo
now func() time.Time
contentTickets *gamedata.GachaContentTicketDesign
attendanceEconomy AttendanceRewardEconomy
beforeMailID uint64
mu sync.Mutex
Starter *Starter
seedPath string
seedStamp fileStamp
grantSpoolPath string
storage stateio.AtomicEntryStore
inventory *player.Inventory
wallet *player.Wallet
collection *player.CollectionStore
costumeDesign player.CostumeDesignSource
state stateSnapshot
dynamic map[uint64]MailDBInfo
issued map[string]uint64
history map[uint64]MailDBInfo
now func() time.Time
}
func (s *Service) AttachContentTickets(design *gamedata.GachaContentTicketDesign) error {
@@ -555,6 +557,20 @@ func (s *Service) open(request []byte) ([]byte, error) {
return nil, fmt.Errorf("mail: unknown mail %d", id)
}
}
// Validate the entire new attendance batch before any reward domain writes.
for _, entry := range selected {
if s.isAttendanceMail(entry.MailID) {
if s.attendanceEconomy == nil {
return nil, errors.New("mail: attendance reward economy unavailable")
}
if !containsID(s.state.Opened, entry.MailID) && uint64(s.now().UnixMilli()) >= entry.ExpiresAt {
return nil, errors.New("mail: attendance mail expired")
}
if err := s.validateAttendanceAttachments(mailAttachments(entry)); err != nil {
return nil, err
}
}
}
var bundle []byte
next := stateSnapshot{Version: s.state.Version, Opened: append([]uint64(nil), s.state.Opened...), NextDynamicMailID: s.state.NextDynamicMailID}
@@ -563,74 +579,82 @@ func (s *Service) open(request []byte) ([]byte, error) {
newHistory := make([]MailDBInfo, 0, len(selected))
for _, entry := range selected {
identity := fmt.Sprintf("mail:%d", entry.MailID)
rewards := make([]gamedata.Reward, len(entry.RewardTypes))
var items []gamedata.BattleReward
var costumeIDs []uint64
for i := range entry.RewardTypes {
reward := gamedata.Reward{Type: entry.RewardTypes[i], ID: entry.RewardIDs[i], Count: entry.RewardCounts[i]}
rewards[i] = reward
switch {
case currencyRewardTypes[reward.Type]:
currency := wire.AppendVarint(nil, 3, reward.Type)
currency = wire.AppendVarint(currency, 4, reward.Count)
bundle = wire.AppendBytes(bundle, 1, currency)
case reward.Type == 11:
if s.collection == nil || s.costumeDesign == nil || reward.ID == 0 || reward.Count == 0 || reward.Count > 6 {
return nil, errors.New("mail: costume reward service unavailable or reward invalid")
}
for copy := uint64(0); copy < reward.Count; copy++ {
costumeIDs = append(costumeIDs, reward.ID)
}
case reward.Type == 28:
// DataManager recognizes this type, but RewardDBInfoBundle
// carries MyRoomTrophyDBInfo in a separate field. Encoding it as
// ItemDBInfo would make the local state and client model disagree.
return nil, errors.New("mail: my-room trophy rewards require MyRoomTrophyDBInfo")
default:
if !s.supportedItemDBInfoReward(reward) {
return nil, fmt.Errorf("mail: unsupported reward type %d", reward.Type)
}
if reward.ID == 0 || reward.Count == 0 {
return nil, errors.New("mail: invalid item reward")
}
items = append(items, gamedata.BattleReward{Type: reward.Type, ID: reward.ID, Count: reward.Count})
}
}
if _, err := s.wallet.GrantQuestOnce(identity+":currency", rewards); err != nil {
return nil, err
}
granted, err := s.inventory.GrantOnce(identity+":items", items)
if err != nil {
return nil, err
}
if len(granted) == 0 {
granted = s.inventory.GrantedItems(identity + ":items")
}
for _, item := range granted {
bundle = wire.AppendBytes(bundle, 1, player.ItemWire(item))
view := wire.AppendVarint(nil, 2, item.ID)
view = wire.AppendVarint(view, 3, item.Type)
view = wire.AppendVarint(view, 4, item.Count)
bundle = wire.AppendBytes(bundle, 6, view)
}
if len(costumeIDs) != 0 {
grant, err := s.collection.GrantCostumes(identity+":costumes", costumeIDs, s.costumeDesign)
if s.isAttendanceMail(entry.MailID) {
granted, err := s.attendanceEconomy.Apply(identity+":attendance", nil, mailAttachments(entry))
if err != nil {
return nil, err
}
var mileage uint64
for _, exchange := range grant.Exchanges {
if exchange.ExchangeItemType != 20 || ^uint64(0)-mileage < exchange.ExchangeCount {
return nil, errors.New("mail: unsupported costume overflow exchange")
bundle = append(bundle, granted...)
} else {
rewards := make([]gamedata.Reward, len(entry.RewardTypes))
var items []gamedata.BattleReward
var costumeIDs []uint64
for i := range entry.RewardTypes {
reward := gamedata.Reward{Type: entry.RewardTypes[i], ID: entry.RewardIDs[i], Count: entry.RewardCounts[i]}
rewards[i] = reward
switch {
case currencyRewardTypes[reward.Type]:
currency := wire.AppendVarint(nil, 3, reward.Type)
currency = wire.AppendVarint(currency, 4, reward.Count)
bundle = wire.AppendBytes(bundle, 1, currency)
case reward.Type == 11:
if s.collection == nil || s.costumeDesign == nil || reward.ID == 0 || reward.Count == 0 || reward.Count > 6 {
return nil, errors.New("mail: costume reward service unavailable or reward invalid")
}
for copy := uint64(0); copy < reward.Count; copy++ {
costumeIDs = append(costumeIDs, reward.ID)
}
case reward.Type == 28:
// DataManager recognizes this type, but RewardDBInfoBundle
// carries MyRoomTrophyDBInfo in a separate field. Encoding it as
// ItemDBInfo would make the local state and client model disagree.
return nil, errors.New("mail: my-room trophy rewards require MyRoomTrophyDBInfo")
default:
if !s.supportedItemDBInfoReward(reward) {
return nil, fmt.Errorf("mail: unsupported reward type %d", reward.Type)
}
if reward.ID == 0 || reward.Count == 0 {
return nil, errors.New("mail: invalid item reward")
}
items = append(items, gamedata.BattleReward{Type: reward.Type, ID: reward.ID, Count: reward.Count})
}
mileage += exchange.ExchangeCount
}
if mileage != 0 {
if _, err := s.wallet.GrantMileageOnce(identity+":costume-overflow", mileage); err != nil {
if _, err := s.wallet.GrantQuestOnce(identity+":currency", rewards); err != nil {
return nil, err
}
granted, err := s.inventory.GrantOnce(identity+":items", items)
if err != nil {
return nil, err
}
if len(granted) == 0 {
granted = s.inventory.GrantedItems(identity + ":items")
}
for _, item := range granted {
bundle = wire.AppendBytes(bundle, 1, player.ItemWire(item))
view := wire.AppendVarint(nil, 2, item.ID)
view = wire.AppendVarint(view, 3, item.Type)
view = wire.AppendVarint(view, 4, item.Count)
bundle = wire.AppendBytes(bundle, 6, view)
}
if len(costumeIDs) != 0 {
grant, err := s.collection.GrantCostumes(identity+":costumes", costumeIDs, s.costumeDesign)
if err != nil {
return nil, err
}
var mileage uint64
for _, exchange := range grant.Exchanges {
if exchange.ExchangeItemType != 20 || ^uint64(0)-mileage < exchange.ExchangeCount {
return nil, errors.New("mail: unsupported costume overflow exchange")
}
mileage += exchange.ExchangeCount
}
if mileage != 0 {
if _, err := s.wallet.GrantMileageOnce(identity+":costume-overflow", mileage); err != nil {
return nil, err
}
}
bundle = appendCollectionRewardBundle(bundle, s.collection, grant)
}
bundle = appendCollectionRewardBundle(bundle, s.collection, grant)
}
if !containsID(next.Opened, entry.MailID) {
next.Opened = append(next.Opened, entry.MailID)
+23
View File
@@ -0,0 +1,23 @@
package mail
import "bd2server/internal/server/wire"
// BeforeDispatch and AfterDispatch observe newly allocated mail inside the
// account request transaction, including mail issued by other domains.
func (s *Service) BeforeDispatch(string, []byte) error {
s.mu.Lock()
defer s.mu.Unlock()
s.beforeMailID = s.state.NextDynamicMailID
return nil
}
func (s *Service) AfterDispatch(string, []byte, []byte) ([]byte, error) {
s.mu.Lock()
defer s.mu.Unlock()
for id, entry := range s.dynamic {
if id >= s.beforeMailID && !containsID(s.state.Opened, id) && entry.ExpiresAt > uint64(s.now().UnixMilli()) {
return wire.AppendVarint(nil, 1, 1), nil // Notify.IsNewMail
}
}
return nil, nil
}
@@ -0,0 +1,34 @@
package mail
import (
"testing"
"time"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/stateio"
"bd2server/internal/server/wire"
)
func TestNewMailNotificationDoesNotRepeatOnUnchangedRequests(t *testing.T) {
s, _, _ := attendanceMailFixture(t, stateio.NewMemory(), &Starter{Version: "2.35.10", MailCount: 1}, &attendanceDesign{})
now := time.Now().UTC()
if err := s.BeforeDispatch("/Attendance", nil); err != nil {
t.Fatal(err)
}
if err := s.IssueAttachmentsOnce("today", "Attendance Rewards", "Claim your rewards.", []gamedata.Reward{{Type: 9, ID: 100, Count: 1}}, now); err != nil {
t.Fatal(err)
}
notice, err := s.AfterDispatch("/Attendance", nil, nil)
if v, ok, parseErr := wire.Varint(notice, 1); err != nil || parseErr != nil || !ok || v != 1 {
t.Fatal("new mail did not notify", err, parseErr)
}
if err := s.BeforeDispatch("/read", nil); err != nil {
t.Fatal(err)
}
if err := s.IssueAttachmentsOnce("today", "Attendance Rewards", "Claim your rewards.", []gamedata.Reward{{Type: 9, ID: 100, Count: 1}}, now); err != nil {
t.Fatal(err)
}
if notice, err := s.AfterDispatch("/read", nil, nil); err != nil || len(notice) != 0 {
t.Fatal("unchanged mail notified again", err)
}
}
@@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"path/filepath"
"reflect"
"testing"
"time"
@@ -13,6 +14,7 @@ import (
"bd2server/internal/server/events"
"bd2server/internal/server/eventtasks"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/mail"
"bd2server/internal/server/player"
"bd2server/internal/server/protocol"
"bd2server/internal/server/wire"
@@ -60,12 +62,13 @@ func TestAttendanceBatchRollsBackAndSuccessfulRetryGrantsOnce(t *testing.T) {
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) {
open := func() (*accountstate.Repository, *player.Wallet, *player.Inventory, *eventtasks.Service, *mail.Service, *attendanceTransactionEconomy) {
t.Helper()
repo, err := accountstate.Open(path)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = repo.Close() })
wallet, err := player.OpenWallet(repo, player.Currency{Gold: 12})
if err != nil {
t.Fatal(err)
@@ -79,11 +82,19 @@ func TestAttendanceBatchRollsBackAndSuccessfulRetryGrantsOnce(t *testing.T) {
if err != nil {
t.Fatal(err)
}
return repo, wallet, inv, svc, eco
mailbox, err := mail.OpenService(repo, &mail.Starter{Version: "2.35.10", MailCount: 1}, inv, wallet)
if err != nil {
t.Fatal(err)
}
if err := mailbox.AttachAttendanceRewardEconomy(eco); err != nil {
t.Fatal(err)
}
svc.AttachAttendanceMail(mailbox)
return repo, wallet, inv, svc, mailbox, eco
}
makeServer := func(repo *accountstate.Repository, svc *eventtasks.Service) *Server {
makeServer := func(repo *accountstate.Repository, svc *eventtasks.Service, mailbox *mail.Service) *Server {
t.Helper()
server, err := NewServer(fakeLogin{}, svc, &mutatingDomain{store: repo})
server, err := NewServer(fakeLogin{}, svc, mailbox, &mutatingDomain{store: repo})
if err != nil {
t.Fatal(err)
}
@@ -92,9 +103,13 @@ func TestAttendanceBatchRollsBackAndSuccessfulRetryGrantsOnce(t *testing.T) {
}
return server
}
batch := func(server *Server, cookie string, seq uint64, fail bool) error {
batch := func(server *Server, cookie, endpoint string, seq uint64, fail bool) error {
t.Helper()
requests := []protocol.BatchRequest{{Path: "/Attendance", RequestData: base64.StdEncoding.EncodeToString(wire.AppendVarint(nil, 1, seq))}}
request := wire.AppendVarint(nil, 1, seq)
if endpoint == "/MailOpen" {
request = wire.AppendVarint(request, 2, 1)
}
requests := []protocol.BatchRequest{{Path: endpoint, RequestData: base64.StdEncoding.EncodeToString(request)}}
if fail {
requests = append(requests, protocol.BatchRequest{Path: "/InjectedFailure", RequestData: base64.StdEncoding.EncodeToString(wire.AppendVarint(nil, 1, seq+1))})
}
@@ -109,7 +124,30 @@ func TestAttendanceBatchRollsBackAndSuccessfulRetryGrantsOnce(t *testing.T) {
_, err = server.DispatchRaw("/BatchRequest", []byte(body), "s="+cookie)
return err
}
repo, wallet, inv, svc, eco := open()
mailState := func(repo *accountstate.Repository) map[string]any {
t.Helper()
result := map[string]any{}
core, err := repo.Load("mail")
if err != nil {
t.Fatal(err)
}
result["core"] = string(core)
for _, bucket := range []string{"dynamic", "issued", "history"} {
entries, err := repo.ListEntries("mail", bucket)
if err != nil {
t.Fatal(err)
}
result[bucket] = entries
}
return result
}
assertUnclaimed := func(wallet *player.Wallet, inv *player.Inventory) {
t.Helper()
if wallet.Snapshot().Gold != 12 || len(inv.All()) != 0 {
t.Fatalf("unclaimed mail credited rewards: wallet=%+v items=%+v", wallet.Snapshot(), inv.All())
}
}
repo, wallet, inv, svc, mailbox, eco := open()
if err := wallet.EnsurePersisted(); err != nil {
t.Fatal(err)
}
@@ -123,19 +161,20 @@ func TestAttendanceBatchRollsBackAndSuccessfulRetryGrantsOnce(t *testing.T) {
if err != nil {
t.Fatal(err)
}
server := makeServer(repo, svc)
beforeMail := mailState(repo)
server := makeServer(repo, svc, mailbox)
session := login(t, server)
if err := batch(server, session.Cookie, 2, true); err == nil {
if err := batch(server, session.Cookie, "/Attendance", 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 eco.calls != 0 {
t.Fatalf("attendance credited economy instead of mailing rewards: 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()
repo, wallet, inv, svc, mailbox, eco = open()
after, err := repo.Load("eventtasks")
if err != nil {
t.Fatal(err)
@@ -143,29 +182,103 @@ func TestAttendanceBatchRollsBackAndSuccessfulRetryGrantsOnce(t *testing.T) {
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())
if !reflect.DeepEqual(mailState(repo), beforeMail) {
t.Fatal("attendance mail or issued ledger survived rollback")
}
server = makeServer(repo, svc)
assertUnclaimed(wallet, inv)
server = makeServer(repo, svc, mailbox)
session = login(t, server)
if err := batch(server, session.Cookie, 2, false); err != nil {
if err := batch(server, session.Cookie, "/Attendance", 2, false); err != nil {
t.Fatal(err)
}
if err := batch(server, session.Cookie, 3, false); err != nil {
if err := batch(server, session.Cookie, "/Attendance", 3, false); err != nil {
t.Fatal(err)
}
if eco.calls != 1 {
t.Fatalf("new sequence duplicated attendance reward: calls=%d", eco.calls)
if eco.calls != 0 {
t.Fatalf("attendance mail prematurely credited economy: calls=%d", eco.calls)
}
assertUnclaimed(wallet, inv)
issuedMail := mailState(repo)
if len(issuedMail["dynamic"].(map[string][]byte)) != 1 || len(issuedMail["issued"].(map[string][]byte)) != 1 {
t.Fatal("attendance did not issue exactly one mail")
}
if err := repo.Close(); err != nil {
t.Fatal(err)
}
repo, wallet, inv, _, _ = open()
repo, wallet, inv, svc, mailbox, eco = open()
server = makeServer(repo, svc, mailbox)
session = login(t, server)
if err := batch(server, session.Cookie, "/Attendance", 2, false); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(mailState(repo), issuedMail) {
t.Fatal("relogin duplicated attendance mail")
}
assertUnclaimed(wallet, inv)
if err := batch(server, session.Cookie, "/MailOpen", 3, true); err == nil {
t.Fatal("failed mailbox batch accepted")
}
if eco.calls != 1 {
t.Fatalf("mail claim did not reach real economy before rollback: calls=%d", eco.calls)
}
if err := repo.Close(); err != nil {
t.Fatal(err)
}
repo, wallet, inv, svc, mailbox, eco = open()
if !reflect.DeepEqual(mailState(repo), issuedMail) {
t.Fatal("mail claim/history persisted after failed batch")
}
assertUnclaimed(wallet, inv)
server = makeServer(repo, svc, mailbox)
session = login(t, server)
if err := batch(server, session.Cookie, "/MailOpen", 2, false); err != nil {
t.Fatal(err)
}
if err := batch(server, session.Cookie, "/MailOpen", 2, false); err != nil {
t.Fatal(err)
}
if err := batch(server, session.Cookie, "/MailOpen", 3, false); err != nil {
t.Fatal(err)
}
if wallet.Snapshot().Gold != 112 || len(inv.All()) != 1 || inv.All()[0].Count != 1 {
t.Fatal("mail replay duplicated persisted rewards")
}
if err := repo.Close(); err != nil {
t.Fatal(err)
}
repo, wallet, inv, svc, mailbox, eco = 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)
}
for _, domain := range []string{"wallet", "items"} {
grants, err := repo.ListEntries(domain, "granted")
if err != nil {
t.Fatal(err)
}
if len(grants) != 1 {
t.Fatalf("%s durable grant ledger duplicated or missing: %v", domain, grants)
}
}
claimedMail := mailState(repo)
var claimed struct {
Opened []uint64 `json:"opened"`
}
if err := json.Unmarshal([]byte(claimedMail["core"].(string)), &claimed); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(claimed.Opened, []uint64{1}) || len(claimedMail["history"].(map[string][]byte)) != 1 {
t.Fatal("mail claim/history was not persisted")
}
server = makeServer(repo, svc, mailbox)
session = login(t, server)
if err := batch(server, session.Cookie, "/MailOpen", 2, false); err != nil {
t.Fatal(err)
}
if wallet.Snapshot().Gold != 112 || len(inv.All()) != 1 || inv.All()[0].Count != 1 {
t.Fatal("reopened mail claim duplicated rewards")
}
var state struct {
Attendance map[string]struct {
Count uint64