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

+479
View File
@@ -0,0 +1,479 @@
package preview
import (
"fmt"
"sort"
"strconv"
"strings"
)
// IssueSeverity describes whether a preview issue prevents the document from
// being used as a new playback document.
type IssueSeverity string
const (
IssueWarning IssueSeverity = "warning"
IssueError IssueSeverity = "error"
)
// Issue is a recoverable ANI parsing problem. Line is one-based and points to
// the source line that introduced the problem when possible.
type Issue struct {
Severity IssueSeverity
Line int
Section string
Message string
}
// Layer is one image layer in an ANI frame.
type Layer struct {
Path string
Index int32
X int32
Y int32
}
// Frame is one decoded ANI frame.
type Frame struct {
Index int32
Delay int32
Layers []Layer
}
// Document is the structured preview model produced from ANI text.
type Document struct {
Valid bool
Loop bool
Shadow bool
FrameMax int32
Frames []Frame
Issues []Issue
}
type token struct {
value string
line int
}
type section struct {
indented bool
name string
line int
values []token
}
type frameBlock struct {
section section
parts []section
}
// ParseANI parses the decompiled TypeScript text used by an .ani file. The
// parser intentionally works on text rather than archive tokens so preview
// results can follow unsaved editor content.
func ParseANI(text string) Document {
sections := parseSections(text)
document := Document{FrameMax: -1, Frames: make([]Frame, 0)}
issues := make([]Issue, 0)
global := make(map[string][]section)
blocks := make([]frameBlock, 0)
var current *frameBlock
for _, item := range sections {
if _, ok := parseFrameName(item.name); ok {
block := frameBlock{section: item, parts: make([]section, 0)}
block.section.name = item.name
blocks = append(blocks, block)
current = &blocks[len(blocks)-1]
continue
}
if strings.HasPrefix(item.name, "frame") && item.name != "frame max" {
issues = append(issues, Issue{Severity: IssueError, Line: item.line, Section: item.name, Message: "帧 section 名称必须是 FRAME 加数字"})
current = nil
continue
}
if current != nil {
if isFramePart(item.name) || (item.indented && (item.name == "loop" || item.name == "shadow")) {
current.parts = append(current.parts, item)
continue
}
// A new top-level section ends the current frame. Keep known
// global sections in the global map for files that place them
// after the frame list.
if isGlobalSection(item.name) {
global[item.name] = append(global[item.name], item)
current = nil
continue
}
current.parts = append(current.parts, item)
continue
}
if isGlobalSection(item.name) {
global[item.name] = append(global[item.name], item)
}
}
parseBooleanField(&document.Loop, global["loop"], "LOOP", &issues)
parseBooleanField(&document.Shadow, global["shadow"], "SHADOW", &issues)
if values := global["frame max"]; len(values) == 0 {
issues = append(issues, Issue{Severity: IssueError, Line: 1, Section: "FRAME MAX", Message: "缺少 [FRAME MAX]"})
} else {
document.FrameMax = parseNonNegativeInt(values[len(values)-1], "FRAME MAX", &issues, -1)
if len(values) > 1 {
issues = append(issues, Issue{Severity: IssueWarning, Line: values[len(values)-1].line, Section: "FRAME MAX", Message: "重复的 [FRAME MAX],使用最后一个值"})
}
}
var imagePaths []token
for _, paths := range global["image path"] {
imagePaths = append(imagePaths, paths.values...)
}
frames := make([]Frame, 0, len(blocks))
seenFrames := make(map[int32]struct{}, len(blocks))
for _, block := range blocks {
frameIndex, _ := parseFrameName(block.section.name)
if _, exists := seenFrames[frameIndex]; exists {
issues = append(issues, Issue{Severity: IssueError, Line: block.section.line, Section: block.section.name, Message: fmt.Sprintf("重复的帧编号 %d", frameIndex)})
continue
}
seenFrames[frameIndex] = struct{}{}
frame := Frame{Index: frameIndex, Delay: 100, Layers: make([]Layer, 0)}
frameLine := block.section.line
var pendingLayer *Layer
posSet := false
delaySeen := false
flushLayerPosition := func(line int) {
if pendingLayer != nil && !posSet {
issues = append(issues, Issue{Severity: IssueWarning, Line: line, Section: block.section.name, Message: "图片缺少 [IMAGE POS],使用坐标 0,0"})
}
}
for _, part := range block.parts {
switch part.name {
case "image", "image ex":
flushLayerPosition(part.line)
var layer Layer
var ok bool
if part.name == "image ex" {
layer, ok = parseImageEx(part, imagePaths, &issues)
} else {
layer, ok = parseImage(part, &issues, block.section.name)
}
if !ok {
pendingLayer = nil
posSet = false
continue
}
frame.Layers = append(frame.Layers, layer)
pendingLayer = &frame.Layers[len(frame.Layers)-1]
posSet = false
case "image pos":
if pendingLayer == nil {
issues = append(issues, Issue{Severity: IssueWarning, Line: part.line, Section: block.section.name, Message: "[IMAGE POS] 没有对应的 [IMAGE] 或 [IMAGE EX],已忽略"})
continue
}
if len(part.values) < 2 {
issues = append(issues, Issue{Severity: IssueError, Line: part.line, Section: "IMAGE POS", Message: "[IMAGE POS] 需要两个整数"})
continue
}
x, xOK := parseInt(part.values[0])
y, yOK := parseInt(part.values[1])
if !xOK || !yOK {
issues = append(issues, Issue{Severity: IssueError, Line: part.line, Section: "IMAGE POS", Message: "[IMAGE POS] 坐标必须是整数"})
continue
}
pendingLayer.X, pendingLayer.Y = x, y
posSet = true
case "delay":
if delaySeen {
issues = append(issues, Issue{Severity: IssueWarning, Line: part.line, Section: block.section.name, Message: "重复的 [DELAY],使用最后一个值"})
}
delaySeen = true
if len(part.values) == 0 {
issues = append(issues, Issue{Severity: IssueWarning, Line: part.line, Section: "DELAY", Message: "[DELAY] 缺少数值,使用 100ms"})
frame.Delay = 100
continue
}
delay, ok := parseInt(part.values[0])
if !ok || delay <= 0 {
issues = append(issues, Issue{Severity: IssueWarning, Line: part.line, Section: "DELAY", Message: "[DELAY] 必须是正整数,使用 100ms"})
frame.Delay = 100
continue
}
frame.Delay = delay
}
}
flushLayerPosition(frameLine)
if !delaySeen {
issues = append(issues, Issue{Severity: IssueWarning, Line: frameLine, Section: block.section.name, Message: "帧缺少 [DELAY],使用 100ms"})
}
if len(frame.Layers) == 0 {
issues = append(issues, Issue{Severity: IssueError, Line: frameLine, Section: block.section.name, Message: "帧没有有效的 [IMAGE] 或 [IMAGE EX]"})
}
frames = append(frames, frame)
}
sort.SliceStable(frames, func(i, j int) bool { return frames[i].Index < frames[j].Index })
document.Frames = frames
if document.FrameMax < 0 {
// Keep the value useful for an invalid document without allowing a
// consumer to mistake it for a valid declared count.
document.FrameMax = int32(len(frames))
}
if document.FrameMax == 0 && len(frames) == 0 {
issues = append(issues, Issue{Severity: IssueError, Line: 1, Section: "FRAME MAX", Message: "动画没有帧"})
}
if document.FrameMax != int32(len(frames)) {
issues = append(issues, Issue{Severity: IssueError, Line: frameMaxLine(global["frame max"]), Section: "FRAME MAX", Message: fmt.Sprintf("声明帧数为 %d,实际解析到 %d 帧", document.FrameMax, len(frames))})
}
for index, frame := range frames {
if frame.Index != int32(index) {
issues = append(issues, Issue{Severity: IssueError, Line: frameLine(blocks, frame.Index), Section: fmt.Sprintf("FRAME%03d", frame.Index), Message: fmt.Sprintf("帧编号不连续,期望 %d,实际 %d", index, frame.Index)})
}
}
document.Issues = issues
document.Valid = !hasErrors(issues) && len(frames) > 0
return document
}
func parseSections(text string) []section {
lines := strings.Split(text, "\n")
sections := make([]section, 0)
var current *section
for lineNumber, raw := range lines {
line := stripComment(strings.TrimSuffix(raw, "\r"))
trimmed := strings.TrimSpace(line)
if trimmed == "" {
continue
}
if strings.HasPrefix(trimmed, "[") {
end := strings.IndexByte(trimmed, ']')
if end >= 0 {
name := normalizeSection(trimmed[1:end])
if strings.HasPrefix(name, "/") {
current = nil
continue
}
sections = append(sections, section{indented: len(line) > 0 && (line[0] == ' ' || line[0] == '\t'), name: name, line: lineNumber + 1, values: make([]token, 0)})
current = &sections[len(sections)-1]
rest := strings.TrimSpace(trimmed[end+1:])
if rest != "" {
current.values = append(current.values, tokenize(rest, lineNumber+1)...)
}
continue
}
}
if current != nil {
current.values = append(current.values, tokenize(trimmed, lineNumber+1)...)
}
}
return sections
}
func tokenize(value string, line int) []token {
result := make([]token, 0, 2)
for index := 0; index < len(value); {
for index < len(value) && isSpace(value[index]) {
index++
}
if index >= len(value) {
break
}
if value[index] == '`' {
index++
var builder strings.Builder
for index < len(value) {
if value[index] != '`' {
builder.WriteByte(value[index])
index++
continue
}
if index+1 < len(value) && value[index+1] == '`' {
builder.WriteByte('`')
index += 2
continue
}
index++
break
}
result = append(result, token{value: builder.String(), line: line})
continue
}
start := index
for index < len(value) && !isSpace(value[index]) {
index++
}
result = append(result, token{value: value[start:index], line: line})
}
return result
}
func stripComment(value string) string {
inBacktick := false
for index := 0; index < len(value); index++ {
switch value[index] {
case '`':
if inBacktick && index+1 < len(value) && value[index+1] == '`' {
index++
continue
}
inBacktick = !inBacktick
case '#':
if !inBacktick {
return value[:index]
}
}
}
return value
}
func normalizeSection(value string) string {
return strings.ToLower(strings.Join(strings.Fields(strings.TrimSpace(value)), " "))
}
func isGlobalSection(name string) bool {
switch name {
case "loop", "shadow", "frame max", "image path":
return true
default:
return false
}
}
func isFramePart(name string) bool {
switch name {
case "image", "image pos", "delay", "image ex":
return true
default:
return false
}
}
func parseFrameName(name string) (int32, bool) {
if !strings.HasPrefix(name, "frame") || name == "frame max" {
return 0, false
}
number := strings.TrimPrefix(name, "frame")
if number == "" {
return 0, false
}
value, err := strconv.ParseInt(number, 10, 32)
return int32(value), err == nil && value >= 0
}
func parseBooleanField(target *bool, values []section, name string, issues *[]Issue) {
if len(values) == 0 {
return
}
field := values[len(values)-1]
if len(field.values) == 0 {
*issues = append(*issues, Issue{Severity: IssueWarning, Line: field.line, Section: name, Message: fmt.Sprintf("[%s] 缺少数值,使用 0", name)})
return
}
value, ok := parseInt(field.values[0])
if !ok {
*issues = append(*issues, Issue{Severity: IssueWarning, Line: field.values[0].line, Section: name, Message: fmt.Sprintf("[%s] 必须是整数,使用 0", name)})
return
}
*target = value != 0
if value != 0 && value != 1 {
*issues = append(*issues, Issue{Severity: IssueWarning, Line: field.values[0].line, Section: name, Message: fmt.Sprintf("[%s] 只建议使用 0 或 1", name)})
}
if len(values) > 1 {
*issues = append(*issues, Issue{Severity: IssueWarning, Line: field.line, Section: name, Message: fmt.Sprintf("重复的 [%s],使用最后一个值", name)})
}
}
func parseNonNegativeInt(value section, name string, issues *[]Issue, fallback int32) int32 {
if len(value.values) == 0 {
*issues = append(*issues, Issue{Severity: IssueError, Line: value.line, Section: name, Message: fmt.Sprintf("[%s] 缺少数值", name)})
return fallback
}
parsed, ok := parseInt(value.values[0])
if !ok || parsed < 0 {
*issues = append(*issues, Issue{Severity: IssueError, Line: value.values[0].line, Section: name, Message: fmt.Sprintf("[%s] 必须是非负整数", name)})
return fallback
}
return parsed
}
func parseImage(value section, issues *[]Issue, frameName string) (Layer, bool) {
if len(value.values) == 0 || strings.TrimSpace(value.values[0].value) == "" {
*issues = append(*issues, Issue{Severity: IssueWarning, Line: value.line, Section: frameName, Message: "[IMAGE] 图片引用为空,此帧按空图片处理"})
return Layer{Index: -1}, true
}
if len(value.values) < 2 {
*issues = append(*issues, Issue{Severity: IssueError, Line: value.line, Section: frameName, Message: "[IMAGE] 需要图片路径和索引"})
return Layer{}, false
}
index, ok := parseInt(value.values[1])
if !ok || index < 0 {
*issues = append(*issues, Issue{Severity: IssueError, Line: value.values[1].line, Section: "IMAGE", Message: "图片索引必须是非负整数"})
return Layer{}, false
}
return Layer{Path: value.values[0].value, Index: index}, true
}
func parseImageEx(value section, paths []token, issues *[]Issue) (Layer, bool) {
if len(value.values) < 2 {
*issues = append(*issues, Issue{Severity: IssueError, Line: value.line, Section: "IMAGE EX", Message: "[IMAGE EX] 需要路径索引和图片索引"})
return Layer{}, false
}
pathIndex, ok := parseInt(value.values[0])
// -1 marks a frame with no image, matching the binary ANI decoder.
// Keep a layer so IMAGE POS and DELAY still apply to this frame.
if ok && pathIndex == -1 {
return Layer{Index: -1}, true
}
if !ok || pathIndex < 0 || int64(pathIndex) >= int64(len(paths)) {
*issues = append(*issues, Issue{Severity: IssueError, Line: value.values[0].line, Section: "IMAGE EX", Message: "[IMAGE EX] 路径索引必须为 -1(空图片)或 [IMAGE PATH] 范围内的非负整数"})
return Layer{}, false
}
path := paths[pathIndex].value
if strings.TrimSpace(path) == "" {
*issues = append(*issues, Issue{Severity: IssueError, Line: paths[pathIndex].line, Section: "IMAGE PATH", Message: "[IMAGE PATH] 图片路径不能为空"})
return Layer{}, false
}
index, ok := parseInt(value.values[1])
if !ok || index < 0 {
*issues = append(*issues, Issue{Severity: IssueError, Line: value.values[1].line, Section: "IMAGE EX", Message: "图片索引必须是非负整数"})
return Layer{}, false
}
return Layer{Path: path, Index: index}, true
}
func parseInt(value token) (int32, bool) {
parsed, err := strconv.ParseInt(strings.TrimSpace(value.value), 10, 32)
return int32(parsed), err == nil
}
func frameMaxLine(values []section) int {
if len(values) == 0 {
return 1
}
return values[len(values)-1].line
}
func frameLine(blocks []frameBlock, index int32) int {
for _, block := range blocks {
frameIndex, ok := parseFrameName(block.section.name)
if ok && frameIndex == index {
return block.section.line
}
}
return 1
}
func hasErrors(issues []Issue) bool {
for _, issue := range issues {
if issue.Severity == IssueError {
return true
}
}
return false
}
func isSpace(value byte) bool {
return value == ' ' || value == '\t' || value == '\r' || value == '\n' || value == '\v' || value == '\f'
}
+229
View File
@@ -0,0 +1,229 @@
package preview
import (
"fmt"
"strings"
"testing"
)
func TestParseANIStandardAnimation(t *testing.T) {
var builder strings.Builder
builder.WriteString("[LOOP]\n\t1\n\n[SHADOW]\n\t0\n\n[FRAME MAX]\n\t105\n")
for index := 0; index < 105; index++ {
fmt.Fprintf(&builder, "\n[FRAME%03d]\n\n[IMAGE]\n\t`Item/Title/15_summer_2.img`\t%d\n\n[IMAGE POS]\n\t0\t0\n\n[DELAY]\n\t100\n", index, index)
}
document := ParseANI(builder.String())
if !document.Valid {
t.Fatalf("document invalid: %#v", document.Issues)
}
if !document.Loop || document.Shadow || document.FrameMax != 105 || len(document.Frames) != 105 {
t.Fatalf("document metadata = %#v", document)
}
for index, frame := range document.Frames {
if frame.Index != int32(index) || frame.Delay != 100 || len(frame.Layers) != 1 {
t.Fatalf("frame %d = %#v", index, frame)
}
layer := frame.Layers[0]
if layer.Path != "Item/Title/15_summer_2.img" || layer.Index != int32(index) || layer.X != 0 || layer.Y != 0 {
t.Fatalf("frame %d layer = %#v", index, layer)
}
}
}
func TestParseANIMultipleLayersAndQuotedComments(t *testing.T) {
text := "[LOOP] # play once\n" +
" 0\n[SHADOW]\n 1\n[FRAME MAX]\n 1\n[FRAME000]\n" +
"[IMAGE]\n `Item/a``b.img` 2 # first layer\n[IMAGE POS]\n" +
" -3 4\n[IMAGE]\n `Item/second.img` 5\n[IMAGE POS]\n" +
" 6 -7\n[DELAY]\n 80\n"
document := ParseANI(text)
if !document.Valid || document.Loop || !document.Shadow {
t.Fatalf("document = %#v", document)
}
if len(document.Frames) != 1 || len(document.Frames[0].Layers) != 2 {
t.Fatalf("layers = %#v", document.Frames)
}
first, second := document.Frames[0].Layers[0], document.Frames[0].Layers[1]
if first.Path != "Item/a`b.img" || first.Index != 2 || first.X != -3 || first.Y != 4 {
t.Fatalf("first layer = %#v", first)
}
if second.Path != "Item/second.img" || second.Index != 5 || second.X != 6 || second.Y != -7 {
t.Fatalf("second layer = %#v", second)
}
}
func TestParseANIRecoverableDefaultsAndStructuralErrors(t *testing.T) {
text := "[FRAME MAX]\n 2\n[FRAME000]\n[IMAGE]\n `Item/a.img` 0\n" +
"[FRAME002]\n[DELAY]\n 0\n"
document := ParseANI(text)
if document.Valid {
t.Fatal("malformed document should not be valid")
}
if len(document.Frames) != 2 || document.Frames[0].Layers[0].X != 0 || document.Frames[0].Layers[0].Y != 0 {
t.Fatalf("best effort frames = %#v", document.Frames)
}
if document.Frames[0].Delay != 100 || document.Frames[1].Delay != 100 {
t.Fatalf("default delays = %#v", document.Frames)
}
assertIssue(t, document.Issues, IssueWarning, "图片缺少 [IMAGE POS]")
assertIssue(t, document.Issues, IssueWarning, "帧缺少 [DELAY]")
assertIssue(t, document.Issues, IssueWarning, "必须是正整数")
assertIssue(t, document.Issues, IssueError, "帧编号不连续")
}
func TestParseANIDuplicateFrameAndMissingImage(t *testing.T) {
text := "[FRAME MAX]\n 2\n[FRAME000]\n[FRAME000]\n[FRAME001]\n" +
"[DELAY]\n 100\n"
document := ParseANI(text)
if document.Valid {
t.Fatal("document with duplicate/missing frames should not be valid")
}
assertIssue(t, document.Issues, IssueError, "重复的帧编号")
assertIssue(t, document.Issues, IssueError, "帧没有有效的 [IMAGE]")
}
func TestParseANIRejectsInvalidFrameNameAndImageIndex(t *testing.T) {
text := "[FRAME MAX]\n 1\n[FRAMEoops]\n[IMAGE]\n `Item/test.img` -1\n"
document := ParseANI(text)
if document.Valid {
t.Fatal("document with invalid frame syntax should not be valid")
}
assertIssue(t, document.Issues, IssueError, "帧 section 名称必须是 FRAME 加数字")
assertIssue(t, document.Issues, IssueError, "声明帧数为 1,实际解析到 0 帧")
invalidIndex := ParseANI("[FRAME MAX]\n 1\n[FRAME000]\n[IMAGE]\n `Item/test.img` -1\n")
if invalidIndex.Valid {
t.Fatal("document with invalid image index should not be valid")
}
assertIssue(t, invalidIndex.Issues, IssueError, "图片索引必须是非负整数")
}
func TestParseANIAllowsEmptyImageFrame(t *testing.T) {
document := ParseANI("[FRAME MAX]\n 1\n[FRAME000]\n[IMAGE]\n[IMAGE POS]\n 3 4\n[DELAY]\n 50\n")
if !document.Valid || len(document.Frames) != 1 || len(document.Frames[0].Layers) != 1 {
t.Fatalf("empty image document = %#v", document)
}
layer := document.Frames[0].Layers[0]
if layer.Path != "" || layer.Index != -1 || layer.X != 3 || layer.Y != 4 {
t.Fatalf("empty image layer = %#v", layer)
}
assertIssue(t, document.Issues, IssueWarning, "图片引用为空")
}
func assertIssue(t *testing.T, issues []Issue, severity IssueSeverity, message string) {
t.Helper()
for _, issue := range issues {
if issue.Severity == severity && strings.Contains(issue.Message, message) {
return
}
}
t.Fatalf("missing %s issue containing %q: %#v", severity, message, issues)
}
func TestParseANIFrameFlagsStayInFrame(t *testing.T) {
text := "[LOOP]\n1\n[SHADOW]\n1\n[FRAME MAX]\n1\n[FRAME000]\n\t[IMAGE]\n\t\t`test.img` 0\n\t[LOOP]\n\t\t0\n\t[SHADOW]\n\t\t0\n\t[DELAY]\n\t\t75\n"
doc := ParseANI(text)
if !doc.Valid || !doc.Loop || !doc.Shadow || len(doc.Frames) != 1 || doc.Frames[0].Delay != 75 {
t.Fatalf("frame flags escaped into global scope: %+v", doc)
}
// Unindented global sections after frames retain the existing behavior.
after := ParseANI("[FRAME MAX]\n1\n[FRAME000]\n[IMAGE]\n`test.img` 0\n[DELAY]\n75\n[LOOP]\n1\n")
if !after.Valid || !after.Loop {
t.Fatal("global section after frame stopped working")
}
}
func TestParseANISharedImagePath(t *testing.T) {
var builder strings.Builder
builder.WriteString("[LOOP]\n1\n[FRAME MAX]\n50\n[IMAGE PATH]\n`Item/Title/2012dnf_festival_yellow.img`\n[/IMAGE PATH]\n")
for index := 0; index < 50; index++ {
fmt.Fprintf(&builder, "[FRAME%03d]\n[IMAGE EX]\n0 %d\n[DELAY]\n80\n", index, index)
}
document := ParseANI(builder.String())
if !document.Valid || !document.Loop || len(document.Frames) != 50 {
t.Fatalf("shared image path document = %#v", document)
}
for index, frame := range document.Frames {
if frame.Delay != 80 || len(frame.Layers) != 1 {
t.Fatalf("frame %d = %#v", index, frame)
}
want := Layer{Path: "Item/Title/2012dnf_festival_yellow.img", Index: int32(index)}
if frame.Layers[0] != want {
t.Fatalf("frame %d layer = %#v, want %#v", index, frame.Layers[0], want)
}
}
}
func TestParseANISharedPathsAndMixedLayers(t *testing.T) {
document := ParseANI("[FRAME MAX]\n1\n[FRAME000]\n" +
"[IMAGE EX]\n1 7\n[IMAGE POS]\n-3 4\n" +
"[IMAGE]\n`Item/direct.img` 2\n[IMAGE POS]\n5 -6\n" +
"[IMAGE EX]\n0 9\n[IMAGE POS]\n1 2\n[DELAY]\n80\n" +
"[IMAGE PATH]\n`Item/first.img`\n`Item/second.img`\n[/IMAGE PATH]\n")
if !document.Valid || len(document.Issues) != 0 || len(document.Frames) != 1 {
t.Fatalf("mixed document = %#v", document)
}
want := []Layer{
{Path: "Item/second.img", Index: 7, X: -3, Y: 4},
{Path: "Item/direct.img", Index: 2, X: 5, Y: -6},
{Path: "Item/first.img", Index: 9, X: 1, Y: 2},
}
got := document.Frames[0].Layers
if len(got) != len(want) {
t.Fatalf("layers = %#v", got)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("layer %d = %#v, want %#v", i, got[i], want[i])
}
}
}
func TestParseANIRejectsInvalidImageEx(t *testing.T) {
for _, test := range []struct {
name, paths, reference, message string
}{
{"missing table", "", "0 1", "路径索引必须"},
{"missing index", "`a.img`", "0", "需要路径索引和图片索引"},
{"negative path", "`a.img`", "-2 0", "路径索引必须"},
{"out of range", "`a.img`", "1 0", "路径索引必须"},
{"invalid path", "`a.img`", "x 0", "路径索引必须"},
{"overflow path", "`a.img`", "2147483648 0", "路径索引必须"},
{"empty path", "``", "0 0", "图片路径不能为空"},
{"negative image", "`a.img`", "0 -1", "图片索引必须"},
{"invalid image", "`a.img`", "0 x", "图片索引必须"},
} {
t.Run(test.name, func(t *testing.T) {
document := ParseANI("[FRAME MAX]\n1\n[IMAGE PATH]\n" + test.paths +
"\n[/IMAGE PATH]\n[FRAME000]\n[IMAGE EX]\n" + test.reference + "\n[DELAY]\n80\n")
if document.Valid || len(document.Frames) != 1 || len(document.Frames[0].Layers) != 0 {
t.Fatalf("invalid reference document = %#v", document)
}
assertIssue(t, document.Issues, IssueError, test.message)
})
}
}
func TestParseANIImageExEmptyFrame(t *testing.T) {
for _, paths := range []string{"", "[IMAGE PATH]\n`Effect/WaveAuraEff.img`\n[/IMAGE PATH]\n"} {
document := ParseANI("[FRAME MAX]\n2\n" + paths +
"[FRAME000]\n[IMAGE EX]\n-1 0\n[IMAGE POS]\n12 0\n[IMAGE RATE]\n0.4124 0.4124\n[DELAY]\n260\n" +
"[FRAME001]\n[IMAGE]\n`Effect/WaveAuraEff.img` 16\n[IMAGE POS]\n-507 -631\n[DELAY]\n72\n")
if !document.Valid || len(document.Issues) != 0 || len(document.Frames) != 2 {
t.Fatalf("empty frame document = %#v", document)
}
first := document.Frames[0]
if first.Delay != 260 || len(first.Layers) != 1 || first.Layers[0] != (Layer{Index: -1, X: 12}) {
t.Fatalf("empty frame = %#v", first)
}
second := document.Frames[1]
if second.Delay != 72 || len(second.Layers) != 1 || second.Layers[0] != (Layer{Path: "Effect/WaveAuraEff.img", Index: 16, X: -507, Y: -631}) {
t.Fatalf("following frame = %#v", second)
}
}
}