DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

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

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

No files matched your search

@@ -0,0 +1,321 @@
package dnfbridge
import (
"bytes"
"context"
"errors"
"fmt"
"time"
"longheng.io/server/internal/modules/dnf/dproto"
dnfproto "longheng.io/server/internal/modules/dnf/protocol"
)
func (s *Service) gameDprotoMode() string {
if s == nil {
return gameDprotoModeLegacy
}
return normalizeGameDprotoMode(s.options.gameDprotoMode)
}
func (s *Service) gameDprotoMaxPacketBytes() int {
if s != nil && s.options.maxPacketBytes > 0 {
return s.options.maxPacketBytes
}
return defaultMaxPacketBytes
}
func (s *Service) validateDprotoConfiguration() error {
if s.gameDprotoMode() == gameDprotoModeNative && s.dprotoProvider == nil {
return dproto.ErrProviderUnavailable
}
return nil
}
func (s *Service) openGameDprotoSession(ctx context.Context, session *gameSession) error {
if s.gameDprotoMode() != gameDprotoModeNative {
return nil
}
if s.dprotoProvider == nil {
return dproto.ErrProviderUnavailable
}
info := dproto.ConnectionInfo{
ConnectionID: session.connID,
ChannelID: session.channel.ID,
}
if session.conn != nil {
if address := session.conn.RemoteAddr(); address != nil {
info.RemoteAddr = address.String()
}
if address := session.conn.LocalAddr(); address != nil {
info.LocalAddr = address.String()
}
}
nativeSession, err := s.dprotoProvider.Open(ctx, info)
if err != nil {
return err
}
if nativeSession == nil {
return dproto.ErrProviderUnavailable
}
session.dproto = nativeSession
s.logGameEvent(session, "game-dproto-opened", "mode", gameDprotoModeNative)
return nil
}
func (s *Service) closeGameDprotoSession(session *gameSession) {
if session == nil || session.dproto == nil {
return
}
session.wireMu.Lock()
err := session.dproto.Close()
session.dproto = nil
session.wireMu.Unlock()
if err != nil {
s.logGameEvent(session, "game-dproto-close-failed", "error", err)
}
}
func (s *Service) handleGameDprotoPacket(session *gameSession, raw []byte) error {
if session.dproto == nil {
envelope, err := dnfproto.ParseDprotoClientEnvelope(raw, s.gameDprotoMaxPacketBytes())
if err != nil {
s.logGameEvent(session, "game-dproto-opaque-parse-failed",
"packet_len", len(raw),
"reason", "native_dproto_provider_unavailable_and_outer_envelope_invalid",
"error", err)
return nil
}
s.logGameEvent(session, "game-dproto-opaque-deferred",
"outer_sequence", envelope.Header.Seq,
"protected_len", len(envelope.Protected),
"reason", "native_dproto_provider_unavailable")
return nil
}
maxPacketBytes := s.gameDprotoMaxPacketBytes()
envelope, err := dnfproto.ParseDprotoClientEnvelope(raw, maxPacketBytes)
if err != nil {
return err
}
ctx := session.ctx
if ctx == nil {
ctx = context.Background()
}
var decoded dproto.DecodeResult
err = s.withGameWire(session, func() error {
var decodeErr error
decoded, decodeErr = session.dproto.DecodeClient(ctx, envelope.Raw)
if decodeErr != nil {
return decodeErr
}
return s.writeDprotoProviderPacketsLocked(session, decoded.OutboundPackets, "client_decode_control")
})
if err != nil {
return err
}
s.logGameEvent(session, "game-dproto-client-decoded",
"outer_sequence", envelope.Header.Seq,
"protected_len", len(envelope.Protected),
"inner_count", len(decoded.InnerPackets),
"control_count", len(decoded.OutboundPackets))
for _, inner := range decoded.InnerPackets {
if len(inner) > maxPacketBytes {
return dnfproto.ErrPacketTooLarge
}
packet, err := dnfproto.ParseChannelPacket(inner)
if err != nil {
return fmt.Errorf("validate decoded inner upper: %w", err)
}
if packet.Header.MsgID == dnfproto.DprotoClientEnvelopeOpcode ||
packet.Header.MsgID == dnfproto.DprotoServerEnvelopeOpcode {
return fmt.Errorf("decoded nested dproto opcode %d: %w", packet.Header.MsgID, dnfproto.ErrDprotoEnvelopeOpcode)
}
if err := s.handleGameUpper(session, inner); err != nil {
return err
}
}
return nil
}
func (s *Service) handleGameDprotoControl(session *gameSession, opcode uint16, raw []byte) error {
if session == nil || session.dproto == nil {
return dproto.ErrProviderUnavailable
}
ctx := session.ctx
if ctx == nil {
ctx = context.Background()
}
return s.withGameWire(session, func() error {
outbound, err := session.dproto.HandleClientControl(ctx, opcode, append([]byte(nil), raw...))
if err != nil {
return err
}
return s.writeDprotoProviderPacketsLocked(session, outbound, "client_control")
})
}
// gameConnWriteBudget 是单次游戏帧写入的期限。
//
// 这里的写全部发生在该会话【唯一】的 event loop 上。客户端一旦停止收包
// (家宽出口饱和、同机多开、进程卡住、机器休眠),conn.Write 会一直阻塞,
// 于是这条会话再也发不出任何包;而小队扇出会通过 callGameSession 同步等待
// 成员会话,阻塞还会向外传染给队长,再叠加入场栅栏就是整队一起卡死。
//
// 这正是 2026-09-24 23:24 那次 8 人双小队进黑雾之源集体掉线的链路:
// 服务端对外静默 22 秒 → 客户端超时 → 自己 RST。
//
// 这条期限把「无界阻塞」变成「有界阻塞」:超时就返回错误、让这条连接自己
// 被拆除,而不是拖住整条 event loop 乃至整支小队。
const gameConnWriteBudget = 5 * time.Second
// writeGameConn is the single exit for every game frame written to a client.
//
// It blocks only for as long as the session writer is allowed to make the loop
// wait. With no writer (a bare session, which is what unit tests build) it is
// the synchronous write it has always been, including its exact error.
func (s *Service) writeGameConn(session *gameSession, payload []byte) error {
if session == nil || session.conn == nil {
return errors.New("dnf game connection is unavailable")
}
if session.writer != nil {
return session.writer.submit(payload)
}
return s.writeGameConnDirect(session, payload)
}
// writeGameConnDirect is the historical body of writeGameConn: one bounded
// blocking write on the caller's goroutine.
func (s *Service) writeGameConnDirect(session *gameSession, payload []byte) error {
if session == nil || session.conn == nil {
return errors.New("dnf game connection is unavailable")
}
if err := session.conn.SetWriteDeadline(time.Now().Add(gameConnWriteBudget)); err != nil {
return err
}
defer func() { _ = session.conn.SetWriteDeadline(time.Time{}) }()
_, err := session.conn.Write(payload)
return err
}
func (s *Service) withGameWire(session *gameSession, operation func() error) error {
if session == nil || session.conn == nil {
return errors.New("dnf game connection is unavailable")
}
session.wireMu.Lock()
defer session.wireMu.Unlock()
return operation()
}
func (s *Service) writeGameRawPacket(session *gameSession, packet []byte) error {
return s.withGameWire(session, func() error {
return s.writeGameRawPacketLocked(session, packet)
})
}
func (s *Service) writeGameRawPacketLocked(session *gameSession, packet []byte) error {
return s.writeGameConn(session, packet)
}
func (s *Service) writeGameUpperPacket(session *gameSession, inner []byte) error {
return s.withGameWire(session, func() error {
return s.writeGameUpperPacketLocked(session, inner)
})
}
func (s *Service) writeGameUpperPacketLocked(session *gameSession, inner []byte) error {
// 诊断(只读):记住本会话最后发出的 upper 包,并在 op38 应答时结束卡住计量。
// 取样用**语义包** inner,dproto 信封不会让 opcode 失真;对发包结果没有任何影响。
s.noteUpperSendDiagnostics(session, inner)
wire := inner
protected := false
if session.dproto != nil {
ctx := session.ctx
if ctx == nil {
ctx = context.Background()
}
encoded, err := session.dproto.EncodeServer(ctx, append([]byte(nil), inner...))
if err != nil {
return err
}
if len(encoded.Packet) == 0 {
return dproto.ErrEmptyProviderOutput
}
wire = encoded.Packet
protected = encoded.Protected
outer, err := dnfproto.ParseChannelPacketUnchecked(wire)
if err != nil {
return fmt.Errorf("validate dproto server output: %w", err)
}
if protected {
if outer.Header.MsgID != dnfproto.DprotoServerEnvelopeOpcode {
return fmt.Errorf("protected server opcode %d: %w", outer.Header.MsgID, dnfproto.ErrDprotoEnvelopeOpcode)
}
} else if !bytes.Equal(wire, inner) {
return errors.New("dnf dproto provider mutated an unprotected server packet")
}
s.logGameEvent(session, "game-dproto-server-encoded",
"inner_len", len(inner),
"wire_len", len(wire),
"protected", protected,
"outer_msg_id", outer.Header.MsgID)
}
if session.upperWriteBatchActive {
session.upperWriteBatch = append(session.upperWriteBatch, wire...)
return nil
}
return s.writeGameConn(session, wire)
}
func (s *Service) beginGameUpperWriteBatch(session *gameSession) error {
return s.withGameWire(session, func() error {
if session.upperWriteBatchActive {
return errors.New("dnf game upper write batch is already active")
}
session.upperWriteBatchActive = true
session.upperWriteBatch = session.upperWriteBatch[:0]
return nil
})
}
func (s *Service) flushGameUpperWriteBatch(session *gameSession) error {
return s.withGameWire(session, func() error {
if !session.upperWriteBatchActive {
return errors.New("dnf game upper write batch is not active")
}
wire := append([]byte(nil), session.upperWriteBatch...)
session.upperWriteBatchActive = false
session.upperWriteBatch = session.upperWriteBatch[:0]
if len(wire) == 0 {
return nil
}
return s.writeGameConn(session, wire)
})
}
func (s *Service) writeDprotoProviderPacketsLocked(session *gameSession, packets [][]byte, owner string) error {
for _, packet := range packets {
if len(packet) == 0 {
return dproto.ErrEmptyProviderOutput
}
if len(packet) > s.gameDprotoMaxPacketBytes() {
return dnfproto.ErrPacketTooLarge
}
outer, err := dnfproto.ParseChannelPacketUnchecked(packet)
if err != nil {
return fmt.Errorf("validate dproto control output: %w", err)
}
switch outer.Header.MsgID {
case dnfproto.DprotoServerControlOpcode, dnfproto.DprotoServerEnvelopeOpcode:
default:
return fmt.Errorf("dproto control opcode %d: %w", outer.Header.MsgID, dnfproto.ErrDprotoEnvelopeOpcode)
}
s.logGameEvent(session, "game-dproto-control-send",
"owner", owner,
"msg_id", outer.Header.MsgID,
"packet_len", len(packet))
if err := s.writeGameConn(session, packet); err != nil {
return err
}
}
return nil
}