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,156 @@
// character.go 负责 DNF 最新客户端创建角色链路的协议适配。
// 它只解析/编码旧客户端 wire 形态,并通过 DNF repository 写基础记录;完整玩法规则后续应下沉到角色 owner。
package dnfbridge
import (
"context"
"longheng.io/server/internal/modules/dnf/dnfenum"
"longheng.io/server/internal/modules/dnf/jobmap"
dnfproto "longheng.io/server/internal/modules/dnf/protocol"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
func (s *Service) handleUpperCreateCharacter(session *gameSession, body []byte) error {
req, err := parseCreateCharacter(body, 30)
if err != nil {
s.logGameEvent(session, "game-upper-create-parse-failed", "body_len", len(body), "error", err)
if looksOpaqueNoPackUpperCreate(body) {
// IDA sub_33AAFE0 和本地 hook 明密文对照证明 op5 C2S body 使用 msgID%14=5 的 Skipjack 8 字节块保护。
// 创建角色是写路径,必须先还原真实职业/名字字段,再允许写仓储。
plain, decErr := decodeUpperKey5(body)
if decErr == nil {
req, err = parseCreateCharacter(plain, 30)
}
if decErr != nil || err != nil {
s.logGameEvent(session, "game-upper-create-decode-failed", "body_len", len(body), "decode_error", decErr, "parse_error", err)
return s.sendUpperProtectedCreateDecodeDeferred(session)
}
s.logGameEvent(session, "game-upper-create-decoded", "body_len", len(body), "plain_len", len(plain), "name_len", len(req.nameRaw), "option_len", len(req.options))
}
if err != nil {
return s.sendUpperCreateError(session, 0x04)
}
}
if !jobmap.Valid(int(req.job)) {
return s.sendUpperCreateError(session, 0x04)
}
return s.createCharacter(session, req, true)
}
func (s *Service) handleGameCreateCharacter(session *gameSession, body []byte) error {
req, err := parseCreateCharacter(body, 18)
if err != nil {
s.logGameEvent(session, "game-create-parse-failed", "body_len", len(body), "error", err)
return s.sendGameCreateError(session, 0x12)
}
if !jobmap.Valid(int(req.job)) {
return s.sendGameCreateError(session, 0x04)
}
return s.createCharacter(session, req, false)
}
func (s *Service) handleGameCheckName(session *gameSession, body []byte) error {
name, code, ok := parseCheckName(body)
if !ok {
if looksNoPackCheckName(body, code) {
// NoPack 当前 692 请求 body 是 8/16 字节保护态;日志里请求仍是 cmd=1/type=692,
// 因此保持同 opcode 回 01,先让客户端进入确认创建。
s.logGameEvent(session, "game-check-name-opaque-accepted", "body_len", len(body), "code", 0x01)
return s.sendGameCheckName(session, 0x01)
}
s.logGameEvent(session, "game-check-name-parse-failed", "body_len", len(body), "code", code)
return s.sendGameCheckName(session, code)
}
repos, hasRepos := s.repositoryGroup()
if !hasRepos || repos.Character == nil {
s.logGameEvent(session, "game-check-name-repository-missing", "name", name)
return s.sendGameCheckName(session, 0x14)
}
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
defer cancel()
existing, err := s.characterNameExists(ctx, repos, nil, name)
if err != nil {
s.logGameEvent(session, "game-check-name-failed", "name", name, "error", err)
return s.sendGameCheckName(session, 0x14)
}
if existing {
s.logGameEvent(session, "game-check-name-duplicated", "name", name)
return s.sendGameCheckName(session, checkNameCodeDuplicated)
}
s.logGameEvent(session, "game-check-name-available", "name", name)
return s.sendGameCheckName(session, 0x01)
}
func (s *Service) sendCreateSuccess(session *gameSession, upper bool, created dnfrepo.CharacterRecord, characters []dnfrepo.CharacterRecord) error {
if upper {
if err := s.sendUpperCreateSuccess(session, created); err != nil {
return err
}
if err := s.sendUpperRoster(session, characters); err != nil {
return err
}
visibleCount := rosterCountForLimit(characters, s.characterSlotLimitForSession(session))
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
defer cancel()
return s.sendCurrentDarkKnightComboPreload(
session,
ctx,
append([]dnfrepo.CharacterRecord(nil), characters[:visibleCount]...),
"create_character_after_roster",
)
}
if err := s.sendGame(session, byte(dnfenum.GameCmdCommand), uint16(dnfenum.GameTypeCreateCharacter), []byte{0x01}); err != nil {
return err
}
return s.sendCharacterList(session, characters)
}
func (s *Service) sendCreateFailure(session *gameSession, upper bool, code byte) error {
if upper {
return s.sendUpperCreateError(session, code)
}
return s.sendGameCreateError(session, code)
}
func (s *Service) sendUpperCreateError(session *gameSession, code byte) error {
return s.sendGameUpperFailure(session, uint16(dnfenum.UpperMsgCreateCharacter), code)
}
func (s *Service) sendUpperCreateSuccess(session *gameSession, record dnfrepo.CharacterRecord) error {
// Current EXE upper_handle_msg5_create_character_result receives the
// common command-success discriminator first, then reads u16 character ID
// and WSTR name from the remaining body. Do not let the character ID low
// byte stand in for that discriminator: IDs other than 1 would shift every
// subsequent field by one byte.
return s.sendGameUpperSuccess(session, uint16(dnfenum.UpperMsgCreateCharacter), buildUpperCreateSuccessBody(record))
}
func buildUpperCreateSuccessBody(record dnfrepo.CharacterRecord) []byte {
var writer packetWriter
writer.writeUint16(uint16(numericCharacterID(record)))
writer.writeRawDstr(rosterRawNameBytes(record))
return writer.bytes()
}
func (s *Service) sendUpperProtectedCreateDecodeDeferred(session *gameSession) error {
// 当前 NoPack 未开启 create hook 时会发送动态保护态 msg5;未还原真实 job/name 前不能写库。
// 这里回非致命拒绝码,避免 0x14 被客户端显示成网络连接中断;真实创建由明文 hook/解码路径处理。
return s.sendUpperCreateError(session, createCodeDuplicated)
}
func (s *Service) sendGameCreateError(session *gameSession, code byte) error {
return s.sendGame(session, byte(dnfenum.GameCmdCommand), uint16(dnfenum.GameTypeCreateCharacter), []byte{code})
}
func (s *Service) sendGameCheckName(session *gameSession, code byte) error {
// Current sub_1CFC9C0 is registered through the common class1 result
// dispatcher: success consumes no command-specific body, while failure
// receives the second byte as its error code. Keep this response plaintext.
body := []byte{1}
if code != 1 {
body = []byte{0, code}
}
return s.sendGameUpperRawClassNoCodec(session, uint16(dnfenum.UpperMsgCheckDoubleCharName), body, dnfproto.DefaultChannelClassification)
}