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