feat: 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,6 +95,8 @@ python .\tools\python\import_seed.py mail .\decoded\MailInfo.pb `
|
||||
|
||||
`dev_mail_grant.py` 是独立的、仅监听环回地址的开发期浏览器工具。它只读取指定版本的 GameData,列出已验证、可走 `ItemDBInfo` 领取路径的有名道具:`FoodTable`(类型 5)、`CookingTable`(7)、`ResourceTable`(8)、`QuestItemTable`(13)、`UseItemTable`(14)、`CollectionTable`(17)、`MyRoomItemTable`(27)和 `InstantUseItemTable`(29)。普通物品的 `ItemNameTextId` 按客户端行为从 `NameTextTable` 解析;随机箱使用独立的 `RandomBoxTextTable`,现金商品搜索别名仍从其声明的 `LocalTextTable` 解析,三者不会依靠碰巧相同的数字文本 ID 串表。确定性随机箱使用真实名称作为内容物搜索别名,因此不依赖固定物品 ID。`ResourceTable.Type=2` 的场景/展示哨兵和无可用名称行不提供;固定内容随机箱只用于反查内容物的真实 ID,所有 type 9 随机箱均不作为邮件选项。金币和天赋神药等服务端已实现的账户货币从当前 `CurrencyTable` 发现,以各自元素类型和 `id0` 直接入账;名称与物品 ID 均不硬编码。工具本身既不属于 `bd2server.exe`,也不修改 `data/state` 的九份账号状态。
|
||||
|
||||
无需 GameData 或第三方 Python 包的 `grant` 子命令可追加一封含多个附件的动态邮件。服务端使用 `--mail-grant-spool` 读取该 JSON,每次 `/MailInfo` 导入未发放的 identity;已发放 identity 在领取和重启后仍保持幂等。CLI 允许付费钻石 `2:0:数量`、免费钻石 `3:0:数量`、金币 `4:0:数量`、天赋神药 `12:0:数量`、金线 `20:0:数量`,以及当前 2.35.10 已核实的抽抽乐券 `8:1000:数量` 和 UR 专用装备抽抽乐券 `8:1104:数量`,每个附件数量均限制为 `1..2147483647`。例如 `python tools/python/dev_mail_grant.py grant --output data/dev/currency-grants.json --identity test-paid-and-tickets-1 --attachment 2:0:100000000 --attachment 8:1000:100000000 --attachment 8:1104:100000000 --attachment 4:0:1000000000`。两种券通过 `ItemDBInfo` 写入背包,钻石和金币直接叠加账户余额;发放前应检查同类券现有库存加附件后的总量仍在客户端 `int32` 范围内。
|
||||
|
||||
启动工具时,`--mail-seed` 是只读的当前基础邮件种子;`--output` 是新生成的完整临时种子;`--settings-output` 保存与账号存档分离的开发选项。工具启动后在浏览器打开 `http://127.0.0.1:8765/`:
|
||||
|
||||
```powershell
|
||||
@@ -117,7 +119,7 @@ python .\tools\python\dev_mail_grant.py serve `
|
||||
|
||||
客户端 `MailDBInfo.ItemType`、`ItemId` 和 `ItemCount` 均为 `int32`,所以该工具把单附件数量限制为 `1..2147483647`;每封工具邮件固定只有一个附件。当前官方样本中单封最多观察到 5 个附件,但没有证据证明这是协议上限,因此工具不据此宣称或实施“5 件”上限。客户端邮箱 UI 按一次请求加载最多 100 封普通邮件,现有本地服务目前回传全部未开封邮件,故大量历史未领取邮件的实际 UI 表现尚待验证。现有本地 `/MailOpen` 对同一邮件 ID 的领取由 `data/state/mail.json` 的 `opened` 集合持久化,重试不会重复发奖;工具会在完整种子中分配唯一递增邮件 ID。
|
||||
|
||||
这不是“所有 GameData 表都可发放”的虚假承诺:角色(元素类型 6)、装备(10)、服装(11)和我的房间奖杯(28)在客户端 `RewardDBInfoBundle` 中分别必须使用 `CharDBInfo`、`EquipDBInfo`、`CostumeDBInfo`、`MyRoomTrophyDBInfo`,而当前本地邮件服务尚未连接相应领域存档,工具不会提供它们;直接伪装成 `ItemDBInfo` 会造成客户端状态错误。付费/普通货币之外的特殊货币亦不在当前本地钱包实现范围内。客户端 `DataManager.GetItemDTO` 对 `ContentTicket`(19)和 `LobbySettingItem`(25)没有可用于 `ItemDBInfo` 领取的 DTO 分支,故也没有提供;`GetItemInfo` 的显示分支不足以证明可安全存储。若要补齐这些类型,需要先实现对应的服务端存储、去重及正确 reward-bundle 字段,不需要客户端 patch。
|
||||
这不是“所有 GameData 表都可发放”的虚假承诺:角色(元素类型 6)、装备(10)、服装(11)和我的房间奖杯(28)在客户端 `RewardDBInfoBundle` 中分别必须使用 `CharDBInfo`、`EquipDBInfo`、`CostumeDBInfo`、`MyRoomTrophyDBInfo`,而当前本地邮件服务尚未连接相应领域存档,工具不会提供它们;直接伪装成 `ItemDBInfo` 会造成客户端状态错误。付费/普通货币之外的特殊货币亦不在当前本地钱包实现范围内。`ContentTicket`(19)仅为已核实的满月一次性专用券开放 `19:450030:1`:客户端 `CommonPacket.AddItemInfo` 将其交给 `AddContentTicketItem`,服务端按实例存入物品存档并沿用邮件领取去重;其他内容券 ID 和其他数量均拒绝。浏览器道具列表不开放内容券,`grant` 子命令可使用这一严格限定的附件。`LobbySettingItem`(25)仍未提供;`GetItemInfo` 的显示分支不足以证明可安全存储。若要补齐其余类型,需要先实现对应的服务端存储、去重及正确 reward-bundle 字段,不需要客户端 patch。
|
||||
|
||||
热载只接受经过 `mail.Starter.Validate` 校验的完整 JSON 种子:文件未变化时不会重新读取;被检测到的坏替换会保留上一次已验证邮箱,并使该次 `/MailInfo` 请求失败而不会部分加载。工具本身总是完整写临时文件、`fsync` 后原子替换,正常发放不会让服务器看到半文件。
|
||||
|
||||
|
||||
@@ -66,7 +66,12 @@ ITEM_SOURCES = (
|
||||
# and mail-claim path are implemented by this server are offered. Their mail
|
||||
# reward ID is zero; names still come from the current GameData rather than
|
||||
# being embedded here.
|
||||
MAIL_CURRENCY_TYPES = frozenset({3, 4, 12, 20})
|
||||
MAIL_CURRENCY_TYPES = frozenset({2, 3, 4, 12, 20})
|
||||
|
||||
# The standalone grant CLI intentionally offers only the two audited draw
|
||||
# ticket resources, rather than accepting arbitrary GameData item IDs.
|
||||
MAIL_DRAW_TICKET_IDS = frozenset({1000, 1104})
|
||||
MAIL_CONTENT_TICKET_ID = 450030
|
||||
|
||||
|
||||
def _varint(value: Any) -> int:
|
||||
@@ -419,7 +424,18 @@ def atomic_json(path: Path, value: dict[str, Any]) -> None:
|
||||
stream.write("\n")
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.replace(temporary, path)
|
||||
for attempt in range(20):
|
||||
try:
|
||||
os.replace(temporary, path)
|
||||
break
|
||||
except PermissionError:
|
||||
# Windows virus scanners and indexers can briefly open the
|
||||
# destination without delete sharing. The grant-file lock
|
||||
# already serializes writers, so retry only this transient OS
|
||||
# condition and never fall back to a non-atomic overwrite.
|
||||
if os.name != "nt" or attempt == 19:
|
||||
raise
|
||||
time.sleep(0.025 * (attempt + 1))
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
|
||||
@@ -466,10 +482,16 @@ def grant_file_lock(path: Path):
|
||||
def _validate_reward(value: Any) -> dict[str, int]:
|
||||
if not isinstance(value, dict) or set(value) != {"type", "id", "count"}:
|
||||
raise ValueError("附件必须只包含 type、id、count")
|
||||
if type(value["type"]) is not int or value["type"] not in MAIL_CURRENCY_TYPES:
|
||||
raise ValueError("附件 type 只允许 3、4、12、20")
|
||||
if type(value["id"]) is not int or value["id"] != 0:
|
||||
raise ValueError("货币附件 id 必须为 0")
|
||||
if type(value["type"]) is not int or type(value["id"]) is not int:
|
||||
raise ValueError("附件 type 和 id 必须是整数")
|
||||
if value["type"] in MAIL_CURRENCY_TYPES:
|
||||
if value["id"] != 0:
|
||||
raise ValueError("货币附件 id 必须为 0")
|
||||
elif value["type"] == 19:
|
||||
if value["id"] != MAIL_CONTENT_TICKET_ID or type(value["count"]) is not int or value["count"] != 1:
|
||||
raise ValueError("内容券附件只允许满月甄选券 type19、id450030、count1")
|
||||
elif value["type"] != 8 or value["id"] not in MAIL_DRAW_TICKET_IDS:
|
||||
raise ValueError("附件只允许货币类型 2、3、4、12、20 的 id0,资源类型 8 的抽抽乐券 id1000、UR 专用装备抽抽乐券 id1104,或内容券 type19、id450030、count1")
|
||||
if type(value["count"]) is not int or not 1 <= value["count"] <= MAX_INT32:
|
||||
raise ValueError(f"附件 count 必须是 1 到 {MAX_INT32} 的整数")
|
||||
return dict(value)
|
||||
@@ -754,9 +776,9 @@ def parser() -> argparse.ArgumentParser:
|
||||
command.add_argument("--listen-port", default=8765, type=int, help="loopback port (default: 8765)")
|
||||
command.add_argument("--expires-days", default=365, type=int, help="development mail validity (default: 365)")
|
||||
command.set_defaults(run=serve)
|
||||
command = commands.add_parser("grant", help="append one durable currency mail grant (standard library only)")
|
||||
command = commands.add_parser("grant", help="append one durable currency or draw ticket mail grant (standard library only)")
|
||||
command.add_argument("--output", type=Path, required=True, help="version=1 development mail grants JSON")
|
||||
command.add_argument("--attachment", type=attachment, action="append", required=True, metavar="TYPE:ID:COUNT", help="currency reward; repeat to include multiple attachments in one mail")
|
||||
command.add_argument("--attachment", type=attachment, action="append", required=True, metavar="TYPE:ID:COUNT", help="supported currency or draw ticket reward; repeat to include multiple attachments in one mail")
|
||||
command.add_argument("--identity", help="stable idempotency identity (default: a new UUID)")
|
||||
command.add_argument("--title", default="开发测试物品", help="mail title (maximum 500 characters)")
|
||||
command.add_argument("--body", default="由本地开发邮件工具发放。", help="mail body (maximum 5000 characters)")
|
||||
|
||||
@@ -67,6 +67,22 @@ class CurrencyMailGrantTests(unittest.TestCase):
|
||||
self.assertEqual(first["grant"], second["grant"])
|
||||
self.assertEqual(self.output.read_bytes(), original)
|
||||
|
||||
def test_paid_jewelry_draw_tickets_and_large_gold_share_one_idempotent_mail(self):
|
||||
extra = ("--identity", "large-rewards", "--attachment", "2:0:100000000",
|
||||
"--attachment", "8:1000:100000000", "--attachment", "8:1104:100000000",
|
||||
"--attachment", "4:0:1000000000")
|
||||
first = self.run_grant(*extra)
|
||||
second = self.run_grant(*extra)
|
||||
self.assertTrue(first["created"])
|
||||
self.assertFalse(second["created"])
|
||||
self.assertEqual(first["grant"], second["grant"])
|
||||
self.assertEqual(first["grant"]["rewards"][1:], [
|
||||
{"type": 2, "id": 0, "count": 100000000},
|
||||
{"type": 8, "id": 1000, "count": 100000000},
|
||||
{"type": 8, "id": 1104, "count": 100000000},
|
||||
{"type": 4, "id": 0, "count": 1000000000},
|
||||
])
|
||||
|
||||
def test_identity_conflict_is_rejected_without_changing_file(self):
|
||||
self.run_grant("--identity", "once")
|
||||
original = self.output.read_bytes()
|
||||
@@ -75,13 +91,21 @@ class CurrencyMailGrantTests(unittest.TestCase):
|
||||
self.run_grant("--identity", "once", *extra)
|
||||
self.assertEqual(self.output.read_bytes(), original)
|
||||
|
||||
def test_one_use_content_ticket_is_an_idempotent_single_item_attachment(self):
|
||||
first = self.run_grant("--identity", "full-moon-ticket", "--attachment", "19:450030:1")
|
||||
second = self.run_grant("--identity", "full-moon-ticket", "--attachment", "19:450030:1")
|
||||
self.assertTrue(first["created"])
|
||||
self.assertFalse(second["created"])
|
||||
self.assertEqual(first["grant"], second["grant"])
|
||||
self.assertEqual(first["grant"]["rewards"][-1], {"type": 19, "id": 450030, "count": 1})
|
||||
|
||||
def test_omitted_identity_appends_distinct_grants(self):
|
||||
first, second = self.run_grant(), self.run_grant()
|
||||
self.assertNotEqual(first["grant"]["identity"], second["grant"]["identity"])
|
||||
self.assertEqual(len(dev_mail_grant.load_grants(self.output)["grants"]), 2)
|
||||
|
||||
def test_invalid_attachments_are_rejected(self):
|
||||
for attachment in ("8:1:1", "4:1:1", "4:0:0", "4:0:-1", "4:0:2147483648", "4:0", "4:0:1:2", "4:0:true"):
|
||||
for attachment in ("8:1:1", "8:0:1", "8:1001:1", "8:1104:2147483648", "8:1000:0", "14:1000:1", "2:1:1", "19:450029:1", "19:450030:0", "19:450030:2", "4:1:1", "4:0:0", "4:0:-1", "4:0:2147483648", "4:0", "4:0:1:2", "4:0:true"):
|
||||
with self.subTest(attachment=attachment), self.assertRaises(SystemExit), redirect_stdout(io.StringIO()):
|
||||
with mock.patch("sys.stderr", new=io.StringIO()):
|
||||
self.args("--attachment", attachment)
|
||||
|
||||
Reference in New Issue
Block a user