Files
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

227 lines
6.9 KiB
Go
Raw Permalink 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 (
"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)
}