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,235 @@
package dnfbridge
import (
"fmt"
"math"
"longheng.io/server/internal/modules/dnf/dnfenum"
dnfproto "longheng.io/server/internal/modules/dnf/protocol"
)
func (s *Service) sendCurrentCeraShopPurchaseFailure(session *gameSession) error {
return s.sendGameUpperRawClass(
session,
uint16(dnfenum.CmdPacketBuyCerashopItem),
buildCurrentCeraShopPurchaseFailureBody(),
dnfproto.DefaultChannelClassification,
)
}
// 90CN 商城**购买失败**回执码表:客户端按 `269 - code` 查这张表(**反序**)。
//
// ★ 这条规律是两次真机试码试出来的,不是抄来的:
//
// 2026-09-27 17:55 服务端回 code=11 → 客户端弹「错误 : 点券不足」= 字符串 258
// 2026-09-27 18:1x 服务端回 code=12 → 客户端弹「错误 : 胜点不足」= 字符串 257
//
// 码 +1、字符串 −1 ⇒ `字符串id = 269 - code`(等价地 `code = 269 - 字符串id`)。
// 我先前猜的 `247 + code` 在 code 12 上应该给出 259「Lv不足」,被第二次实测否掉。
//
// 客户端能显示的整张表(字符串 253..265,把 269 减过去就是码):
//
// id 253 无法购买的物品 → code 16
// id 254 错误的商品编号 → code 15
// id 255 物品栏空间不足 → code 14
// id 256 金币不足 → code 13
// id 257 胜点不足 → code 12 ← 实测确认
// id 258 点券不足 → code 11 ← 实测确认
// id 259 Lv不足 → code 10
// id 260 不存在的物品 → code 9
// id 261 使用期限设置错误 → code 8
// id 262 物品功能设置错误 → code 7
// id 263 付费服务器错误 → code 6
// id 264 请求购买信息错误 → code 5
// id 265 未知错误 → code 4
//
// ⚠⚠ 另一张表 `21969 + code`(21979 该商品无法赠送 / 21980 超出赠送金额上限 /
// 21981 年龄不符合赠送条件 …)**op64 的回包够不到**:全客户端只有一处引用 21972
// (RVA 0x00B0494D),而且在一个 `arg2 == 13` 的充值/商城页对话框里,不是按 code
// 索引的购买回执表。用它的码去回,客户端会查到上面这张表里的**另一句话**。
//
// 后果:op64 阶段**表达不了赠送专用文案**(没有"赠送金额上限""年龄不符合赠送条件"
// 这种句子)。所以约定是:**精确文案一律放在 op136 那一步**(那张表有
// 70107/70108/70109 这些专用句子,好友 7 天、每日 10 万额度都在那一步回),
// op64 只回这张表里语义最接近的一条,真实原因看服务端日志的 failure_code + reason。
const (
// currentCeraShopPurchaseFailureCodeUnknown 4 = 未知错误(畸形包、内部错误)。
currentCeraShopPurchaseFailureCodeUnknown byte = 4
// currentCeraShopPurchaseFailureCodeConnection 5 = 请求购买信息错误(瞬时故障)。
currentCeraShopPurchaseFailureCodeConnection byte = 5
// currentCeraShopPurchaseFailureCodeWrongCommodity 15 = 错误的商品编号。
currentCeraShopPurchaseFailureCodeWrongCommodity byte = 15
// currentCeraShopPurchaseFailureCodeInventoryFull 14 = 物品栏空间不足。
currentCeraShopPurchaseFailureCodeInventoryFull byte = 14
// currentCeraShopPurchaseFailureCodeInsufficientGold 13 = 金币不足。
currentCeraShopPurchaseFailureCodeInsufficientGold byte = 13
// currentCeraShopPurchaseFailureCodeInsufficientCera 11 = 点券不足(实测确认)。
currentCeraShopPurchaseFailureCodeInsufficientCera byte = 11
// currentCeraShopPurchaseFailureCodeLevelTooLow 10 = Lv不足。
currentCeraShopPurchaseFailureCodeLevelTooLow byte = 10
// currentCeraShopPurchaseFailureCodeCannotBuy 16 = 无法购买的物品。
// 这是**兜底**码:语义最宽的"这一单买不成",任何没被单独映射的失败都用它。
currentCeraShopPurchaseFailureCodeCannotBuy byte = 16
)
// currentCeraShopPurchaseLimitFailureCode 是「商城限购已满」回给客户端的失败码。
//
// 限购在这张表里没有专用文案,用兜底码(无法购买的物品):客户端会弹一个明确提示
// 而不是静默。真实原因在 `game-cera-shop-purchase-limit-rejected` 日志里可查。
const currentCeraShopPurchaseLimitFailureCode byte = currentCeraShopPurchaseFailureCodeCannotBuy
// currentCeraShopPurchaseFailureCodeGiftUnsupported 是「这件商品不能赠送」的回执码。
//
// 这张表里没有"该商品无法赠送"这句话(那句在另一张表、op64 够不到),只能用兜底码。
const currentCeraShopPurchaseFailureCodeGiftUnsupported byte = currentCeraShopPurchaseFailureCodeCannotBuy
// 赠送规则在 op64 阶段的回执码:
//
// 除"每日额度用完"外 → 16(无法购买的物品)
//
// ⚠ **op64 阶段表达不了赠送专用的文案**:能查的那张表只有 253..265 这 13 句通用购买
// 错误(见文件顶部),"该商品无法赠送""年龄不符合赠送条件"这些句子在另一张表里、
// op64 的回包够不到。所以约定是:**精确文案一律在 op136 阶段给**(0xA4 好友不满 7 天、
// 0xA5 不是好友、0xA3 每日额度用完,各有专用句子),op64 只回语义最接近的一条,
// 真实原因看服务端日志(`failure_code` + `reason`)。
const (
// currentCeraShopPurchaseFailureCodeGiftNotGiftable 商品不可邮寄 / 收件人职业不符。
currentCeraShopPurchaseFailureCodeGiftNotGiftable byte = currentCeraShopPurchaseFailureCodeCannotBuy
// currentCeraShopPurchaseFailureCodeGiftAccountTooNew 赠送方账号不满 7 天。
currentCeraShopPurchaseFailureCodeGiftAccountTooNew byte = currentCeraShopPurchaseFailureCodeCannotBuy
// currentCeraShopPurchaseFailureCodeGiftForbidden 其余赠送判定失败(好友/大区/自身)。
currentCeraShopPurchaseFailureCodeGiftForbidden byte = currentCeraShopPurchaseFailureCodeCannotBuy
// currentCeraShopPurchaseFailureCodeGiftAmountCap 每日赠送额度用完。
//
// 这张表里没有"超出赠送金额上限"这句话(那句在另一张表、op64 够不到),
// 所以用兜底码「无法购买的物品」。精确的"每天最多10万"提示在 op136 阶段给(0xA3)。
currentCeraShopPurchaseFailureCodeGiftAmountCap byte = currentCeraShopPurchaseFailureCodeCannotBuy
)
// sendCurrentCeraShopPurchaseFailureWithCode 用指定码回 op64 失败包。
func (s *Service) sendCurrentCeraShopPurchaseFailureWithCode(session *gameSession, code byte) error {
return s.sendGameUpperRawClass(
session,
uint16(dnfenum.CmdPacketBuyCerashopItem),
buildCurrentCeraShopPurchaseFailureBodyWithCode(code),
dnfproto.DefaultChannelClassification,
)
}
// sendCurrentCeraShopGiftFailure 用指定的失败码回 op64 失败包(赠送路径专用)。
//
// 支持用试码文件临时覆盖(见 cera_shop_gift_limit.go 的说明):真机定位"哪个码显示
// 哪句话"时不必重新编译。每次发送都会把**实际发出的码**记进日志,方便和玩家看到的
// 提示一一对应。
func (s *Service) sendCurrentCeraShopGiftFailure(session *gameSession, code byte) error {
sent := code
if override, ok := currentCeraShopGiftFailureCodeProbe(); ok {
sent = override
}
if s != nil {
s.logGameEvent(session, "game-cera-shop-gift-failure-acked",
"char_id", 0,
"mapped_code", code,
"sent_code", sent,
"probe_override", sent != code,
"body_hex", fmt.Sprintf("%x", buildCurrentCeraShopPurchaseFailureBodyWithCode(sent)))
}
return s.sendGameUpperRawClass(
session,
uint16(dnfenum.CmdPacketBuyCerashopItem),
buildCurrentCeraShopPurchaseFailureBodyWithCode(sent),
dnfproto.DefaultChannelClassification,
)
}
// sendCurrentCeraShopGiftUnsupportedFailure 在"赠送形态收到但本服还不能投递"时回失败。
func (s *Service) sendCurrentCeraShopGiftUnsupportedFailure(session *gameSession) error {
return s.sendGameUpperRawClass(
session,
uint16(dnfenum.CmdPacketBuyCerashopItem),
buildCurrentCeraShopPurchaseFailureBodyWithCode(currentCeraShopPurchaseFailureCodeGiftUnsupported),
dnfproto.DefaultChannelClassification,
)
}
// sendCurrentCeraShopPurchaseLimitFailure 在限购已满时回失败包。
func (s *Service) sendCurrentCeraShopPurchaseLimitFailure(session *gameSession) error {
return s.sendGameUpperRawClass(
session,
uint16(dnfenum.CmdPacketBuyCerashopItem),
buildCurrentCeraShopPurchaseFailureBodyWithCode(currentCeraShopPurchaseLimitFailureCode),
dnfproto.DefaultChannelClassification,
)
}
func buildCurrentCeraShopPurchaseSuccessBody(commodityID uint32, bonuses ...currentCeraShopBonusItem) []byte {
return buildCurrentCeraShopPurchaseSuccessBodyWithCount(commodityID, 0, bonuses...)
}
// buildCurrentCeraShopPurchaseSuccessBodyWithCount keeps the three trailing
// u32 fields zero. 2026-07-25 live evidence: writing the purchased quantity
// into the first trailing u32 hangs the result dialog (client left
// unclickable), so those fields are not the row counts. Quantity display
// remains an open investigation; the zero shape is the proven safe one.
func buildCurrentCeraShopPurchaseSuccessBodyWithCount(commodityID uint32, count uint32, bonuses ...currentCeraShopBonusItem) []byte {
_ = count
var writer packetWriter
writer.writeByte(1)
writer.writeByte(0)
writer.writeInt32(-1) // category=-1: current reader performs a safe full catalog lookup.
writer.writeUint32(commodityID)
writer.writeUint32(0)
writer.writeUint32(0)
writer.writeUint32(0)
// Bonus display rows drive the dialog's right-side 商城购物奖励 panel
// (one-plus-one IPG gifts, e.g. 充满爱慕的信 -> 泰迪礼盒 + 幸运魔锤).
// Row layout u32 itemID + u32 count.
writer.writeUint16(uint16(len(bonuses)))
for _, bonus := range bonuses {
writer.writeUint32(bonus.ItemID)
writer.writeUint32(bonus.Count)
}
return writer.bytes()
}
func buildCurrentCeraShopPurchaseFailureBody() []byte {
return buildCurrentCeraShopPurchaseFailureBodyWithCode(currentCeraShopPurchaseFailureCodeCannotBuy)
}
// buildCurrentCeraShopPurchaseFailureBodyWithCode 生成商城购买失败包。形状固定为
// `u8 0 + u8 失败码 + 5×u32`(见 86 参考对 sub_CD9490 失败分支的逆向:字段太少会
// 让客户端读到下一个包的垃圾当索引而崩)。
func buildCurrentCeraShopPurchaseFailureBodyWithCode(errorCode byte) []byte {
var writer packetWriter
writer.writeByte(0)
writer.writeByte(errorCode)
writer.writeInt32(-1)
writer.writeInt32(-1)
writer.writeUint32(0)
writer.writeUint32(0)
writer.writeUint32(0)
return writer.bytes()
}
func buildCurrentCeraShopBalanceBody(cera int64) []byte {
return buildCurrentCeraShopWalletBody(cera, 0, 0)
}
func buildCurrentCeraShopWalletBody(cera, tokenCera, happyTokenCera int64) []byte {
var writer packetWriter
writer.writeByte(1)
writer.writeInt32(clampCurrentCeraBalance(cera))
writer.writeInt32(clampCurrentCeraBalance(tokenCera))
writer.writeInt32(clampCurrentCeraBalance(happyTokenCera))
return writer.bytes()
}
func clampCurrentCeraBalance(value int64) int {
if value < 0 {
return 0
}
if value > math.MaxInt32 {
return math.MaxInt32
}
return int(value)
}