DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
hanfuit committed 2026-11-04 14:31:55 +08:00
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)
}
}
}