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

157 lines
6.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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)
}