// 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 }