服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
355 lines
13 KiB
Go
355 lines
13 KiB
Go
package dnfbridge
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"math"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"longheng.io/server/internal/modules/dnf/dnfenum"
|
||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||
)
|
||
|
||
// gmPostCommitPushTimeout 状态**已经落库之后**那次客户端推送的超时上限。
|
||
//
|
||
// 取 30 秒(比 createWriteTimeout 宽松得多)的原因见 gmPostCommitPushContext。
|
||
const gmPostCommitPushTimeout = 30 * time.Second
|
||
|
||
// gmPostCommitPushContext 给"状态已经落库之后的客户端推送"用的独立 context。
|
||
//
|
||
// ── 为什么不能直接用 HTTP 请求的 ctx(2026-09-28 修的真实线上问题)─────────────
|
||
// GM 接口的流程是"① 先把状态写进数据库 → ② 再把新状态推给在线客户端"。第 ② 步要排队
|
||
// 进游戏会话的事件循环(会话在跑副本/网络卡顿时队列会积压,几秒到十几秒都正常)。
|
||
//
|
||
// 而 eventloop 在取出事件时会先检查事件自带的 Context:
|
||
//
|
||
// // internal/platform/eventloop/loop.go processEvent
|
||
// if err := ctx.Err(); err != nil { ...; return } // ← 直接丢弃,run() 根本不执行
|
||
//
|
||
// 于是"运维工具 8 秒超时断连 → 请求 ctx 被取消 → 排在队列里的推送事件被丢"成为必然。
|
||
// 后果是最难排查的一类故障:**钱在数据库里已经加了,接口日志也写着成功,但客户端
|
||
// 界面永远不刷新**(泡点反馈的"点券不实时刷新"就是这一条)。
|
||
//
|
||
// 所以:凡是"状态已经提交、推送只是通知"的调用,一律用本函数拿独立 context ——
|
||
// 客户端断不断开,都不该影响一次已经发生的变更被送达。
|
||
//
|
||
// 注意:**只有已经落库之后的推送才能用它**。参与事务/仓储读写的调用仍必须用请求 ctx,
|
||
// 否则客户端断开后服务端还会继续干活,那是另一种资源浪费。
|
||
func gmPostCommitPushContext() (context.Context, context.CancelFunc) {
|
||
return context.WithTimeout(context.Background(), gmPostCommitPushTimeout)
|
||
}
|
||
|
||
// gmRefreshOnlineCharacter 把"已经落库的新状态"推给在线角色。
|
||
//
|
||
// **故意不接收 context.Context**(2026-09-28 修真实线上问题):本函数是 7 个 GM 操作
|
||
// (SP/TP、等级、职业、副本权限、装备栏、黑钻、道具格刷新)统一的"提交后推送"出口。
|
||
// 以前它接收请求 ctx,于是运维工具 8 秒超时断连 → 请求 ctx 取消 → 排在游戏会话事件
|
||
// 循环里的推送事件被 eventloop 直接丢弃(它取出事件时先查 ctx.Err()),
|
||
// 结果是"改是改成功了、数据库也对,但客户端界面不刷新"。
|
||
//
|
||
// 去掉参数就是把这个契约写死:**这里只做通知,不属于任何一次请求的生命周期**。
|
||
// 详见 gmPostCommitPushContext。
|
||
func (s *Service) gmRefreshOnlineCharacter(
|
||
characterID string,
|
||
source string,
|
||
send func(*gameSession) error,
|
||
) GMNativeRefreshResult {
|
||
result := GMNativeRefreshResult{}
|
||
numericID, err := strconv.ParseUint(strings.TrimSpace(characterID), 10, 16)
|
||
if err != nil || numericID == 0 || numericID > math.MaxUint16 {
|
||
return result
|
||
}
|
||
session, online := s.onlineGameSession(uint16(numericID))
|
||
result.Online = online
|
||
if !online {
|
||
return result
|
||
}
|
||
if send == nil {
|
||
result.RefreshError = "native client packet projection is unavailable"
|
||
return result
|
||
}
|
||
pushCtx, pushCancel := gmPostCommitPushContext()
|
||
defer pushCancel()
|
||
err = s.callGameSession(pushCtx, session, source, func() error {
|
||
if session.selectedCharacterID != uint16(numericID) {
|
||
return errors.New("selected character changed before native GM state projection")
|
||
}
|
||
return send(session)
|
||
})
|
||
if err != nil {
|
||
result.RefreshError = err.Error()
|
||
return result
|
||
}
|
||
result.Refreshed = true
|
||
if player, found := s.onlinePlayers.PlayerForCharacter(uint16(numericID)); found {
|
||
s.notifyCurrentUnitedFriendPresence(
|
||
player.Name,
|
||
currentFriendPresence{ChannelID: uint16(session.channel.ID)},
|
||
source+"_friend_absolute_refresh",
|
||
)
|
||
}
|
||
return result
|
||
}
|
||
|
||
func (s *Service) gmOnlineCharacterInDungeon(characterID string) bool {
|
||
numericID, err := strconv.ParseUint(strings.TrimSpace(characterID), 10, 16)
|
||
if err != nil || numericID == 0 || numericID > math.MaxUint16 {
|
||
return false
|
||
}
|
||
session, online := s.onlineGameSession(uint16(numericID))
|
||
if !online {
|
||
return false
|
||
}
|
||
session.dungeon.mu.Lock()
|
||
defer session.dungeon.mu.Unlock()
|
||
return session.dungeon.runtime != nil
|
||
}
|
||
|
||
func (s *Service) sendGMLevelPostCommitState(session *gameSession) error {
|
||
return s.sendSelectedCharacterProgressionPostCommitState(session, "gm_admin_level")
|
||
}
|
||
|
||
func (s *Service) sendSelectedCharacterProgressionPostCommitState(session *gameSession, source string) error {
|
||
if err := s.sendSelectedCharacterNativeProgressionState(session, source); err != nil {
|
||
return err
|
||
}
|
||
if err := s.sendSelectedActorCurrentSceneSkillInfo(session, source+"_after_op37"); err != nil {
|
||
return err
|
||
}
|
||
return s.sendGMPersistedQuestPostActionState(session, source+"_after_op37_op19")
|
||
}
|
||
|
||
func (s *Service) sendSelectedCharacterNativeProgressionState(session *gameSession, source string) error {
|
||
if session == nil || session.selectedCharacterID == 0 {
|
||
return errors.New("selected character is unavailable for level projection")
|
||
}
|
||
if strings.TrimSpace(source) == "" {
|
||
source = "character_progression"
|
||
}
|
||
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
|
||
defer cancel()
|
||
repositories, ok := s.repositoryGroup()
|
||
if !ok || repositories.Character == nil || repositories.Skill == nil {
|
||
return ErrGMRepositoryUnavailable
|
||
}
|
||
characterID := strconv.Itoa(int(session.selectedCharacterID))
|
||
character, found, err := repositories.Character.Load(ctx, characterID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if !found {
|
||
return ErrGMCharacterNotFound
|
||
}
|
||
skill, found, err := repositories.Skill.Load(ctx, characterID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if !found {
|
||
return errors.New("committed level has no skill-point ledger")
|
||
}
|
||
// Current NoPack op418 (sub_1D197C0) is the native level-up owner: its
|
||
// success callback adds the returned EXP and SP/TP deltas before this
|
||
// absolute projection runs. A level change must also update the already
|
||
// bound town actor's raw47 level byte. Mode0 is the proved in-place actor
|
||
// refresh; do not send full mode1 here because it recreates equipment/actor
|
||
// state and can block the next town movement request.
|
||
if currentTownSelfActorAppearanceReady(session) {
|
||
if err := s.sendSelectedActorMode0AppearanceRefresh(
|
||
session,
|
||
source,
|
||
"native_op418_delta_then_same_owner_mode0_level_then_op37_absolute_progression",
|
||
); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
|
||
// SkillRecord.Points is the committed per-character baseline advanced by
|
||
// the EXP owner. Alternate-tree ledgers are synchronized immediately by
|
||
// the following op19 builder. Reading the active tree here would publish
|
||
// its pre-sync value (observed live as SP=0 after op418 granted SP=30) and
|
||
// overwrite the native callback's point update.
|
||
points := skill.Points
|
||
body := buildCurrentFinishLoadingCharacterStateBody(character, points)
|
||
if len(body) != currentFinishLoadingCharacterStateBodySize {
|
||
return errors.New("committed level produced an invalid current-client character state")
|
||
}
|
||
if err := s.sendGameUpperRawClass(session, currentDungeonCharacterStateMsgID, body, 0); err != nil {
|
||
return err
|
||
}
|
||
s.logGameEvent(session, "game-upper-character-progression-op37-send",
|
||
"char_id", session.selectedCharacterID,
|
||
"level", character.Level,
|
||
"remaining_sp", points.RemainingSP,
|
||
"remaining_tp", points.RemainingTP,
|
||
"body_len", len(body),
|
||
"sequence", "native_op418_delta_then_optional_town_mode0_level_then_op37_then_op19_quests",
|
||
"body_source", source+"_post_commit_character_state")
|
||
// 与 op37 同批把客户端的剧情摘要基线拉平到当前等级。
|
||
//
|
||
// 当前客户端只在收到 class0/op1378 时更新内部 last_level,场景 finalizer
|
||
// 再拿它和角色等级选出的段比较;两者不同就会自动补播一段剧情摘要 +
|
||
// 章节动画。GM 改等级、经验书/升级券升级都走这条 progression 投影,
|
||
// 不补这一包就会出现「升到 17 级、第一次进副本自动播章节动画」。
|
||
if character.Level > 0 {
|
||
if err := s.sendCurrentStoryDigestLastLevelForLevelSync(
|
||
session,
|
||
uint32(character.Level),
|
||
source+"_story_digest_sync_after_progression",
|
||
); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *Service) sendGMPersistedQuestPostActionState(session *gameSession, source string) error {
|
||
if err := s.sendCurrentActiveQuestSnapshotForSession(session, source+"_active_op574"); err != nil {
|
||
return err
|
||
}
|
||
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
|
||
body, err := s.buildCurrentClearQuestListTransportBodyForSession(ctx, session, source+"_completed_op356")
|
||
cancel()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if err := s.sendGameUpperFixed16Transport(
|
||
session,
|
||
currentClearQuestListMsgID,
|
||
body,
|
||
0,
|
||
1,
|
||
true,
|
||
currentClearQuestListTransportCodec,
|
||
); err != nil {
|
||
return err
|
||
}
|
||
return s.sendCurrentAcceptableQuestListOnlyForSession(session, source+"_acceptable_op21")
|
||
}
|
||
|
||
func (s *Service) sendGMProfessionPostCommitState(session *gameSession) error {
|
||
if session == nil || session.selectedCharacterID == 0 {
|
||
return errors.New("selected character is unavailable for profession projection")
|
||
}
|
||
if s.gmOnlineCharacterInDungeon(strconv.Itoa(int(session.selectedCharacterID))) {
|
||
return errors.New("profession projection is only valid in town")
|
||
}
|
||
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
|
||
defer cancel()
|
||
repositories, ok := s.repositoryGroup()
|
||
if !ok || repositories.Character == nil {
|
||
return ErrGMRepositoryUnavailable
|
||
}
|
||
characterID := strconv.Itoa(int(session.selectedCharacterID))
|
||
character, found, err := repositories.Character.Load(ctx, characterID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if !found || strings.TrimSpace(character.AccountID) == "" {
|
||
return ErrGMCharacterNotFound
|
||
}
|
||
var legacyRepo dnfrepo.LegacyUserInfoRepository
|
||
legacyRepo = repositories.LegacyUserInfo
|
||
mode0 := s.buildCSharpSelectedUserInfoBody(
|
||
ctx,
|
||
session,
|
||
legacyRepo,
|
||
0,
|
||
character,
|
||
true,
|
||
session.selectedCharacterID,
|
||
character.Name,
|
||
)
|
||
if err := s.sendGameUpperRawClass(session, uint16(dnfenum.CmdPacketSetUDPIPPort), mode0, 0); err != nil {
|
||
return err
|
||
}
|
||
summary := s.currentAccountAdventureGroupSummaryForPacket(ctx, session, character, true)
|
||
mode1 := s.buildCurrentSelectedUserInfoMode1BodyWithAdventureLevelInContext(
|
||
ctx,
|
||
session,
|
||
legacyRepo,
|
||
character,
|
||
true,
|
||
session.selectedCharacterID,
|
||
uint32(summary.ManageLevel),
|
||
currentTownActorOwnerContext(session),
|
||
)
|
||
if err := s.sendGameUpperRawClass(session, uint16(dnfenum.CmdPacketSetUDPIPPort), mode1, 0); err != nil {
|
||
return err
|
||
}
|
||
s.logGameEvent(session, "game-upper-gm-profession-actor-rebind-send",
|
||
"char_id", session.selectedCharacterID,
|
||
"grow_type", character.Stats["grow_type"],
|
||
"sequence", "mode0_mode1_op19_quest_snapshots",
|
||
"mode0_body_len", len(mode0),
|
||
"mode1_body_len", len(mode1),
|
||
"body_source", "repository_pvf_post_profession_commit_native_actor_rebind")
|
||
if err := s.sendSelectedActorCurrentSceneSkillInfo(session, "gm_admin_profession_after_mode0_mode1"); err != nil {
|
||
return err
|
||
}
|
||
return s.sendGMPersistedQuestPostActionState(session, "gm_admin_profession_after_actor_skill_rebind")
|
||
}
|
||
|
||
func (s *Service) sendGMDungeonPermissionPostCommitState(ctx context.Context, session *gameSession) error {
|
||
entries, err := s.currentDungeonPermissionSnapshotEntries(ctx, session)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if len(entries) == 0 {
|
||
return errors.New("persisted dungeon permissions have no current-PVF client projection")
|
||
}
|
||
body := buildCurrentDungeonResourceStateEntriesBody(entries)
|
||
s.logGameEvent(session, "game-upper-gm-dungeon-permission-op5-snapshot-send",
|
||
"char_id", session.selectedCharacterID,
|
||
"msg_id", currentDungeonResourceStateMsgID,
|
||
"classification", 0,
|
||
"row_count", len(entries),
|
||
"body_len", len(body),
|
||
"body_source", "current_exe_sub_1D37AC0_u16_count_u32_key_u8_state")
|
||
return s.sendGameUpperRawClass(session, currentDungeonResourceStateMsgID, body, 0)
|
||
}
|
||
|
||
func (s *Service) sendGMEquipSlotPostCommitState(session *gameSession) error {
|
||
if session == nil || session.selectedCharacterID == 0 {
|
||
return errors.New("selected character is unavailable for equipment-slot projection")
|
||
}
|
||
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
|
||
defer cancel()
|
||
repositories, ok := s.repositoryGroup()
|
||
if !ok || repositories.Character == nil {
|
||
return ErrGMRepositoryUnavailable
|
||
}
|
||
characterID := strconv.Itoa(int(session.selectedCharacterID))
|
||
character, found, err := repositories.Character.Load(ctx, characterID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if !found {
|
||
return ErrGMCharacterNotFound
|
||
}
|
||
var legacyRepo dnfrepo.LegacyUserInfoRepository
|
||
legacyRepo = repositories.LegacyUserInfo
|
||
summary := s.currentAccountAdventureGroupSummaryForPacket(ctx, session, character, true)
|
||
body := s.buildCurrentSelectedUserInfoMode1BodyWithAdventureLevelInContext(
|
||
ctx,
|
||
session,
|
||
legacyRepo,
|
||
character,
|
||
true,
|
||
session.selectedCharacterID,
|
||
uint32(summary.ManageLevel),
|
||
currentTownActorOwnerContext(session),
|
||
)
|
||
if err := s.sendGameUpperRawClass(session, uint16(dnfenum.CmdPacketSetUDPIPPort), body, 0); err != nil {
|
||
return err
|
||
}
|
||
s.logGameEvent(session, "game-upper-gm-equip-slot-mode1-send",
|
||
"char_id", session.selectedCharacterID,
|
||
"ex_equip_slot_stat", character.Stats["ex_equip_slot_stat"],
|
||
"body_len", len(body),
|
||
"mode3_suppressed", true,
|
||
"body_source", "quest_finish_slot_expansion_proved_mode1_carrier")
|
||
return nil
|
||
}
|