feat(server): grant paid currency and draw tickets by mail
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user