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 }