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

219 lines
8.2 KiB
Go

package services
import (
"os"
"strings"
"testing"
"unicode/utf16"
"pvfine/internal/pvf"
)
func shopFixture(t *testing.T) (*core, int32) {
t.Helper()
a := pvf.New()
for p, text := range map[string]string{
shopCategoryPath: "[itemshop category]\n[category name]\n`basic job`\n[category entry]\n[id]\n0\n[text]\n`男鬼剑士`\n[/category entry]\n[category entry]\n[id]\n1\n[text]\n`女格斗家`\n[/category entry]\n[/itemshop category]\n[itemshop category]\n[category name]\n`expert job`\n[category entry]\n[id]\n0\n[text]\n`炼金术师`\n[/category entry]\n[category entry]\n[id]\n1\n[text]\n`附魔师`\n[/category entry]\n[category entry]\n[id]\n2\n[text]\n`控偶师`\n[/category entry]\n[category entry]\n[id]\n3\n[text]\n`分解师`\n[/category entry]\n[/itemshop category]\n[itemshop category]\n[category name]\n`expert job non filter`\n[category entry]\n[id]\n0\n[text]\n`炼金术师`\n[/category entry]\n[category entry]\n[id]\n1\n[text]\n`附魔师`\n[/category entry]\n[category entry]\n[id]\n2\n[text]\n`控偶师`\n[/category entry]\n[category entry]\n[id]\n3\n[text]\n`分解师`\n[/category entry]\n[/itemshop category]",
"stackable/stackable.lst": "1 `one.stk` 2 `two.stk` 3 `three.stk`",
"stackable/one.stk": "[name]\n`长名称药剂`\n[icon]\n`item/test.img` 7\n[price]\n100000\n[need material]\n2 10 3 20\n[/need material]",
"stackable/two.stk": "[name]\n`材料甲`\n[icon]\n`item/test.img` 8",
"stackable/three.stk": "[name]\n`材料乙`",
"equipment/equipment.lst": "4 `four.equ`",
"equipment/four.equ": "[name]\n`佩刀`\n[price]\n3500",
"character/character.lst": "0 `sword.chr` 1 `fighter.chr`",
"character/sword.chr": "[name]\n`不能用此名称`\n[growtype name]\n`鬼剑士` `剑魂`",
"character/fighter.chr": "[name]\n`格斗家`",
"npc/npc.lst": "10 `shop.npc`",
"npc/shop.npc": "[name]\n`商人`",
} {
if _, err := a.AddFileText(p, text, pvf.TypeScript); err != nil {
t.Fatal(err)
}
}
i, err := a.AddFileText("itemshop/test.shp", "[NPC]\n10", pvf.TypeScript)
if err != nil {
t.Fatal(err)
}
c := NewCore()
c.archive = a
return c, i
}
func TestShopDraftCostsAndReadOnly(t *testing.T) {
c, i := shopFixture(t)
before, _ := c.archive.RawBytes(i)
saved := append([]byte(nil), before...)
modified := c.archive.ModifiedCount()
text := "[NPC]\n10\n[sell info]\n[tab]\n`珍贵😀`\n[item list]\n1 1 2 4 999\n[/item list]\n[/tab]\n[tab]\n`空`\n[item list]\n[/item list]\n[/tab]\n[/sell info]"
doc, err := NewFileGUIService(c).ReadShop(i, text)
if err != nil {
t.Fatal(err)
}
if doc.Name != "商人" || len(doc.Tabs) != 2 || len(doc.Categories) != 0 {
t.Fatalf("document = %#v", doc)
}
entries := doc.Tabs[0].Groups[0].Items
if len(entries) != 5 || entries[0].Item.ID != entries[1].Item.ID {
t.Fatalf("entries = %#v", entries)
}
item := entries[0].Item
if item.Name != "长名称药剂" || item.Icon == nil || item.Icon.Index != 7 || len(item.Costs) != 3 {
t.Fatalf("item = %#v", item)
}
if item.Costs[0].Kind != "gold" || item.Costs[0].Quantity != "100000" || item.Costs[1].ItemID != "2" || item.Costs[1].Quantity != "10" || item.Costs[1].Name != "材料甲" || item.Costs[1].Icon == nil {
t.Fatalf("costs = %#v", item.Costs)
}
if len(entries[2].Item.Costs) != 0 || entries[3].Item.Costs[0].Quantity != "3500" || !strings.Contains(entries[4].Item.Name, "999") || len(doc.Issues) != 1 {
t.Fatal("missing price / equipment / unknown item handling")
}
runes := utf16.Encode([]rune(text))
for _, entry := range entries {
if string(utf16.Decode(runes[entry.SourceStart:entry.SourceEnd])) != entry.Item.ID {
t.Fatal("source range mismatch")
}
}
after, _ := c.archive.RawBytes(i)
if string(after) != string(saved) || c.archive.ModifiedCount() != modified {
t.Fatal("GUI read mutated archive")
}
}
func TestShopCategoryKinds(t *testing.T) {
c, i := shopFixture(t)
for _, tc := range []struct{ kind, id, want string }{
{"basic job", "0", "男鬼剑士"}, {"basic job", "1", "女格斗家"}, {"expert job", "1", "附魔师"}, {"expert job", "0", "炼金术师"}, {"expert job", "3", "分解师"}, {"expert job", "2", "控偶师"},
{"expert job non filter", "1", "附魔师"}, {"expert job non filter", "0", "炼金术师"}, {"expert job non filter", "3", "分解师"}, {"expert job non filter", "2", "控偶师"},
} {
t.Run(tc.kind+tc.id, func(t *testing.T) {
text := "[sell info]\n[use category]\n`" + tc.kind + "`\n[tab]\n`分类商品`\n[category entry]\n[id]\n" + tc.id + "\n[item list]\n1 2\n[/item list]\n[/category entry]\n[/tab]\n[/sell info]"
doc, err := NewFileGUIService(c).ReadShop(i, text)
if err != nil {
t.Fatal(err)
}
name := ""
for _, category := range doc.Categories {
if category.ID == tc.id {
name = category.Name
}
}
if name != tc.want || doc.Tabs[0].Groups[0].CategoryID != tc.id || len(doc.Tabs[0].Groups[0].Items) != 2 {
t.Fatalf("document = %#v", doc)
}
if doc.CategoryType != tc.kind || len(doc.Issues) != 0 {
t.Fatalf("category type = %q, issues = %#v", doc.CategoryType, doc.Issues)
}
})
}
}
func TestShopMalformedAndEmpty(t *testing.T) {
for _, text := range []string{"", "[item list]\n1\n[/item list]", "[sell info]\n[tab]\n`bad`\n[item list]\n`not an id`\n[/item list]\n[/tab]\n[/sell info]"} {
if len(parseShop(text).Issues) == 0 {
t.Fatalf("missing issue for %q", text)
}
}
doc := parseShop("")
costs := shopCosts("[price]\n-1\n[need material]\n2 10 3\n[/need material]", "1", doc)
if len(costs) != 1 || costs[0].ItemID != "2" || len(doc.Issues) < 3 {
t.Fatalf("costs=%#v issues=%#v", costs, doc.Issues)
}
doc = parseShop("")
if costs := shopCosts("[price]\n0", "1", doc); len(costs) != 1 || costs[0].Quantity != "0" {
t.Fatal("explicit zero price must be preserved")
}
}
func TestShopReal90CN(t *testing.T) {
filename := os.Getenv("PVF_TESTFILE")
if filename == "" {
t.Skip("PVF_TESTFILE 未设置")
}
a, err := pvf.Open(filename)
if err != nil {
t.Fatal(err)
}
if a.ClientVersion() != "90CN" {
t.Skip("商店样例使用 90CN")
}
c := NewCore()
c.archive = a
service := NewFileGUIService(c)
before := a.ModifiedCount()
for _, p := range []string{"itemshop/101_joann_box.shp", "itemshop/equipmentshop1.shp"} {
i, ok := a.Find(p)
if !ok {
t.Fatal("sample missing")
}
text, err := a.Text(i)
if err != nil {
t.Fatal(err)
}
doc, err := service.ReadShop(i, text)
if err != nil {
t.Fatal(err)
}
if strings.Contains(p, "joann") {
if len(doc.Tabs) != 3 || len(doc.Categories) != 0 {
t.Fatal("joann structure")
}
for ti, count := range []int{5, 6, 6} {
if len(doc.Tabs[ti].Groups[0].Items) != count {
t.Fatal("joann item count")
}
}
cost := doc.Tabs[0].Groups[0].Items[0].Item.Costs
if len(cost) != 1 || cost[0].ItemID != "10092849" || cost[0].Quantity != "10" {
t.Fatalf("joann cost=%#v", cost)
}
} else {
if len(doc.Tabs) != 4 || len(doc.Categories) != 16 || doc.Categories[0].Name != "鬼剑士(男)" {
t.Fatalf("equipment categories=%#v tabs=%d", doc.Categories, len(doc.Tabs))
}
if doc.Tabs[0].Groups[0].Items[0].Item.Costs[0].Quantity != "10" {
t.Fatal("equipment gold price")
}
}
if len(doc.Issues) > 0 {
t.Fatalf("sample has issues: %#v", doc.Issues)
}
}
if a.ModifiedCount() != before {
t.Fatal("real archive mutated")
}
}
func TestShopCategoryRealArchive(t *testing.T) {
filename := os.Getenv("PVF_TESTFILE")
if filename == "" {
t.Skip("PVF_TESTFILE 未设置")
}
a, err := pvf.Open(filename)
if err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
kind string
firstID string
count int
}{
{"job", "0", 0},
{"expert job", "0", 4},
{"expert job non filter", "0", 4},
{"basic job", "0", 16},
} {
categories, err := readShopCategories(a, tc.kind)
if err != nil {
t.Fatal(err)
}
if len(categories) < tc.count || categories[0].ID != tc.firstID || categories[0].Name == "" || strings.Contains(categories[0].Name, "<4::") {
t.Fatalf("%s categories = %#v", tc.kind, categories)
}
}
if a.ClientVersion() == "110US" {
categories, err := readShopCategories(a, "basic job")
if err != nil || len(categories) != 17 || categories[16].ID != "16" {
t.Fatalf("110US basic job = %#v, %v", categories, err)
}
}
}