服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
254 lines
9.8 KiB
Go
254 lines
9.8 KiB
Go
package dnfbridge
|
||
|
||
import (
|
||
"sort"
|
||
"strconv"
|
||
|
||
dnfquest "longheng.io/server/internal/modules/dnf/quest"
|
||
)
|
||
|
||
// The current NoPack channel selector checks quest 12829 (0x321d) before it
|
||
// publishes channel types 23 and 25, reading the completed-quest state owned by
|
||
// the current scene. A late op356 updates the task book, but the already-built
|
||
// channel list keeps the entry absent until the scene is reconstructed.
|
||
const currentAntonRaidChannelUnlockQuestID = 12829
|
||
|
||
// sendCurrentQuestGatedTownSceneRefresh rebuilds only a currently loaded town
|
||
// scene whose PVF map or client feature is gated by the quest that just
|
||
// completed. The current client consumes op356 while constructing op24's
|
||
// destination scene; the op356 re-seed both op34 tails already send updates
|
||
// task and NPC state after the fact, but it cannot restore an omitted
|
||
// [quest condition] passage, map animation or cached feature entry - only a
|
||
// same-area op24 does.
|
||
//
|
||
// This is deliberately narrower than handleTownSetUserArea: it does not write
|
||
// durable coordinates, close stores, change online-area membership or touch a
|
||
// dungeon runtime. The player stays in the same town/area at the latest
|
||
// session-owned op35 position.
|
||
func (s *Service) sendCurrentQuestGatedTownSceneRefresh(
|
||
session *gameSession,
|
||
result dnfquest.FinishCommitResult,
|
||
source string,
|
||
) error {
|
||
if s == nil || session == nil || session.selectedCharacterID == 0 ||
|
||
!result.AtomicCommitted || result.QuestID <= 0 ||
|
||
result.CharacterID != strconv.FormatUint(uint64(session.selectedCharacterID), 10) {
|
||
return nil
|
||
}
|
||
return s.sendCurrentQuestGatedTownSceneRefreshForQuestIDs(session, result, []int64{result.QuestID}, source)
|
||
}
|
||
|
||
// sendCurrentQuestGatedTownSceneRefreshForQuestIDs is the bulk-completion
|
||
// counterpart of the single-quest refresh above: it rebuilds the same-area town
|
||
// scene when ANY quest id in gateQuestIDs gates the current map or a client
|
||
// feature. The op1429 bulk auto-complete closes every eligible low-level epic at
|
||
// once; a [quest condition] gate may belong to any one of those rows, so the
|
||
// gate check must scan the whole changed set, not only the highest completed
|
||
// quest. The same in-place op22 -> op23 -> op356 -> op24 leg is used, so the
|
||
// player never moves.
|
||
func (s *Service) sendCurrentQuestGatedTownSceneRefreshForQuestIDs(
|
||
session *gameSession,
|
||
result dnfquest.FinishCommitResult,
|
||
gateQuestIDs []int64,
|
||
source string,
|
||
) error {
|
||
if s == nil || session == nil || session.selectedCharacterID == 0 ||
|
||
!result.AtomicCommitted ||
|
||
result.CharacterID != strconv.FormatUint(uint64(session.selectedCharacterID), 10) {
|
||
return nil
|
||
}
|
||
|
||
session.dungeon.mu.Lock()
|
||
hasDungeonRuntime := session.dungeon.runtime != nil
|
||
session.dungeon.mu.Unlock()
|
||
if hasDungeonRuntime {
|
||
return nil
|
||
}
|
||
|
||
characterID := session.selectedCharacterID
|
||
session.townMu.Lock()
|
||
defer session.townMu.Unlock()
|
||
snapshot := session.townPositionSnapshot
|
||
if session.townSceneReadyCharacterID != characterID ||
|
||
snapshot.CharacterID != characterID || !snapshot.PositionValid {
|
||
return nil
|
||
}
|
||
townID, areaID, err := currentSceneTransitionLocation(result.PostCommitCharacter, true)
|
||
if err != nil || snapshot.TownID != townID || snapshot.AreaID != areaID {
|
||
return nil
|
||
}
|
||
area, found := s.townArea(int64(townID), int64(areaID))
|
||
if !found {
|
||
return nil
|
||
}
|
||
mapQuestGateCompleted := anyTownMapQuestCondition(area.QuestConditions, gateQuestIDs)
|
||
clientFeatureGateCompleted := anyCurrentClientFeatureGateQuest(gateQuestIDs)
|
||
// 章节过场动画:本次完成的任务若是某个城镇区域的 [need quest],且角色等级
|
||
// 已满足该区域的 [need level],那个区域就开启了 —— 客户端在场景重建时会把它
|
||
// 画到世界地图上,并播放 etc/townmovielist.etc 为该区域配置的过场影片。
|
||
//
|
||
// 优先用本次交付的任务本身;批量补完成时退回 gateQuestIDs 里的第一个。
|
||
// 全表查找(anywhere=true):开区任务可能在副本结算页交付,玩家不在目标城镇,
|
||
// 但只要当前有城镇场景需要重建,就借这次重建把开区状态送到客户端。
|
||
cutsceneQuestID := result.QuestID
|
||
if cutsceneQuestID <= 0 {
|
||
cutsceneQuestID = firstGateQuestID(gateQuestIDs)
|
||
}
|
||
openedTownID, openedArea, openedMovie, townAreaOpened := s.townMovieAreaOpenedByQuest(
|
||
0, cutsceneQuestID, int64(result.PostCommitCharacter.Level), true)
|
||
if !mapQuestGateCompleted && !clientFeatureGateCompleted && !townAreaOpened {
|
||
s.logGameEvent(session, "game-upper-finish-quest-town-scene-refresh-skipped",
|
||
"source", source,
|
||
"quest_id", result.QuestID,
|
||
"gate_quest_ids", gateQuestIDs,
|
||
"town_id", townID,
|
||
"area_id", areaID,
|
||
"map_path", area.MapPath,
|
||
"reason", "completed_quest_set_gates_neither_map_condition_nor_client_feature_nor_town_area_open")
|
||
return nil
|
||
}
|
||
if townAreaOpened {
|
||
s.logGameEvent(session, "game-upper-finish-quest-town-area-opened-cutscene",
|
||
"source", source,
|
||
"source_quest_id", cutsceneQuestID,
|
||
"opened_town_id", openedTownID,
|
||
"opened_area_id", openedArea.ID,
|
||
"opened_area_map", openedArea.MapPath,
|
||
"need_level", openedArea.MinLevel,
|
||
"need_quests", openedArea.NeedQuests,
|
||
"character_level", result.PostCommitCharacter.Level,
|
||
"movie", openedMovie.FilePath,
|
||
"movie_title", openedMovie.Title,
|
||
"action", "same_area_scene_reconstruction_plus_client_trigger_file",
|
||
"scene_reentry", "disabled_by_request")
|
||
// 2026-10-20 按需求移除「假场景切换」:不再在 2 秒后把玩家切到同城镇的
|
||
// 中间区域再切回来。章节过场改由客户端补丁的内置播放器播放 ——
|
||
// sendCurrentFinishQuestStoryDigestBeforeSceneRefresh 会把影片号 arm 进
|
||
// cutsceneArmed,客户端下一个入站包到达时写 90CN_play_cutscene.txt,
|
||
// 补丁当场调用客户端播放器播 origin_cinematic_NN.avi。
|
||
// 需要恢复旧行为(靠完整场景加载触发原生播放)时,重新加回这段调用即可:
|
||
// go func(...) { time.Sleep(2*time.Second); svc.forceTownSceneReentry(...) }(...)
|
||
}
|
||
areaState, ok := result.PostCommitCharacter.Stats["area_state"]
|
||
if !ok || areaState < 0 || areaState > int64(^byte(0)) {
|
||
s.logGameEvent(session, "game-upper-finish-quest-town-scene-refresh-skipped",
|
||
"quest_id", result.QuestID,
|
||
"town_id", townID,
|
||
"area_id", areaID,
|
||
"map_path", area.MapPath,
|
||
"reason", "committed_character_area_state_missing_or_invalid")
|
||
return nil
|
||
}
|
||
|
||
selfRow := currentSceneTransitionRow{
|
||
ObjectOrResourceKey: currentSceneActorObjectKey(characterID),
|
||
Value1: snapshot.PositionX,
|
||
Value2: snapshot.PositionY,
|
||
Value3: snapshot.MovementCode,
|
||
Value4: byte(areaState),
|
||
}
|
||
transitionRows := make([]currentSceneTransitionRow, 0, 1)
|
||
if s.onlinePlayers != nil {
|
||
areaPlayers := s.onlinePlayers.GetAreaPlayers(
|
||
townPresenceChannelID(session), townID, areaID)
|
||
sort.Slice(areaPlayers, func(i, j int) bool {
|
||
return areaPlayers[i].CharacterID < areaPlayers[j].CharacterID
|
||
})
|
||
for i := range areaPlayers {
|
||
if areaPlayers[i].CharacterID == 0 || areaPlayers[i].CharacterID == characterID {
|
||
continue
|
||
}
|
||
transitionRows = append(transitionRows, currentSceneTransitionRow{
|
||
ObjectOrResourceKey: currentSceneActorObjectKey(areaPlayers[i].CharacterID),
|
||
Value1: areaPlayers[i].PositionX,
|
||
Value2: areaPlayers[i].PositionY,
|
||
Value3: areaPlayers[i].Direction,
|
||
Value4: areaPlayers[i].AreaState,
|
||
})
|
||
}
|
||
}
|
||
// The current client binds the final op24 row as the selected actor.
|
||
transitionRows = append(transitionRows, selfRow)
|
||
transitionBody, err := buildCurrentSceneTransitionBody(townID, areaID, transitionRows)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
s.logGameEvent(session, "game-upper-finish-quest-town-scene-refresh-send",
|
||
"source", source,
|
||
"quest_id", result.QuestID,
|
||
"gate_quest_ids", gateQuestIDs,
|
||
"town_id", townID,
|
||
"area_id", areaID,
|
||
"map_path", area.MapPath,
|
||
"map_quest_conditions", area.QuestConditions,
|
||
"map_quest_gate_completed", mapQuestGateCompleted,
|
||
"client_feature_gate_completed", clientFeatureGateCompleted,
|
||
"town_area_opened", townAreaOpened,
|
||
"opened_town_id", openedTownID,
|
||
"opened_area_id", openedArea.ID,
|
||
"opened_area_map", openedArea.MapPath,
|
||
"opened_area_movie", openedMovie.FilePath,
|
||
"opened_area_movie_title", openedMovie.Title,
|
||
"position_x", snapshot.PositionX,
|
||
"position_y", snapshot.PositionY,
|
||
"row_count", len(transitionRows),
|
||
"sequence", "op22_then_op23_then_committed_op356_then_same_area_op24",
|
||
"reason", "completed_pvf_map_or_client_feature_or_town_area_open_requires_scene_reconstruction")
|
||
if err := s.sendCurrentTownLocationNotifications(
|
||
session,
|
||
characterID,
|
||
townID,
|
||
areaID,
|
||
selfRow,
|
||
source+"_same_area_scene_rebuild",
|
||
); err != nil {
|
||
return err
|
||
}
|
||
if err := s.sendCurrentClearQuestListFromCommittedQuest(
|
||
session,
|
||
result.PostCommitQuest,
|
||
source+"_after_op23_before_op24_seed_completed_quest_scene_state",
|
||
); err != nil {
|
||
return err
|
||
}
|
||
return s.sendGameUpperRawClass(session, currentSceneTransitionMsgID, transitionBody, 0)
|
||
}
|
||
|
||
func anyCurrentClientFeatureGateQuest(gateQuestIDs []int64) bool {
|
||
for _, questID := range gateQuestIDs {
|
||
if questID == currentAntonRaidChannelUnlockQuestID {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func anyTownMapQuestCondition(questConditions, gateQuestIDs []int64) bool {
|
||
for _, questID := range gateQuestIDs {
|
||
if containsTownMapQuestCondition(questConditions, questID) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func containsTownMapQuestCondition(questConditions []int64, questID int64) bool {
|
||
for _, candidate := range questConditions {
|
||
if candidate == questID {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// firstGateQuestID 返回本批完成的任务里第一个有效 ID(批量补完成时用)。
|
||
func firstGateQuestID(gateQuestIDs []int64) int64 {
|
||
for _, questID := range gateQuestIDs {
|
||
if questID > 0 {
|
||
return questID
|
||
}
|
||
}
|
||
return 0
|
||
}
|