Files
bd2/go/internal/server/mail/grant_spool_test.go
T
Flechazo 5b5a219dc2 feat(server): add special loot and data-driven gameplay rules
Generate equipment loot atomically, grant random boxes and prevent repeat claims.
Read gacha pools, pity, products, tickets, missions, presets, chapter rewards
and inventory costs from GameData; extend both collaboration UR ticket pools.
Sync owned recipes, configure startup state and calendars in versioned seeds,
and validate unlock tickets and revival talents.
2026-10-04 19:15:59 +08:00

470 lines
16 KiB
Go

package mail
import (
"bd2server/internal/server/gamedata"
"encoding/json"
"errors"
"math"
"os"
"path/filepath"
"strings"
"testing"
"time"
"bd2server/internal/server/accountstate"
"bd2server/internal/server/player"
"bd2server/internal/server/stateio"
"bd2server/internal/server/wire"
)
func spoolTestService(t *testing.T, store stateio.Store) (*Service, *player.Inventory, *player.Wallet) {
t.Helper()
inventory, 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)
}
service, err := OpenService(store, &Starter{Version: "2.35.10", MailCount: 1, MaxMailID: 100}, inventory, wallet)
if err != nil {
t.Fatal(err)
}
return service, inventory, wallet
}
func spoolGrant(identity string, rewards ...GrantReward) Grant {
return Grant{Identity: identity, Title: "系统奖励", Body: "请领取邮件附件。", SentAt: time.Date(2026, 10, 3, 1, 0, 0, 0, time.UTC).UnixMilli(), Rewards: rewards}
}
func writeSpool(t *testing.T, path string, grants ...Grant) {
t.Helper()
data, err := json.Marshal(GrantSpool{Version: 1, Grants: grants})
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatal(err)
}
}
func TestGrantSpoolImportsDynamicMailOnceAcrossOpenAndRestart(t *testing.T) {
store := stateio.NewMemory()
service, _, wallet := spoolTestService(t, store)
path := filepath.Join(t.TempDir(), "grants.json")
if err := service.AttachGrantSpoolPath(path); err != nil {
t.Fatal(err)
}
request := wire.AppendVarint(nil, 1, 1)
if _, _, _, err := service.Handle("/MailInfo", request); err != nil {
t.Fatal(err)
}
writeSpool(t, path,
spoolGrant("dev-mail:a", GrantReward{Type: 4, Count: 123}),
spoolGrant("dev-mail:b", GrantReward{Type: 8, ID: 9, Count: 10}),
)
// Spool attachment never changes the versioned starter mailbox.
if len(service.Starter.Mails) != 0 {
t.Fatal("starter changed")
}
for i := 0; i < 2; i++ {
code, response, handled, err := service.Handle("/MailInfo", request)
if err != nil || !handled || code != packetCode {
t.Fatalf("code=%d handled=%v err=%v", code, handled, err)
}
count, _, _ := wire.Varint(response, 2)
max, _, _ := wire.Varint(response, 3)
if count != 3 || max != 102 {
t.Fatalf("count=%d max=%d", count, max)
}
}
if wallet.Snapshot().Gold != 0 {
t.Fatal("unopened mail granted currency")
}
open := wire.AppendVarint(wire.AppendVarint(nil, 1, 2), 2, 101)
if _, _, _, err := service.Handle("/MailOpen", open); err != nil {
t.Fatal(err)
}
reopened, inventory, wallet := spoolTestService(t, store)
if err := reopened.AttachGrantSpoolPath(path); err != nil {
t.Fatal(err)
}
_, response, _, err := reopened.Handle("/MailInfo", request)
if err != nil {
t.Fatal(err)
}
if count, _, _ := wire.Varint(response, 2); count != 2 {
t.Fatalf("count=%d", count)
}
if len(reopened.dynamic) != 2 || reopened.state.NextDynamicMailID != 103 {
t.Fatalf("reissued mail: %+v", reopened.state)
}
open = wire.AppendVarint(wire.AppendVarint(nil, 1, 3), 2, 102)
if _, _, _, err := reopened.Handle("/MailOpen", open); err != nil {
t.Fatal(err)
}
if wallet.Snapshot().Gold != 123 || len(inventory.All()) != 1 || inventory.All()[0].Count != 10 {
t.Fatalf("wallet=%+v inventory=%+v", wallet.Snapshot(), inventory.All())
}
if _, _, _, err := reopened.Handle("/MailInfo", request); err != nil {
t.Fatal(err)
}
if reopened.state.NextDynamicMailID != 103 {
t.Fatal("opened grants were reissued")
}
}
func TestGrantSpoolPersistsIssuedIdentityInSQLiteRequestTransaction(t *testing.T) {
dir := t.TempDir()
database := filepath.Join(dir, "state.db")
path := filepath.Join(dir, "grants.json")
writeSpool(t, path, spoolGrant("dev-mail:sqlite", GrantReward{Type: 4, Count: 123}))
repository, err := accountstate.Open(database)
if err != nil {
t.Fatal(err)
}
service, _, _ := spoolTestService(t, repository)
if err := service.AttachGrantSpoolPath(path); err != nil {
t.Fatal(err)
}
operation, err := repository.BeginOperation()
if err != nil {
t.Fatal(err)
}
request := wire.AppendVarint(nil, 1, 1)
if _, _, _, err := service.Handle("/MailInfo", request); err != nil {
t.Fatal(err)
}
if err := operation.Commit(); err != nil {
t.Fatal(err)
}
if err := repository.Close(); err != nil {
t.Fatal(err)
}
repository, err = accountstate.Open(database)
if err != nil {
t.Fatal(err)
}
defer repository.Close()
service, _, _ = spoolTestService(t, repository)
if err := service.AttachGrantSpoolPath(path); err != nil {
t.Fatal(err)
}
operation, err = repository.BeginOperation()
if err != nil {
t.Fatal(err)
}
if _, _, _, err := service.Handle("/MailInfo", request); err != nil {
t.Fatal(err)
}
if err := operation.Commit(); err != nil {
t.Fatal(err)
}
if len(service.dynamic) != 1 || service.issued["dev-mail:sqlite"] != 101 || service.state.NextDynamicMailID != 102 {
t.Fatalf("SQLite restart reissued grant: state=%+v issued=%+v", service.state, service.issued)
}
}
func TestGrantSpoolPaidJewelryAndDrawTicketsClaimAndRestart(t *testing.T) {
store := stateio.NewMemory()
service, _, _ := spoolTestService(t, store)
path := filepath.Join(t.TempDir(), "grants.json")
writeSpool(t, path, spoolGrant("paid-and-draw-tickets",
GrantReward{Type: 2, Count: 100000000},
GrantReward{Type: 8, ID: 1000, Count: 100000000},
GrantReward{Type: 8, ID: 1104, Count: 100000000},
GrantReward{Type: 4, Count: 1000000000},
))
if err := service.AttachGrantSpoolPath(path); err != nil {
t.Fatal(err)
}
if _, _, _, err := service.Handle("/MailInfo", wire.AppendVarint(nil, 1, 1)); err != nil {
t.Fatal(err)
}
request := wire.AppendVarint(wire.AppendVarint(nil, 1, 2), 2, 101)
_, response, _, err := service.Handle("/MailOpen", request)
if err != nil {
t.Fatal(err)
}
bundle, found, err := wire.Bytes(response, 1)
if err != nil || !found {
t.Fatalf("reward bundle missing: %v", err)
}
items := map[[2]uint64]uint64{}
views := map[[2]uint64]uint64{}
if err := wire.Walk(bundle, func(field wire.Field) error {
if field.Number != 1 && field.Number != 6 {
t.Fatalf("unexpected reward bundle field %d", field.Number)
}
id, _, err := wire.Varint(field.Value, 2)
if err != nil {
return err
}
typ, _, err := wire.Varint(field.Value, 3)
if err != nil {
return err
}
count, _, err := wire.Varint(field.Value, 4)
if err != nil {
return err
}
index, _, err := wire.Varint(field.Value, 1)
if err != nil {
return err
}
if field.Number == 1 {
if typ == 8 && index == 0 || typ != 8 && index != 0 {
t.Fatalf("incorrect item instance for type=%d index=%d", typ, index)
}
items[[2]uint64{typ, id}] = count
} else {
views[[2]uint64{typ, id}] = count
}
return nil
}); err != nil {
t.Fatal(err)
}
if len(items) != 4 || items[[2]uint64{2, 0}] != 100000000 || items[[2]uint64{4, 0}] != 1000000000 ||
items[[2]uint64{8, 1000}] != 100000000 || items[[2]uint64{8, 1104}] != 100000000 ||
len(views) != 2 || views[[2]uint64{8, 1000}] != 100000000 || views[[2]uint64{8, 1104}] != 100000000 {
t.Fatalf("items=%v views=%v", items, views)
}
service, inventory, wallet := spoolTestService(t, store)
if err := service.AttachGrantSpoolPath(path); err != nil {
t.Fatal(err)
}
if _, _, _, err := service.Handle("/MailInfo", wire.AppendVarint(nil, 1, 3)); err != nil {
t.Fatal(err)
}
if _, _, _, err := service.Handle("/MailOpen", request); err != nil {
t.Fatal(err)
}
if got := wallet.Snapshot(); got.Jewelry != 100000000 || got.Gold != 1000000000 || len(inventory.All()) != 2 || service.state.NextDynamicMailID != 102 {
t.Fatalf("grant duplicated after restart: wallet=%+v items=%+v state=%+v", got, inventory.All(), service.state)
}
}
func TestGrantSpoolEnforcesMailInt32IDAndCountBoundaries(t *testing.T) {
for _, reward := range []GrantReward{
{Type: 8, ID: math.MaxInt32, Count: math.MaxInt32},
{Type: 2, Count: math.MaxInt32},
{Type: 19, ID: 450030, Count: 1},
} {
input, err := json.Marshal(GrantSpool{Version: 1, Grants: []Grant{spoolGrant("boundary", reward)}})
if err != nil {
t.Fatal(err)
}
if _, err := decodeGrantSpool(input); err != nil {
t.Fatalf("valid boundary reward rejected: %+v: %v", reward, err)
}
}
for _, reward := range []GrantReward{
{Type: 8, ID: math.MaxInt32 + 1, Count: 1},
{Type: 8, ID: 1000, Count: math.MaxInt32 + 1},
{Type: 2, ID: 1, Count: 1},
{Type: 2, Count: math.MaxInt32 + 1},
{Type: 19, ID: 450030, Count: 0},
{Type: 19, ID: 450030, Count: 2},
} {
input, err := json.Marshal(GrantSpool{Version: 1, Grants: []Grant{spoolGrant("boundary", reward)}})
if err != nil {
t.Fatal(err)
}
if _, err := decodeGrantSpool(input); err == nil {
t.Fatalf("invalid boundary reward accepted: %+v", reward)
}
}
}
func TestGrantSpoolOneUseContentTicketItemDBInfoPersistsAndIsIdempotent(t *testing.T) {
// In 2.35.10 CommonPacket.AddItemInfo dispatches ElementType 19 to
// AddContentTicketItem; it consumes a normal ItemDBInfo with an instance ID.
store := stateio.NewMemory()
service, inventory, _ := spoolTestService(t, store)
if err := service.AttachContentTickets(&gamedata.GachaContentTicketDesign{IDs: map[uint64]bool{450030: true, 660003: true}}); err != nil {
t.Fatal(err)
}
path := filepath.Join(t.TempDir(), "grants.json")
writeSpool(t, path, spoolGrant("full-moon-one-use", GrantReward{Type: 19, ID: 450030, Count: 1}))
if err := service.AttachGrantSpoolPath(path); err != nil {
t.Fatal(err)
}
if _, _, _, err := service.Handle("/MailInfo", wire.AppendVarint(nil, 1, 1)); err != nil {
t.Fatal(err)
}
request := wire.AppendVarint(wire.AppendVarint(nil, 1, 2), 2, 101)
_, response, _, err := service.Handle("/MailOpen", request)
if err != nil {
t.Fatal(err)
}
bundle, found, err := wire.Bytes(response, 1)
if err != nil || !found {
t.Fatalf("reward bundle missing: %v", err)
}
item, found, err := wire.Bytes(bundle, 1)
if err != nil || !found {
t.Fatalf("ItemDBInfo missing: %v", err)
}
for field, want := range map[int]uint64{2: 450030, 3: 19, 4: 1} {
if got, present, err := wire.Varint(item, field); err != nil || !present || got != want {
t.Fatalf("ItemDBInfo field %d=%d present=%v err=%v", field, got, present, err)
}
}
index, _, err := wire.Varint(item, 1)
if err != nil || index == 0 {
t.Fatalf("content ticket instance=%d err=%v", index, err)
}
if got := inventory.All(); len(got) != 1 || got[0].Type != 19 || got[0].ID != 450030 || got[0].Count != 1 || got[0].InvenIndex != index {
t.Fatalf("content ticket not stored: %+v", got)
}
service, inventory, _ = spoolTestService(t, store)
service.AttachContentTickets(&gamedata.GachaContentTicketDesign{IDs: map[uint64]bool{450030: true, 660003: true}})
if err := service.AttachGrantSpoolPath(path); err != nil {
t.Fatal(err)
}
if _, _, _, err := service.Handle("/MailInfo", wire.AppendVarint(nil, 1, 3)); err != nil {
t.Fatal(err)
}
if _, _, _, err := service.Handle("/MailOpen", request); err != nil {
t.Fatal(err)
}
if got := inventory.All(); len(got) != 1 || got[0].Count != 1 || got[0].InvenIndex != index || service.state.NextDynamicMailID != 102 {
t.Fatalf("content ticket reissued: items=%+v state=%+v", got, service.state)
}
}
func TestGrantSpoolValidatesEntireBatchBeforeIssuing(t *testing.T) {
store := stateio.NewMemory()
service, _, _ := spoolTestService(t, store)
path := filepath.Join(t.TempDir(), "grants.json")
if err := service.AttachGrantSpoolPath(path); err != nil {
t.Fatal(err)
}
good := spoolGrant("a", GrantReward{Type: 4, Count: 10})
bad := spoolGrant("b", GrantReward{Type: 6, ID: 1, Count: 1})
writeSpool(t, path, good, bad)
if _, _, _, err := service.Handle("/MailInfo", wire.AppendVarint(nil, 1, 1)); err == nil {
t.Fatal("accepted unsupported second grant")
}
assertNoSpoolMail(t, service, store)
writeSpool(t, path, good, good)
if _, _, _, err := service.Handle("/MailInfo", wire.AppendVarint(nil, 1, 2)); err == nil {
t.Fatal("accepted duplicate identity")
}
assertNoSpoolMail(t, service, store)
writeSpool(t, path, good)
if _, _, _, err := service.Handle("/MailInfo", wire.AppendVarint(nil, 1, 3)); err != nil {
t.Fatal(err)
}
if service.issued["a"] != 101 {
t.Fatal("valid replacement did not import")
}
}
func assertNoSpoolMail(t *testing.T, service *Service, store stateio.EntryStore) {
t.Helper()
if len(service.dynamic) != 0 || len(service.issued) != 0 || service.state.NextDynamicMailID != 101 {
t.Fatalf("partial memory update: %+v", service.state)
}
for _, bucket := range []string{"dynamic", "issued"} {
entries, err := store.ListEntries("mail", bucket)
if err != nil || len(entries) != 0 {
t.Fatalf("partial storage %s: entries=%v err=%v", bucket, entries, err)
}
}
}
type failingSpoolStore struct {
*stateio.Memory
fail bool
writes int
}
func (s *failingSpoolStore) SaveWithEntries(domain string, core []byte, changes []stateio.EntryMutation) error {
if domain == "mail" && len(changes) != 0 {
s.writes++
if s.fail {
return errors.New("injected spool save failure")
}
}
return s.Memory.SaveWithEntries(domain, core, changes)
}
func TestGrantSpoolBatchWriteFailureLeavesMemoryAndStorageUnchanged(t *testing.T) {
store := &failingSpoolStore{Memory: stateio.NewMemory(), fail: true}
service, _, _ := spoolTestService(t, store)
path := filepath.Join(t.TempDir(), "grants.json")
writeSpool(t, path, spoolGrant("a", GrantReward{Type: 4, Count: 10}), spoolGrant("b", GrantReward{Type: 4, Count: 20}))
if err := service.AttachGrantSpoolPath(path); err != nil {
t.Fatal(err)
}
request := wire.AppendVarint(nil, 1, 1)
if _, _, _, err := service.Handle("/MailInfo", request); err == nil {
t.Fatal("save failure ignored")
}
assertNoSpoolMail(t, service, store)
if store.writes != 1 {
t.Fatalf("batch used %d writes", store.writes)
}
store.fail = false
if _, _, _, err := service.Handle("/MailInfo", request); err != nil {
t.Fatal(err)
}
if len(service.dynamic) != 2 || service.state.NextDynamicMailID != 103 {
t.Fatal("retry did not issue complete batch")
}
}
func TestGrantSpoolBatchIDExhaustionDoesNotIssueFirstMail(t *testing.T) {
store := stateio.NewMemory()
service, _, _ := spoolTestService(t, store)
service.state.NextDynamicMailID = ^uint64(0) - 1
path := filepath.Join(t.TempDir(), "grants.json")
writeSpool(t, path, spoolGrant("a", GrantReward{Type: 4, Count: 10}), spoolGrant("b", GrantReward{Type: 4, Count: 20}))
if err := service.AttachGrantSpoolPath(path); err != nil {
t.Fatal(err)
}
if _, _, _, err := service.Handle("/MailInfo", wire.AppendVarint(nil, 1, 1)); err == nil {
t.Fatal("accepted overflowing batch")
}
if len(service.dynamic) != 0 || service.state.NextDynamicMailID != ^uint64(0)-1 {
t.Fatal("partially imported overflowing batch")
}
}
func TestGrantSpoolRejectsMalformedSchema(t *testing.T) {
valid := `{"version":1,"grants":[{"identity":"a","title":"t","body":"b","sent_at":1790989200000,"rewards":[{"type":4,"id":0,"count":10}]}]}`
if _, err := decodeGrantSpool([]byte(valid)); err != nil {
t.Fatal(err)
}
invalid := []string{
`null`, `{}`, `{"version":1,"grants":null}`, `{"version":1,"grants":[],"extra":0}`,
strings.Replace(valid, `"version":1`, `"version":2`, 1),
strings.Replace(valid, `"version":1`, `"version":1,"version":1`, 1),
strings.Replace(valid, `"title":"t"`, `"title":"t","title":"u"`, 1),
strings.Replace(valid, `"title":"t"`, `"Title":"t"`, 1),
strings.Replace(valid, `"body":"b"`, `"body":null`, 1),
strings.Replace(valid, `"identity":"a"`, `"identity":" "`, 1),
strings.Replace(valid, `"sent_at":1790989200000`, `"sent_at":0`, 1),
strings.Replace(valid, `"sent_at":1790989200000`, `"sent_at":9223372036854775807`, 1),
strings.Replace(valid, `"count":10`, `"count":0`, 1),
strings.Replace(valid, `"count":10`, `"count":1.5`, 1),
strings.Replace(valid, `"count":10`, `"count":null`, 1),
strings.Replace(valid, `"count":10`, `"count":10,"count":10`, 1),
strings.Replace(valid, `"count":10`, `"count":10,"extra":0`, 1),
strings.Replace(valid, `"id":0,`, ``, 1),
strings.Replace(valid, `"id":0`, `"id":1`, 1),
strings.Replace(valid, `"type":4`, `"type":8`, 1),
valid + `{}`,
}
for _, input := range invalid {
if _, err := decodeGrantSpool([]byte(input)); err == nil {
t.Errorf("accepted malformed spool: %s", input)
}
}
if _, err := decodeGrantSpool([]byte(`{"version":1,"grants":[]}`)); err != nil {
t.Fatal(err)
}
}