Files
90CNServer/pvf/services/shop_115cn_test.go
T
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

462 lines
16 KiB
Go

package services
import (
"bytes"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"pvfine/internal/pvf"
)
const structuredShopList = "[sell item list]\n[item]\n7\n[index]\n1\n[purchase amount]\n3\n[need material]\n2 9\n[/need material]\n[/item]\n[item]\n9\n[index]\n1\n[/item]\n[/sell item list]\n"
func TestShopStructuredLayouts(t *testing.T) {
for name, text := range map[string]string{
"tab": "[sell info]\n[tab]\n`商品`\n" + structuredShopList + "[/tab]\n[/sell info]",
"implicit": "[sell info]\n" + structuredShopList + "[/sell info]",
"root-tab": "[sell info]\n[tab]\n`商品`\n" + structuredShopList + "[/tab]",
"category": "[sell info]\n[use category]\n`expert job non filter`\n[tab]\n`商品`\n[category entry]\n[id]\n1\n" + structuredShopList + "[/category entry]\n[/tab]\n[/sell info]",
"implicit-category": "[sell info]\n[use category]\n`expert job non filter`\n[category entry]\n[id]\n1\n" + structuredShopList + "[/category entry]\n[/sell info]",
} {
t.Run(name, func(t *testing.T) {
c, s, r := editFixture(t, text)
doc, err := s.ReadShop(r.FileIndex, r.Text)
if err != nil {
t.Fatal(err)
}
if len(doc.Issues) != 0 || len(doc.Tabs) != 1 || len(doc.Tabs[0].Groups) != 1 || len(doc.Tabs[0].Groups[0].Items) != 2 {
t.Fatalf("unexpected document: %#v", doc)
}
entries := doc.Tabs[0].Groups[0].Items
if doc.Tabs[0].Implicit != strings.HasPrefix(name, "implicit") || !entries[0].HasLocalCosts || entries[1].HasLocalCosts {
t.Fatal("frontend capability flags do not match shop structure")
}
if entries[0].Item.Costs[1].Quantity != "9" || len(entries[0].Item.Costs) != 2 || len(entries[1].Item.Costs) != 3 {
t.Fatal("local override leaked into duplicate product or was not applied")
}
itemIndex, _ := c.archive.Find("stackable/one.stk")
before, _ := c.archive.RawBytes(itemIndex)
before = bytes.Clone(before)
r.Action, r.SourceStart, r.ItemID = "edit-item", entries[0].SourceStart, "4"
r.SetGold, r.Gold = true, "123"
if _, err := s.ApplyShopEdit(r); err == nil {
t.Fatal("local override cost mutation accepted")
}
after, _ := c.archive.RawBytes(itemIndex)
if !bytes.Equal(before, after) || c.batchRevision != r.Revision {
t.Fatal("rejected mutation changed archive")
}
r.SetGold = false
if _, err := s.ApplyShopEdit(r); err != nil {
t.Fatal(err)
}
r.Text, _ = c.archive.Text(r.FileIndex)
parsed := parseShop(r.Text)
got := parsed.Tabs[0].Groups[0].Items
if got[0].Item.ID != "4" || got[1].Item.ID != "1" || !strings.Contains(r.Text, "[purchase amount]") || got[0].localCosts[0].Quantity != "9" {
t.Fatal("replacement lost record metadata or changed duplicate")
}
r.Revision = c.batchRevision
r.Action, r.ItemID, r.CategoryID = "add-item", "2", "1"
if _, err := s.ApplyShopEdit(r); err != nil {
t.Fatal(err)
}
r.Text, _ = c.archive.Text(r.FileIndex)
r.Revision = c.batchRevision
got = parseShop(r.Text).Tabs[0].Groups[0].Items
if len(got) != 3 || got[2].Item.ID != "2" || got[2].localMaterials {
t.Fatal("new record inherited overrides")
}
raw, _ := c.archive.RawBytes(r.FileIndex)
tree, err := c.archive.ParseScriptDocument(raw)
if err != nil || len(tree.Warnings()) != 0 {
t.Fatalf("invalid binary tree: %v", err)
}
loc := parsed.Tabs[0].location
list := shopItemLists(tree.Sections(loc.path)[loc.occurrence])[0]
if list.Children()[2].Values()[0].Value != int64(10) {
t.Fatal("entry key not allocated above existing keys")
}
r.Action, r.SourceStart = "delete-item", got[0].SourceStart
if _, err := s.ApplyShopEdit(r); err != nil {
t.Fatal(err)
}
r.Text, _ = c.archive.Text(r.FileIndex)
got = parseShop(r.Text).Tabs[0].Groups[0].Items
if len(got) != 2 || got[0].Item.ID != "1" || strings.Contains(r.Text, "[purchase amount]") {
t.Fatal("delete did not remove exactly the selected item block")
}
})
}
}
func TestShopStructuredPageOperations(t *testing.T) {
for _, root := range []bool{false, true} {
text := "[sell info]\n[tab]\n`one`\n" + structuredShopList + "[/tab]"
if !root {
text += "\n[/sell info]"
}
c, s, r := editFixture(t, text)
r.Action, r.Name = "add-tab", "two"
if _, err := s.ApplyShopEdit(r); err != nil {
t.Fatal(err)
}
r.Text, _ = c.archive.Text(r.FileIndex)
r.Revision, r.TabIndex = c.batchRevision, 1
r.Action, r.ItemID = "add-item", "2"
if _, err := s.ApplyShopEdit(r); err != nil {
t.Fatal(err)
}
r.Text, _ = c.archive.Text(r.FileIndex)
if !strings.Contains(r.Text, "[sell item list]") || strings.Contains(r.Text, "[item list]") {
t.Fatal("new page used legacy list")
}
r.Revision, r.Action, r.Name = c.batchRevision, "rename-tab", "three"
if _, err := s.ApplyShopEdit(r); err != nil {
t.Fatal(err)
}
r.Text, _ = c.archive.Text(r.FileIndex)
if parseShop(r.Text).Tabs[1].Name != "three" {
t.Fatal("rename failed")
}
r.Revision, r.Action = c.batchRevision, "delete-tab"
if _, err := s.ApplyShopEdit(r); err != nil {
t.Fatal(err)
}
text, _ = c.archive.Text(r.FileIndex)
if len(parseShop(text).Tabs) != 1 {
t.Fatal("page deletion failed")
}
}
}
func TestShopImplicitPageAndMalformedGuards(t *testing.T) {
for _, text := range []string{
"[sell info]\n" + structuredShopList + "[/sell info]",
"[sell info]\n[tab]\n`bad`\n[sell info]\n" + structuredShopList + "[/sell info]",
} {
c, s, r := editFixture(t, text)
before, _ := c.archive.RawBytes(r.FileIndex)
before = bytes.Clone(before)
for _, action := range []string{"add-tab", "rename-tab", "delete-tab"} {
r.Action, r.Name = action, "test"
if _, err := s.ApplyShopEdit(r); err == nil {
t.Fatalf("%s accepted", action)
}
}
after, _ := c.archive.RawBytes(r.FileIndex)
if !bytes.Equal(before, after) {
t.Fatal("guarded operation changed source")
}
}
}
func TestShopStructuredEmptySlotsAndGlobalEntryKeys(t *testing.T) {
text := "[sell info]\n[use category]\n`expert job non filter`\n[tab]\n`shop`\n[category entry]\n[id]\n1\n" +
strings.Replace(structuredShopList, "[index]\n1", "[index]\n-1", 1) +
"[/category entry]\n[category entry]\n[id]\n2\n[sell item list]\n[item]\n99\n[index]\n4\n[price]\n0\n[/item]\n[/sell item list]\n[/category entry]\n[/tab]\n[/sell info]"
c, s, r := editFixture(t, text)
doc, err := s.ReadShop(r.FileIndex, r.Text)
if err != nil || len(doc.Issues) != 0 {
t.Fatalf("document=%#v err=%v", doc, err)
}
if len(doc.Tabs[0].Groups[0].Items) != 1 {
t.Fatal("empty slot shown as product")
}
costs := doc.Tabs[0].Groups[1].Items[0].Item.Costs
if len(costs) == 0 || costs[len(costs)-1].Kind != "gold" || costs[len(costs)-1].Quantity != "0" {
t.Fatal("explicit local zero lost")
}
r.Action, r.SetGold, r.Gold = "batch-costs", true, "42"
before, _ := c.archive.RawBytes(r.FileIndex)
before = bytes.Clone(before)
if _, err := s.ApplyShopEdit(r); err == nil {
t.Fatal("batch ignored local cost protection")
}
after, _ := c.archive.RawBytes(r.FileIndex)
if !bytes.Equal(before, after) || r.Revision != c.batchRevision {
t.Fatal("rejected batch changed the shop")
}
r.Action, r.SetGold, r.CategoryID, r.ItemID = "add-item", false, "3", "2"
if _, err := s.ApplyShopEdit(r); err != nil {
t.Fatal(err)
}
r.Text, _ = c.archive.Text(r.FileIndex)
parsed := parseShop(r.Text)
if len(parsed.Tabs[0].Groups) != 3 || parsed.Tabs[0].Groups[2].Items[0].Item.ID != "2" {
t.Fatal("new category item missing")
}
raw, _ := c.archive.RawBytes(r.FileIndex)
tree, err := c.archive.ParseScriptDocument(raw)
if err != nil {
t.Fatal(err)
}
lists := shopItemLists(tree.Sections([]string{"sell info", "tab"})[0])
if len(lists) != 3 || lists[2].Name() != "sell item list" || lists[2].Children()[0].Values()[0].Value != int64(100) {
t.Fatal("new category schema or unique entry key lost")
}
r.Action, r.SourceStart, r.Revision = "delete-item", parsed.Tabs[0].Groups[0].Items[0].SourceStart, c.batchRevision
if _, err := s.ApplyShopEdit(r); err != nil {
t.Fatal(err)
}
raw, _ = c.archive.RawBytes(r.FileIndex)
tree, err = c.archive.ParseScriptDocument(raw)
if err != nil {
t.Fatal(err)
}
list := shopItemLists(tree.Sections([]string{"sell info", "tab"})[0])[0]
if len(list.Children()) != 1 || list.Children()[0].Children()[0].Values()[0].Value != int64(-1) {
t.Fatal("visible item deletion removed empty slot")
}
}
func TestShopUnclassifiedProductsRemainAccessible(t *testing.T) {
text := "[sell info]\n[use category]\n`expert job non filter`\n" + structuredShopList + "[/sell info]"
_, s, r := editFixture(t, text)
doc, err := s.ReadShop(r.FileIndex, r.Text)
if err != nil {
t.Fatal(err)
}
if len(doc.Categories) == 0 || doc.Categories[0].ID != "" || doc.Categories[0].Name != "未分类" || len(doc.Issues) == 0 {
t.Fatal("unclassified products must remain selectable with a diagnostic")
}
}
func TestShopEmptyListFormat(t *testing.T) {
if shopListName(nil, "115CN") != "sell item list" || shopListName(nil, "90CN") != "item list" {
t.Fatal("empty shop default list schema incorrect")
}
}
// Opt-in corpus regression: compare every visible ID and its binary edit
// target, not merely whether the shop has tabs. Never change the source PVF.
func TestShopReal115CNStructure(t *testing.T) {
filename := os.Getenv("PVF_SHOP_TESTFILE")
if filename == "" {
t.Skip("PVF_SHOP_TESTFILE required")
}
a, err := pvf.Open(filename)
if err != nil {
t.Fatal(err)
}
defer a.Release()
total, empty, issues, records, unsupported := 0, 0, 0, 0, 0
layouts := map[string]int{}
issueSections := map[string]int{}
categoryWarnings := map[string]int{}
emptyUnclassified := 0
for i := int32(0); i < a.FileCount(); i++ {
if !strings.HasSuffix(strings.ToLower(a.Path(i)), ".shp") {
continue
}
total++
text, err := a.Text(i)
if err != nil {
t.Fatal(err)
}
doc := parseShop(text)
if len(doc.Tabs) == 0 {
empty++
}
if len(doc.Issues) > 0 {
issues++
for _, issue := range doc.Issues {
issueSections[issue.Section]++
if issue.Section == "category entry" {
categoryWarnings[issue.Message]++
}
}
}
raw, err := a.RawBytes(i)
if err != nil {
t.Fatal(err)
}
tree, err := a.ParseScriptDocument(raw)
if err != nil {
t.Fatal(err)
}
if len(tree.Warnings()) > 0 {
unsupported++
continue
}
expected := 0
for _, e := range pvf.ParseScriptView(text).Elements {
p := strings.Join(e.SectionPath, "/")
if e.Kind == pvf.ScriptElementToken && (strings.HasSuffix(p, "/sell item list/item/index") || strings.HasSuffix(p, "/item list")) {
if strings.HasSuffix(p, "/sell item list/item/index") && e.Value == "-1" {
continue
}
expected++
}
}
actual := 0
for _, tab := range doc.Tabs {
loc := tab.location
target := tree.Sections(loc.path)[loc.occurrence]
binaryRecords := shopRecords(target)
var entries []ShopEntry
for _, group := range tab.Groups {
if doc.CategoryType != "" && group.CategoryID == "" && len(group.Items) == 0 {
emptyUnclassified++
}
entries = append(entries, group.Items...)
}
if len(binaryRecords) != len(entries) {
t.Fatalf("shop=%d location=%v occurrence=%d binary=%d visible=%d issues=%v", i, loc.path, loc.occurrence, len(binaryRecords), len(entries), doc.Issues)
}
for n, entry := range entries {
v := binaryRecords[n].index.Values()[binaryRecords[n].offset]
if strconv.FormatInt(v.Value.(int64), 10) != entry.Item.ID {
t.Fatal("view and edit target IDs differ")
}
}
for _, list := range shopItemLists(target) {
layouts[strings.Join(list.Path(), "/")]++
}
actual += len(entries)
}
if actual != expected {
t.Fatalf("shop %d lost records: expected=%d actual=%d", i, expected, actual)
}
records += actual
}
t.Logf("shops=%d without-tabs=%d with-issues=%d malformed=%d visible-records=%d layouts=%v issue-sections=%v", total, empty, issues, unsupported, records, layouts, issueSections)
t.Logf("category-warnings=%v empty-unclassified-groups=%d", categoryWarnings, emptyUnclassified)
if total == 0 || records == 0 {
t.Fatal("no shop records tested")
}
}
func TestShopReal115CNEditRoundTrip(t *testing.T) {
filename := os.Getenv("PVF_SHOP_TESTFILE")
if filename == "" {
t.Skip("PVF_SHOP_TESTFILE required")
}
a, err := pvf.Open(filename)
if err != nil {
t.Fatal(err)
}
defer a.Release()
c := newCore()
c.archive = a
service := NewFileGUIService(c)
type sample struct {
index int32
tab int
category, id string
before, after []byte
}
samples := map[string]*sample{}
for i := int32(0); i < a.FileCount() && len(samples) < 5; i++ {
if !strings.HasSuffix(strings.ToLower(a.Path(i)), ".shp") {
continue
}
text, err := a.Text(i)
if err != nil {
t.Fatal(err)
}
doc := parseShop(text)
if len(doc.Issues) != 0 {
continue
}
for ti, tab := range doc.Tabs {
for _, group := range tab.Groups {
if len(group.Items) == 0 {
continue
}
layout := strings.Join(tab.location.path, "/")
if tab.Implicit {
layout += "/implicit"
}
if group.CategoryID != "" {
layout += "/category"
}
if samples[layout] != nil {
continue
}
entry := group.Items[0]
if _, err := service.ReadItem(entry.Item.ID, nil); err != nil {
continue
}
raw, err := a.RawBytes(i)
if err != nil {
t.Fatal(err)
}
tree, err := a.ParseScriptDocument(raw)
if err != nil || len(tree.Warnings()) != 0 {
continue
}
samples[layout] = &sample{index: i, tab: ti, category: group.CategoryID, id: entry.Item.ID, before: bytes.Clone(raw)}
}
}
}
if len(samples) != 5 {
t.Fatalf("expected five layouts, got %d", len(samples))
}
for layout, sample := range samples {
text, err := a.Text(sample.index)
if err != nil {
t.Fatal(err)
}
req := ShopEditRequest{FileIndex: sample.index, Path: a.Path(sample.index), Text: text, Revision: c.batchRevision, Action: "add-item", TabIndex: sample.tab, CategoryID: sample.category, ItemID: sample.id}
result, _, _, err := service.applyShopEditLocked(req)
if err != nil {
t.Fatalf("layout=%s: %v", layout, err)
}
if len(result.Files) != 1 || result.Files[0].FileIndex != sample.index {
t.Fatal("add modified unrelated files")
}
raw, err := a.RawBytes(sample.index)
if err != nil {
t.Fatal(err)
}
sample.after = bytes.Clone(raw)
}
output := filepath.Join(t.TempDir(), "shop-115cn-roundtrip.pvf")
if err := a.SaveAs(output); err != nil {
t.Fatal(err)
}
reopened, err := pvf.Open(output)
if err != nil {
t.Fatal(err)
}
defer reopened.Release()
fresh := newCore()
fresh.archive = reopened
freshService := NewFileGUIService(fresh)
for layout, sample := range samples {
raw, err := reopened.RawBytes(sample.index)
if err != nil || !bytes.Equal(raw, sample.after) {
t.Fatalf("layout=%s did not survive save/reopen: %v", layout, err)
}
text, err := reopened.Text(sample.index)
if err != nil {
t.Fatal(err)
}
doc := parseShop(text)
var last *ShopEntry
for _, group := range doc.Tabs[sample.tab].Groups {
if group.CategoryID == sample.category && len(group.Items) > 0 {
entry := group.Items[len(group.Items)-1]
last = &entry
break
}
}
if last == nil || last.Item.ID != sample.id {
t.Fatalf("layout=%s added item missing", layout)
}
req := ShopEditRequest{FileIndex: sample.index, Path: reopened.Path(sample.index), Text: text, Revision: fresh.batchRevision, Action: "delete-item", TabIndex: sample.tab, SourceStart: last.SourceStart}
if _, _, _, err := freshService.applyShopEditLocked(req); err != nil {
t.Fatal(err)
}
raw, err = reopened.RawBytes(sample.index)
if err != nil || !bytes.Equal(raw, sample.before) {
t.Fatalf("layout=%s deletion did not restore original binary payload: %v", layout, err)
}
}
t.Log("five layouts: add, save/reopen, delete; original shop payloads restored byte-for-byte; source PVF untouched")
}