服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
184 lines
7.4 KiB
Go
184 lines
7.4 KiB
Go
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)
|
|
})
|
|
}
|