Files
90CNServer/server/internal/services/dnfbridge/purchase_limit_apply.go
T
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

160 lines
6.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 本文件把 PVF 限购接进购买事务(NPC 商店与商场共用)。
//
// 计数归属:
// - `character` → 角色记录 Stats
// - `account` → 账号记录 Metadata
//
// 两者都在购买事务内读写,超限时返回错误让整笔回滚(金币/点券、材料、道具、
// 计数一起失败),提交时计数与发货同时生效。
package dnfbridge
import (
"errors"
"os"
"strconv"
"strings"
"sync"
"time"
"longheng.io/server/internal/modules/dnf/purchaselimit"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
var (
// errCurrentPurchaseLimitReached:PVF `[purchase limit]` 上限已满。
errCurrentPurchaseLimitReached = errors.New("dnf current purchase limit reached")
// errCurrentPurchaseLimitUnavailable:限购状态容器缺失(角色记录或账号记录
// 没拿到),此时不能安全地记账。
errCurrentPurchaseLimitUnavailable = errors.New("dnf current purchase limit state is unavailable")
)
// currentPurchaseLimitErrorCode 是超限时回给客户端的失败码。
//
// 逆向实证(2026-09-14):客户端文案「超过购买限制」的字符串 id 是 44465(0xADB1)。
// 全 EXE 只有 3 处 `push 44465`,其中一处(rva 0x0192842F)位于 op21 购买回包处理器
// `sub_1D27590`(入口 0x01927590,就是本服日志里 ack_body=current_exe_sub_1D27590
// 指的那个函数)的失败分支,形如 `cmp bl, 0x85 / push 0 / push 44465`。即客户端按
// 失败包第二个字节查文案表:0x85 → 超过购买限制。同表其它取值实测:
// 0x0A/0x24/0xC8 → 所持金额不足(747)、0xCB → 物品保管箱已满(72839)、
// 0xD9 → 现在不是出售时间(44466)、0x15 → 当前状态无法购买活动商品(46057)、
// 0x16 → 不符合购买条件(70743)、0x01 → 被禁闭在赛丽亚旅馆(70352)。
const currentPurchaseLimitErrorCode = 0x85
// currentPurchaseLimitPolicy 取出道具的限购策略;无字段或字段畸形时返回 false
// (畸形字段在解析期已记录,购买链路上按“不限购”处理以免锁死一件商品)。
func currentPurchaseLimitPolicy(definition dungeonDropItemDefinition) (purchaselimit.Policy, bool) {
if !definition.PurchaseLimitFound {
return purchaselimit.Policy{}, false
}
if err := definition.PurchaseLimit.Valid(); err != nil {
return purchaselimit.Policy{}, false
}
return definition.PurchaseLimit, true
}
// currentPurchaseLimitClockOffset 是限购窗口的“时间旅行”偏移,只用于验收“到期刷新”:
// 环境变量 DNF90_PURCHASE_LIMIT_CLOCK_OFFSET_HOURS 非 0 时,限购窗口判定用的时间整体平移
// 该小时数(正数=往未来)。例:+200 小时会跨过周四 06:00 的周重置边界,于是本周窗口
// 变成下周窗口、存量计数被判过期而归零——和真正到期时的代码路径完全一致。
//
// 只影响限购窗口判定,不影响疲劳、掉落、任务等其它时间逻辑;设置后每次 op715 请求的
// 日志都会带上 clock_offset_hours 字段,避免忘了关掉还看不出来。
var (
currentPurchaseLimitClockOffsetOnce sync.Once
currentPurchaseLimitClockOffsetValue time.Duration
)
func currentPurchaseLimitClockOffset() time.Duration {
currentPurchaseLimitClockOffsetOnce.Do(func() {
raw := strings.TrimSpace(os.Getenv(currentPurchaseLimitClockOffsetEnv))
if raw == "" {
return
}
hours, err := strconv.ParseFloat(raw, 64)
if err != nil || hours == 0 {
return
}
currentPurchaseLimitClockOffsetValue = time.Duration(hours * float64(time.Hour))
})
return currentPurchaseLimitClockOffsetValue
}
const currentPurchaseLimitClockOffsetEnv = "DNF90_PURCHASE_LIMIT_CLOCK_OFFSET_HOURS"
// currentPurchaseLimitTime 返回限购窗口判定使用的时间;测试用
// Service.purchaseLimitClock 注入,运维可用上面那个环境变量做时间旅行。
func (s *Service) currentPurchaseLimitTime() time.Time {
now := time.Now()
if s != nil && s.purchaseLimitClock != nil {
now = s.purchaseLimitClock()
}
return now.Add(currentPurchaseLimitClockOffset())
}
// readCurrentPurchaseLimit 返回道具的限购策略与当前已购次数。
func readCurrentPurchaseLimit(
characterStats map[string]int64,
accountMetadata map[string]string,
definition dungeonDropItemDefinition,
now time.Time,
) (purchaselimit.Policy, purchaselimit.State, bool) {
policy, ok := currentPurchaseLimitPolicy(definition)
if !ok {
return purchaselimit.Policy{}, purchaselimit.State{}, false
}
return policy, purchaselimit.Read(characterStats, accountMetadata, policy, now), true
}
// consumeCurrentPurchaseLimit 在购买事务里消费 count 次限购,返回被消费的策略
// 与消费前快照(供日志)。无策略时返回零值策略与 nil 错误。
func consumeCurrentPurchaseLimit(
character *dnfrepo.CharacterRecord,
account *dnfrepo.AccountRecord,
definition dungeonDropItemDefinition,
count uint32,
now time.Time,
) (purchaselimit.Policy, purchaselimit.State, error) {
policy, ok := currentPurchaseLimitPolicy(definition)
if !ok {
return purchaselimit.Policy{}, purchaselimit.State{}, nil
}
return consumeCurrentPurchaseLimitPolicy(character, account, policy, count, now)
}
// consumeCurrentPurchaseLimitPolicy 按**显式策略**消费,容器与回滚语义和
// consumeCurrentPurchaseLimit 完全一致。用于策略不是来自道具 PVF 的场景:
// 商城的 `[buy restrict item]`(按商品号)与道具说明文案里的账号限购。
func consumeCurrentPurchaseLimitPolicy(
character *dnfrepo.CharacterRecord,
account *dnfrepo.AccountRecord,
policy purchaselimit.Policy,
count uint32,
now time.Time,
) (purchaselimit.Policy, purchaselimit.State, error) {
if err := policy.Valid(); err != nil {
// 策略本身不合法(次数为 0 等):按“不限购”处理,绝不因为一条畸形数据
// 锁死一件商品。
return purchaselimit.Policy{}, purchaselimit.State{}, nil
}
if character == nil {
return policy, purchaselimit.State{}, errCurrentPurchaseLimitUnavailable
}
var accountMetadata map[string]string
if policy.Scope == purchaselimit.ScopeAccount {
if account == nil {
return policy, purchaselimit.State{}, errCurrentPurchaseLimitUnavailable
}
if account.Metadata == nil {
account.Metadata = make(map[string]string)
}
accountMetadata = account.Metadata
} else if character.Stats == nil {
character.Stats = make(map[string]int64)
}
state := purchaselimit.Read(character.Stats, accountMetadata, policy, now)
if err := purchaselimit.Consume(character.Stats, accountMetadata, policy, count, now); err != nil {
return policy, state, err
}
return policy, state, nil
}