feat(server): grant paid currency and draw tickets by mail

This commit is contained in:
2026-10-03 01:25:36 +08:00
parent e13d03a3f2
commit f1ebf8613c
9 changed files with 277 additions and 14 deletions
+1 -1
View File
@@ -53,7 +53,7 @@ func (g compensation) validate() error {
return errors.New("mail: invalid compensation identity, content, time or rewards")
}
for _, reward := range g.rewards {
if reward.Count == 0 || reward.Count > math.MaxInt32 || (!currencyRewardTypes[reward.Type] && (!itemDBInfoTypes[reward.Type] || reward.ID == 0)) ||
if reward.Count == 0 || reward.Count > math.MaxInt32 || reward.ID > math.MaxInt32 || (!currencyRewardTypes[reward.Type] && !supportedItemDBInfoReward(reward)) ||
(currencyRewardTypes[reward.Type] && reward.ID != 0) {
return fmt.Errorf("mail: compensation %q has unsupported reward type=%d id=%d count=%d", g.identity, reward.Type, reward.ID, reward.Count)
}
+166
View File
@@ -3,6 +3,7 @@ package mail
import (
"encoding/json"
"errors"
"math"
"os"
"path/filepath"
"strings"
@@ -164,6 +165,171 @@ func TestGrantSpoolPersistsIssuedIdentityInSQLiteRequestTransaction(t *testing.T
}
}
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: 450031, Count: 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)
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)
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)
+16 -1
View File
@@ -38,11 +38,21 @@ var itemDBInfoTypes = map[uint64]bool{
13: true, // quest item
14: true, // use item
17: true, // collection item
19: true, // audited one-use content ticket; restricted below
27: true, // my-room item
29: true, // instant-use item
}
// The currently audited ContentTicket claim uses CommonPacket.AddContentTicketItem
// through ItemDBInfo. Other content tickets have independent state and are not
// enabled by this local mail implementation.
func supportedItemDBInfoReward(reward gamedata.Reward) bool {
return itemDBInfoTypes[reward.Type] && reward.ID != 0 &&
(reward.Type != 19 || reward.ID == 450030 && reward.Count == 1)
}
var currencyRewardTypes = map[uint64]bool{
2: true, // paid jewelry
3: true, // free jewelry
4: true, // gold
12: true, // catalyst / talent elixir
@@ -106,6 +116,11 @@ func (s *Starter) Validate() error {
if len(m.RewardTypes) != len(m.RewardIDs) || len(m.RewardTypes) != len(m.RewardCounts) {
return errors.New("mail: reward arrays differ in length")
}
for i, typ := range m.RewardTypes {
if typ == 19 && !supportedItemDBInfoReward(gamedata.Reward{Type: typ, ID: m.RewardIDs[i], Count: m.RewardCounts[i]}) {
return errors.New("mail: content ticket reward requires id 450030 and count 1")
}
}
}
return nil
}
@@ -486,7 +501,7 @@ func (s *Service) open(request []byte) ([]byte, error) {
// ItemDBInfo would make the local state and client model disagree.
return nil, errors.New("mail: my-room trophy rewards require MyRoomTrophyDBInfo")
default:
if !itemDBInfoTypes[reward.Type] {
if !supportedItemDBInfoReward(reward) {
return nil, fmt.Errorf("mail: unsupported reward type %d", reward.Type)
}
if reward.ID == 0 || reward.Count == 0 {
+15
View File
@@ -130,6 +130,21 @@ func TestMailOpenGrantsItemsAndCurrencyAndPersists(t *testing.T) {
}
}
func TestStarterContentTicketOnlyAllowsAuditedSingleUseReward(t *testing.T) {
for _, reward := range []struct {
id, count uint64
valid bool
}{{450030, 1, true}, {450029, 1, false}, {450030, 0, false}, {450030, 2, false}} {
seed := &Starter{Version: "2.35.10", MailCount: 2, MaxMailID: 13, Mails: []MailDBInfo{{
MailID: 13, MailType: 2, ExpiresAt: 100, SentAt: 10,
RewardTypes: []uint64{19}, RewardIDs: []uint64{reward.id}, RewardCounts: []uint64{reward.count},
}}}
if err := seed.Validate(); (err == nil) != reward.valid {
t.Fatalf("id=%d count=%d valid=%v err=%v", reward.id, reward.count, reward.valid, err)
}
}
}
func TestMailOpenGrantsNonResourceItemDBInfoType(t *testing.T) {
seed := &Starter{Version: "2.35.10", MailCount: 2, MaxMailID: 13, Mails: []MailDBInfo{{
MailID: 13, MailType: 2, ExpiresAt: 100, SentAt: 10,
+5
View File
@@ -313,6 +313,11 @@ func (s *Wallet) GrantQuestOnce(identity string, rewards []gamedata.Reward) (Cur
next := cloneWallet(s.state)
for _, reward := range rewards {
switch reward.Type {
case 2:
if math.MaxUint64-next.Jewelry < reward.Count {
return Currency{}, errors.New("player: paid jewelry overflow")
}
next.Jewelry += reward.Count
case 3:
if math.MaxUint64-next.FreeJewelry < reward.Count {
return Currency{}, errors.New("player: free jewelry overflow")
+16 -2
View File
@@ -1,6 +1,7 @@
package player
import (
"math"
"path/filepath"
"testing"
@@ -13,7 +14,7 @@ func TestWalletGrantPersistsAndIsIdempotent(t *testing.T) {
if err != nil {
t.Fatal(err)
}
rewards := []gamedata.Reward{{Type: 4, Count: 1500}, {Type: 3, Count: 70}, {Type: 12, Count: 45}, {Type: 25, ID: 210, Count: 1}}
rewards := []gamedata.Reward{{Type: 2, Count: 100000000}, {Type: 4, Count: 1500}, {Type: 3, Count: 70}, {Type: 12, Count: 45}, {Type: 25, ID: 210, Count: 1}}
if _, err := wallet.GrantQuestOnce("quest:1", rewards); err != nil {
t.Fatal(err)
}
@@ -25,7 +26,20 @@ func TestWalletGrantPersistsAndIsIdempotent(t *testing.T) {
t.Fatal(err)
}
got := wallet.Snapshot()
if got.Gold != 1600 || got.FreeJewelry != 70 || got.Catalyst != 45 {
if got.Jewelry != 100000000 || got.Gold != 1600 || got.FreeJewelry != 70 || got.Catalyst != 45 {
t.Fatalf("wallet=%+v", got)
}
}
func TestWalletPaidJewelryOverflowDoesNotCommitGrant(t *testing.T) {
wallet, err := OpenWallet(testStore(filepath.Join(t.TempDir(), "wallet.json")), Currency{Jewelry: math.MaxUint64})
if err != nil {
t.Fatal(err)
}
if _, err := wallet.GrantQuestOnce("paid-overflow", []gamedata.Reward{{Type: 4, Count: 10}, {Type: 2, Count: 1}}); err == nil {
t.Fatal("paid jewelry overflow accepted")
}
if got := wallet.Snapshot(); got.Jewelry != math.MaxUint64 || got.Gold != 0 || wallet.WasGranted("paid-overflow") {
t.Fatalf("failed grant changed wallet: %+v", got)
}
}