DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
hanfuit committed 2026-11-04 14:31:55 +08:00
commit 645b64fe21
4388 files changed
+1405457

No files matched your search

@@ -0,0 +1,233 @@
// 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
}
@@ -0,0 +1,75 @@
package storydigest
import (
"os"
"testing"
platformpvf "longheng.io/server/internal/platform/pvf"
)
// TestRealScriptPVFDigestSegmentsMatchCinematics 校验 digest.etc 的解析结果,
// 并钉住「章节过场影片 ↔ 段落」的对应关系:
//
// etc/storydigest/scene.etc 里段 900+N 是 `[video] origin_cinematic_(N-1).avi`
// digest.etc 里段 M 的 [ending] 是 900+N → 该段结束播 origin_cinematic_(N-1)
//
// 开区过场就靠这条链:城镇区域的影片属于哪一段,就把 last_level 下发到那段的
// [level] 下界,客户端才会在该段结束处播放影片。
func TestRealScriptPVFDigestSegmentsMatchCinematics(t *testing.T) {
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
if pvfPath == "" {
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE to verify the story digest table")
}
archive, err := platformpvf.LoadArchive(platformpvf.Options{Path: pvfPath})
if err != nil {
t.Fatalf("load real Script.pvf: %v", err)
}
digest, err := Load(archive, DefaultDigestPath)
if err != nil {
t.Fatalf("load story digest: %v", err)
}
segments := digest.Segments()
if len(segments) != 17 {
t.Fatalf("segments=%d, want 17", len(segments))
}
for i, segment := range segments {
if segment.Index != i+1 {
t.Errorf("segment[%d].Index=%d, want %d", i, segment.Index, i+1)
}
if segment.LevelMin <= 0 || segment.LevelMax < segment.LevelMin {
t.Errorf("segment %d level range=[%d,%d]", segment.Index, segment.LevelMin, segment.LevelMax)
}
if len(segment.Scenes) == 0 {
t.Errorf("segment %d has no [scene]", segment.Index)
}
if segment.Ending <= 0 {
t.Errorf("segment %d has no [ending]", segment.Index)
}
}
// 具体样例:段 5(40..45 级)以 905 结尾 → origin_cinematic_05,
// 正是「斯顿雪域」开区任务 3280 的过场。
segment, ok := digest.SegmentForEnding(905)
if !ok {
t.Fatal("no segment ends with 905")
}
if segment.Index != 5 || segment.LevelMin != 40 || segment.LevelMax != 45 {
t.Fatalf("ending 905 -> segment %d [%d,%d], want segment 5 [40,45]",
segment.Index, segment.LevelMin, segment.LevelMax)
}
// 段 6(46..49)以 906 结尾 → origin_cinematic_06(诺斯玛尔,任务 3332)。
segment, ok = digest.SegmentForEnding(906)
if !ok || segment.Index != 6 || segment.LevelMin != 46 {
t.Fatalf("ending 906 -> segment %+v ok=%t, want segment 6 starting at 46", segment, ok)
}
// 段 1(17..23)以 901 结尾 → origin_cinematic_01(开区任务 3156,need level 17)。
segment, ok = digest.SegmentForEnding(901)
if !ok || segment.Index != 1 || segment.LevelMin != 17 {
t.Fatalf("ending 901 -> segment %+v ok=%t, want segment 1 starting at 17", segment, ok)
}
// 等级反查:46 级落在段 6。
segment, ok = digest.SegmentForLevel(46)
if !ok || segment.Index != 6 {
t.Fatalf("level 46 -> segment %+v ok=%t, want segment 6", segment, ok)
}
}