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,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...)
|
||||
}
|
||||
Reference in new issue
Block a user