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

252 lines
9.3 KiB
Go

package dnfbridge
import (
"context"
"fmt"
"sort"
"strconv"
"strings"
"longheng.io/server/internal/modules/dnf/alignedcmd"
dnffatigue "longheng.io/server/internal/modules/dnf/fatigue"
)
// currentItemDailyUseStateMsgID is the client's 每日可用次数 state packet.
//
// Reverse engineering 2026-09-15 (DNF.exe):
//
// client registration : RVA 0x0197F3F2 push 0x21D; push handler; call register
// handler : RVA 0x0197B2B0 (per-item byte applier)
// handler effect : its only call into the item is sub_01F20590, the unique
// writer of [item+0x46C] / [item+0x470]
// tooltip : "每天可用次数 : %d/%d" (string 33077) is formatted from
// sub_01F17F70(item) = [item+0x470] - [item+0x46C] and
// [definition+0x3F4]
//
// The op14 item row never writes those fields, and op541 does not appear anywhere
// in this server's packet log: the counter was frozen at the full value because
// the packet was never sent.
//
// Class: the registration table that carries op541 (registrar RVA 0x01E62980,
// 994 entries) does NOT contain op44/op515, which this server has always sent
// successfully as class 1, while op541 itself answers nothing when sent as class
// 1 (live 2026-09-14 23:50: the client kept rendering 3/3 with class 1). So op541
// belongs to the other registry: class 0.
const currentItemDailyUseStateMsgID uint16 = 541
// currentItemDailyUseStateClass is the wire classification of the state packet.
const currentItemDailyUseStateClass byte = 0
// buildCurrentItemDailyUseStateBody encodes the per-item counters.
//
// Layout (from the handler's own reads): a leading count byte, then one entry per
// item of a 4-byte item id followed by the used-count byte. The client looks each
// id up in the receiving character's containers and applies the byte to that
// item.
func buildCurrentItemDailyUseStateBody(states []alignedcmd.DailyUseState) ([]byte, error) {
if len(states) == 0 {
return nil, nil
}
if len(states) > 0xFF {
return nil, fmt.Errorf("daily use state carries %d items, maximum is 255", len(states))
}
body := make([]byte, 0, 1+5*len(states))
body = append(body, byte(len(states)))
for _, state := range states {
if state.ItemID == 0 {
return nil, fmt.Errorf("daily use state item id is zero")
}
body = append(body,
byte(state.ItemID), byte(state.ItemID>>8), byte(state.ItemID>>16), byte(state.ItemID>>24),
state.Used)
}
return body, nil
}
// dailyUseStatesFromCounters turns the durable "used today" counters into the
// client's state packet. The byte is the used count itself, so the durable stat
// can be projected as-is -- zero included, which resets the tooltip to the full
// value (live 2026-09-15: sending 2 with one potion already spent rendered 1/3,
// i.e. 上限 - 字节).
func dailyUseStatesFromCounters(stats map[string]int64) []alignedcmd.DailyUseState {
if len(stats) == 0 {
return nil
}
used := make(map[uint32]int64, 4)
collect := func(prefix string) {
for key, value := range stats {
if !strings.HasPrefix(key, prefix) {
continue
}
itemID, err := strconv.ParseUint(strings.TrimPrefix(key, prefix), 10, 32)
if err != nil || itemID == 0 {
continue
}
if value < 0 {
value = 0
}
if value > 0xFF {
value = 0xFF
}
// A zero counter is still emitted on purpose: it is what clears a
// client that stayed online across the 06:00 reset. Coexisting
// prefixes keep the larger value instead of emitting twice.
if current, seen := used[uint32(itemID)]; !seen || value > current {
used[uint32(itemID)] = value
}
}
}
// The generic key is the owner for every [total usable count] item; the
// legacy fatigue-potion spelling stays readable so a counter written before
// the migration is not lost within the same service day. An item present
// under both keys keeps the larger value, so it cannot be counted twice.
collect(dnffatigue.ItemUsageStatPrefix)
collect(dnffatigue.PotionUsageStatPrefix)
if len(used) == 0 {
return nil
}
states := make([]alignedcmd.DailyUseState, 0, len(used))
for itemID, value := range used {
states = append(states, alignedcmd.DailyUseState{ItemID: itemID, Used: byte(value)})
}
sort.Slice(states, func(i, j int) bool { return states[i].ItemID < states[j].ItemID })
return states
}
// sendCurrentCharacterDailyUseStates replays those counters once the character's
// containers are visible. The client's 每日可用次数 state is memory-only, so
// without this replay every login would show a partly used item as untouched
// until its next use.
func (s *Service) sendCurrentCharacterDailyUseStates(session *gameSession, source string) error {
if s == nil || session == nil || session.selectedCharacterID == 0 {
return nil
}
repositories, ok := s.repositoryGroup()
if !ok || repositories.Character == nil {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
defer cancel()
character, found, err := repositories.Character.Load(ctx, strconv.Itoa(int(session.selectedCharacterID)))
if err != nil || !found {
return err
}
states := dailyUseStatesFromCounters(character.Stats)
body, err := buildCurrentItemDailyUseStateBody(states)
if err != nil {
s.logGameEvent(session, "game-item-daily-use-state-blocked",
"source", source,
"states", len(states),
"error", err)
return nil
}
if len(body) == 0 {
return nil
}
if err := s.sendCurrentItemDailyUseStatePacket(session, body); err != nil {
return err
}
s.logGameEvent(session, "game-item-daily-use-state-replayed",
"source", source,
"char_id", session.selectedCharacterID,
"entries", len(states),
"body_len", len(body),
"msg_id", currentItemDailyUseStateMsgID,
"classification", currentItemDailyUseStateClass)
return nil
}
// sendCurrentItemDailyUseStatePacket emits the state packet.
//
// Class 0. Live 2026-09-15 (23:5x) settled it: the same body sent as class 1 was
// ignored outright (the tooltip kept rendering 3/3), while class 0 was applied
// immediately (one potion already spent, server sent 2, tooltip showed 1/3).
func (s *Service) sendCurrentItemDailyUseStatePacket(session *gameSession, body []byte) error {
return s.sendGameUpperRawClass(session, currentItemDailyUseStateMsgID, body, currentItemDailyUseStateClass)
}
// sendAlignedDailyUseStates projects the counters a command changed to the client.
// It runs after the command's own ACK so the item state is already visible when
// the tooltip redraws.
func (s *Service) sendAlignedDailyUseStates(session *gameSession, result alignedcmd.Result) {
if s == nil || session == nil || len(result.DailyUseStates) == 0 {
return
}
body, err := buildCurrentItemDailyUseStateBody(result.DailyUseStates)
if err != nil {
s.logGameEvent(session, "game-item-daily-use-state-blocked",
"operation", result.Operation,
"states", len(result.DailyUseStates),
"error", err)
return
}
if len(body) == 0 {
return
}
if err := s.sendCurrentItemDailyUseStatePacket(session, body); err != nil {
s.logGameEvent(session, "game-item-daily-use-state-blocked",
"operation", result.Operation,
"states", len(result.DailyUseStates),
"error", err)
return
}
for _, state := range result.DailyUseStates {
s.logGameEvent(session, "game-item-daily-use-state-sent",
"operation", result.Operation,
"item_id", state.ItemID,
"remaining", state.Used,
"msg_id", currentItemDailyUseStateMsgID,
"classification", currentItemDailyUseStateClass,
"body_len", len(body))
}
}
// sendCurrentItemDailyUseState 把一次"非 aligned 通道"的消耗之后「每天可用次数」
// 的进度告诉客户端。op160 开箱与 op518 礼包都走这里。
//
// 它们不像 op44/op515 那样经过 aligned 通道,所以必须自己发这一包:客户端的已用
// 计数**只**由 op541 写入,不发的话道具的悬浮说明永远停在满值,本地也不会拦住第二次
// 点击 —— 那正是"礼盒的每天可用次数形同不存在"的成因。包序与 aligned 通道一致:
// 业务结果包在前、op541 在后(客户端此时已经拿到了这件道具的对象)。
//
// 发送失败只记日志:消费已经提交,一个投影包不该让到手的奖励回退。
//
// 注意 op27 抽奖罐**不**调用这里:那条路径有一条被测试钉死的"结果包之后不得再有包"
// 约束(真机上插入背包包会让客户端崩溃),其计数改由登录回放刷新。详见 lottery_item.go。
func (s *Service) sendCurrentItemDailyUseState(
session *gameSession,
itemID uint32,
used int64,
limit int64,
source string,
) {
if s == nil || session == nil || limit <= 0 || itemID == 0 {
return
}
if used < 0 {
used = 0
}
if used > 0xFF {
used = 0xFF
}
body, err := buildCurrentItemDailyUseStateBody([]alignedcmd.DailyUseState{{
ItemID: itemID,
Used: byte(used),
}})
if err != nil {
s.logGameEvent(session, "game-item-daily-use-state-blocked", "source", source, "error", err)
return
}
if err := s.sendCurrentItemDailyUseStatePacket(session, body); err != nil {
s.logGameEvent(session, "game-item-daily-use-state-blocked", "source", source, "error", err)
return
}
s.logGameEvent(session, "game-item-daily-use-state-sent",
"source", source,
"item_id", itemID,
"used", used,
"limit", limit,
"msg_id", currentItemDailyUseStateMsgID,
"classification", currentItemDailyUseStateClass)
}