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