Files
90CNServer/server/internal/services/dnfbridge/quest_finish_town_scene_refresh.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

254 lines
9.8 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 (
"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
}