Files
90CNServer/server/internal/services/dnfbridge/stat_change_potion_projection.go
T
hanfuit 645b64fe21 DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
2026-11-04 14:31:55 +08:00

428 lines
18 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package dnfbridge
import (
"context"
"encoding/binary"
"math"
"strconv"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
"longheng.io/server/internal/modules/dnf/statbuff"
)
// 城镇增益药剂(史诗Buff药剂一族)真正让**图标出现**的那一发。
//
// 现场 2026-10-03(服主报,测试x = char 11):药剂修好"能用"之后,客户端只弹了一行
// "[史诗Buff药剂]的效果时间还剩10分钟",buff 栏里没有图标。原因是 class0/op174
// (load effect item info)在这个客户端里是**效果道具的倒计时列表**,不是 buff 注册。
//
// ★ 2026-10-03 第四轮:通知号在**我们自己的客户端**里定标,不照抄参考服务端 ★
//
// 服主指出"A21 客户端与我们不同,协议内容和协议号可能都不一样,不能直接照抄"——复核后
// 确认他是对的。前几轮用的 488/489/490 是按 A21 枚举(CHURN_GRADE_ACTION=486)推出来的,
// 而本客户端的 NOTIPACKET 基址是 **485**。定标方法(全部用本仓库**现场验证过**的号):
//
// 客户端启动时把 294 个 ENUM_NOTIPACKET_* 名字按枚举顺序注册进连续全局
// (0x52bc170 + 4*i,脚本 tools/_gb_noti_table_re.py),于是 id = base + slot;
//
// slot 98 RAID_SET_SYMBOL ↔ raid/body.go RaidSymbolMsgID = 0x247 = 583
// slot 99 RAID_DUNGEON_PARTICIPATION_INFO ↔ RaidParticipationMsgID = 0x248 = 584
// slot 100 RAID_DUNGEON_STATE ↔ RaidDungeonStateMsgID = 0x249 = 585
// slot 101 RAID_CHANGE_DUNGEON_STATE ↔ RaidDungeonChangeMsgID = 0x24a = 586
// slot 102 RAID_STATE ↔ RaidStateMsgID = 0x24b = 587
// ⇒ base = 485(五个连号名字对五个连号 opcode,偏移恒为 485)
// slot 95 SEND_DECK_INFO ↔ inventory/guardian_shield_protocol.go
// currentGuardianShieldDeckNotificationMsgID = 580
// ⇒ 独立锚点:580 - 95 = 485 ✓("牌组信息"语义也吻合)
//
// 所以本客户端的 buff 三兄弟是 **487 / 488 / 489**:
//
// 487 CHARACTER_ADD_BUFF 建/更新 buff 条目(图标靠这一发)
// 488 CHARACTER_DEL_BUFF 删
// 489 CHARACTER_BUFF_DUNGEON 置"地下城内激活"标志(列表必须是**完整**激活集合)
//
// 之前发的 488/489/490 在本客户端分别是 DEL_BUFF / BUFF_DUNGEON /
// USER_FATIGUE_ACCELERATION_STATE —— 也就是说 ADD_BUFF **一次都没发出去过**,图标当然
// 不会出现(这解释了前面连续几次部署都没图标)。
//
// 协议**形状**仍沿用参考实现(形状与号无关,且与我们实测一致):
// ADD = u8 1 + i32 buffId + 3×i32 0;DEL = u8 count + i32 ids…;
// BUFF_DUNGEON = u16 count + i32 ids…(空列表清本地激活标志)。
// 三个 i32 状态字段参考实现一律填 0(持续时长由 buff 定义表的 `[apply type] expire_time`
// 与客户端自己的窗口决定),我们也填 0。
const (
currentCharacterAddBuffMsgID uint16 = 487 // ENUM_NOTIPACKET_CHARACTER_ADD_BUFF
currentCharacterRemoveBuffMsgID uint16 = 488 // ENUM_NOTIPACKET_CHARACTER_DEL_BUFF
currentCharacterBuffDungeonMsgID uint16 = 489 // ENUM_NOTIPACKET_CHARACTER_BUFF_DUNGEON
)
// currentCharacterAddBuffEvidence 记录这一组号是怎么定出来的(写进包日志,便于以后复核)。
const currentCharacterAddBuffEvidence = "notipacket base 485 calibrated on this client (raid 583..587=slot 98..102, deck-info 580=slot 95); ADD_BUFF=487 DEL_BUFF=488 BUFF_DUNGEON=489"
// buildCurrentCharacterBuffDungeonBody 是 CHARACTER_BUFF_DUNGEON 的记录体:
// u16 count + 每项 u32 buffId。空列表(count=0)用来清掉本地激活标志。
func buildCurrentCharacterBuffDungeonBody(buffIDs []int32) []byte {
rows := make([]int32, 0, len(buffIDs))
for _, buffID := range buffIDs {
if buffID <= 0 {
continue
}
rows = append(rows, buffID)
if len(rows) == 0xFFFF {
break
}
}
body := make([]byte, 2+4*len(rows))
binary.LittleEndian.PutUint16(body[0:2], uint16(len(rows)))
for index, buffID := range rows {
binary.LittleEndian.PutUint32(body[2+4*index:6+4*index], uint32(buffID))
}
return body
}
// buildCurrentCharacterAddBuffBody 是 CHARACTER_ADD_BUFF 的记录体(17 字节)。
func buildCurrentCharacterAddBuffBody(buffID int32) []byte {
body := make([]byte, 17)
body[0] = 1
binary.LittleEndian.PutUint32(body[1:5], uint32(buffID))
// 三个状态字段:参考实现全 0。
return body
}
// buildCurrentCharacterRemoveBuffBody 是 CHARACTER_REMOVE_BUFF 的记录体。
func buildCurrentCharacterRemoveBuffBody(buffIDs []int32) []byte {
rows := make([]int32, 0, len(buffIDs))
for _, buffID := range buffIDs {
if buffID <= 0 {
continue
}
rows = append(rows, buffID)
if len(rows) == 0xFF {
break
}
}
if len(rows) == 0 {
return nil
}
body := make([]byte, 1+4*len(rows))
body[0] = byte(len(rows))
for index, buffID := range rows {
binary.LittleEndian.PutUint32(body[1+4*index:5+4*index], uint32(buffID))
}
return body
}
// sendCurrentStatChangePotionBuffProjection 在角色容器可见之后,把仍然生效的
// 城镇增益药剂 buff 补发一次(对应参考实现的 EpicBuffPotionInitBodyBuilder)。
//
// 没有生效窗口就不发包:客户端登录时本来就是空的,而每次回城都补发一遍的话,
// 同一条 buff 会被反复 add。
func (s *Service) sendCurrentStatChangePotionBuffProjection(session *gameSession, source string) error {
if s == nil || session == nil || session.selectedCharacterID == 0 ||
session.statChangePotionBuffProjectionSent {
return nil
}
repositories, ok := s.repositoryGroup()
if !ok || repositories.Character == nil {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
defer cancel()
characterID := strconv.FormatUint(uint64(session.selectedCharacterID), 10)
character, found, err := repositories.Character.Load(ctx, characterID)
if err != nil || !found {
return err
}
now := s.gameplayNow().UTC()
active := s.currentStatChangePotionActiveWindows(character, now)
if len(active) == 0 {
// 一个生效窗口都没有:客户端不会自己摘图标(参考服务端的 buff 注册不带时长,靠它
// 自己的定时器到期后补发 CHARACTER_DEL_BUFF)。所以在回城/登录取个天然检查点上,
// 把这一族**所有** buff id 清一遍 —— 清一个客户端本来就没有的 buff 是无害的,
// 而漏清会留下一个永远不掉的图标。
s.cancelCurrentStatChangePotionTimer(session, "window_already_expired")
return s.clearCurrentStatChangePotionBuffs(session, source)
}
// 恢复每一族:op174(效果道具倒计时)+ op487(buff 注册,图标)+ op489(激活)。
// 只在恢复路径发 op174 —— 首次使用由客户端自己按 op44 本地登记并打那行提示
//(服主 2026-10-03:"药剂刚能用那一版提示只有一行")。与经验药水那条完全同形。
buffIDs := make([]int32, 0, len(active))
latestExpire := time.Time{}
for _, window := range active {
if window.BuffID <= 0 || window.BuffID > math.MaxInt32 {
continue
}
if err := s.sendCurrentStatChangePotionEffectItemInfoForWindow(session, window, source); err != nil {
return err
}
// 只发"建条目"那一发:激活列表由下面**一次性**按完整集合发(否则同一发会重复,
// 客户端会先清空再置位两次,白跑一遍)。
if err := s.sendCurrentCharacterAddBuffOnly(session, int32(window.BuffID),
"restore_"+source); err != nil {
return err
}
buffIDs = append(buffIDs, int32(window.BuffID))
if window.ExpireAt.After(latestExpire) {
latestExpire = window.ExpireAt
}
}
if len(buffIDs) == 0 {
s.cancelCurrentStatChangePotionTimer(session, "no_projectable_window")
return s.clearCurrentStatChangePotionBuffs(session, source)
}
// 第二发:把**完整**的激活集合发过去(客户端会先清空本地激活标志再按列表置位)。
activateBody := buildCurrentCharacterBuffDungeonBody(buffIDs)
if err := s.sendGameUpperRawClass(session, currentCharacterBuffDungeonMsgID, activateBody, 0); err != nil {
return err
}
// op487 不带时长:按角色记录里的**真实**到期时刻武装"到期摘图标"定时器。多个族同时
// 生效时按最晚的那个排,到点再重判(fire 会重新读角色,谁的点到了摘谁)。
// 换频道/重登是新会话,所以这一发不会因为"每会话只补一次"而被跳过。
s.armCurrentStatChangePotionTimer(session, session.selectedCharacterID, latestExpire, "restore_"+source)
session.statChangePotionBuffProjectionSent = true
return nil
}
// currentStatChangePotionWindow 是一个仍然生效、需要恢复给客户端的窗口。
type currentStatChangePotionWindow struct {
ItemID int64
BuffID int64
Percent int64
ExpireAt time.Time
Family string
Kind string
}
// currentStatChangePotionActiveWindows 枚举角色身上**所有**仍然生效的这一族窗口:
//
// 掉落率(statbuff.ActiveEpicDropRate)
// 强化 / 增幅成功率(KindUpgradeSuccess / KindAmplifySuccess)
// 地下城通关经验(KindDungeonClearExp)
// 客户端自施放的那些族(城镇移速 village_move_speed、进图属性 move_speed …)
//
// 各族互不覆盖、各按自己的到期时间结束,所以必须逐个枚举 —— 只认掉落率那一族的话,
// 城镇移速/经验这类 buff 重登之后就再也回不来了。
func (s *Service) currentStatChangePotionActiveWindows(
character dnfrepo.CharacterRecord,
now time.Time,
) []currentStatChangePotionWindow {
out := make([]currentStatChangePotionWindow, 0, 4)
if effect, ok := statbuff.ActiveEpicDropRate(character, now); ok {
out = append(out, currentStatChangePotionWindow{
ItemID: effect.ItemID, BuffID: effect.BuffID, Percent: effect.MultiplierPercent,
ExpireAt: effect.ExpireAt, Kind: "epic_drop_rate",
})
}
for _, kind := range []statbuff.Kind{statbuff.KindUpgradeSuccess, statbuff.KindAmplifySuccess,
statbuff.KindDungeonClearExp} {
if window, ok := statbuff.ActiveRate(character, kind, now); ok {
out = append(out, currentStatChangePotionWindow{
ItemID: window.ItemID, BuffID: window.BuffID, Percent: window.Percent,
ExpireAt: window.ExpireAt, Kind: string(kind),
})
}
}
for _, family := range s.currentClientBuffFamilies() {
if window, ok := statbuff.ActiveClientBuff(character, family, now); ok {
out = append(out, currentStatChangePotionWindow{
ItemID: window.ItemID, BuffID: window.BuffID, Percent: window.Percent,
ExpireAt: window.ExpireAt, Family: window.Family, Kind: "client_buff",
})
}
}
return out
}
// currentClientBuffFamilies 列出 PVF 里"客户端自施放"的那些 family(buff effect name)。
func (s *Service) currentClientBuffFamilies() []string {
catalog, err := s.currentPVFItemCatalog()
if err != nil || catalog == nil {
return nil
}
tables, ok := catalog.currentVariousBuffTables()
if !ok {
return nil
}
return tables.clientBuffFamilies()
}
// sendCurrentStatChangePotionEffectItemInfoForWindow 是上面那一发的窗口版本。
func (s *Service) sendCurrentStatChangePotionEffectItemInfoForWindow(
session *gameSession,
window currentStatChangePotionWindow,
source string,
) error {
return s.sendCurrentStatChangePotionEffectItemInfo(session, statbuff.Effect{
ItemID: window.ItemID,
BuffID: window.BuffID,
ExpireAt: window.ExpireAt,
}, source)
}
// sendCurrentStatChangePotionEffectItemInfo 是登录/重登时的"效果道具倒计时"恢复
// (class0/op174):{u8 1, u32 itemID, u32 remainingSeconds}。
//
// 客户端按 itemID 去 etc/variousbuffstackablelist.etc 认这件道具、再按掷到的 buff id
// 取 etc/variousbufflist.etc 的 [icon index]/[tooltip text];这一发让"还剩多久"在重登
// 之后依然正确。使用当次**不能**发(会让客户端把同一个效果道具登记两次 → 两行提示)。
func (s *Service) sendCurrentStatChangePotionEffectItemInfo(
session *gameSession,
effect statbuff.Effect,
source string,
) error {
if effect.ItemID <= 0 || effect.ItemID > math.MaxUint32 {
s.logGameEvent(session, "game-stat-change-potion-restore-skipped",
"char_id", session.selectedCharacterID,
"source", source,
"item_id", effect.ItemID,
"reason", "invalid_effect_item_id")
return nil
}
remaining := int64(math.Ceil(effect.ExpireAt.Sub(s.gameplayNow().UTC()).Seconds()))
if remaining < 1 {
return nil
}
if remaining > math.MaxUint32 {
remaining = math.MaxUint32
}
body := currentStatChangePotionEffectItemInfoBody(uint32(effect.ItemID), uint32(remaining))
s.logPacketEvent("game-aligned-effect-item-info-send",
"conn_id", session.connID,
"char_id", session.selectedCharacterID,
"source", source,
"item_id", effect.ItemID,
"remaining_seconds", remaining,
"msg_id", currentLoadEffectItemInfoMsgID,
"classification", 0,
"body_len", len(body),
"sequence", "restore_only_path_relogin_recreates_the_client_side_effect_item_timer")
return s.sendGameUpperRawClass(session, currentLoadEffectItemInfoMsgID, body, 0)
}
// clearCurrentStatChangePotionBuffs 发 CHARACTER_REMOVE_BUFF,把这一族可能残留的
// buff id 一次清掉(id 列表直接来自 PVF 的 buff 定义表:带
// `epic_rarity_item_drop_rate` 效果的那些)。
func (s *Service) clearCurrentStatChangePotionBuffs(session *gameSession, source string) error {
buffIDs := s.currentStatChangePotionBuffIDs()
if len(buffIDs) == 0 {
return nil
}
body := buildCurrentCharacterRemoveBuffBody(buffIDs)
if len(body) == 0 {
return nil
}
s.logPacketEvent("game-character-remove-buff-send",
"conn_id", session.connID,
"char_id", session.selectedCharacterID,
"source", source,
"buff_count", int(body[0]),
"msg_id", currentCharacterRemoveBuffMsgID,
"classification", 0,
"body_len", len(body),
"sequence", "expired_window_clears_stale_buff_icons")
if err := s.sendGameUpperRawClass(session, currentCharacterRemoveBuffMsgID, body, 0); err != nil {
return err
}
// 参考实现的 ClearAsync 是"REMOVE_BUFF → BUFF_DUNGEON(空)":空列表把本地激活标志清掉。
clearBody := buildCurrentCharacterBuffDungeonBody(nil)
s.logPacketEvent("game-character-buff-dungeon-send",
"conn_id", session.connID,
"char_id", session.selectedCharacterID,
"source", source,
"active_count", 0,
"msg_id", currentCharacterBuffDungeonMsgID,
"classification", 0,
"body_len", len(clearBody),
"sequence", "class0_op489_empty_list_clears_local_active_flags")
return s.sendGameUpperRawClass(session, currentCharacterBuffDungeonMsgID, clearBody, 0)
}
// currentStatChangePotionBuffIDs 列出这一族药剂**可能发出去的**全部 buff id
// (= etc/variousbuffstackablelist.etc 里出现过的所有 buff,见
// tables.statChangePotionGrantableBuffIDs)。
//
// 覆盖全部五件赛丽亚药剂:掉落率 1090-1092 / 强化成功率 1097-1099 / 城镇移速 1100-1102 /
// 通关经验 1103-1105 / 进图属性 1106-1108,以及史诗Buff药剂、小灯笼、强化秘药那一批。
// 用在"一个生效窗口都没有"时的一次性清除:清一个客户端本来就没有的 buff 是无害的,
// 漏清才会留下一个永远不掉的图标。
func (s *Service) currentStatChangePotionBuffIDs() []int32 {
catalog, err := s.currentPVFItemCatalog()
if err != nil || catalog == nil {
return nil
}
tables, ok := catalog.currentVariousBuffTables()
if !ok {
return nil
}
return tables.statChangePotionGrantableBuffIDs()
}
func (s *Service) sendCurrentCharacterAddBuff(session *gameSession, buffID int32, source string) error {
body := buildCurrentCharacterAddBuffBody(buffID)
s.logPacketEvent("game-character-add-buff-send",
"conn_id", session.connID,
"channel_id", session.channel.ID,
"char_id", session.selectedCharacterID,
"source", source,
"buff_id", buffID,
"msg_id", currentCharacterAddBuffMsgID,
"classification", 0,
"body_len", len(body),
"sequence", "class0_op487_character_add_buff_creates_the_entry")
if err := s.sendGameUpperRawClass(session, currentCharacterAddBuffMsgID, body, 0); err != nil {
return err
}
// 第二发:把它置为**激活**。参考实现的 dungeon buff 流程永远两发一起发,
// 注释写明 0x01EA 才是"toggles existing entries active",且列表必须是**完整**的
// 激活集合(客户端先清空本地标志再按列表置位)。
activateBody := buildCurrentCharacterBuffDungeonBody([]int32{buffID})
s.logPacketEvent("game-character-buff-dungeon-send",
"conn_id", session.connID,
"channel_id", session.channel.ID,
"char_id", session.selectedCharacterID,
"source", source,
"buff_id", buffID,
"active_count", 1,
"msg_id", currentCharacterBuffDungeonMsgID,
"classification", 0,
"body_len", len(activateBody),
"sequence", "class0_op489_character_buff_dungeon_activates_the_entry",
"evidence", currentCharacterAddBuffEvidence)
return s.sendGameUpperRawClass(session, currentCharacterBuffDungeonMsgID, activateBody, 0)
}
// currentStatChangePotionEffectItemInfoBody 与 buff 一起发的倒计时记录(class0/op174)。
//
// 参考实现的 ItemStates(Effect) 与 buff 是**两套**:前者让客户端显示
// "[道具]的效果时间还剩N",后者才是 buff 栏图标。两发都要有(现场 2026-10-03:
// 只有 op174 时服主看到的正是那行倒计时文字)。
func currentStatChangePotionEffectItemInfoBody(itemID, remainingSeconds uint32) []byte {
return buildCurrentEffectItemInfoBody(itemID, remainingSeconds)
}
// sendCurrentCharacterAddBuffOnly 只发 class0/op487(建/更新 buff 条目),不发激活列表。
//
// 恢复路径要一次恢复多个 family,激活列表必须按**完整集合**发一次(op489 的语义是
// "先清空本地激活标志、再按列表置位"),所以这里把"建条目"和"置激活"拆开。
func (s *Service) sendCurrentCharacterAddBuffOnly(
session *gameSession,
buffID int32,
source string,
) error {
if buffID <= 0 {
return nil
}
body := buildCurrentCharacterAddBuffBody(buffID)
s.logPacketEvent("game-character-add-buff-send",
"conn_id", session.connID,
"char_id", session.selectedCharacterID,
"source", source,
"buff_id", buffID,
"msg_id", currentCharacterAddBuffMsgID,
"classification", 0,
"body_len", len(body),
"sequence", "restore_only_builds_the_entry_activation_follows_as_one_list")
return s.sendGameUpperRawClass(session, currentCharacterAddBuffMsgID, body, 0)
}