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,183 @@
package dnfbridge
import (
"encoding/hex"
"errors"
"strconv"
"longheng.io/server/internal/modules/dnf/dnfenum"
dnfguild "longheng.io/server/internal/modules/dnf/guild"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
// 公会基地 (guild agit) -- class1/op227/228/229/231.
//
// The current client drives the whole feature from four packets and turns every
// refusal into a toast whose text comes from a per-opcode table:
//
// 227 check-create empty request
// 228 create empty request (push 0xE4 at 026AE560)
// 229 delete empty request (push 0xE5 at 026AE580)
// 231 upgrade u8 target stage (push 0xE7 + sub_3454FC0 at 026AE5A3)
//
// Every reply is {u8 success, u8 code}: zero means failure and the code then
// selects the toast; anything else is success. The handlers (01D0C040 /
// 01D0C150 / 01D0C2B0 / 01D0C400) read nothing else on success -- their success
// branches end in `pop ebp; ret` -- so {1} can never walk the client off the
// packet, which is exactly what made the op747 stub crash. The failure codes
// below are decoded from those handlers' jump tables (D:\re_tools\agit_codes.txt):
//
// 36 -> 829 您不是公会会长 (228, 231)
// 100 -> 19140 您还未加入公会 (all four)
// 181 -> 21492 已经生成公会基地。 (227, 228)
// 182 -> 21496 公会基地不存在。 (229, 231)
// 183 -> 6427 公会Lv不足 (227, 228)
// 187 -> 21495 金币不足,无法创建公会基地。 (228) / 1660 金币不足 (231)
// 188 -> 21508 公会基地升级Lv设置错误。 (231)
const (
currentGuildAgitErrorNotMember = byte(100)
currentGuildAgitErrorNotMaster = byte(36)
currentGuildAgitErrorExists = byte(181)
currentGuildAgitErrorMissing = byte(182)
currentGuildAgitErrorLevel = byte(183)
currentGuildAgitErrorGold = byte(187)
currentGuildAgitErrorStage = byte(188)
)
func currentGuildAgitErrorCode(err error) byte {
switch {
case errors.Is(err, dnfguild.ErrPermissionDenied):
return currentGuildAgitErrorNotMaster
case errors.Is(err, dnfguild.ErrNotMember), errors.Is(err, dnfguild.ErrCharacterNotFound):
return currentGuildAgitErrorNotMember
case errors.Is(err, dnfguild.ErrBaseExists):
return currentGuildAgitErrorExists
case errors.Is(err, dnfguild.ErrBaseMissing):
return currentGuildAgitErrorMissing
case errors.Is(err, dnfguild.ErrBaseStage):
return currentGuildAgitErrorStage
default:
return currentGuildAgitErrorNotMember
}
}
// memberGuild resolves the request's guild, returning ErrNotMember when the
// selected character belongs to none.
func memberGuild(registry *dnfguild.Registry, characterID string) (*dnfguild.Guild, error) {
guild, _, found := registry.GuildForCharacter(characterID)
if !found || guild == nil {
return nil, dnfguild.ErrNotMember
}
return guild, nil
}
// logCurrentGuildAgitRequest records every 公会基地 request with its body and the
// stage the server held when it arrived, from inside the guild mutation lock. The
// client drives the whole 公会基地 window from these four opcodes, so this line is
// what tells us which button was pressed and what the server believed the stage
// was. See the 2026-09-12 stage-display investigation in the project notes.
func (s *Service) logCurrentGuildAgitRequest(session *gameSession, opcode uint16, body []byte, guild *dnfguild.Guild) {
stage := byte(0)
if guild != nil {
stage = guild.BaseStage
}
s.logGameEvent(session, "game-guild-base-request",
"opcode", opcode, "body", hex.EncodeToString(body), "stage", stage)
}
// handleCurrentGuildAgitCheck answers 228's precondition probe. The client uses
// only its failure branch, so a member whose guild may place a base gets success.
func (s *Service) handleCurrentGuildAgitCheck(session *gameSession, body []byte) error {
opcode := uint16(dnfenum.CmdPacketCheckCreateGuildAgit)
err := s.mutateCurrentGuildAgit(session, func(registry *dnfguild.Registry, characterID string) error {
guild, err := memberGuild(registry, characterID)
if err != nil {
return err
}
s.logCurrentGuildAgitRequest(session, opcode, body, guild)
if guild.BaseStage != 0 {
return dnfguild.ErrBaseExists
}
return nil
})
if err != nil {
return s.sendGameUpperFailure(session, opcode, currentGuildAgitErrorCode(err))
}
return s.sendGameUpperSuccess(session, opcode, nil)
}
func (s *Service) handleCurrentGuildAgitCreate(session *gameSession, body []byte) error {
opcode := uint16(dnfenum.CmdPacketCreateGuildAgit)
err := s.mutateCurrentGuildAgit(session, func(registry *dnfguild.Registry, characterID string) error {
guild, err := memberGuild(registry, characterID)
if err != nil {
return err
}
s.logCurrentGuildAgitRequest(session, opcode, body, guild)
return registry.CreateBase(characterID, s.gameplayNow())
})
if err != nil {
return s.sendGameUpperFailure(session, opcode, currentGuildAgitErrorCode(err))
}
s.logGameEvent(session, "game-guild-base-created", "char_id", session.selectedCharacterID)
return s.sendGameUpperSuccess(session, opcode, nil)
}
func (s *Service) handleCurrentGuildAgitDelete(session *gameSession, body []byte) error {
opcode := uint16(dnfenum.CmdPacketDeleteGuildAgit)
err := s.mutateCurrentGuildAgit(session, func(registry *dnfguild.Registry, characterID string) error {
guild, err := memberGuild(registry, characterID)
if err != nil {
return err
}
s.logCurrentGuildAgitRequest(session, opcode, body, guild)
return registry.DeleteBase(characterID, s.gameplayNow())
})
if err != nil {
return s.sendGameUpperFailure(session, opcode, currentGuildAgitErrorCode(err))
}
s.logGameEvent(session, "game-guild-base-deleted", "char_id", session.selectedCharacterID)
return s.sendGameUpperSuccess(session, opcode, nil)
}
// handleCurrentGuildAgitUpgrade consumes the single stage byte op231 carries.
func (s *Service) handleCurrentGuildAgitUpgrade(session *gameSession, body []byte) error {
opcode := uint16(dnfenum.CmdPacketUpgradeGuildAgit)
if len(body) != 1 {
return s.sendGameUpperFailure(session, opcode, currentGuildAgitErrorStage)
}
stage := body[0]
err := s.mutateCurrentGuildAgit(session, func(registry *dnfguild.Registry, characterID string) error {
guild, err := memberGuild(registry, characterID)
if err != nil {
return err
}
s.logCurrentGuildAgitRequest(session, opcode, body, guild)
return registry.UpgradeBase(characterID, stage, s.gameplayNow())
})
if err != nil {
return s.sendGameUpperFailure(session, opcode, currentGuildAgitErrorCode(err))
}
s.logGameEvent(session, "game-guild-base-upgraded",
"char_id", session.selectedCharacterID, "stage", stage)
return s.sendGameUpperSuccess(session, opcode, nil)
}
// mutateCurrentGuildAgit runs one guild mutation for the request's selected
// character. These handlers are request-driven, so taking guildMu here is the
// same pattern the other guild mutators use -- and, unlike the online publish
// path, this goroutine cannot already hold it.
func (s *Service) mutateCurrentGuildAgit(
session *gameSession,
apply func(registry *dnfguild.Registry, characterID string) error,
) error {
if s == nil || session == nil || session.selectedCharacterID == 0 {
return dnfguild.ErrCharacterNotFound
}
characterID := strconv.Itoa(int(session.selectedCharacterID))
ctx, cancel := s.currentGuildContext()
defer cancel()
return s.mutateCurrentGuild(ctx, func(_ dnfrepo.Group, registry *dnfguild.Registry) error {
return apply(registry, characterID)
})
}