服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
227 lines
6.9 KiB
Go
227 lines
6.9 KiB
Go
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)
|
||
}
|