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,162 @@
package dnfbridge
import (
"context"
"fmt"
"os"
"sort"
"strconv"
"strings"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
// currentMidSceneSkillRefreshSwitchEnv 是场景内技能信息(op19)刷新的诊断开关。
//
// auto(默认):按一次性 actor 被动风险决定(见下);
// off:场景内一律不发(诊断用:验证"客户端自身的等级切换会不会也掉形态");
// all:一律发(即修复前的旧行为)。
const currentMidSceneSkillRefreshSwitchEnv = "DNF90_MID_SCENE_SKILL_REFRESH"
// currentMidSceneSkillRefreshMode 读取诊断开关;只认 off/all,其它(含空)都是 auto。
func currentMidSceneSkillRefreshMode() string {
switch strings.ToLower(strings.TrimSpace(os.Getenv(currentMidSceneSkillRefreshSwitchEnv))) {
case "off":
return "off"
case "all":
return "all"
default:
return "auto"
}
}
// currentMidSceneSkillRefreshDecision 是"场景内要不要再发一次 op19"的判定结果。
type currentMidSceneSkillRefreshDecision struct {
Send bool
Reason string
Mode string
// AtRiskSkills 列出会因这次 op19 丢失 actor 状态的一次性被动技能。
AtRiskSkills []int64
}
// decideCurrentMidSceneSkillRefresh 统一判定场景内 op19 刷新(副本内升级、副本内交任务)
// 是否可以安全下发。
//
// 背景:op19 是完整技能信息快照,客户端收到后重建技能运行时。对"一次性 actor 被动"
// (现场确认的是契魔者/女鬼剑士的蛇腹剑)来说,这类被动是在副本 mode1 构造玩家角色时挂
// 上去的,场景内重建不会重跑那次钩子,只会把形态/判定数据带掉——现场就是"副本内升级后
// 蛇腹剑外观消失、普攻打不到怪"。等级切换只改 SP/TP,而 SP/TP 已由同批的角色状态包
// (op37)下发,所以跳过不丢信息。
//
// 影响面被刻意压到最小:只有 currentMidSceneActorPassiveRiskSkillsByJob 里登记的职业 +
// 该被动已启用,才会跳过。其它职业(含女漫游、黑暗武士)一律照旧收到刷新,行为与本次修
// 复前完全一致。
func (s *Service) decideCurrentMidSceneSkillRefresh(
character dnfrepo.CharacterRecord,
skill dnfrepo.SkillRecord,
) currentMidSceneSkillRefreshDecision {
mode := currentMidSceneSkillRefreshMode()
switch mode {
case "off":
return currentMidSceneSkillRefreshDecision{Reason: "diagnostic_switch_off", Mode: mode}
case "all":
return currentMidSceneSkillRefreshDecision{Send: true, Mode: mode}
}
if atRisk, risky := currentMidSceneActorPassiveRebindRisk(character, skill); risky {
return currentMidSceneSkillRefreshDecision{
Reason: "enabled_actor_passive_would_lose_weapon_form",
Mode: mode,
AtRiskSkills: atRisk,
}
}
return currentMidSceneSkillRefreshDecision{Send: true, Mode: mode}
}
// sendCurrentDungeonLevelUpSkillRuntimeRefresh rehydrates the current client's
// already-bound skill and passive runtime from the exact skill snapshot that
// committed with a monster EXP level change. The client resets passive runtime
// controllers during a level transition; without this op19 replay, female
// ranger's Style passive (and its softening-count HUD) remains absent until a
// later scene rebuild.
//
// This is deliberately a projection only. It does not learn skills, grant
// profession rewards, alter layouts, or write any repository state. Dark
// Knight uses the same refresh, but its op19 is built with the persisted
// per-tree combo children before the adjacent editor projection is restored.
//
// 例外:只要**蛇腹剑(该职业登记在 currentMidSceneActorPassiveRiskSkillsByJob 里)已启用**
// (不分转职阶段,未转职的女鬼剑士也会学蛇腹剑),就**跳过**这次刷新——场景内重建技能运
// 行时会把这个一次 actor 被动的武器形态/判定数据带掉,而这次 op19 只重复 SP/TP(已由
// op37 下发)。
func (s *Service) sendCurrentDungeonLevelUpSkillRuntimeRefresh(
session *gameSession,
character dnfrepo.CharacterRecord,
skill dnfrepo.SkillRecord,
source string,
) error {
if session == nil {
return fmt.Errorf("dungeon level-up skill refresh has no session")
}
decision := s.decideCurrentMidSceneSkillRefresh(character, skill)
if !decision.Send {
s.logGameEvent(session, "game-dungeon-levelup-skill-refresh-skipped",
"source", source,
"character_id", character.CharacterID,
"job", character.Job,
"level", character.Level,
"mode", decision.Mode,
"at_risk_skill_ids", fmt.Sprint(decision.AtRiskSkills),
"reason", decision.Reason)
return nil
}
characterID := strconv.FormatUint(uint64(session.selectedCharacterID), 10)
if character.CharacterID != characterID || skill.CharacterID != characterID {
return fmt.Errorf("dungeon level-up skill refresh character mismatch: selected=%s character=%s skill=%s", characterID, character.CharacterID, skill.CharacterID)
}
job, ok := characterJobByte(character)
if !ok {
return fmt.Errorf("dungeon level-up skill refresh has invalid job %q", character.Job)
}
activeTree, err := currentSceneActiveSkillTree(character)
if err != nil {
return fmt.Errorf("dungeon level-up skill refresh active tree: %w", err)
}
record := dnfrepo.CloneSkill(skill)
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
defer cancel()
body, activeSlots, err := s.buildCurrentSceneSkillInfoRuntimeBody(ctx, character, record, job, activeTree)
if err != nil {
return fmt.Errorf("dungeon level-up skill refresh body: %w", err)
}
activeLayout := record.Layouts[int(activeTree)]
quickSkillIDs := make([]uint16, 0, len(activeSlots))
for _, slot := range activeSlots {
quickSkillIDs = append(quickSkillIDs, activeLayout[slot])
}
// learned_skill_ids 必须和场景构建路径一样填全:验证"场景内这一发 op19 到底带了什么"
// 只能靠日志,缺字段会让日志看着像发了空快照。
activeState, found := dnfrepo.SkillTree(record, int(activeTree))
if !found {
activeState = dnfrepo.SkillTreeState{Skills: record.Skills, Points: record.Points}
}
learnedSkillIDs := make([]int64, 0, len(activeState.Skills))
for skillID, state := range activeState.Skills {
if state.Enabled && state.Level > 0 {
learnedSkillIDs = append(learnedSkillIDs, skillID)
}
}
sort.Slice(learnedSkillIDs, func(left, right int) bool {
return learnedSkillIDs[left] < learnedSkillIDs[right]
})
projection := currentSceneSkillInfoProjection{
body: body,
characterID: character.CharacterID,
job: job,
learnedCount: len(activeState.Skills),
learnedSkillIDs: learnedSkillIDs,
activeSlots: activeSlots,
quickSkillIDs: quickSkillIDs,
activeTree: activeTree,
}
return s.sendCurrentSceneSkillInfoProjectionWithTreeRestore(session, projection, source)
}