fix(server): deliver attendance rewards through idempotent mail and notify new messages
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user