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