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) } }