package dnfbridge import ( "context" "fmt" "strings" dnfstorydigest "longheng.io/server/internal/modules/dnf/storydigest" dnftown "longheng.io/server/internal/modules/dnf/town" ) func (s *Service) preloadTownCatalog(ctx context.Context) error { if s == nil { return nil } if ctx == nil { ctx = context.Background() } if err := ctx.Err(); err != nil { return err } s.initialEquipmentMu.Lock() archive, err := s.initialEquipmentArchiveLocked() s.initialEquipmentMu.Unlock() if err != nil { return fmt.Errorf("preload town pvf: %w", err) } table, err := dnftown.Load(ctx, archive, dnftown.Options{}) if err != nil { return fmt.Errorf("preload town catalog: %w", err) } // 城镇区域过场表:缺失不算错误(部分 profile 不带这个文件), // 此时没有任何区域会播过场。 movieTable, err := dnftown.LoadTownMovieTable(archive, dnftown.DefaultMovieListPath) if err != nil { return fmt.Errorf("preload town movie catalog: %w", err) } // 章节摘要段表:给出每个过场影片所属的段落边界。 digestTable, err := dnfstorydigest.Load(archive, dnfstorydigest.DefaultDigestPath) if err != nil { return fmt.Errorf("preload story digest catalog: %w", err) } s.townCatalogMu.Lock() s.townCatalog = table s.townMovieCatalog = movieTable s.storyDigestCatalog = digestTable s.townCatalogMu.Unlock() snapshot := table.Snapshot() s.logPacketEvent("dnf-town-catalog-loaded", "towns", snapshot.Towns, "areas", snapshot.Areas, "movie_plays", len(movieTable.Plays()), "story_digest_segments", digestTable.Len()) return nil } // storyDigestLevelForTownMovie 返回「要让客户端播这段过场,op1378 应下发的值」。 // // 机制(PVF 实测): // // etc/storydigest/scene.etc 段 900+N 是 `[video] origin_cinematic_(N).avi` // etc/storydigest/digest.etc 段 M 的 [ending] 指出该段结束播哪个影片段 // 客户端比较「由 last_level 选出的段」与「由当前等级选出的段」,**不同才播**, // 而实际播放的段落由**角色当前等级**决定。 // // 所以要让客户端立刻播某段的结尾影片,必须让 last_level 落在**该段之前的一段**, // 制造段差。这里取「该段下界 - 1」—— 它落在上一段的区间里(例如段 6 下界 46 → 45, // 45 属于段 5 的 40..45),于是: // // last_level=45 → 客户端选段 5;角色 46 级 → 客户端选段 6;两段不同 // → 播放段 6(其 [ending] 就是 origin_cinematic_06) // // ok=false 表示这段影片不在任何段里(配置缺失),调用方应跳过。 func (s *Service) storyDigestLevelForTownMovie(movie dnftown.TownMoviePlay) (int, int, bool) { if s == nil || movie.FilePath == "" { return 0, 0, false } sequence, ok := townMovieCinematicSequence(movie.FilePath) if !ok { return 0, 0, false } s.townCatalogMu.RLock() digest := s.storyDigestCatalog s.townCatalogMu.RUnlock() if digest == nil { return 0, 0, false } // scene.etc 的影片段号是 900+N,其中 N 是场景段号(1..17)。 segment, found := digest.SegmentForEnding(900 + sequence) if !found { return 0, 0, false } // 下发「该段下界 - 1」,落在上一段,制造段差让客户端立刻播放该段影片。 level := segment.LevelMin - 1 if level < 0 { level = 0 } return level, segment.Index, true } // townMovieCinematicSequence 从 `Video/movie/origin_cinematic_05.avi` 取出 5。 func townMovieCinematicSequence(path string) (int, bool) { const prefix = "origin_cinematic_" normalized := strings.ReplaceAll(strings.TrimSpace(path), "\\", "/") name := normalized if idx := strings.LastIndex(normalized, "/"); idx >= 0 { name = normalized[idx+1:] } name = strings.TrimSuffix(name, ".avi") idx := strings.LastIndex(name, prefix) if idx < 0 { return 0, false } digits := name[idx+len(prefix):] if digits == "" { return 0, false } value := 0 for _, r := range digits { if r < '0' || r > '9' { return 0, false } value = value*10 + int(r-'0') } if value <= 0 { return 0, false } return value, true } // townAreaMovie 返回某个城镇区域开启后应播放的过场;没有该区域的条目时 ok=false。 func (s *Service) townAreaMovie(townID, areaID int64) (dnftown.TownMoviePlay, bool) { if s == nil { return dnftown.TownMoviePlay{}, false } s.townCatalogMu.RLock() table := s.townMovieCatalog s.townCatalogMu.RUnlock() if table == nil { return dnftown.TownMoviePlay{}, false } return table.Find(townID, areaID) } // townMovieAreaOpenedByQuest 返回「该任务开启的、且带过场」的城镇区域。 // // 这是章节过场动画的唯一触发判据:任务完成时,若它正是某个城镇区域的 // [need quest](且角色等级满足该区域的 [need level]),该区域即开启, // 并播放 etc/townmovielist.etc 为该区域配置的影片。 // // anywhere=true 时在全表范围查找(玩家可能在副本结算页,不在目标城镇); // 否则只在 townID 对应的城镇里查找。 func (s *Service) townMovieAreaOpenedByQuest( townID, questID, characterLevel int64, anywhere bool, ) (int64, dnftown.Area, dnftown.TownMoviePlay, bool) { if s == nil || questID <= 0 { return 0, dnftown.Area{}, dnftown.TownMoviePlay{}, false } s.townCatalogMu.RLock() places := s.townCatalog movies := s.townMovieCatalog s.townCatalogMu.RUnlock() if places == nil || movies == nil { return 0, dnftown.Area{}, dnftown.TownMoviePlay{}, false } if anywhere { return movies.MovieForQuestAnywhere(places, questID, characterLevel) } area, movie, ok := movies.MovieForQuest(places, townID, questID, characterLevel) if !ok { return 0, dnftown.Area{}, dnftown.TownMoviePlay{}, false } return townID, area, movie, true } // townSeriaRoomReturnArea is the town area owning the PVF portal into the // shared Seria room, with its safe landing point; see town.FindSeriaRoomReturnArea. func (s *Service) townSeriaRoomReturnArea(townID int64) (dnftown.Area, bool) { if s == nil { return dnftown.Area{}, false } s.townCatalogMu.RLock() table := s.townCatalog s.townCatalogMu.RUnlock() if table == nil { return dnftown.Area{}, false } return table.FindSeriaRoomReturnArea(townID) } func (s *Service) townArea(townID, areaID int64) (dnftown.Area, bool) { if s == nil { return dnftown.Area{}, false } s.townCatalogMu.RLock() table := s.townCatalog s.townCatalogMu.RUnlock() if table == nil { return dnftown.Area{}, false } return table.FindArea(townID, areaID) } func (s *Service) townGateArea(townID int64) (dnftown.Area, bool) { if s == nil { return dnftown.Area{}, false } s.townCatalogMu.RLock() table := s.townCatalog s.townCatalogMu.RUnlock() if table == nil { return dnftown.Area{}, false } return table.FindGateArea(townID) } func (s *Service) townAllowsNPCEnter(townID, areaID, targetNPCID int64) bool { if s == nil { return false } s.townCatalogMu.RLock() table := s.townCatalog s.townCatalogMu.RUnlock() return table != nil && table.AllowsNPCEnterTown(townID, areaID, targetNPCID) }