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,396 @@
package skillcmd
import (
"context"
"fmt"
"longheng.io/server/internal/modules/dnf/alignedcmd"
"longheng.io/server/internal/modules/dnf/dnfenum"
dnfproto "longheng.io/server/internal/modules/dnf/protocol"
)
type handler struct{}
func NewHandler() alignedcmd.Handler {
return handler{}
}
func (handler) Domain() dnfenum.AlignedDomain {
return dnfenum.AlignedDomainSkill
}
func (handler) Handle(ctx context.Context, req alignedcmd.Request) (alignedcmd.Result, error) {
switch dnfenum.CmdPacket(req.Opcode) {
case dnfenum.CmdPacketChangeSkillslot:
parsed, err := DecodeChangeSkillSlotRequest(req.Body)
cmd := NewSlotCommand(req, parsed)
return applySlotOwner(ctx, req, cmd, err), nil
case dnfenum.CmdPacketBuySkill:
parsed, err := DecodeBuySkillRequest(req.Body)
cmd := NewBuyCommand(req, parsed)
return applyBuyOwner(ctx, req, cmd, err), nil
case dnfenum.CmdPacketSkillInit:
parsed, err := DecodeSkillInitRequest(req.Body)
cmd := NewResetCommand(req, parsed)
return applyResetOwner(ctx, req, cmd, err), nil
case dnfenum.CmdPacketChangeAnotherSkillTree:
parsed, err := DecodeChangeAnotherSkillTreeRequest(req.Body)
cmd := NewTreeCommand(req, parsed)
return applyTreeOwner(ctx, req, cmd, err), nil
case dnfenum.CmdPacketSkillCommandCustomizing:
parsed, err := DecodeSkillCommandRequest(req.Body)
cmd := NewSkillCommand(req, parsed)
return applyCommandOwner(ctx, req, cmd, err), nil
case dnfenum.CmdPacketSkillCommandAllDefault:
cmd := NewSimpleCommand(req, "skill_command_all_default", "skill owner + command reset persistence + NOTI 0x13 order")
if len(req.Body) != 0 {
return blockedParsedResult(cmd.Operation, cmd.String(), fmt.Errorf("invalid body length: got %d want 0", len(req.Body))), nil
}
return applyCommandDefaultOwner(ctx, req, cmd), nil
default:
return alignedcmd.Result{
Handled: false,
ResponseAllowed: false,
Operation: "unregistered",
Reason: fmt.Sprintf("skill module has no opcode %d handler", req.Opcode),
}, nil
}
}
func applyCommandOwner(ctx context.Context, req alignedcmd.Request, cmd Command, parseErr error) alignedcmd.Result {
if parseErr != nil {
return blockedParsedResult(cmd.Operation, cmd.String(), parseErr)
}
owner, err := NewOwner(req.Repositories)
if err != nil {
return ownerBlockedResult(cmd, err)
}
result, err := owner.ApplyCommands(ctx, cmd)
if err != nil {
return ownerBlockedResult(cmd, planError(cmd.Operation, err))
}
return alignedcmd.Result{
Handled: true,
ResponseAllowed: true,
Operation: cmd.Operation,
Reason: fmt.Sprintf("applied %s: char=%s records=%d; persisted before current EXE ACK-only op331 response", cmd.Operation, result.CharacterID, result.RecordCount),
UpperResponses: []alignedcmd.UpperResponse{{
MsgID: req.Opcode,
Body: BuildSkillCommandSuccess(req.Body),
Classification: dnfproto.DefaultChannelClassification,
AllowCodec: true,
}},
}
}
func applyCommandDefaultOwner(ctx context.Context, req alignedcmd.Request, cmd Command) alignedcmd.Result {
owner, err := NewOwner(req.Repositories)
if err != nil {
return ownerBlockedResult(cmd, err)
}
result, err := owner.ApplyAllCommandsDefault(ctx, cmd)
if err != nil {
return ownerBlockedResult(cmd, planError(cmd.Operation, err))
}
return alignedcmd.Result{
Handled: true,
ResponseAllowed: true,
Operation: cmd.Operation,
Reason: fmt.Sprintf("applied %s: char=%s; persisted before current EXE ACK-only op332 response", cmd.Operation, result.CharacterID),
UpperResponses: []alignedcmd.UpperResponse{{
MsgID: req.Opcode,
Body: BuildSkillCommandAllDefaultSuccess(),
Classification: dnfproto.DefaultChannelClassification,
AllowCodec: true,
}},
}
}
func applyTreeOwner(ctx context.Context, req alignedcmd.Request, cmd Command, parseErr error) alignedcmd.Result {
if parseErr != nil {
return treeSwitchFailureResult(req, cmd, parseErr)
}
owner, err := NewOwner(req.Repositories)
if err != nil {
return treeSwitchFailureResult(req, cmd, err)
}
result, err := owner.ApplyTreeSwitch(ctx, cmd)
if err != nil {
return treeSwitchFailureResult(req, cmd, planError(cmd.Operation, err))
}
body, err := BuildChangeAnotherSkillTreeSuccess(result)
if err != nil {
return treeSwitchFailureResult(req, cmd, planError(cmd.Operation, err))
}
return alignedcmd.Result{
Handled: true,
ResponseAllowed: true,
Operation: cmd.Operation,
Reason: fmt.Sprintf(
"applied %s: char=%s current=%d target=%d; persisted before current EXE op260 success",
cmd.Operation, result.CharacterID, result.Current, result.Target,
),
UpperResponses: []alignedcmd.UpperResponse{{
MsgID: req.Opcode,
Body: body,
Classification: dnfproto.DefaultChannelClassification,
AllowCodec: true,
}},
}
}
func treeSwitchFailureResult(req alignedcmd.Request, cmd Command, err error) alignedcmd.Result {
return alignedcmd.Result{
Handled: true,
ResponseAllowed: true,
Operation: cmd.Operation,
Reason: fmt.Sprintf("rejected %s: %v; current EXE op260 failure 19 returned", cmd.Operation, err),
UpperResponses: []alignedcmd.UpperResponse{{
MsgID: req.Opcode,
Body: BuildChangeAnotherSkillTreeFailure(),
Classification: dnfproto.DefaultChannelClassification,
AllowCodec: true,
}},
}
}
func applyResetOwner(ctx context.Context, req alignedcmd.Request, cmd Command, parseErr error) alignedcmd.Result {
if parseErr != nil {
return blockedParsedResult(cmd.Operation, cmd.String(), parseErr)
}
owner, err := NewOwner(req.Repositories, OwnerOptions{
Catalog: req.SkillCatalog,
InitialLevels: req.InitialSkillLevels,
PointBaseline: req.SkillPointBaseline,
})
if err != nil {
return ownerBlockedResult(cmd, err)
}
result, err := owner.ApplyReset(ctx, cmd)
if err != nil {
return ownerBlockedResult(cmd, planError(cmd.Operation, err))
}
body, err := BuildSkillInitSuccess(result)
if err != nil {
return ownerBlockedResult(cmd, planError(cmd.Operation, err))
}
return alignedcmd.Result{
Handled: true,
ResponseAllowed: true,
Operation: cmd.Operation,
Reason: fmt.Sprintf(
"applied %s: char=%s tree=%d mode=%d initial_skills=%d remainSP=%d remainTP=%d; current EXE op491 response opened",
cmd.Operation, result.CharacterID, result.SkillTree, result.Mode, result.SkillCount,
result.Points.RemainingSP, result.Points.RemainingTP,
),
UpperResponses: []alignedcmd.UpperResponse{{
MsgID: req.Opcode,
Body: body,
Classification: dnfproto.DefaultChannelClassification,
AllowCodec: true,
}},
PostActions: []alignedcmd.PostAction{alignedcmd.PostActionRefreshSelectedActorSkills},
}
}
func applySlotOwner(ctx context.Context, req alignedcmd.Request, cmd Command, parseErr error) alignedcmd.Result {
if parseErr != nil {
return slotFailureResult(req, cmd, parseErr)
}
owner, err := NewOwner(req.Repositories, OwnerOptions{
Catalog: req.SkillCatalog,
InitialLevels: req.InitialSkillLevels,
})
if err != nil {
return slotFailureResult(req, cmd, err)
}
result, err := owner.ApplySlot(ctx, cmd)
if err != nil {
return slotFailureResult(req, cmd, planError(cmd.Operation, err))
}
body, err := BuildChangeSkillSlotSuccess(result)
if err != nil {
return ownerBlockedResult(cmd, planError(cmd.Operation, err))
}
return alignedcmd.Result{
Handled: true,
ResponseAllowed: true,
Operation: cmd.Operation,
Reason: fmt.Sprintf(
"applied %s: char=%s tree=%d from=%d to=%d source_skill=%d destination_skill=%d destination_occupied=%t; current EXE op28 response opened",
cmd.Operation,
result.CharacterID,
result.SkillTree,
result.From,
result.To,
result.FromSkillID,
result.ToSkillID,
result.ToOccupied,
),
UpperResponses: []alignedcmd.UpperResponse{{
MsgID: req.Opcode,
Body: body,
Classification: dnfproto.DefaultChannelClassification,
AllowCodec: true,
}},
}
}
func slotFailureResult(req alignedcmd.Request, cmd Command, err error) alignedcmd.Result {
return alignedcmd.Result{
Handled: true,
ResponseAllowed: true,
Operation: cmd.Operation,
Reason: fmt.Sprintf("rejected %s: %v; current EXE op28 failure returned to clear pending drag", cmd.Operation, err),
UpperResponses: []alignedcmd.UpperResponse{{
MsgID: req.Opcode,
Body: BuildChangeSkillSlotFailure(),
Classification: dnfproto.DefaultChannelClassification,
AllowCodec: true,
}},
}
}
func applyBuyOwner(ctx context.Context, req alignedcmd.Request, cmd Command, parseErr error) alignedcmd.Result {
if parseErr != nil {
return buyFailureResult(req, cmd, parseErr)
}
if err := validateBuySkillTree(cmd.SkillTree); err != nil {
return buyFailureResult(req, cmd, err)
}
owner, err := NewOwner(req.Repositories, OwnerOptions{
Catalog: req.SkillCatalog,
InitialLevels: req.InitialSkillLevels,
PointBaseline: req.SkillPointBaseline,
})
if err != nil {
return buyFailureResult(req, cmd, err)
}
result, err := owner.ApplyBuy(ctx, cmd)
if err != nil {
return buyFailureResult(req, cmd, planError(cmd.Operation, err))
}
body, err := BuildBuySkillSuccess(result)
if err != nil {
return buyFailureResult(req, cmd, planError(cmd.Operation, err))
}
postActions := []alignedcmd.PostAction{}
if result.ConsumedRefundItem {
// The 遗忘河之水 stack changed in the same transaction; the main list
// must refresh or the client keeps showing the old count (86JP sends
// SendUpdateItemList for the consumed water slot).
postActions = append(postActions, alignedcmd.PostActionRefreshSelectedItemContainers)
}
// The current EXE op29 ACK applies only the incremental batch. Live mode-0
// learn traffic proved that leaving the final successful batch there can
// keep the dungeon-entry skill guard in its initialization state even
// though the repository and visible skill UI contain the learned skills.
// Rebuild the native manager from the committed full op19 projection after
// every successful batch. This also covers an expired-contract sweep, whose
// removals are not present in the incremental ACK.
postActions = append(postActions, alignedcmd.PostActionRefreshSelectedActorSkills)
return alignedcmd.Result{
Handled: true,
ResponseAllowed: true,
Operation: cmd.Operation,
Reason: fmt.Sprintf(
"applied %s: char=%s tree=%d entries=%d remainSP=%d remainTP=%d refund_water=%t; current EXE op29 response opened",
cmd.Operation,
result.CharacterID,
result.SkillTree,
len(result.Entries),
result.Points.RemainingSP,
result.Points.RemainingTP,
result.ConsumedRefundItem,
),
UpperResponses: []alignedcmd.UpperResponse{{
MsgID: req.Opcode,
Body: body,
Classification: dnfproto.DefaultChannelClassification,
AllowCodec: true,
}},
PostActions: postActions,
}
}
func buyFailureResult(req alignedcmd.Request, cmd Command, err error) alignedcmd.Result {
return alignedcmd.Result{
Handled: true,
ResponseAllowed: true,
Operation: cmd.Operation,
Reason: fmt.Sprintf(
"rejected %s: %v; current EXE op29 failure 0x%02X returned to clear pending skill learn",
cmd.Operation, err, currentEXEBuySkillFailureCode,
),
UpperResponses: []alignedcmd.UpperResponse{{
MsgID: req.Opcode,
Body: BuildBuySkillFailure(currentEXEBuySkillFailureCode),
Classification: dnfproto.DefaultChannelClassification,
AllowCodec: true,
}},
}
}
func applyOwner(ctx context.Context, req alignedcmd.Request, cmd Command, parseErr error) alignedcmd.Result {
if parseErr != nil {
return blockedParsedResult(cmd.Operation, cmd.String(), parseErr)
}
owner, err := NewOwner(req.Repositories, OwnerOptions{
Catalog: req.SkillCatalog,
InitialLevels: req.InitialSkillLevels,
})
if err != nil {
return ownerBlockedResult(cmd, err)
}
result, err := owner.Plan(ctx, cmd)
if err != nil {
return ownerBlockedResult(cmd, planError(cmd.Operation, err))
}
return ownerPlannedResult(cmd, result)
}
func blockedParsedResult(operation string, summary string, err error) alignedcmd.Result {
if err != nil {
return alignedcmd.Result{
Handled: true,
ResponseAllowed: false,
Operation: operation,
Reason: fmt.Sprintf("%s body parse failed: %v; success ACK is blocked", operation, err),
}
}
return alignedcmd.Result{
Handled: true,
ResponseAllowed: false,
Operation: operation,
Reason: fmt.Sprintf("parsed %s: %s; skill state, SP/TP, and NOTI 0x13 refresh are not closed, success ACK is blocked", operation, summary),
}
}
func ownerBlockedResult(cmd Command, err error) alignedcmd.Result {
return alignedcmd.Result{
Handled: true,
ResponseAllowed: false,
Operation: cmd.Operation,
Reason: fmt.Sprintf("parsed %s: %s; skill owner preflight failed: %v; success ACK is blocked", cmd.Operation, cmd.String(), err),
}
}
func ownerPlannedResult(cmd Command, result PlanResult) alignedcmd.Result {
return alignedcmd.Result{
Handled: true,
ResponseAllowed: false,
Operation: cmd.Operation,
Reason: fmt.Sprintf(
"parsed %s: %s; skill owner verified account=%s char=%s known=%t skillCount=%d cooldownCount=%d requested=%v refunds=%d; SP/TP persistence and raw 0x13 refresh are not closed, success ACK is blocked",
cmd.Operation,
cmd.String(),
result.AccountID,
result.CharacterID,
result.Known,
result.SkillCount,
result.CooldownCount,
result.RequestedSkillIDs,
result.RefundCount,
),
}
}