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

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

231 lines
7.7 KiB
Go

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
}