Files
90CNServer/server/internal/modules/dnf/town/movielist.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

263 lines
7.3 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 town
import (
"fmt"
"sort"
"strings"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
)
// DefaultMovieListPath 是「开启城镇区域后自动播放的过场」表。
//
// 表结构(实测 etc/townmovielist.etc,13 条):
//
// [movie play]
// [town index] 39 ← 城镇
// [area index] 5 ← 区域
// [play info]
// [title] `诺斯玛尔`
// [file path] `Video/movie/origin_cinematic_06.avi`
// [/play info]
// [/movie play]
//
// 这张表只回答「播哪段影片」;「什么时候算开启」由各城镇 .twn 里每个 area 的
// [permission] 决定(NeedLevel + NeedQuests),也就是 Area.MinLevel / Area.NeedQuests。
// 两者合起来才是完整规则:完成某区域的 NeedQuests 任务且达到 NeedLevel 时,
// 该区域开启,并播放该区域的影片。
const DefaultMovieListPath = "etc/townmovielist.etc"
// TownMoviePlay 是 [movie play] 的一条记录。
type TownMoviePlay struct {
TownID int64 `json:"town_id"`
AreaID int64 `json:"area_id"`
Title string `json:"title,omitempty"`
FilePath string `json:"file_path"`
}
// TownMovieTable 按 (town, area) 索引过场表。
type TownMovieTable struct {
plays map[[2]int64]TownMoviePlay
}
// Find 返回某个城镇区域开启后应播放的过场。
func (t *TownMovieTable) Find(townID, areaID int64) (TownMoviePlay, bool) {
if t == nil {
return TownMoviePlay{}, false
}
play, ok := t.plays[[2]int64{townID, areaID}]
return play, ok
}
// Plays 按 (town, area) 稳定顺序返回全部条目。
func (t *TownMovieTable) Plays() []TownMoviePlay {
if t == nil {
return nil
}
out := make([]TownMoviePlay, 0, len(t.plays))
for _, play := range t.plays {
out = append(out, play)
}
sort.Slice(out, func(i, j int) bool {
if out[i].TownID != out[j].TownID {
return out[i].TownID < out[j].TownID
}
return out[i].AreaID < out[j].AreaID
})
return out
}
// MovieForQuest 返回「该任务开启的、且带过场」的城镇区域。
//
// 这是本功能的判定核心:给定一个刚完成的任务与角色等级,找出它开启了哪个
// 带过场的城镇区域。同一个任务通常只开一个区域;若命中多个,取 area 序号最小的,
// 保证一次只播一段。
//
// 需要同时满足(与客户端读 .twn 的口径一致):
// - 区域声明了 [need quest],且其中包含该任务
// - 角色等级 >= 区域的 [need level](0 表示不限)
// - 该区域在过场表里有影片
func (t *TownMovieTable) MovieForQuest(
table *Table,
townID int64,
questID int64,
characterLevel int64,
) (Area, TownMoviePlay, bool) {
if t == nil || table == nil || questID <= 0 {
return Area{}, TownMoviePlay{}, false
}
town, found := table.Find(townID)
if !found {
return Area{}, TownMoviePlay{}, false
}
var (
bestArea Area
bestMovie TownMoviePlay
bestSet bool
)
for _, area := range town.Areas {
if len(area.NeedQuests) == 0 {
continue
}
if area.MinLevel > 0 && characterLevel < area.MinLevel {
continue
}
if !int64SliceContains(area.NeedQuests, questID) {
continue
}
movie, ok := t.Find(townID, area.ID)
if !ok {
continue
}
if !bestSet || area.ID < bestArea.ID {
bestArea, bestMovie, bestSet = area, movie, true
}
}
return bestArea, bestMovie, bestSet
}
// MovieForQuestAnywhere 在**所有**城镇里查找该任务开启的带过场区域。
// 任务完成时玩家可能不在目标城镇(例如在副本结算页),因此需要在全表范围判定。
func (t *TownMovieTable) MovieForQuestAnywhere(
table *Table,
questID int64,
characterLevel int64,
) (int64, Area, TownMoviePlay, bool) {
if t == nil || table == nil || questID <= 0 {
return 0, Area{}, TownMoviePlay{}, false
}
var (
bestTown int64
bestArea Area
bestMovie TownMoviePlay
bestSet bool
)
for townID := range table.towns {
area, movie, ok := t.MovieForQuest(table, townID, questID, characterLevel)
if !ok {
continue
}
if !bestSet || townID < bestTown || (townID == bestTown && area.ID < bestArea.ID) {
bestTown, bestArea, bestMovie, bestSet = townID, area, movie, true
}
}
return bestTown, bestArea, bestMovie, bestSet
}
func int64SliceContains(values []int64, want int64) bool {
for _, value := range values {
if value == want {
return true
}
}
return false
}
// LoadTownMovieTable 读取 etc/townmovielist.etc。
//
// 文件缺失不算错误:部分 profile 不带这张表,此时没有任何区域会播过场。
func LoadTownMovieTable(source Source, listPath string) (*TownMovieTable, error) {
if source == nil {
return nil, ErrSourceRequired
}
if strings.TrimSpace(listPath) == "" {
listPath = DefaultMovieListPath
}
text, err := source.ReadText(listPath)
if err != nil {
return &TownMovieTable{plays: map[[2]int64]TownMoviePlay{}}, nil
}
doc, err := dnfpvf.Parse(listPath, text)
if err != nil {
return nil, fmt.Errorf("parse town movie list %s: %w", listPath, err)
}
return parseTownMovieTable(doc)
}
func parseTownMovieTable(doc *dnfpvf.Document) (*TownMovieTable, error) {
table := &TownMovieTable{plays: make(map[[2]int64]TownMoviePlay)}
if doc == nil {
return table, nil
}
tokens := doc.Tokens
for index := 0; index < len(tokens); index++ {
token := tokens[index]
if token.Kind != dnfpvf.TokenSection || sectionName(token.Value) != "movie play" {
continue
}
end := townMovieUnitEnd(tokens, index+1)
play, include, err := parseTownMoviePlay(tokens[index+1 : end])
if err != nil {
return nil, err
}
if include {
table.plays[[2]int64{play.TownID, play.AreaID}] = play
}
index = end
}
return table, nil
}
func townMovieUnitEnd(tokens []dnfpvf.Token, start int) int {
for idx := start; idx < len(tokens); idx++ {
if tokens[idx].Kind == dnfpvf.TokenSection && sectionName(tokens[idx].Value) == "/movie play" {
return idx
}
}
return len(tokens)
}
func parseTownMoviePlay(tokens []dnfpvf.Token) (TownMoviePlay, bool, error) {
var (
play TownMoviePlay
townOK bool
areaOK bool
pathOK bool
)
for idx := 0; idx < len(tokens); idx++ {
token := tokens[idx]
if token.Kind != dnfpvf.TokenSection {
continue
}
switch sectionName(token.Value) {
case "town index":
if value, ok := nextInt(tokens, idx+1); ok {
play.TownID, townOK = value, true
}
case "area index":
if value, ok := nextInt(tokens, idx+1); ok {
play.AreaID, areaOK = value, true
}
case "title":
if value, ok := nextText(tokens, idx+1); ok {
play.Title = value
}
case "file path":
if value, ok := nextText(tokens, idx+1); ok {
play.FilePath = strings.TrimSpace(value)
pathOK = play.FilePath != ""
}
}
}
if !townOK || !areaOK || play.TownID < 0 || play.AreaID < 0 {
return TownMoviePlay{}, false, fmt.Errorf("invalid town movie identity %d/%d", play.TownID, play.AreaID)
}
if !pathOK {
// 没有影片路径的占位条目不参与播放。
return TownMoviePlay{}, false, nil
}
return play, true, nil
}
// nextText 返回 start 之后第一个字符串/标识值,遇到下一个段头即停。
func nextText(tokens []dnfpvf.Token, start int) (string, bool) {
for idx := start; idx < len(tokens); idx++ {
if tokens[idx].Kind == dnfpvf.TokenSection {
return "", false
}
if tokens[idx].Kind == dnfpvf.TokenString || tokens[idx].Kind == dnfpvf.TokenIdent {
return strings.TrimSpace(tokens[idx].Value), true
}
}
return "", false
}