服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
414 lines
15 KiB
Go
414 lines
15 KiB
Go
// 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...)
|
|
}
|