服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
462 lines
16 KiB
Go
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")
|
|
}
|