DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
commit
645b64fe21
4388 files changed
+1405457
No files matched your search
@@ -0,0 +1,230 @@
|
||||
package premium
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"math"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInventoryActivationAccountRequired = errors.New("premium inventory activation requires an account")
|
||||
ErrInventoryActivationInventoryRequired = errors.New("premium inventory activation requires an inventory")
|
||||
ErrInventoryActivationContractInvalid = errors.New("premium inventory activation contract is invalid")
|
||||
ErrInventoryActivationDurationOverflow = errors.New("premium inventory activation duration overflows")
|
||||
)
|
||||
|
||||
// InventoryContract is one runtime-PVF premiumlist item mapping. The bridge
|
||||
// resolves these values from the active PVF; inventory metadata is never an
|
||||
// authority for either type or duration.
|
||||
type InventoryContract struct {
|
||||
ItemID int64
|
||||
PremiumType int64
|
||||
DurationSeconds int64
|
||||
}
|
||||
|
||||
// InventoryActivation describes the final account entitlement produced by
|
||||
// consuming one or more inventory contract stacks of the same premium type.
|
||||
type InventoryActivation struct {
|
||||
PremiumType int64
|
||||
Units int64
|
||||
DurationSeconds int64
|
||||
ExpireAt int64
|
||||
}
|
||||
|
||||
// InventoryActivationResult is the deterministic result of consuming every
|
||||
// PVF-proven contract item in the selected character's main inventory.
|
||||
type InventoryActivationResult struct {
|
||||
Activations []InventoryActivation
|
||||
RemovedSlots []int16
|
||||
}
|
||||
|
||||
func (r InventoryActivationResult) Changed() bool {
|
||||
return len(r.RemovedSlots) != 0 || len(r.Activations) != 0
|
||||
}
|
||||
|
||||
// ActivateContractUnits converts explicit contract units (item id -> count) into
|
||||
// account expiry time without requiring an inventory row.
|
||||
//
|
||||
// Reward flows that roll premium contract items together with ordinary rewards
|
||||
// use this so a contract never occupies a bag cell: the cell stays available for
|
||||
// a real reward instead of being filled and swept a moment later, which is what
|
||||
// pushed ordinary rewards into the overflow mail. The account entitlement math is
|
||||
// identical to ActivateInventoryContracts; only the source of the units differs.
|
||||
func ActivateContractUnits(
|
||||
account *dnfrepo.AccountRecord,
|
||||
contracts map[int64]InventoryContract,
|
||||
units map[int64]int64,
|
||||
now time.Time,
|
||||
) (InventoryActivationResult, error) {
|
||||
if account == nil || strings.TrimSpace(account.AccountID) == "" {
|
||||
return InventoryActivationResult{}, ErrInventoryActivationAccountRequired
|
||||
}
|
||||
if now.IsZero() {
|
||||
now = time.Now().UTC()
|
||||
}
|
||||
|
||||
secondsByType := make(map[int64]int64)
|
||||
unitsByType := make(map[int64]int64)
|
||||
for itemID, count := range units {
|
||||
if count <= 0 {
|
||||
continue
|
||||
}
|
||||
contract, exists := contracts[itemID]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
if contract.ItemID != itemID || contract.PremiumType <= 0 || contract.DurationSeconds <= 0 {
|
||||
return InventoryActivationResult{}, ErrInventoryActivationContractInvalid
|
||||
}
|
||||
if count > math.MaxInt64/contract.DurationSeconds {
|
||||
return InventoryActivationResult{}, ErrInventoryActivationDurationOverflow
|
||||
}
|
||||
delta := count * contract.DurationSeconds
|
||||
if secondsByType[contract.PremiumType] > math.MaxInt64-delta ||
|
||||
unitsByType[contract.PremiumType] > math.MaxInt64-count {
|
||||
return InventoryActivationResult{}, ErrInventoryActivationDurationOverflow
|
||||
}
|
||||
secondsByType[contract.PremiumType] += delta
|
||||
unitsByType[contract.PremiumType] += count
|
||||
}
|
||||
if len(secondsByType) == 0 {
|
||||
return InventoryActivationResult{}, nil
|
||||
}
|
||||
for premiumType, seconds := range secondsByType {
|
||||
base := now.Unix()
|
||||
if current := ExpireAt(*account, premiumType); current > base {
|
||||
base = current
|
||||
}
|
||||
if seconds > math.MaxInt64-base {
|
||||
return InventoryActivationResult{}, ErrInventoryActivationDurationOverflow
|
||||
}
|
||||
}
|
||||
|
||||
types := make([]int64, 0, len(secondsByType))
|
||||
for premiumType := range secondsByType {
|
||||
types = append(types, premiumType)
|
||||
}
|
||||
sort.Slice(types, func(i, j int) bool { return types[i] < types[j] })
|
||||
result := InventoryActivationResult{}
|
||||
for _, premiumType := range types {
|
||||
Upsert(account, premiumType, secondsByType[premiumType], 1, now)
|
||||
result.Activations = append(result.Activations, InventoryActivation{
|
||||
PremiumType: premiumType,
|
||||
Units: unitsByType[premiumType],
|
||||
DurationSeconds: secondsByType[premiumType],
|
||||
ExpireAt: ExpireAt(*account, premiumType),
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
type inventoryActivationPlan struct {
|
||||
key string
|
||||
slot int16
|
||||
}
|
||||
|
||||
// ActivateInventoryContracts converts all PVF-proven contract stacks in list
|
||||
// 0 into account expiry time. Validation is completed before either aggregate
|
||||
// is mutated, so callers can safely use this inside their repository unit of
|
||||
// work without exposing a partial result.
|
||||
func ActivateInventoryContracts(
|
||||
account *dnfrepo.AccountRecord,
|
||||
inventory *dnfrepo.InventoryRecord,
|
||||
contracts map[int64]InventoryContract,
|
||||
now time.Time,
|
||||
) (InventoryActivationResult, error) {
|
||||
if account == nil || strings.TrimSpace(account.AccountID) == "" {
|
||||
return InventoryActivationResult{}, ErrInventoryActivationAccountRequired
|
||||
}
|
||||
if inventory == nil || strings.TrimSpace(inventory.CharacterID) == "" {
|
||||
return InventoryActivationResult{}, ErrInventoryActivationInventoryRequired
|
||||
}
|
||||
if now.IsZero() {
|
||||
now = time.Now().UTC()
|
||||
}
|
||||
|
||||
plans := make([]inventoryActivationPlan, 0)
|
||||
secondsByType := make(map[int64]int64)
|
||||
unitsByType := make(map[int64]int64)
|
||||
for key, stack := range inventory.Slots {
|
||||
slot, main := premiumMainInventorySlot(key)
|
||||
if !main || stack.ItemID <= 0 || stack.Count <= 0 {
|
||||
continue
|
||||
}
|
||||
contract, exists := contracts[stack.ItemID]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
if contract.ItemID != stack.ItemID || contract.PremiumType <= 0 || contract.DurationSeconds <= 0 {
|
||||
return InventoryActivationResult{}, ErrInventoryActivationContractInvalid
|
||||
}
|
||||
if stack.Count > math.MaxInt64/contract.DurationSeconds {
|
||||
return InventoryActivationResult{}, ErrInventoryActivationDurationOverflow
|
||||
}
|
||||
delta := stack.Count * contract.DurationSeconds
|
||||
if secondsByType[contract.PremiumType] > math.MaxInt64-delta ||
|
||||
unitsByType[contract.PremiumType] > math.MaxInt64-stack.Count {
|
||||
return InventoryActivationResult{}, ErrInventoryActivationDurationOverflow
|
||||
}
|
||||
secondsByType[contract.PremiumType] += delta
|
||||
unitsByType[contract.PremiumType] += stack.Count
|
||||
plans = append(plans, inventoryActivationPlan{key: key, slot: slot})
|
||||
}
|
||||
if len(plans) == 0 {
|
||||
return InventoryActivationResult{}, nil
|
||||
}
|
||||
|
||||
for premiumType, seconds := range secondsByType {
|
||||
base := now.Unix()
|
||||
if current := ExpireAt(*account, premiumType); current > base {
|
||||
base = current
|
||||
}
|
||||
if seconds > math.MaxInt64-base {
|
||||
return InventoryActivationResult{}, ErrInventoryActivationDurationOverflow
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(plans, func(i, j int) bool {
|
||||
if plans[i].slot == plans[j].slot {
|
||||
return plans[i].key < plans[j].key
|
||||
}
|
||||
return plans[i].slot < plans[j].slot
|
||||
})
|
||||
result := InventoryActivationResult{RemovedSlots: make([]int16, 0, len(plans))}
|
||||
for _, plan := range plans {
|
||||
delete(inventory.Slots, plan.key)
|
||||
result.RemovedSlots = append(result.RemovedSlots, plan.slot)
|
||||
}
|
||||
types := make([]int64, 0, len(secondsByType))
|
||||
for premiumType := range secondsByType {
|
||||
types = append(types, premiumType)
|
||||
}
|
||||
sort.Slice(types, func(i, j int) bool { return types[i] < types[j] })
|
||||
for _, premiumType := range types {
|
||||
Upsert(account, premiumType, secondsByType[premiumType], 1, now)
|
||||
result.Activations = append(result.Activations, InventoryActivation{
|
||||
PremiumType: premiumType,
|
||||
Units: unitsByType[premiumType],
|
||||
DurationSeconds: secondsByType[premiumType],
|
||||
ExpireAt: ExpireAt(*account, premiumType),
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func premiumMainInventorySlot(key string) (int16, bool) {
|
||||
if !strings.HasPrefix(key, "0:") {
|
||||
return 0, false
|
||||
}
|
||||
raw := strings.TrimSpace(strings.TrimPrefix(key, "0:"))
|
||||
value, err := strconv.ParseInt(raw, 10, 16)
|
||||
if err != nil || value < 0 {
|
||||
return 0, false
|
||||
}
|
||||
return int16(value), true
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package premium
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||||
)
|
||||
|
||||
func TestActivateInventoryContractsConsumesMainBagAndStacksDurations(t *testing.T) {
|
||||
now := time.Unix(1_800_000_000, 0).UTC()
|
||||
account := dnfrepo.AccountRecord{AccountID: "account-1", Metadata: make(map[string]string)}
|
||||
Upsert(&account, TypeOverSkill, 86400, 1, now)
|
||||
inventory := dnfrepo.InventoryRecord{
|
||||
CharacterID: "19",
|
||||
Slots: map[string]dnfrepo.ItemStack{
|
||||
"0:10": {ItemID: 44, Count: 2},
|
||||
"0:11": {ItemID: 46, Count: 1},
|
||||
"0:12": {ItemID: 500, Count: 7},
|
||||
"1:10": {ItemID: 44, Count: 9},
|
||||
},
|
||||
Warehouse: map[string]dnfrepo.ItemStack{
|
||||
"2:5": {ItemID: 44, Count: 4},
|
||||
},
|
||||
}
|
||||
result, err := ActivateInventoryContracts(&account, &inventory, map[int64]InventoryContract{
|
||||
44: {ItemID: 44, PremiumType: TypeOverSkill, DurationSeconds: 3 * 86400},
|
||||
46: {ItemID: 46, PremiumType: TypeOverSkill, DurationSeconds: 15 * 86400},
|
||||
}, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(result.Activations) != 1 || result.Activations[0].PremiumType != TypeOverSkill ||
|
||||
result.Activations[0].Units != 3 || result.Activations[0].DurationSeconds != 21*86400 ||
|
||||
result.Activations[0].ExpireAt != now.Unix()+22*86400 {
|
||||
t.Fatalf("activation = %+v", result.Activations)
|
||||
}
|
||||
if len(result.RemovedSlots) != 2 || result.RemovedSlots[0] != 10 || result.RemovedSlots[1] != 11 {
|
||||
t.Fatalf("removed slots = %v", result.RemovedSlots)
|
||||
}
|
||||
if _, exists := inventory.Slots["0:10"]; exists {
|
||||
t.Fatal("first contract stack remains in main inventory")
|
||||
}
|
||||
if _, exists := inventory.Slots["0:11"]; exists {
|
||||
t.Fatal("second contract stack remains in main inventory")
|
||||
}
|
||||
if inventory.Slots["0:12"].Count != 7 || inventory.Slots["1:10"].Count != 9 || inventory.Warehouse["2:5"].Count != 4 {
|
||||
t.Fatalf("unrelated containers changed: %+v warehouse=%+v", inventory.Slots, inventory.Warehouse)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActivateInventoryContractsRejectsOverflowBeforeMutation(t *testing.T) {
|
||||
now := time.Unix(1_800_000_000, 0).UTC()
|
||||
account := dnfrepo.AccountRecord{AccountID: "account-1", Metadata: make(map[string]string)}
|
||||
inventory := dnfrepo.InventoryRecord{
|
||||
CharacterID: "19",
|
||||
Slots: map[string]dnfrepo.ItemStack{
|
||||
"0:10": {ItemID: 44, Count: math.MaxInt64},
|
||||
},
|
||||
}
|
||||
_, err := ActivateInventoryContracts(&account, &inventory, map[int64]InventoryContract{
|
||||
44: {ItemID: 44, PremiumType: TypeOverSkill, DurationSeconds: 3},
|
||||
}, now)
|
||||
if !errors.Is(err, ErrInventoryActivationDurationOverflow) {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
if inventory.Slots["0:10"].Count != math.MaxInt64 || ExpireAt(account, TypeOverSkill) != 0 {
|
||||
t.Fatalf("overflow mutated state: inventory=%+v account=%+v", inventory, account)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package premium
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||||
)
|
||||
|
||||
// TestActivateContractUnitsExtendsEntitlementWithoutInventoryRows pins the
|
||||
// reward-path contract: a premium contract that a box rolls is turned into
|
||||
// account time directly, so it never needs a bag cell (and therefore cannot
|
||||
// push an ordinary reward into overflow mail). The result must still look like
|
||||
// an activation so the bridge can notify the client.
|
||||
func TestActivateContractUnitsExtendsEntitlementWithoutInventoryRows(t *testing.T) {
|
||||
now := time.Unix(1_700_000_000, 0).UTC()
|
||||
account := dnfrepo.AccountRecord{AccountID: "acct", Metadata: map[string]string{}}
|
||||
contracts := map[int64]InventoryContract{
|
||||
66001: {ItemID: 66001, PremiumType: 22, DurationSeconds: 86400},
|
||||
}
|
||||
result, err := ActivateContractUnits(&account, contracts, map[int64]int64{66001: 3}, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !result.Changed() || len(result.RemovedSlots) != 0 {
|
||||
t.Fatalf("result=%+v want activations without removed slots", result)
|
||||
}
|
||||
if len(result.Activations) != 1 {
|
||||
t.Fatalf("activations=%+v", result.Activations)
|
||||
}
|
||||
activation := result.Activations[0]
|
||||
if activation.PremiumType != 22 || activation.Units != 3 || activation.DurationSeconds != 3*86400 {
|
||||
t.Fatalf("activation=%+v", activation)
|
||||
}
|
||||
if expire := ExpireAt(account, 22); expire != now.Unix()+3*86400 {
|
||||
t.Fatalf("expire=%d want=%d", expire, now.Unix()+3*86400)
|
||||
}
|
||||
|
||||
// Unknown ids and zero counts change nothing.
|
||||
empty, err := ActivateContractUnits(&account, contracts, map[int64]int64{999999: 5, 66001: 0}, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if empty.Changed() {
|
||||
t.Fatalf("unexpected activation=%+v", empty)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
// Package premium owns the account-level premium contract state shared by the
|
||||
// inventory, skill, equipment and cera-shop modules: metadata storage keys,
|
||||
// expiry math, and the current-EXE wire bodies for contract notifications.
|
||||
// PVF resolution stays in dnfbridge; this package only sees typed premium
|
||||
// types and durations.
|
||||
package premium
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||||
)
|
||||
|
||||
// Premium types come from the runtime PVF premiumlist_new.etc [type] blocks:
|
||||
// 22 霸王契约(over-equip), 27 达人契约(over-skill), 84 成长契约(bonus-exp),
|
||||
// 97 晶之契约, 33 遗忘河水契约. The 魔王契约 selectable perks use internal
|
||||
// types DevilTypeBase+slot (86JP offset to avoid PVF type collisions) and are
|
||||
// folded into the single display type DevilFolded in the select-character ACK.
|
||||
const (
|
||||
// TypePrivateStore is the current-client 阿拉德大陆商贸许可证. The
|
||||
// auction coupon (TypeAuction) includes this permission, but the client
|
||||
// keeps the town companion/private-store license as a distinct premium
|
||||
// row and effect resource.
|
||||
TypePrivateStore int64 = 8
|
||||
TypeAuction int64 = 29
|
||||
|
||||
TypeOverEquip int64 = 22
|
||||
TypeOverSkill int64 = 27
|
||||
TypeBonusExp int64 = 84
|
||||
TypeCrystal int64 = 97
|
||||
TypeLethe int64 = 33
|
||||
|
||||
// Black Diamond is deliberately split into a server-owned entitlement and
|
||||
// a current-client presentation type. Types 1, 17 and 29 are independent
|
||||
// PVF contracts and must never be treated as Black Diamond.
|
||||
TypeBlackDiamondEntitlement int64 = 1000
|
||||
TypeBlackDiamondWire int64 = 56
|
||||
|
||||
DevilTypeBase int64 = 580
|
||||
DevilFolded int64 = 58
|
||||
DevilSlotCount int64 = 8
|
||||
)
|
||||
|
||||
// Devil slot indexes from cerashop.etc [selectable character premium].
|
||||
const (
|
||||
DevilSlotGoldCard int64 = 0
|
||||
DevilSlotDoubleJar int64 = 1
|
||||
DevilSlotQuestHelper int64 = 2
|
||||
DevilSlotSevenBuff int64 = 3
|
||||
DevilSlotHpMpRecover int64 = 4
|
||||
DevilSlotFreeWeakness int64 = 5
|
||||
DevilSlotAutoRepair int64 = 6
|
||||
DevilSlotFastJarOpen int64 = 7
|
||||
)
|
||||
|
||||
const (
|
||||
dailyUsageDayKey = "premium_service_day_id"
|
||||
dailyUsageKeyPrefix = "premium_service_used_"
|
||||
)
|
||||
|
||||
func DevilSlotType(slot int64) int64 {
|
||||
return DevilTypeBase + slot
|
||||
}
|
||||
|
||||
func IsDevilSlotType(premiumType int64) bool {
|
||||
return premiumType >= DevilTypeBase && premiumType < DevilTypeBase+DevilSlotCount
|
||||
}
|
||||
|
||||
// CanNotifyActivation reports whether a premium type fits the current EXE
|
||||
// class0/op66 activation body. That handler reads the type as one byte.
|
||||
// Devil-contract service slots are internal account types (580..587) and must
|
||||
// never be truncated into this notification.
|
||||
func CanNotifyActivation(premiumType int64) bool {
|
||||
return premiumType > 0 && premiumType <= 0xff && !IsDevilSlotType(premiumType)
|
||||
}
|
||||
|
||||
func MetadataKey(premiumType int64) string {
|
||||
return "premium_expire_" + strconv.FormatInt(premiumType, 10)
|
||||
}
|
||||
|
||||
// IsBlackDiamondStorageType reports the sole durable Black Diamond identity.
|
||||
// Wire type 56 is presentation-only and is never a second durable contract.
|
||||
func IsBlackDiamondStorageType(premiumType int64) bool {
|
||||
return premiumType == TypeBlackDiamondEntitlement
|
||||
}
|
||||
|
||||
// CanonicalType maps the presentation type to the dedicated entitlement when
|
||||
// an internal caller accidentally supplies the wire value. Other contracts
|
||||
// remain unchanged.
|
||||
func CanonicalType(premiumType int64) int64 {
|
||||
if premiumType == TypeBlackDiamondEntitlement || premiumType == TypeBlackDiamondWire {
|
||||
return TypeBlackDiamondEntitlement
|
||||
}
|
||||
return premiumType
|
||||
}
|
||||
|
||||
// ActivationTypes returns the durable/current-client premium rows activated
|
||||
// by one contract. The auction coupon includes the independently rendered
|
||||
// private-store license; every other contract activates only itself.
|
||||
func ActivationTypes(premiumType int64) []int64 {
|
||||
premiumType = CanonicalType(premiumType)
|
||||
if premiumType == TypeAuction {
|
||||
return []int64{TypeAuction, TypePrivateStore}
|
||||
}
|
||||
return []int64{premiumType}
|
||||
}
|
||||
|
||||
// BlackDiamondExpireAt reads only the dedicated server-owned entitlement.
|
||||
func BlackDiamondExpireAt(account dnfrepo.AccountRecord) int64 {
|
||||
return ExpireAtRaw(account, TypeBlackDiamondEntitlement)
|
||||
}
|
||||
|
||||
func BlackDiamondActive(account dnfrepo.AccountRecord, now time.Time) bool {
|
||||
return BlackDiamondExpireAt(account) > now.Unix()
|
||||
}
|
||||
|
||||
func ExpireAtRaw(account dnfrepo.AccountRecord, premiumType int64) int64 {
|
||||
raw := strings.TrimSpace(account.Metadata[MetadataKey(premiumType)])
|
||||
if raw == "" {
|
||||
return 0
|
||||
}
|
||||
value, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil || value < 0 {
|
||||
return 0
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// ExpireAt returns the unix-second expiry of one premium type, 0 when absent
|
||||
// or malformed. Expired entries stay in metadata (86JP lazy expiry: no sweep).
|
||||
func ExpireAt(account dnfrepo.AccountRecord, premiumType int64) int64 {
|
||||
if premiumType == TypeBlackDiamondEntitlement || premiumType == TypeBlackDiamondWire {
|
||||
return BlackDiamondExpireAt(account)
|
||||
}
|
||||
return ExpireAtRaw(account, premiumType)
|
||||
}
|
||||
|
||||
func Active(account dnfrepo.AccountRecord, premiumType int64, now time.Time) bool {
|
||||
expire := ExpireAt(account, premiumType)
|
||||
return expire > 0 && expire > now.Unix()
|
||||
}
|
||||
|
||||
// Upsert renews one contract per the 86JP rule: the new expiry is
|
||||
// max(now, current expiry) + duration*count, so renewals stack. Duration is
|
||||
// in seconds. The caller owns the account-scoped transaction and the Save.
|
||||
func Upsert(account *dnfrepo.AccountRecord, premiumType int64, durationSeconds int64, count int64, now time.Time) {
|
||||
premiumType = CanonicalType(premiumType)
|
||||
if account.Metadata == nil {
|
||||
account.Metadata = make(map[string]string, 8)
|
||||
}
|
||||
if count < 1 {
|
||||
count = 1
|
||||
}
|
||||
expire := now.Unix()
|
||||
if current := ExpireAt(*account, premiumType); current > expire {
|
||||
expire = current
|
||||
}
|
||||
expire += durationSeconds * count
|
||||
account.Metadata[MetadataKey(premiumType)] = strconv.FormatInt(expire, 10)
|
||||
if premiumType == TypeAuction && ExpireAtRaw(*account, TypePrivateStore) < expire {
|
||||
// The auction coupon's advertised private-store permission is valid for
|
||||
// the full coupon term, including renewals stacked on an existing coupon.
|
||||
account.Metadata[MetadataKey(TypePrivateStore)] = strconv.FormatInt(expire, 10)
|
||||
}
|
||||
}
|
||||
|
||||
// ServiceDayID identifies the DNF service day that resets at 06:00 Beijing
|
||||
// time. 06:00 UTC+8 is 22:00 UTC on the previous civil day, hence the two-hour
|
||||
// unix shift before dividing into 24-hour buckets.
|
||||
func ServiceDayID(now time.Time) int64 {
|
||||
return (now.Unix() + int64(2*time.Hour/time.Second)) / int64(24*time.Hour/time.Second)
|
||||
}
|
||||
|
||||
func DailyUsageKey(slot int64) string {
|
||||
return dailyUsageKeyPrefix + strconv.FormatInt(slot, 10)
|
||||
}
|
||||
|
||||
// DailyLimit returns the PVF-defined daily cap for one devil-contract slot.
|
||||
// Zero means that the service has no character-day counter in this state
|
||||
// block. HP/MP recovery is once per dungeon and fast jar opening is uncapped.
|
||||
func DailyLimit(slot int64) int64 {
|
||||
switch slot {
|
||||
case DevilSlotGoldCard:
|
||||
return 10
|
||||
case DevilSlotDoubleJar:
|
||||
return 8
|
||||
case DevilSlotSevenBuff:
|
||||
return 10
|
||||
case DevilSlotFreeWeakness:
|
||||
return 10
|
||||
case DevilSlotAutoRepair:
|
||||
return 6
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// DailyUsage reads one character's current service-day use count. Stale
|
||||
// counters are treated as zero without mutating repository state.
|
||||
func DailyUsage(character dnfrepo.CharacterRecord, slot int64, now time.Time) int64 {
|
||||
if slot < 0 || slot >= DevilSlotCount || character.Stats == nil {
|
||||
return 0
|
||||
}
|
||||
if character.Stats[dailyUsageDayKey] != ServiceDayID(now) {
|
||||
return 0
|
||||
}
|
||||
used := character.Stats[DailyUsageKey(slot)]
|
||||
if used < 0 {
|
||||
return 0
|
||||
}
|
||||
return used
|
||||
}
|
||||
|
||||
// TryConsumeDaily increments one capped service atomically in the caller's
|
||||
// character transaction. Uncapped services return true without changing
|
||||
// character state.
|
||||
func TryConsumeDaily(character *dnfrepo.CharacterRecord, slot int64, now time.Time) bool {
|
||||
limit := DailyLimit(slot)
|
||||
if character == nil || slot < 0 || slot >= DevilSlotCount {
|
||||
return false
|
||||
}
|
||||
if limit == 0 {
|
||||
return true
|
||||
}
|
||||
if character.Stats == nil {
|
||||
character.Stats = make(map[string]int64, 16)
|
||||
}
|
||||
dayID := ServiceDayID(now)
|
||||
if character.Stats[dailyUsageDayKey] != dayID {
|
||||
character.Stats[dailyUsageDayKey] = dayID
|
||||
for currentSlot := int64(0); currentSlot < DevilSlotCount; currentSlot++ {
|
||||
delete(character.Stats, DailyUsageKey(currentSlot))
|
||||
}
|
||||
}
|
||||
key := DailyUsageKey(slot)
|
||||
used := character.Stats[key]
|
||||
if used < 0 {
|
||||
used = 0
|
||||
}
|
||||
if used >= limit {
|
||||
return false
|
||||
}
|
||||
character.Stats[key] = used + 1
|
||||
return true
|
||||
}
|
||||
|
||||
// SelectAckEntries builds the select-character ACK premium section:
|
||||
// ActivatedNotificationMessageID is the current-EXE class0 premium activation
|
||||
// notification (sub_1D61460, registered at 0x1D7EBA8). Every path that extends
|
||||
// an entitlement has to send it, or the account keeps the new expiry while the
|
||||
// client goes on drawing the old one.
|
||||
const ActivatedNotificationMessageID uint16 = 0x42
|
||||
|
||||
// u8 count + count*(u8 type + i64LE remaining seconds). The eight devil slots
|
||||
// fold into one entry at DevilFolded with the longest remaining duration
|
||||
// (86JP SelectCharacterAckBodyBuilder order).
|
||||
func SelectAckEntries(account dnfrepo.AccountRecord, now time.Time) []byte {
|
||||
entries := make([]byte, 0, 54)
|
||||
emittedTypes := make(map[int64]struct{}, 8)
|
||||
appendEntry := func(premiumType int64, remaining int64) {
|
||||
if remaining <= 0 || premiumType <= 0 || premiumType > 0xff {
|
||||
return
|
||||
}
|
||||
if _, exists := emittedTypes[premiumType]; exists {
|
||||
return
|
||||
}
|
||||
emittedTypes[premiumType] = struct{}{}
|
||||
entries = append(entries, byte(premiumType))
|
||||
for shift := uint(0); shift < 8; shift++ {
|
||||
entries = append(entries, byte(remaining>>(8*shift)))
|
||||
}
|
||||
}
|
||||
for _, premiumType := range []int64{
|
||||
TypeOverEquip,
|
||||
TypeOverSkill,
|
||||
TypeBonusExp,
|
||||
TypeCrystal,
|
||||
TypeLethe,
|
||||
} {
|
||||
appendEntry(premiumType, ExpireAt(account, premiumType)-now.Unix())
|
||||
}
|
||||
appendEntry(TypeBlackDiamondWire, BlackDiamondExpireAt(account)-now.Unix())
|
||||
// Old saves may contain only the auction row because earlier server builds
|
||||
// treated its bundled private-store permission as the same type. Project a
|
||||
// type-8 row for the longest of the explicit license and the active auction
|
||||
// coupon so those accounts regain the native town companion on login.
|
||||
privateStoreExpire := ExpireAtRaw(account, TypePrivateStore)
|
||||
if auctionExpire := ExpireAtRaw(account, TypeAuction); auctionExpire > privateStoreExpire {
|
||||
privateStoreExpire = auctionExpire
|
||||
}
|
||||
// premiumlist_new.etc contains additional current-client contract families
|
||||
// (for example types 29, 80, 88, 92 and 100). Activations are stored under
|
||||
// the same premium_expire_<type> contract, so include every active u8 type
|
||||
// deterministically instead of losing those durations on character select.
|
||||
// Devil slots are internal account types above u8 range and remain folded
|
||||
// into the one native type-58 entry below.
|
||||
extraTypes := make([]int64, 0, len(account.Metadata))
|
||||
for key := range account.Metadata {
|
||||
if !strings.HasPrefix(key, "premium_expire_") {
|
||||
continue
|
||||
}
|
||||
premiumType, err := strconv.ParseInt(strings.TrimPrefix(key, "premium_expire_"), 10, 64)
|
||||
if err != nil || premiumType <= 0 || premiumType > 0xff || premiumType == DevilFolded ||
|
||||
IsBlackDiamondStorageType(premiumType) || premiumType == TypeBlackDiamondWire {
|
||||
continue
|
||||
}
|
||||
if _, exists := emittedTypes[premiumType]; exists {
|
||||
continue
|
||||
}
|
||||
extraTypes = append(extraTypes, premiumType)
|
||||
}
|
||||
if privateStoreExpire > now.Unix() {
|
||||
found := false
|
||||
for _, premiumType := range extraTypes {
|
||||
if premiumType == TypePrivateStore {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
extraTypes = append(extraTypes, TypePrivateStore)
|
||||
}
|
||||
}
|
||||
sort.Slice(extraTypes, func(i, j int) bool {
|
||||
return extraTypes[i] < extraTypes[j]
|
||||
})
|
||||
for _, premiumType := range extraTypes {
|
||||
expireAt := ExpireAt(account, premiumType)
|
||||
if premiumType == TypePrivateStore && privateStoreExpire > expireAt {
|
||||
expireAt = privateStoreExpire
|
||||
}
|
||||
appendEntry(premiumType, expireAt-now.Unix())
|
||||
}
|
||||
devilRemaining := int64(0)
|
||||
for slot := int64(0); slot < DevilSlotCount; slot++ {
|
||||
if remaining := ExpireAt(account, DevilSlotType(slot)) - now.Unix(); remaining > devilRemaining {
|
||||
devilRemaining = remaining
|
||||
}
|
||||
}
|
||||
appendEntry(DevilFolded, devilRemaining)
|
||||
out := []byte{byte(len(entries) / 9)}
|
||||
return append(out, entries...)
|
||||
}
|
||||
|
||||
// BuildActivatedBody is the NOTI 0x0042 body emitted for one wire-compatible
|
||||
// activation: u16(2) + u8 type + i64LE remaining seconds. Callers must first
|
||||
// require CanNotifyActivation; this function remains allocation-only so it can
|
||||
// be used by the aligned response builders.
|
||||
func BuildActivatedBody(premiumType int64, remainingSeconds int64) []byte {
|
||||
body := []byte{2, 0, byte(premiumType)}
|
||||
for shift := uint(0); shift < 8; shift++ {
|
||||
body = append(body, byte(remainingSeconds>>(8*shift)))
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
// ServiceType values the current EXE's class1/op903 handler 0x01CFE1D0 branches
|
||||
// on (its u16 read sits at body[1:3]).
|
||||
const (
|
||||
// ServiceTypePanel applies the body to the contract singleton the coupon box
|
||||
// and Cera checkout read and, when the client's own one-shot "show the panel"
|
||||
// flag is still armed (armed by its class1/op4 SELECT_CHARACTER handler,
|
||||
// cleared by the first type-1 body), opens the contract panel.
|
||||
ServiceTypePanel uint16 = 1
|
||||
// ServiceTypeSilent stores the body through the client's plain memcpy path
|
||||
// and performs no UI action at all.
|
||||
ServiceTypeSilent uint16 = 4
|
||||
)
|
||||
|
||||
// BuildServiceDataBody is the current EXE class1/op903 premium-service body:
|
||||
// u8(a2=1) + u16(serviceType) + 74-byte blob. Each devil slot owns u32LE
|
||||
// absolute Unix expiry at blob offset 6+slot*9 and u32LE current-day usage at
|
||||
// 10+slot*9. The current client subtracts its CHANNELINFO/native server clock
|
||||
// when rendering the remaining duration, so a relative duration here is
|
||||
// immediately treated as expired.
|
||||
//
|
||||
// serviceType is not cosmetic: the handler treats 1 as "apply and refresh the
|
||||
// contract UI" (and pops the panel while its flag is armed) and every other
|
||||
// value -- 4 is the one the client itself asks with -- as a silent store.
|
||||
func BuildServiceDataBody(
|
||||
account dnfrepo.AccountRecord,
|
||||
character dnfrepo.CharacterRecord,
|
||||
now time.Time,
|
||||
serviceType uint16,
|
||||
) []byte {
|
||||
blob := make([]byte, 74)
|
||||
for slot := int64(0); slot < DevilSlotCount; slot++ {
|
||||
expireAt := ExpireAt(account, DevilSlotType(slot))
|
||||
if expireAt <= now.Unix() {
|
||||
expireAt = 0
|
||||
}
|
||||
if expireAt > int64(^uint32(0)) {
|
||||
expireAt = int64(^uint32(0))
|
||||
}
|
||||
offset := 6 + slot*9
|
||||
binary.LittleEndian.PutUint32(blob[offset:offset+4], uint32(expireAt))
|
||||
usedOffset := 10 + slot*9
|
||||
used := uint32(DailyUsage(character, slot, now))
|
||||
if usedOffset+4 <= int64(len(blob)) {
|
||||
binary.LittleEndian.PutUint32(blob[usedOffset:usedOffset+4], used)
|
||||
} else {
|
||||
// Slot 7 ends at the 74-byte blob tail. The current EXE reads
|
||||
// only its low usage byte from that final position.
|
||||
blob[usedOffset] = byte(used)
|
||||
}
|
||||
}
|
||||
body := []byte{1, byte(serviceType), byte(serviceType >> 8)}
|
||||
return append(body, blob...)
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
package premium
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||||
)
|
||||
|
||||
func TestUpsertFreshRenewAndExpiredStack(t *testing.T) {
|
||||
now := time.Unix(1_700_000_000, 0)
|
||||
account := &dnfrepo.AccountRecord{}
|
||||
|
||||
Upsert(account, TypeOverEquip, 86400, 1, now)
|
||||
if got, want := ExpireAt(*account, TypeOverEquip), now.Unix()+86400; got != want {
|
||||
t.Fatalf("fresh expire = %d, want %d", got, want)
|
||||
}
|
||||
|
||||
// Renewal while active stacks on the current expiry, not on now.
|
||||
Upsert(account, TypeOverEquip, 3*86400, 1, now)
|
||||
if got, want := ExpireAt(*account, TypeOverEquip), now.Unix()+4*86400; got != want {
|
||||
t.Fatalf("renew expire = %d, want %d", got, want)
|
||||
}
|
||||
|
||||
// Renewal after expiry restarts from now.
|
||||
later := now.Add(10 * 86400 * time.Second)
|
||||
Upsert(account, TypeOverEquip, 86400, 1, later)
|
||||
if got, want := ExpireAt(*account, TypeOverEquip), later.Unix()+86400; got != want {
|
||||
t.Fatalf("expired renew expire = %d, want %d", got, want)
|
||||
}
|
||||
|
||||
// Count multiplies the duration.
|
||||
Upsert(account, TypeOverSkill, 86400, 3, now)
|
||||
if got, want := ExpireAt(*account, TypeOverSkill), now.Unix()+3*86400; got != want {
|
||||
t.Fatalf("count expire = %d, want %d", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuctionUpsertIncludesPrivateStoreLicenseForTheFullRenewedTerm(t *testing.T) {
|
||||
now := time.Unix(1_700_000_000, 0)
|
||||
account := &dnfrepo.AccountRecord{Metadata: map[string]string{
|
||||
MetadataKey(TypeAuction): strconv.FormatInt(now.Add(24*time.Hour).Unix(), 10),
|
||||
}}
|
||||
Upsert(account, TypeAuction, int64(24*time.Hour/time.Second), 1, now)
|
||||
want := now.Add(48 * time.Hour).Unix()
|
||||
if got := ExpireAtRaw(*account, TypeAuction); got != want {
|
||||
t.Fatalf("auction expire=%d want=%d", got, want)
|
||||
}
|
||||
if got := ExpireAtRaw(*account, TypePrivateStore); got != want {
|
||||
t.Fatalf("private-store expire=%d want bundled auction expiry=%d", got, want)
|
||||
}
|
||||
if got := ActivationTypes(TypeAuction); !reflect.DeepEqual(got, []int64{TypeAuction, TypePrivateStore}) {
|
||||
t.Fatalf("auction activation types=%v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActiveAndExpireAtMalformed(t *testing.T) {
|
||||
now := time.Unix(1_700_000_000, 0)
|
||||
account := dnfrepo.AccountRecord{Metadata: map[string]string{
|
||||
MetadataKey(TypeCrystal): "garbage",
|
||||
MetadataKey(TypeLethe): "-5",
|
||||
}}
|
||||
if ExpireAt(account, TypeCrystal) != 0 || ExpireAt(account, TypeLethe) != 0 {
|
||||
t.Fatalf("malformed metadata must read as 0: %+v", account.Metadata)
|
||||
}
|
||||
if Active(account, TypeCrystal, now) || Active(account, TypeLethe, now) || Active(account, TypeOverEquip, now) {
|
||||
t.Fatal("inactive premiums must not report active")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectAckEntriesFoldsDevilSlots(t *testing.T) {
|
||||
now := time.Unix(1_700_000_000, 0)
|
||||
account := dnfrepo.AccountRecord{Metadata: map[string]string{}}
|
||||
Upsert(&account, TypeOverEquip, 86400, 1, now)
|
||||
Upsert(&account, DevilSlotType(DevilSlotAutoRepair), 3*86400, 1, now)
|
||||
Upsert(&account, DevilSlotType(DevilSlotGoldCard), 86400, 1, now)
|
||||
// Expired devil slot must not win the fold.
|
||||
account.Metadata[MetadataKey(DevilSlotType(DevilSlotQuestHelper))] = "1"
|
||||
|
||||
got := SelectAckEntries(account, now)
|
||||
if got[0] != 2 {
|
||||
t.Fatalf("count = %d, want 2 (over-equip + folded devil): % X", got[0], got)
|
||||
}
|
||||
if len(got) != 1+2*9 {
|
||||
t.Fatalf("len = %d, want %d", len(got), 1+2*9)
|
||||
}
|
||||
if got[1] != byte(TypeOverEquip) {
|
||||
t.Fatalf("first entry type = %d, want %d", got[1], TypeOverEquip)
|
||||
}
|
||||
if got[10] != byte(DevilFolded) {
|
||||
t.Fatalf("second entry type = %d, want %d", got[10], DevilFolded)
|
||||
}
|
||||
var remaining int64
|
||||
for i := 0; i < 8; i++ {
|
||||
remaining |= int64(got[11+i]) << (8 * i)
|
||||
}
|
||||
if want := int64(3 * 86400); remaining != want {
|
||||
t.Fatalf("folded devil remaining = %d, want %d", remaining, want)
|
||||
}
|
||||
|
||||
empty := SelectAckEntries(dnfrepo.AccountRecord{}, now)
|
||||
if !bytes.Equal(empty, []byte{0}) {
|
||||
t.Fatalf("empty entries = % X, want [0]", empty)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectAckEntriesKeepsAllActivatedRuntimePVFContractTypes(t *testing.T) {
|
||||
now := time.Unix(1_700_000_000, 0)
|
||||
account := dnfrepo.AccountRecord{Metadata: map[string]string{
|
||||
MetadataKey(100): "1700259200",
|
||||
MetadataKey(29): "1700086400",
|
||||
MetadataKey(80): "1700172800",
|
||||
// Malformed, expired, synthetic folded and out-of-wire-range keys must
|
||||
// not become select-character premium rows.
|
||||
MetadataKey(88): "garbage",
|
||||
MetadataKey(92): "1",
|
||||
MetadataKey(DevilFolded): "1700086400",
|
||||
MetadataKey(256): "1700086400",
|
||||
}}
|
||||
|
||||
got := SelectAckEntries(account, now)
|
||||
if got[0] != 4 {
|
||||
t.Fatalf("count = %d, want private-store compatibility + 3 generic runtime-PVF contracts: % X", got[0], got)
|
||||
}
|
||||
gotTypes := []byte{got[1], got[10], got[19], got[28]}
|
||||
wantTypes := []byte{byte(TypePrivateStore), 29, 80, 100}
|
||||
if !bytes.Equal(gotTypes, wantTypes) {
|
||||
t.Fatalf("types = %v, want deterministic %v; body=% X", gotTypes, wantTypes, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectAckEntriesCountsTheFourCurrentCouponFamilies(t *testing.T) {
|
||||
now := time.Unix(1_700_000_000, 0)
|
||||
account := dnfrepo.AccountRecord{Metadata: map[string]string{
|
||||
// PVF type 29 is an ordinary contract and stays independent from Black
|
||||
// Diamond storage type 1000 / wire type 56.
|
||||
MetadataKey(29): "1700086400",
|
||||
MetadataKey(TypeOverEquip): "1700086400",
|
||||
MetadataKey(TypeOverSkill): "1700086400",
|
||||
MetadataKey(TypeBonusExp): "1700086400",
|
||||
}}
|
||||
|
||||
got := SelectAckEntries(account, now)
|
||||
if got[0] != 5 || len(got) != 1+5*9 {
|
||||
t.Fatalf("coupon count/body = %d/%d, want 5/%d: % X", got[0], len(got), 1+5*9, got)
|
||||
}
|
||||
gotTypes := []byte{got[1], got[10], got[19], got[28], got[37]}
|
||||
wantTypes := []byte{byte(TypeOverEquip), byte(TypeOverSkill), byte(TypeBonusExp), byte(TypePrivateStore), 29}
|
||||
if !bytes.Equal(gotTypes, wantTypes) {
|
||||
t.Fatalf("coupon types = %v, want %v; body=% X", gotTypes, wantTypes, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildActivatedBodyExactBytes(t *testing.T) {
|
||||
got := BuildActivatedBody(TypeOverEquip, 86400)
|
||||
want := []byte{2, 0, 22, 0x80, 0x51, 0x01, 0, 0, 0, 0, 0}
|
||||
if !bytes.Equal(got, want) {
|
||||
t.Fatalf("body = % X, want % X", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanNotifyActivationRejectsInternalDevilSlots(t *testing.T) {
|
||||
if !CanNotifyActivation(TypeOverEquip) {
|
||||
t.Fatal("ordinary u8 premium type must use the current activation notification")
|
||||
}
|
||||
for slot := int64(0); slot < DevilSlotCount; slot++ {
|
||||
if CanNotifyActivation(DevilSlotType(slot)) {
|
||||
t.Fatalf("internal devil slot %d type=%d must not be truncated into class0/op66", slot, DevilSlotType(slot))
|
||||
}
|
||||
}
|
||||
if CanNotifyActivation(0) || CanNotifyActivation(256) {
|
||||
t.Fatal("zero and values outside u8 must not use the current activation notification")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlackDiamondUsesOnlyDedicatedEntitlementAndProjectsOneWireRow(t *testing.T) {
|
||||
now := time.Unix(1_700_000_000, 0)
|
||||
account := dnfrepo.AccountRecord{Metadata: map[string]string{
|
||||
MetadataKey(1): "1700086400",
|
||||
MetadataKey(17): "1700086400",
|
||||
MetadataKey(29): "1700086400",
|
||||
MetadataKey(TypeBlackDiamondEntitlement): "1700043200",
|
||||
}}
|
||||
if got := BlackDiamondExpireAt(account); got != 1_700_043_200 {
|
||||
t.Fatalf("dedicated Black Diamond expiry=%d", got)
|
||||
}
|
||||
entries := SelectAckEntries(account, now)
|
||||
if len(entries) != 1+5*9 || entries[0] != 5 {
|
||||
t.Fatalf("premium select projection=% X, want five rows including bundled private-store compatibility", entries)
|
||||
}
|
||||
wantTypes := []byte{byte(TypeBlackDiamondWire), 1, byte(TypePrivateStore), 17, 29}
|
||||
gotTypes := []byte{entries[1], entries[10], entries[19], entries[28], entries[37]}
|
||||
if !bytes.Equal(gotTypes, wantTypes) {
|
||||
t.Fatalf("premium select types=%v, want %v; body=% X", gotTypes, wantTypes, entries)
|
||||
}
|
||||
Upsert(&account, 29, 86400, 1, now)
|
||||
if got := ExpireAtRaw(account, 29); got != 1_700_172_800 {
|
||||
t.Fatalf("PVF type-29 renewal expiry=%d", got)
|
||||
}
|
||||
if got := ExpireAtRaw(account, TypePrivateStore); got != 1_700_172_800 {
|
||||
t.Fatalf("type-29 renewal did not align private-store expiry=%d", got)
|
||||
}
|
||||
if got := ExpireAtRaw(account, TypeBlackDiamondEntitlement); got != 1_700_043_200 {
|
||||
t.Fatalf("type-29 renewal changed Black Diamond expiry=%d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildServiceDataBodyDevilOffsets(t *testing.T) {
|
||||
now := time.Unix(1_700_000_000, 0)
|
||||
account := dnfrepo.AccountRecord{Metadata: map[string]string{
|
||||
MetadataKey(DevilSlotType(DevilSlotAutoRepair)): "1700604800",
|
||||
MetadataKey(DevilSlotType(DevilSlotGoldCard)): "1699999999",
|
||||
}}
|
||||
character := dnfrepo.CharacterRecord{Stats: map[string]int64{
|
||||
dailyUsageDayKey: ServiceDayID(now),
|
||||
DailyUsageKey(DevilSlotAutoRepair): 4,
|
||||
}}
|
||||
got := BuildServiceDataBody(account, character, now, ServiceTypePanel)
|
||||
if len(got) != 3+74 {
|
||||
t.Fatalf("len = %d, want 77", len(got))
|
||||
}
|
||||
if got[0] != 1 || got[1] != 1 || got[2] != 0 {
|
||||
t.Fatalf("prefix = % X, want 01 01 00", got[:3])
|
||||
}
|
||||
// serviceType is a u16 at body[1:3]; the silent variant the client itself
|
||||
// asks with must not look like the panel variant.
|
||||
if silent := BuildServiceDataBody(account, character, now, ServiceTypeSilent); len(silent) != 3+74 ||
|
||||
silent[0] != 1 || silent[1] != 4 || silent[2] != 0 {
|
||||
t.Fatalf("silent prefix = % X, want 01 04 00", silent[:3])
|
||||
}
|
||||
offset := 3 + 6 + DevilSlotAutoRepair*9
|
||||
var expireAt uint32
|
||||
for i := 0; i < 4; i++ {
|
||||
expireAt |= uint32(got[int(offset)+i]) << (8 * i)
|
||||
}
|
||||
if expireAt != 1_700_604_800 {
|
||||
t.Fatalf("slot6 absolute expiry = %d, want %d", expireAt, 1_700_604_800)
|
||||
}
|
||||
usedOffset := 3 + 10 + DevilSlotAutoRepair*9
|
||||
var used uint32
|
||||
for i := 0; i < 4; i++ {
|
||||
used |= uint32(got[int(usedOffset)+i]) << (8 * i)
|
||||
}
|
||||
if used != 4 {
|
||||
t.Fatalf("slot6 used = %d, want 4", used)
|
||||
}
|
||||
// Expired and unused slots stay zero.
|
||||
other := 3 + 6 + DevilSlotGoldCard*9
|
||||
if got[other] != 0 || got[other+1] != 0 || got[other+2] != 0 || got[other+3] != 0 {
|
||||
t.Fatalf("slot0 expired absolute expiry must be zero: % X", got[other:other+4])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevilServiceDurationAndDailyUsageRules(t *testing.T) {
|
||||
now := time.Date(2026, time.July, 27, 5, 59, 59, 0, time.FixedZone("CST", 8*60*60))
|
||||
account := &dnfrepo.AccountRecord{}
|
||||
|
||||
Upsert(account, DevilSlotType(DevilSlotGoldCard), 7*24*60*60, 1, now)
|
||||
Upsert(account, DevilSlotType(DevilSlotGoldCard), 30*24*60*60, 1, now)
|
||||
if got, want := ExpireAt(*account, DevilSlotType(DevilSlotGoldCard)), now.Unix()+37*24*60*60; got != want {
|
||||
t.Fatalf("7-day plus 30-day expiry = %d, want %d", got, want)
|
||||
}
|
||||
|
||||
character := &dnfrepo.CharacterRecord{}
|
||||
for count := int64(0); count < DailyLimit(DevilSlotGoldCard); count++ {
|
||||
if !TryConsumeDaily(character, DevilSlotGoldCard, now) {
|
||||
t.Fatalf("consume %d unexpectedly rejected", count+1)
|
||||
}
|
||||
}
|
||||
if TryConsumeDaily(character, DevilSlotGoldCard, now) {
|
||||
t.Fatal("eleventh gold-card use must be rejected")
|
||||
}
|
||||
if got := DailyUsage(*character, DevilSlotGoldCard, now); got != 10 {
|
||||
t.Fatalf("gold-card usage = %d, want 10", got)
|
||||
}
|
||||
|
||||
afterReset := now.Add(time.Second)
|
||||
if got := DailyUsage(*character, DevilSlotGoldCard, afterReset); got != 0 {
|
||||
t.Fatalf("usage after 06:00 reset = %d, want 0", got)
|
||||
}
|
||||
if !TryConsumeDaily(character, DevilSlotGoldCard, afterReset) {
|
||||
t.Fatal("first use after reset must be accepted")
|
||||
}
|
||||
if got := DailyUsage(*character, DevilSlotGoldCard, afterReset); got != 1 {
|
||||
t.Fatalf("usage after reset consume = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDailyLimitsMatchPVF(t *testing.T) {
|
||||
want := map[int64]int64{
|
||||
DevilSlotGoldCard: 10,
|
||||
DevilSlotDoubleJar: 8,
|
||||
DevilSlotQuestHelper: 0,
|
||||
DevilSlotSevenBuff: 10,
|
||||
DevilSlotHpMpRecover: 0,
|
||||
DevilSlotFreeWeakness: 10,
|
||||
DevilSlotAutoRepair: 6,
|
||||
DevilSlotFastJarOpen: 0,
|
||||
}
|
||||
for slot, limit := range want {
|
||||
if got := DailyLimit(slot); got != limit {
|
||||
t.Fatalf("slot %d limit = %d, want %d", slot, got, limit)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user