Files
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

411 lines
16 KiB
Go
Raw Permalink 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.
package dnfbridge
import (
"bytes"
"context"
"fmt"
"strconv"
"strings"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
func currentCharacterGameOptionKeymapScope(characterID string) string {
return "character:" + strings.TrimSpace(characterID) + ":game_option_keymap"
}
func loadCurrentGameOptionKeymapFromScope(
ctx context.Context,
repository dnfrepo.SettingsRepository,
scope string,
) ([]byte, bool, error) {
if repository == nil || strings.TrimSpace(scope) == "" {
return nil, false, nil
}
record, found, err := repository.Load(ctx, scope)
if err != nil || !found {
return nil, found, err
}
body, ok := decodeCurrentGameOptionValue(record, currentGameOptionKeymapValueKey, validCurrentGameOptionKeymapBody)
return body, ok, nil
}
func saveCurrentGameOptionKeymapToScope(
ctx context.Context,
repository dnfrepo.SettingsRepository,
scope string,
body []byte,
) error {
if repository == nil || strings.TrimSpace(scope) == "" || !validCurrentGameOptionKeymapBody(body) {
return fmt.Errorf("current game option scoped keymap is invalid")
}
return saveCurrentGameOptionScopedValue(ctx, repository, scope, currentGameOptionKeymapValueKey, body)
}
func (s *Service) selectedCharacterJobForKeymap(
ctx context.Context,
repositories dnfrepo.Group,
session *gameSession,
) (dnfrepo.CharacterRecord, byte, bool, error) {
if session == nil || session.selectedCharacterID == 0 || repositories.Character == nil {
return dnfrepo.CharacterRecord{}, 0, false, nil
}
characterID := strconv.Itoa(int(session.selectedCharacterID))
character, found, err := repositories.Character.Load(ctx, characterID)
if err != nil || !found {
return dnfrepo.CharacterRecord{}, 0, found, err
}
if accountID := strings.TrimSpace(s.accountIDForSession(session)); accountID != "" {
if owner := strings.TrimSpace(character.AccountID); owner != "" && owner != accountID {
return dnfrepo.CharacterRecord{}, 0, false, fmt.Errorf("character %s keymap owner mismatch", characterID)
}
}
job, ok := characterJobByte(character)
if !ok {
return dnfrepo.CharacterRecord{}, 0, false, fmt.Errorf("character %s keymap job %q is invalid", characterID, character.Job)
}
return character, job, true, nil
}
func (s *Service) currentGameOptionKeymapForSelectedCharacter(
ctx context.Context,
repositories dnfrepo.Group,
session *gameSession,
accountKeymap []byte,
) ([]byte, string, error) {
_, job, found, err := s.selectedCharacterJobForKeymap(ctx, repositories, session)
if err != nil {
return nil, "", err
}
if !found || !currentSpecialJobOwnsCharacterKeymap(job) {
return append([]byte(nil), accountKeymap...), "account_scoped_current_exe_type198_keymap", nil
}
// Creator 的输入模型不是普通可重排的快捷栏表,它的那一份存在**角色**作用域
// (handleCurrentSaveGameOptionKeymap 用 currentCharacterGameOptionKeymapScope)。
//
// 活体证据 2026-09-15:库里 `character:13:game_option_keymap` 存着玩家改过的
// `I21=30, I22=25`,而登录时这里**无视它、直接下发 PVF 默认表**
// (`I21..I25 = 46..50`),于是"改技能快捷键 → 小退重上全恢复原样"。
//
// 但也不能无条件信任那份存储:客户端点"恢复默认"会给出一张**普通角色**的表
// (I01..I05 = 2/0/3/1/78,而不是缔造者的 77/55/73/58/78),教学期也会回显一张临时表。
// 所以判据是"这份存储必须带着缔造者的输入模型"(keymapHasCreatorInputModel):
// I01..I05 逐行等于职业默认表那五行。玩家改键只动 I21..I25 那些技能选择键,
// 依然满足这条;普通表怎么都满足不了。
profile, profileErr := s.currentCreatorHotkeyProfile(ctx)
if profileErr != nil {
return nil, "", profileErr
}
stored, storedFound, loadErr := loadCurrentGameOptionKeymapFromScope(
ctx,
repositories.Settings,
currentCharacterGameOptionKeymapScope(strconv.Itoa(int(session.selectedCharacterID))),
)
if loadErr == nil && storedFound && validStoredCreatorKeymap(stored) &&
keymapHasCreatorInputModel(stored, profile.keymap) {
return append([]byte(nil), stored...), "character_scoped_creator_type198_keymap", nil
}
if loadErr == nil && storedFound && len(stored) != 0 {
// 老数据自愈:库里存着一份不带缔造者输入模型的表(客户端"恢复默认"或教学回显
// 留下的),当它不存在,下发 PVF 表。
s.logGameEvent(session, "game-game-option-keymap-creator-default-store-ignored",
"char_id", session.selectedCharacterID,
"body_len", len(stored),
"reason", "stored_creator_keymap_does_not_carry_the_creator_input_model",
"state_source", "pvf_creator_ver2_wasd_space_digit_skill_keymap_locked")
}
return append([]byte(nil), profile.keymap...), "pvf_creator_ver2_wasd_space_digit_skill_keymap_locked", nil
}
// validStoredCreatorKeymap 判断一份存储的 keymap 是不是"一份完整的缔造者表"。
//
// 只认形状,不认具体键位:缔造者的表必须有 I01(普攻)与 I21..I25(五个技能选择键),
// 这几行正是玩家在游戏里改技能快捷键时会被改写的那几行。教学期回显进来的临时普通
// 表不是这个形状,所以它永远拿不到权威地位。
func validStoredCreatorKeymap(keymap []byte) bool {
if len(keymap) == 0 || len(keymap) > currentGameOptionKeymapMaxBodySize {
return false
}
rows := keymapRowsByIdentifier(keymap)
for _, identifier := range []string{"I01", "I21", "I22", "I23", "I24", "I25"} {
if _, present := rows[identifier]; !present {
return false
}
}
return true
}
// keymapRowsByIdentifier 把 keymap 正文解析成 行名 -> 值。
// 行格式是 `I21\t30\n`;值原样保留字符串,比较时不需要解释成按键。
func keymapRowsByIdentifier(keymap []byte) map[string]string {
rows := make(map[string]string, 32)
for _, rawLine := range bytes.Split(keymap, []byte{'\n'}) {
fields := bytes.Split(bytes.TrimRight(rawLine, "\r"), []byte{'\t'})
if len(fields) != 2 {
continue
}
rows[string(fields[0])] = string(fields[1])
}
return rows
}
// currentCreatorKeymapAttackRows 是缔造者输入模型里 I01..I05 这五行 —— 也就是
// "普攻/移动"那一组。它们同时也是**分辨一份表是不是缔造者表**的指纹。
//
// 线上数据(2026-09-15):
// - PVF 缔造者表: I01=77 I02=55 I03=73 I04=58 I05=78
// - 客户端"恢复默认"给的表(普通角色表):I01=2 I02=0 I03=3 I04=1 I05=78
//
// 两边的 I21..I25 都可能是 55/73/58/60/61(技能与"物品/技能"分组的对应关系在客户端的
// 默认表里是错位的),所以**只能靠 I01..I05 认**。
var currentCreatorKeymapAttackRows = []string{"I01", "I02", "I03", "I04", "I05"}
// keymapHasCreatorInputModel 判断一份 keymap 是不是"缔造者的输入模型":I01..I05
// 逐行等于职业默认表里那五行。
//
// 这一条既是"玩家自己改过键"的合格线(改键只会动 I21..I25 那些技能选择键),
// 也是"客户端把普通角色表塞进来"的检出线(普通表的 I01..I05 是 2/0/3/1/78)。
func keymapHasCreatorInputModel(keymap, creatorProfile []byte) bool {
if len(keymap) == 0 || len(creatorProfile) == 0 {
return false
}
rows := keymapRowsByIdentifier(keymap)
profileRows := keymapRowsByIdentifier(creatorProfile)
matched := 0
for _, identifier := range currentCreatorKeymapAttackRows {
profileValue, profilePresent := profileRows[identifier]
rowValue, rowPresent := rows[identifier]
if !profilePresent || !rowPresent {
continue
}
if rowValue == profileValue {
matched++
}
}
return matched == len(currentCreatorKeymapAttackRows)
}
// currentCreatorSaveIsClientDefaultTable 判断一张表是不是"客户端自己的默认表"。
//
// 指纹来自线上实测(2026-09-15):
//
// 移动组 I01..I04 —— 缔造者 77/55/73/58(WASD) vs 客户端默认 2/0/3/1(←↑↓→)
// 跳跃 I06 —— 缔造者 21(空格) vs 客户端默认 57(C)
//
// 只要这五行里对上了移动组至少 3 行且 I06 也一致,就认定是客户端默认表。
// 只看"是不是缔造者形状"是不够的:玩家手动把键全清空(整表 134)也是一种合法状态,
// 那不该被当成"恢复默认"(现场 05:53:17 那次就是这样被我误判的)。
func currentCreatorSaveIsClientDefaultTable(keymap []byte) bool {
rows := keymapRowsByIdentifier(keymap)
if len(rows) == 0 {
return false
}
movement := []string{"I01", "I02", "I03", "I04"}
matched := 0
for _, identifier := range movement {
if value, present := rows[identifier]; present && value == currentClientDefaultKeymapMovement[identifier] {
matched++
}
}
if matched < 3 {
return false
}
jump, present := rows["I06"]
return present && jump == currentClientDefaultKeymapJumpRow
}
// currentClientDefaultKeymapMovement / ...JumpRow 是客户端默认表的移动组与跳跃行,
// 实测值(见上)。它们对"普通角色默认"与"客户端给缔造者的默认"是同一组值。
var currentClientDefaultKeymapMovement = map[string]string{
"I01": "2", "I02": "0", "I03": "3", "I04": "1",
}
const currentClientDefaultKeymapJumpRow = "57"
// keymapBodyMatchesPrefix 判断 candidate 的每一行都与 reference 同行的值相同。
//
// 逐字节比较在这里不成立:客户端发上来的普通表与库里账号表可能行数/个别行不同
// (实测 94 行 vs 91 行),所以按行比、并且允许 candidate 更短。
func keymapBodyMatchesPrefix(candidate, reference []byte) bool {
candidateRows := keymapRowsByIdentifier(candidate)
referenceRows := keymapRowsByIdentifier(reference)
if len(candidateRows) == 0 {
return false
}
for identifier, value := range candidateRows {
if referenceRows[identifier] != value {
return false
}
}
return true
}
// restoreCurrentCreatorKeymapDefault 处理缔造者的"恢复默认按键"。
//
// 客户端的这个按钮只是在**本地**把键位表换成普通角色的默认表,然后 op198 通知服务端;
// 所以光拒绝保存是不够的 —— 界面上已经变成错的表了,必须由服务端把**这个职业自己的**
// PVF 默认表推回去(同一条 op454 应用路径)。
//
// 同时清掉角色作用域里存的自定义键位表:这样下次登录也会回到 PVF 默认表,而不是又发出
// 玩家之前改过的绑定 —— "恢复默认"本来就该把改动一并丢掉。
func (s *Service) restoreCurrentCreatorKeymapDefault(
ctx context.Context,
repositories dnfrepo.Group,
session *gameSession,
character dnfrepo.CharacterRecord,
) error {
if s == nil || session == nil || repositories.Settings == nil {
return nil
}
if err := clearCurrentSpecialJobKeymapStore(ctx, repositories.Settings, character.CharacterID); err != nil {
return fmt.Errorf("clear creator keymap store: %w", err)
}
return s.sendCurrentCreatorKeymapInitialization(session, ctx, character, "creator_restore_default_keymap")
}
// clearCurrentSpecialJobKeymapStore 写一条没有键位表正文的记录,等价于"回到职业默认"。
// 不清空记录本身是为了不动设置表的其它值,也避免依赖仓储的删除语义。
func clearCurrentSpecialJobKeymapStore(
ctx context.Context,
repository dnfrepo.SettingsRepository,
characterID string,
) error {
characterID = strings.TrimSpace(characterID)
if repository == nil || characterID == "" {
return fmt.Errorf("current special job keymap store is invalid")
}
record, found, err := repository.Load(ctx, currentCharacterGameOptionKeymapScope(characterID))
if err != nil {
return err
}
if !found {
return nil
}
record = dnfrepo.CloneSettings(record)
if record.Values != nil {
delete(record.Values, currentGameOptionKeymapValueKey)
}
record.UpdatedAt = time.Now().UTC()
return repository.Save(ctx, record)
}
// sendCurrentCreatorKeymapInitialization restores Creator's class-specific
// input table after a dungeon has finished rebuilding its actor/skill HUD.
// Town initialization has its own type1345 boundary, but a newly created
// Creator enters the tutorial dungeon directly and never reaches that callback.
//
// 关键:这里必须下发**角色自己那份**(玩家改过的绑定优先),只有没存过时才用 PVF 默认表。
// 现场 2026-09-15:玩家给缔造者改好技能键,一进副本就被这里推的 PVF 默认表覆盖回
// "默认",因为副本每次重建 HUD 都会走这个边界(日志里 source=...after_final_scene_skill_manager)。
func (s *Service) sendCurrentCreatorKeymapInitialization(
session *gameSession,
ctx context.Context,
character dnfrepo.CharacterRecord,
source string,
) error {
if s == nil || session == nil || session.selectedCharacterID == 0 {
return nil
}
job, ok := characterJobByte(character)
if !ok {
return fmt.Errorf("creator keymap character %s job %q is invalid", character.CharacterID, character.Job)
}
if job != csharpCreatorMageJob {
return nil
}
profile, err := s.currentCreatorHotkeyProfile(ctx)
if err != nil {
return fmt.Errorf("load creator dungeon keymap: %w", err)
}
keymap := append([]byte(nil), profile.keymap...)
keymapSource := "pvf_creator_ver2_default"
if repositories, ok := s.repositoryGroup(); ok && repositories.Settings != nil {
if stored, found, loadErr := loadCurrentGameOptionKeymapFromScope(
ctx, repositories.Settings, currentCharacterGameOptionKeymapScope(character.CharacterID),
); loadErr == nil && found && validStoredCreatorKeymap(stored) &&
keymapHasCreatorInputModel(stored, profile.keymap) {
// 玩家自己改过的绑定:副本边界不能把它冲掉。
keymap = append([]byte(nil), stored...)
keymapSource = "character_scoped_creator_rebind"
}
}
transport, err := buildCurrentGameOptionKeymapTransportBody(keymap)
if err != nil {
return err
}
session.currentGameOptionMu.Lock()
defer session.currentGameOptionMu.Unlock()
session.currentGameOptionNativeApplyKeymapBody = keymap
session.currentGameOptionNativeApplyKeymapAt = time.Now()
s.logPacketEvent("game-upper-current-creator-keymap-restore-send",
"conn_id", session.connID,
"channel_id", session.channel.ID,
"source", source,
"account_id", strings.TrimSpace(s.accountIDForSession(session)),
"char_id", session.selectedCharacterID,
"msg_id", currentGameOptionKeymapMsgID,
"classification", 0,
"body_len", len(transport),
"keymap_body_len", len(keymap),
"keymap_mode", currentGameOptionKeymapMode,
"input_contract", "creator_wasd_space_1_2_3_4_5",
"state_source", keymapSource)
if err := s.sendGameUpperRawClass(session, currentGameOptionKeymapMsgID, transport, 0); err != nil {
session.currentGameOptionNativeApplyKeymapBody = nil
session.currentGameOptionNativeApplyKeymapAt = time.Time{}
return err
}
return nil
}
func (s *Service) currentGameOptionKeymapSaveScope(
ctx context.Context,
repositories dnfrepo.Group,
session *gameSession,
accountID string,
) (string, string, error) {
character, job, found, err := s.selectedCharacterJobForKeymap(ctx, repositories, session)
if err != nil {
return "", "", err
}
if found && currentSpecialJobOwnsCharacterKeymap(job) {
return currentCharacterGameOptionKeymapScope(character.CharacterID), "character_settings_special_job", nil
}
return currentGameOptionScope(accountID), "account_settings", nil
}
// currentGameOptionKeymapSaveIsCreatorDefaultRequest 判断这次 op198 保存是不是
// "缔造者把键位表交回给客户端默认"(客户端点恢复默认后保存)。
//
// 判据是**这张表带不带客户端默认指纹**(currentCreatorSaveIsClientDefaultTable),
// 而不是"像不像缔造者表" —— 玩家手动清空(整表未绑定)等合法状态必须放行。
// 只对缔造者生效;非缔造者一律返回 false。
func (s *Service) currentGameOptionKeymapSaveIsCreatorDefaultRequest(
ctx context.Context,
repositories dnfrepo.Group,
session *gameSession,
keymap []byte,
accountID string,
) bool {
if s == nil || session == nil || len(keymap) == 0 {
return false
}
_, job, found, err := s.selectedCharacterJobForKeymap(ctx, repositories, session)
if err != nil || !found || !currentSpecialJobOwnsCharacterKeymap(job) {
return false
}
if currentCreatorSaveIsClientDefaultTable(keymap) {
return true
}
if repositories.Settings != nil {
if accountKeymap, accountFound, loadErr := loadCurrentGameOptionKeymap(
ctx, repositories.Settings, accountID); loadErr == nil && accountFound &&
keymapBodyMatchesPrefix(keymap, accountKeymap) {
return true
}
}
return false
}