Files
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

321 lines
11 KiB
Go

package services
import (
"io/fs"
"os"
"path/filepath"
"strings"
"testing"
"time"
modpkg "pvfine/internal/mod"
"pvfine/internal/pvf"
)
func importModFixture(t *testing.T) (*core, *ModImportService, string) {
t.Helper()
c, _ := exportFixture(t)
service := NewModImportService(c)
dir := t.TempDir()
pkg := modpkg.Package{Metadata: modpkg.Metadata{Name: "测试导入", Version: "2.0"}, Entries: []modpkg.Entry{
{Path: "stackable/demo.stk", Operation: modpkg.ReplaceFile, Encoding: modpkg.Text, DataType: pvf.TypeScript, Data: []byte("[name]\n`导入名称`\n[price]\n200")},
{Path: "stackable/new.stk", Operation: modpkg.ReplaceFile, Encoding: modpkg.Text, DataType: pvf.TypeScript, Data: []byte("[price]\n300")},
{Path: "list/stackable.lst", Operation: modpkg.MergeList, Pairs: []modpkg.Pair{{Key: "2", Value: "stackable/new.stk"}}},
{Path: "string/demo.str", Operation: modpkg.MergeStrings, Pairs: []modpkg.Pair{{Key: "name", Value: " 新值 > "}, {Key: "added", Value: ""}}},
}}
if err := (modpkg.Extend110Writer{}).Write(dir, pkg); err != nil {
t.Fatal(err)
}
return c, service, dir
}
func modText(t *testing.T, c *core, p string) string {
t.Helper()
i, ok := c.archive.Find(p)
if !ok {
t.Fatalf("missing %s", p)
}
text, err := c.archive.Text(i)
if err != nil {
t.Fatal(err)
}
return text
}
func TestModImportFrozenPreviewAndMerge(t *testing.T) {
c, s, dir := importModFixture(t)
before := modText(t, c, "stackable/demo.stk")
preview, err := s.Prepare(dir, "110USextend")
if err != nil {
t.Fatal(err)
}
defer s.Release(preview.ID)
if preview.ImportedCount != 1 || preview.OverwrittenCount != 3 || len(preview.Files) != 4 {
t.Fatalf("preview = %#v", preview)
}
if got := modText(t, c, "stackable/demo.stk"); got != before {
t.Fatal("preview changed archive")
}
for _, file := range preview.Files {
if file.Path == "string/demo.str" && (len(file.Pairs) != 2 || !file.Pairs[0].Overwrite || file.Pairs[1].Overwrite || file.Pairs[0].Previous != "测试物品") {
t.Fatalf("pairs = %#v", file.Pairs)
}
}
// Confirmation uses the frozen content even if the original package disappears.
if err := os.RemoveAll(dir); err != nil {
t.Fatal(err)
}
result, err := s.Execute(preview.ID)
if err != nil {
t.Fatal(err)
}
if result.ImportedCount != 1 || result.OverwrittenCount != 3 || len(result.ChangedPaths) != 3 {
t.Fatalf("result = %#v", result)
}
if got := modText(t, c, "stackable/demo.stk"); !strings.Contains(got, "导入名称") {
t.Fatalf("script = %s", got)
}
if got := modText(t, c, "string/demo.str"); !strings.Contains(got, "name> 新值 > ") || !strings.Contains(got, "keep>保留") || !strings.Contains(got, "empty>") || !strings.Contains(got, "added>") {
t.Fatalf("strings = %q", got)
}
list, err := pvf.ParseListText(modText(t, c, "list/stackable.lst"))
if err != nil || len(list) != 2 || list[0].ID != "1" || list[1].ID != "2" {
t.Fatalf("list = %#v, %v", list, err)
}
if _, err := s.Execute(preview.ID); err == nil {
t.Fatal("plan reused")
}
}
func TestModImportRejectsStalePreview(t *testing.T) {
for _, reason := range []string{"revision", "edit", "archive", "version", "expired", "release"} {
t.Run(reason, func(t *testing.T) {
c, s, dir := importModFixture(t)
preview, err := s.Prepare(dir, "110USextend")
if err != nil {
t.Fatal(err)
}
defer s.Release(preview.ID)
before := modText(t, c, "stackable/demo.stk")
switch reason {
case "revision":
c.batchRevision++
case "edit":
index, _ := c.archive.Find("stackable/demo.stk")
if err := NewEditorService(c).SetText(index, "[price]\n999"); err != nil {
t.Fatal(err)
}
before = modText(t, c, "stackable/demo.stk")
case "archive":
c.archiveGeneration++
case "version":
c.versionLoadID++
case "expired":
s.plan.created = time.Now().Add(-11 * time.Minute)
case "release":
s.Release(preview.ID)
}
if _, err := s.Execute(preview.ID); err == nil {
t.Fatal("stale preview accepted")
}
if modText(t, c, "stackable/demo.stk") != before {
t.Fatal("stale import changed archive")
}
})
}
}
func TestModImportInvalidPayloadLeavesArchiveUnchanged(t *testing.T) {
c, s, dir := importModFixture(t)
before := modText(t, c, "stackable/demo.stk")
if err := os.WriteFile(filepath.Join(dir, "merge/string/demo.str"), []byte("invalid row"), 0600); err != nil {
t.Fatal(err)
}
if _, err := s.Prepare(dir, "110USextend"); err == nil {
t.Fatal("invalid table accepted")
}
if modText(t, c, "stackable/demo.stk") != before {
t.Fatal("invalid import changed archive")
}
if _, ok := c.archive.Find("stackable/new.stk"); ok {
t.Fatal("partial import installed")
}
}
func TestModMergeTables(t *testing.T) {
text, rows, err := mergeModTable(" // 保留注释\r\na>原值\r\nb> 保留空格 ", modpkg.Entry{Operation: modpkg.MergeStrings, Pairs: []modpkg.Pair{{Key: "a", Value: ""}, {Key: "c", Value: "new>text"}}})
if err != nil || text != " // 保留注释\r\na>\r\nb> 保留空格 \nc>new>text\n" || len(rows) != 2 {
t.Fatalf("merge = %q, %#v, %v", text, rows, err)
}
text, rows, err = mergeModTable("1 `old`\n2 `keep`", modpkg.Entry{Operation: modpkg.MergeList, Pairs: []modpkg.Pair{{Key: "1", Value: "new"}, {Key: "3", Value: "added"}}})
if err != nil || text != "1\t`new`\n2\t`keep`\n3\t`added`\n" || len(rows) != 2 || !rows[0].Overwrite {
t.Fatalf("merge = %q, %#v, %v", text, rows, err)
}
}
func TestModImportVersionUndo(t *testing.T) {
c, s, dir := importModFixture(t)
versions := NewVersionService(c)
if _, err := versions.Initialize(); err != nil {
t.Fatal(err)
}
before := modText(t, c, "stackable/demo.stk")
preview, err := s.Prepare(dir, "110USextend")
if err != nil {
t.Fatal(err)
}
if _, err := s.Execute(preview.ID); err != nil {
t.Fatal(err)
}
status := versions.Status()
if status.PendingChangeSets != 1 || status.ChangedFiles != 4 {
t.Fatalf("status = %#v", status)
}
if _, err := versions.Undo(); err != nil {
t.Fatal(err)
}
if modText(t, c, "stackable/demo.stk") != before {
t.Fatal("undo did not restore replaced file")
}
if _, ok := c.archive.Find("stackable/new.stk"); ok {
t.Fatal("undo did not remove new file")
}
if got := modText(t, c, "string/demo.str"); strings.Contains(got, "added>") {
t.Fatal("undo did not restore merged table")
}
}
func TestModImportStageFailureRollsBack(t *testing.T) {
c, s, dir := importModFixture(t)
preview, err := s.Prepare(dir, "110USextend")
if err != nil {
t.Fatal(err)
}
before := modText(t, c, "stackable/demo.stk")
// Inject a failure after at least one valid staged write. The live archive
// must remain intact even if staging fails after preview has succeeded.
s.plan.files = append(s.plan.files, importFile{targetPath: "fail/invalid.stk", dataType: 999, text: "invalid"})
if _, err := s.Execute(preview.ID); err == nil {
t.Fatal("invalid staged type accepted")
}
if modText(t, c, "stackable/demo.stk") != before {
t.Fatal("failed transaction changed archive")
}
if _, ok := c.archive.Find("stackable/new.stk"); ok {
t.Fatal("failed transaction installed partial new file")
}
}
func TestModImportCreatesMissingTables(t *testing.T) {
c, s, dir := importModFixture(t)
if err := os.MkdirAll(filepath.Join(dir, "merge/new"), 0700); err != nil {
t.Fatal(err)
}
for p, text := range map[string]string{"list.lst": "1 `stackable/new.stk`\n", "strings.str": "name>\n"} {
if err := os.WriteFile(filepath.Join(dir, "merge/new", p), []byte(text), 0600); err != nil {
t.Fatal(err)
}
}
preview, err := s.Prepare(dir, "110USextend")
if err != nil {
t.Fatal(err)
}
if preview.ImportedCount != 3 {
t.Fatalf("preview = %#v", preview)
}
if _, err := s.Execute(preview.ID); err != nil {
t.Fatal(err)
}
if got := modText(t, c, "new/strings.str"); got != "name>\n" {
t.Fatalf("new strings = %q", got)
}
if pairs, err := pvf.ParseListText(modText(t, c, "new/list.lst")); err != nil || len(pairs) != 1 {
t.Fatalf("new list = %#v, %v", pairs, err)
}
}
func TestModMergeExistingBOM(t *testing.T) {
for _, base := range []string{"\ufeffname>old\nkeep>value\n", "\ufeff"} {
text, rows, err := mergeModTable(base, modpkg.Entry{Operation: modpkg.MergeStrings, Pairs: []modpkg.Pair{{Key: "name", Value: "new"}}})
if err != nil || len(rows) != 1 || !strings.HasPrefix(text, "\ufeffname>new\n") || strings.Count(text, "name>") != 1 {
t.Fatalf("BOM merge = %q, %#v, %v", text, rows, err)
}
}
}
// Both test readers can recognize the same directory; only the selected reader
// is allowed to run, so adding formats cannot introduce ambiguous detection.
type selectedModReader struct {
detected bool
pkg modpkg.Package
detects, reads int
}
func (r *selectedModReader) Detect(fs.FS) (bool, error) { r.detects++; return r.detected, nil }
func (r *selectedModReader) Read(fs.FS) (modpkg.Package, error) { r.reads++; return r.pkg, nil }
func TestModImportSelectedFormat(t *testing.T) {
c, s, dir := importModFixture(t)
selected := &selectedModReader{detected: true, pkg: modpkg.Package{
Metadata: modpkg.Metadata{Name: "另一种格式"},
Entries: []modpkg.Entry{{Path: "string/alternate.str", Operation: modpkg.ReplaceFile, Encoding: modpkg.Text, DataType: pvf.TypeUnicode, Data: []byte("name>alternate")}},
}}
other := &selectedModReader{detected: true}
for _, format := range []modpkg.Format{
{Descriptor: modpkg.Descriptor{ID: "alternate", Name: "另一种格式"}, Reader: selected},
{Descriptor: modpkg.Descriptor{ID: "other", Name: "其他格式"}, Reader: other},
{Descriptor: modpkg.Descriptor{ID: "write-only", Name: "仅导出格式"}, Writer: modpkg.Extend110Writer{}},
} {
if err := s.registry.Register(format); err != nil {
t.Fatal(err)
}
}
formats := s.Formats()
if len(formats) != 4 {
t.Fatalf("readable formats = %#v", formats)
}
for _, format := range formats {
if !format.CanRead || format.ID == "write-only" {
t.Fatalf("unreadable option: %#v", format)
}
}
preview, err := s.Prepare(dir, "alternate")
if err != nil {
t.Fatal(err)
}
if preview.Format != "alternate" || preview.Name != "另一种格式" || selected.detects != 1 || selected.reads != 1 || other.detects != 0 || other.reads != 0 {
t.Fatalf("wrong format dispatch: preview=%#v selected=%#v other=%#v", preview, selected, other)
}
if _, err := s.Execute(preview.ID); err != nil {
t.Fatal(err)
}
if text := modText(t, c, "string/alternate.str"); text != "name>alternate" {
t.Fatalf("alternate text = %q", text)
}
}
func TestModImportRejectsInvalidFormatSelection(t *testing.T) {
c, s, dir := importModFixture(t)
mismatch := &selectedModReader{}
for _, format := range []modpkg.Format{
{Descriptor: modpkg.Descriptor{ID: "mismatch", Name: "不匹配格式"}, Reader: mismatch},
{Descriptor: modpkg.Descriptor{ID: "write-only", Name: "仅导出格式"}, Writer: modpkg.Extend110Writer{}},
} {
if err := s.registry.Register(format); err != nil {
t.Fatal(err)
}
}
before := modText(t, c, "stackable/demo.stk")
for _, id := range []string{"", "unknown", "write-only", "mismatch"} {
if _, err := s.Prepare(dir, id); err == nil {
t.Fatalf("invalid format %q accepted", id)
}
}
if mismatch.detects != 1 || mismatch.reads != 0 || s.plan != nil {
t.Fatalf("mismatch was read or prepared: %#v", mismatch)
}
if modText(t, c, "stackable/demo.stk") != before {
t.Fatal("invalid format selection changed archive")
}
}