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

234 lines
6.5 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 storydigest 提供 etc/storydigest/digest.etc 的只读目录。
//
// 这张表定义「章节过场动画」的播放边界。每段结构:
//
// [digest]
// `5` ← 段落号
// [level]
// 40 45 ← 该段对应的角色等级区间
// [opening]
// 0
// [scene]
// 1 2 3 4 5 ← 该段播放文字摘要时要展示的场景
// [ending]
// 905 ← 段落结束时要播放的影片段号
// `6` ← 下一段由此开始(同一 [digest] 块内续写)
//
// etc/storydigest/scene.etc 把段号映射到内容:段 N(N=1..17)是第 N 章的
// 文字摘要,段 900+N 是 `[video]` 影片段。因此 `[ending] 905` 对应的影片段
// 就是 scene.etc 里的 905 → Video/movie/origin_cinematic_05.avi。
//
// 客户端 sub_11DBE40 的判定:比较「由 last_level 选出的小段」与「由角色当前
// 等级选出的小段」,不同才播。所以要让客户端播某段结尾的影片,服务端需要
// 把 last_level 下发到该段的 [level] 下界(该段起点)。
package storydigest
import (
"errors"
"fmt"
"sort"
"strings"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
)
// DefaultDigestPath 是章节摘要段表。
const DefaultDigestPath = "etc/storydigest/digest.etc"
// ErrSourceRequired 表示没有传入可读的 PVF 源。
var ErrSourceRequired = errors.New("dnf story digest pvf source is required")
// Source 是读取 PVF 文本的最小接口(与 town 等包保持一致)。
type Source interface {
ReadText(string) (string, error)
}
// Segment 是 digest.etc 的一个 [digest] 段。
type Segment struct {
// Index 是段号(1..17)。
Index int
// LevelMin/LevelMax 是该段对应的角色等级区间。
LevelMin int
LevelMax int
// Scenes 是该段要展示的文字摘要场景号。
Scenes []int
// Ending 是段落结束时要播放的影片段号(scene.etc 里的 900+N)。
// 0 表示该段没有结尾影片。
Ending int
}
// Digest 是段表。
type Digest struct {
segments []Segment
}
// Segments 按段号升序返回全部段。
func (d *Digest) Segments() []Segment {
if d == nil {
return nil
}
out := make([]Segment, len(d.segments))
copy(out, d.segments)
return out
}
// Len 返回段数。
func (d *Digest) Len() int {
if d == nil {
return 0
}
return len(d.segments)
}
// SegmentForEnding 返回以该影片段号结尾的段。
//
// 用途:城镇区域开启后要知道「该区域的过场影片属于哪一段」,从而把
// last_level 下发到那段,让客户端在段结束处播放影片。
func (d *Digest) SegmentForEnding(ending int) (Segment, bool) {
if d == nil || ending <= 0 {
return Segment{}, false
}
for _, segment := range d.segments {
if segment.Ending == ending {
return segment, true
}
}
return Segment{}, false
}
// SegmentForLevel 返回包含该角色等级的段(等级区间命中)。
func (d *Digest) SegmentForLevel(level int) (Segment, bool) {
if d == nil {
return Segment{}, false
}
for _, segment := range d.segments {
if level >= segment.LevelMin && level <= segment.LevelMax {
return segment, true
}
}
return Segment{}, false
}
// Load 读取 etc/storydigest/digest.etc。
//
// 文件缺失不算错误:此时没有任何段,过场不会被触发。
func Load(source Source, path string) (*Digest, error) {
if source == nil {
return nil, ErrSourceRequired
}
if strings.TrimSpace(path) == "" {
path = DefaultDigestPath
}
text, err := source.ReadText(path)
if err != nil {
return &Digest{}, nil
}
doc, err := dnfpvf.Parse(path, text)
if err != nil {
return nil, fmt.Errorf("parse story digest %s: %w", path, err)
}
return parseDigest(doc)
}
// parseDigest 解析 digest.etc。
//
// 该文件的写法比较特殊:[digest] 只出现一次,段与段之间**没有闭合标记** ——
// 段号以 `` `N` `` 形式直接写在上一段的 [ending] 值之后。因此这里按 token
// 顺序扫描:遇到 [digest] 段头开始,之后每遇到一个「[level] 段头」就收一段。
func parseDigest(doc *dnfpvf.Document) (*Digest, error) {
result := &Digest{}
if doc == nil || len(doc.Tokens) == 0 {
return result, nil
}
tokens := doc.Tokens
index := -1
for i := 0; i < len(tokens); i++ {
if tokens[i].Kind == dnfpvf.TokenSection && strings.EqualFold(strings.TrimSpace(tokens[i].Value), "digest") {
index = i + 1
break
}
}
if index < 0 {
return result, nil
}
var current *Segment
for i := index; i < len(tokens); i++ {
token := tokens[i]
if token.Kind == dnfpvf.TokenSection {
switch strings.ToLower(strings.TrimSpace(token.Value)) {
case "level":
// 每个 [level] 开启新的一段。段号是它前面最近的一个「裸字符串」
// (形如 `5`),也就是上一段 [ending] 之后紧跟的那个 `` `N` ``。
segment := Segment{Index: bareIntBefore(tokens, i)}
values := intsUntilSection(tokens, i+1)
if len(values) >= 1 {
segment.LevelMin = values[0]
}
if len(values) >= 2 {
segment.LevelMax = values[1]
}
result.segments = append(result.segments, segment)
current = &result.segments[len(result.segments)-1]
case "scene":
if current != nil {
current.Scenes = append(current.Scenes, intsUntilSection(tokens, i+1)...)
}
case "ending":
if current != nil {
if values := intsUntilSection(tokens, i+1); len(values) >= 1 {
current.Ending = int(values[0])
}
}
}
}
}
sort.SliceStable(result.segments, func(a, b int) bool {
return result.segments[a].Index < result.segments[b].Index
})
return result, nil
}
// bareIntBefore 返回 tokens[before] 之前最近的一个裸字符串里蕴含的整数
// (digest.etc 用 `` `5` `` 写段号)。找不到返回 -1。
func bareIntBefore(tokens []dnfpvf.Token, before int) int {
for i := before - 1; i >= 0; i-- {
token := tokens[i]
if token.Kind != dnfpvf.TokenString && token.Kind != dnfpvf.TokenIdent {
continue
}
value := strings.Trim(strings.TrimSpace(token.Value), "`")
if value == "" {
continue
}
n := 0
ok := true
for _, r := range value {
if r < '0' || r > '9' {
ok = false
break
}
n = n*10 + int(r-'0')
}
if ok {
return n
}
// 只回看一步:段号紧邻 [level]。
return -1
}
return -1
}
func intsUntilSection(tokens []dnfpvf.Token, start int) []int {
values := make([]int, 0, 2)
for i := start; i < len(tokens); i++ {
if tokens[i].Kind == dnfpvf.TokenSection {
break
}
if tokens[i].Kind == dnfpvf.TokenInt {
values = append(values, int(tokens[i].Int))
}
}
return values
}