服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
358 lines
11 KiB
Go
358 lines
11 KiB
Go
package mod
|
|
|
|
import (
|
|
"archive/zip"
|
|
"bytes"
|
|
"encoding/json"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"testing/fstest"
|
|
|
|
"golang.org/x/text/encoding/simplifiedchinese"
|
|
)
|
|
|
|
func cn115Fixture(t *testing.T) (cn115Manifest, fstest.MapFS) {
|
|
t.Helper()
|
|
m := cn115Manifest{
|
|
Schema: 1, Client: "115CN", Kind: "resource", ID: "unique-test-mod", Name: "测试 MOD", Version: "1.0.0",
|
|
Components: []Component{
|
|
{ID: "base", Title: "基础", DefaultSelected: true},
|
|
{ID: "optional", Title: "可选", Requires: []string{"base"}},
|
|
},
|
|
PVF: []cn115Operation{
|
|
{Component: "base", Source: "payload/item.stk", Path: "stackable/demo.stk", Mode: "replace", Type: 1},
|
|
{Component: "base", Source: "payload/list.lst", Path: "list/stackable.lst", Mode: "merge", Type: 1},
|
|
{Component: "optional", Source: "payload/text.str", Path: "string/demo.str", Mode: "merge", Type: 3},
|
|
{Component: "optional", Source: "payload/new.stk", Path: "stackable/new.stk", Mode: "add"},
|
|
},
|
|
}
|
|
files := fstest.MapFS{
|
|
"payload/item.stk": {Data: []byte("[price]\n200")},
|
|
"payload/new.stk": {Data: []byte("[price]\n300")},
|
|
"payload/list.lst": {Data: []byte("2\t`stackable/new.stk`\r\n")},
|
|
"payload/text.str": {Data: []byte("name> 新名称 > \r\nempty>\r\n")},
|
|
}
|
|
setCN115Manifest(t, files, m)
|
|
return m, files
|
|
}
|
|
|
|
func setCN115Manifest(t *testing.T, files fstest.MapFS, m cn115Manifest) {
|
|
t.Helper()
|
|
data, err := json.Marshal(m)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
files["mod.json"] = &fstest.MapFile{Data: data}
|
|
}
|
|
|
|
func TestCN115ReadAndSelection(t *testing.T) {
|
|
_, files := cn115Fixture(t)
|
|
for _, prefix := range []string{"", "中文包/"} {
|
|
t.Run(prefix, func(t *testing.T) {
|
|
root := fstest.MapFS{}
|
|
for p, f := range files {
|
|
root[prefix+p] = f
|
|
}
|
|
pkg, err := (CN115Reader{}).Read(root)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defaults, ids, err := SelectComponents(pkg, nil)
|
|
if err != nil || len(ids) != 1 || ids[0] != "base" || len(defaults.Entries) != 2 {
|
|
t.Fatalf("defaults: %+v %v %v", defaults, ids, err)
|
|
}
|
|
all, _, err := SelectComponents(pkg, []string{"base", "optional"})
|
|
if err != nil || len(all.Entries) != 4 || all.Entries[3].Operation != AddFile || all.Entries[2].Pairs[0].Value != " 新名称 > " {
|
|
t.Fatalf("all: %+v %v", all, err)
|
|
}
|
|
empty, _, err := SelectComponents(pkg, []string{})
|
|
if err != nil || len(empty.Entries) != 0 {
|
|
t.Fatal("empty selection became defaults", err)
|
|
}
|
|
for _, ids := range [][]string{{"optional"}, {"unknown"}, {"base", "base"}} {
|
|
if _, _, err := SelectComponents(pkg, ids); err == nil {
|
|
t.Fatalf("accepted invalid selection %v", ids)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCN115RejectMalformedPackages(t *testing.T) {
|
|
for _, kind := range []string{"schema", "client", "kind", "id", "component", "cycle", "missing-dependency", "target", "uppercase", "source", "sensitive", "missing-source", "patch", "type", "merge-type", "merge-extension", "duplicate-key", "encoding", "parent-symlink", "file-symlink", "nested-root"} {
|
|
t.Run(kind, func(t *testing.T) {
|
|
m, files := cn115Fixture(t)
|
|
switch kind {
|
|
case "schema":
|
|
m.Schema = 2
|
|
case "client":
|
|
m.Client = "110US"
|
|
case "kind":
|
|
m.Kind = "script"
|
|
case "id":
|
|
m.ID = "Not-Unique"
|
|
case "component":
|
|
m.PVF[0].Component = "unknown"
|
|
case "cycle":
|
|
m.Components[0].Requires = []string{"optional"}
|
|
case "missing-dependency":
|
|
m.Components[0].Requires = []string{"unknown"}
|
|
case "target":
|
|
m.PVF[0].Path = "../escape.stk"
|
|
case "uppercase":
|
|
m.PVF[0].Path = "Stackable/demo.stk"
|
|
case "source":
|
|
m.PVF[0].Source = "../escape"
|
|
case "sensitive":
|
|
m.PVF[0].Source = ".env"
|
|
case "missing-source":
|
|
delete(files, "payload/item.stk")
|
|
case "patch":
|
|
m.PVF[0].Mode = "patch"
|
|
case "type":
|
|
m.PVF[0].Type = 9
|
|
case "merge-type":
|
|
m.PVF[1].Type = 3
|
|
case "merge-extension":
|
|
m.PVF[1].Path = "etc/merge.etc"
|
|
case "duplicate-key":
|
|
files["payload/list.lst"].Data = []byte("2 `a`\n2 `b`")
|
|
case "encoding":
|
|
files["payload/item.stk"].Data = []byte{0xff}
|
|
case "parent-symlink":
|
|
files["payload"] = &fstest.MapFile{Mode: fs.ModeSymlink}
|
|
case "file-symlink":
|
|
files["payload/item.stk"].Mode = fs.ModeSymlink
|
|
case "nested-root":
|
|
nested := fstest.MapFS{}
|
|
setCN115Manifest(t, files, m)
|
|
for p, f := range files {
|
|
nested["one/two/"+p] = f
|
|
}
|
|
files = nested
|
|
}
|
|
if kind != "nested-root" {
|
|
setCN115Manifest(t, files, m)
|
|
}
|
|
if _, err := (CN115Reader{}).Read(files); err == nil {
|
|
t.Fatal("accepted malformed package")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCN115SelectionConflicts(t *testing.T) {
|
|
m, files := cn115Fixture(t)
|
|
m.Components[0].ExclusiveGroup = "variant"
|
|
m.Components[1].ExclusiveGroup = "variant"
|
|
setCN115Manifest(t, files, m)
|
|
pkg, err := (CN115Reader{}).Read(files)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, _, err := SelectComponents(pkg, []string{"base", "optional"}); err == nil {
|
|
t.Fatal("exclusive group ignored")
|
|
}
|
|
pkg.Components[0].ExclusiveGroup = ""
|
|
pkg.Components[1].ExclusiveGroup = ""
|
|
pkg.Entries[3].Path = pkg.Entries[0].Path
|
|
if _, _, err := SelectComponents(pkg, []string{"base", "optional"}); err == nil {
|
|
t.Fatal("duplicate target ignored")
|
|
}
|
|
if _, _, err := SelectComponents(pkg, []string{"base"}); err != nil {
|
|
t.Fatal("unselected alternative blocked selection", err)
|
|
}
|
|
pkg.Entries[3].Path = "stackable/demo.stk/child"
|
|
if _, _, err := SelectComponents(pkg, []string{"base", "optional"}); err == nil {
|
|
t.Fatal("file/directory conflict ignored")
|
|
}
|
|
}
|
|
|
|
func TestCN115WriteRoundTrip(t *testing.T) {
|
|
pkg := samplePackage()
|
|
pkg.Metadata.ID = "my-unique-mod"
|
|
pkg.Metadata.Version = "2.0.0"
|
|
writer := CN115Writer{}
|
|
if err := writer.Validate(pkg); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
dir := t.TempDir()
|
|
if err := writer.Write(dir, pkg); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var data bytes.Buffer
|
|
if err := writer.WriteArchive(&data, pkg); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
z, err := zip.NewReader(bytes.NewReader(data.Bytes()), int64(data.Len()))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, root := range []fs.FS{os.DirFS(dir), z} {
|
|
got, err := (CN115Reader{}).Read(root)
|
|
if err != nil || got.Metadata != pkg.Metadata || len(got.Entries) != len(pkg.Entries) {
|
|
t.Fatalf("roundtrip: %+v %v", got, err)
|
|
}
|
|
for i, entry := range got.Entries {
|
|
if entry.Path != pkg.Entries[i].Path || entry.Operation != pkg.Entries[i].Operation {
|
|
t.Fatalf("entry changed: %+v", entry)
|
|
}
|
|
}
|
|
}
|
|
pkg.Metadata.ID = ""
|
|
if err := writer.Validate(pkg); err == nil {
|
|
t.Fatal("missing unique ID accepted")
|
|
}
|
|
}
|
|
|
|
func TestCN115ZIPSource(t *testing.T) {
|
|
_, files := cn115Fixture(t)
|
|
prefix, err := simplifiedchinese.GB18030.NewEncoder().String("中文包")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, kind := range []string{"root", "wrapper", "gbk", "traversal", "case-collision", "symlink", "file-directory"} {
|
|
t.Run(kind, func(t *testing.T) {
|
|
var buffer bytes.Buffer
|
|
z := zip.NewWriter(&buffer)
|
|
for p, f := range files {
|
|
name := p
|
|
switch kind {
|
|
case "wrapper":
|
|
name = "中文包/" + p
|
|
case "gbk":
|
|
name = prefix + "/" + p
|
|
}
|
|
header := &zip.FileHeader{Name: name, NonUTF8: kind == "gbk"}
|
|
w, err := z.CreateHeader(header)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := w.Write(f.Data); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
switch kind {
|
|
case "traversal":
|
|
_, err = z.Create("../escape")
|
|
case "case-collision":
|
|
_, err = z.Create("MOD.JSON")
|
|
case "symlink":
|
|
header := &zip.FileHeader{Name: "link"}
|
|
header.SetMode(fs.ModeSymlink | 0777)
|
|
_, err = z.CreateHeader(header)
|
|
case "file-directory":
|
|
_, err = z.Create("payload")
|
|
}
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := z.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
filename := filepath.Join(t.TempDir(), "package.dnfmod")
|
|
if err := os.WriteFile(filename, buffer.Bytes(), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
root, closer, err := OpenSource(filename, true)
|
|
valid := kind == "root" || kind == "wrapper" || kind == "gbk"
|
|
if valid {
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer closer.Close()
|
|
if _, err := (CN115Reader{}).Read(root); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
} else if err == nil {
|
|
closer.Close()
|
|
t.Fatal("unsafe ZIP accepted")
|
|
}
|
|
if _, closer, err := OpenSource(filename, false); err == nil {
|
|
closer.Close()
|
|
t.Fatal("ZIP accepted by directory-only format")
|
|
}
|
|
jar := filename + ".jar"
|
|
if err := os.WriteFile(jar, buffer.Bytes(), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, closer, err := OpenSource(jar, true); err == nil {
|
|
closer.Close()
|
|
t.Fatal("JAR accepted as a MOD ZIP")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCN115ZIPSizeLimit(t *testing.T) {
|
|
var buffer bytes.Buffer
|
|
z := zip.NewWriter(&buffer)
|
|
if _, err := z.CreateRaw(&zip.FileHeader{Name: "huge.stk", UncompressedSize64: maxPayloadSize + 1}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := z.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
filename := filepath.Join(t.TempDir(), "oversize.zip")
|
|
if err := os.WriteFile(filename, buffer.Bytes(), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, closer, err := OpenSource(filename, true); err == nil {
|
|
closer.Close()
|
|
t.Fatal("ZIP declared expansion limit ignored")
|
|
}
|
|
}
|
|
|
|
func TestCN115ResourceWarningsAndLimits(t *testing.T) {
|
|
m, files := cn115Fixture(t)
|
|
size := int64(20)
|
|
m.Files = []cn115Resource{{Component: "base", Source: "payload/resource.npk", Target: "ImagePacks2/unique.npk", Size: &size, SHA256: strings.Repeat("a", 64)}}
|
|
m.Requires = []string{"other-mod"}
|
|
setCN115Manifest(t, files, m)
|
|
pkg, err := (CN115Reader{}).Read(files)
|
|
if err != nil || len(pkg.Warnings) != 2 {
|
|
t.Fatal("missing resource/dependency warnings", pkg.Warnings, err)
|
|
}
|
|
for _, target := range []string{"../a.npk", "bin/file.dll", "SoundPacks/sub/file.npk", "Stream/Video/movie.exe"} {
|
|
m.Files[0].Target = target
|
|
setCN115Manifest(t, files, m)
|
|
if _, err := (CN115Reader{}).Read(files); err == nil {
|
|
t.Fatalf("accepted resource target %s", target)
|
|
}
|
|
}
|
|
if _, err := readPackageFile(files, "payload/item.stk", 2); err == nil {
|
|
t.Fatal("payload limit ignored")
|
|
}
|
|
}
|
|
|
|
func TestCN115RealMOD(t *testing.T) {
|
|
filename := os.Getenv("PVF_MOD_TESTFILE")
|
|
if filename == "" {
|
|
t.Skip("PVF_MOD_TESTFILE required")
|
|
}
|
|
root, closer, err := OpenSource(filename, true)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer closer.Close()
|
|
pkg, err := (CN115Reader{}).Read(root)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
pkg, ids, err := SelectComponents(pkg, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
counts := map[Operation]int{}
|
|
for _, entry := range pkg.Entries {
|
|
counts[entry.Operation]++
|
|
}
|
|
t.Logf("MOD: %s %s; selected=%v entries=%d operations=%v", pkg.Metadata.Name, pkg.Metadata.Version, ids, len(pkg.Entries), counts)
|
|
if len(pkg.Entries) == 0 {
|
|
t.Fatal("no PVF operations")
|
|
}
|
|
}
|