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

332 lines
9.8 KiB
Go

package services
import (
"bytes"
"strings"
"testing"
"pvfine/internal/pvf"
)
const editableShop = "[NPC]\n10\n[sell info]\n[tab]\n`第一页`\n[item list]\n1 1 4\n[/item list]\n[/tab]\n[tab]\n`第二页`\n[item list]\n2\n[/item list]\n[/tab]\n[/sell info]\n[message]\n`保留的消息`"
func editFixture(t *testing.T, text string) (*core, *FileGUIService, ShopEditRequest) {
t.Helper()
c, i := shopFixture(t)
if err := c.archive.SetText(i, text); err != nil {
t.Fatal(err)
}
if err := c.setArchive(c.archive); err != nil {
t.Fatal(err)
}
t.Cleanup(c.closeArchive)
return c, NewFileGUIService(c), ShopEditRequest{FileIndex: i, Path: c.archive.Path(i), Text: text, Revision: c.batchRevision, TabIndex: 0}
}
func TestShopEditItemCostsAndDrafts(t *testing.T) {
c, s, r := editFixture(t, editableShop)
doc, err := s.ReadShop(r.FileIndex, r.Text)
if err != nil {
t.Fatal(err)
}
r.Action = "edit-item"
r.SourceStart = doc.Tabs[0].Groups[0].Items[0].SourceStart
r.ItemID = "1"
r.SetGold = true
r.Gold = "123"
r.SetMaterials = true
r.Materials = []ShopMaterialInput{{ItemID: "3", Quantity: "7"}}
i, _ := c.archive.Find("stackable/one.stk")
text, err := c.archive.Text(i)
if err != nil {
t.Fatal(err)
}
r.Drafts = []ShopDraft{{FileIndex: i, Path: c.archive.Path(i), Text: text + "\n[explain]\n`草稿中的其他修改`"}}
result, err := s.ApplyShopEdit(r)
if err != nil {
t.Fatal(err)
}
if result.AffectedItems != 1 || len(result.Files) != 1 {
t.Fatalf("result=%#v", result)
}
got, err := c.archive.Text(i)
if err != nil {
t.Fatal(err)
}
if firstSectionValue(got, "price") != "123" || !strings.Contains(got, "草稿中的其他修改") {
t.Fatalf("text=%s", got)
}
material := shopCosts(got, "1", &ShopDocument{})
if len(material) != 2 || material[1].ItemID != "3" || material[1].Quantity != "7" {
t.Fatalf("cost=%#v", material)
}
// Both references reflect the shared product cost, but the shop structure is unchanged.
shop, _ := c.archive.Text(r.FileIndex)
if !strings.Contains(shop, "保留的消息") {
t.Fatal("unrelated shop field lost")
}
}
func TestShopEditReplacesOneOccurrenceAndLeavesOldItem(t *testing.T) {
c, s, r := editFixture(t, editableShop)
doc, _ := s.ReadShop(r.FileIndex, r.Text)
oldIndex, _ := c.archive.Find("stackable/one.stk")
old, _ := c.archive.RawBytes(oldIndex)
old = append([]byte(nil), old...)
r.Action = "edit-item"
r.SourceStart = doc.Tabs[0].Groups[0].Items[0].SourceStart
r.ItemID = "4"
r.SetGold = true
r.Gold = "3500"
r.SetMaterials = true
if _, err := s.ApplyShopEdit(r); err != nil {
t.Fatal(err)
}
text, _ := c.archive.Text(r.FileIndex)
entries := parseShop(text).Tabs[0].Groups[0].Items
if entries[0].Item.ID != "4" || entries[1].Item.ID != "1" {
t.Fatal("wrong duplicate replaced")
}
current, _ := c.archive.RawBytes(oldIndex)
if !bytes.Equal(old, current) {
t.Fatal("old product mutated")
}
}
func TestShopBatchAllCategoriesAtomic(t *testing.T) {
text := "[sell info]\n[use category]\n`expert job non filter`\n[tab]\n`商品`\n[category entry]\n[id]\n1\n[item list]\n1 1\n[/item list]\n[/category entry]\n[category entry]\n[id]\n2\n[item list]\n4\n[/item list]\n[/category entry]\n[/tab]\n[/sell info]"
c, s, r := editFixture(t, text)
r.Action = "batch-costs"
r.SetGold = true
r.Gold = "500"
r.SetMaterials = true
r.Materials = []ShopMaterialInput{{ItemID: "999999", Quantity: "1"}}
before := map[int32][]byte{}
for i := int32(0); i < c.archive.FileCount(); i++ {
raw, _ := c.archive.RawBytes(i)
before[i] = append([]byte(nil), raw...)
}
if _, err := s.ApplyShopEdit(r); err == nil {
t.Fatal("unknown material accepted")
}
if c.batchRevision != r.Revision {
t.Fatal("failed edit advanced revision")
}
for i, old := range before {
raw, _ := c.archive.RawBytes(i)
if !bytes.Equal(raw, old) {
t.Fatal("partially applied batch")
}
}
r.Materials = []ShopMaterialInput{{ItemID: "2", Quantity: "3"}}
result, err := s.ApplyShopEdit(r)
if err != nil {
t.Fatal(err)
}
if result.AffectedItems != 2 || len(result.Files) != 2 {
t.Fatalf("result=%#v", result)
}
for _, p := range []string{"stackable/one.stk", "equipment/four.equ"} {
i, _ := c.archive.Find(p)
text, _ := c.archive.Text(i)
costs := shopCosts(text, p, &ShopDocument{})
if len(costs) != 2 || costs[0].Quantity != "500" || costs[1].Quantity != "3" {
t.Fatalf("costs=%#v", costs)
}
}
}
func TestShopTabOperationsAndDeletion(t *testing.T) {
c, s, r := editFixture(t, editableShop)
apply := func(action string) {
t.Helper()
r.Action = action
r.Revision = c.batchRevision
r.Text, _ = c.archive.Text(r.FileIndex)
if _, err := s.ApplyShopEdit(r); err != nil {
t.Fatal(err)
}
}
r.Name = "重命名"
apply("rename-tab")
text, _ := c.archive.Text(r.FileIndex)
if parseShop(text).Tabs[0].Name != "重命名" {
t.Fatal("rename failed")
}
r.Name = "新分页"
apply("add-tab")
text, _ = c.archive.Text(r.FileIndex)
doc := parseShop(text)
if len(doc.Tabs) != 3 || len(doc.Tabs[2].Groups) != 1 {
t.Fatal("new tab layout")
}
r.TabIndex = 2
r.ItemID = "4"
apply("add-item")
text, _ = c.archive.Text(r.FileIndex)
doc = parseShop(text)
if len(doc.Tabs[2].Groups[0].Items) != 1 {
t.Fatal("new product")
}
r.SourceStart = doc.Tabs[2].Groups[0].Items[0].SourceStart
apply("delete-item")
text, _ = c.archive.Text(r.FileIndex)
if len(parseShop(text).Tabs[2].Groups[0].Items) != 0 {
t.Fatal("delete entry")
}
apply("delete-tab")
text, _ = c.archive.Text(r.FileIndex)
if len(parseShop(text).Tabs) != 2 || !strings.Contains(text, "保留的消息") {
t.Fatal("delete tab changed other sections")
}
if _, ok := c.archive.Find("equipment/four.equ"); !ok {
t.Fatal("deleted item file")
}
}
func TestShopEditRejectsStaleAndInvalid(t *testing.T) {
c, s, r := editFixture(t, editableShop)
r.Action = "rename-tab"
r.Name = "new"
r.Revision++
if _, err := s.ApplyShopEdit(r); err == nil {
t.Fatal("stale accepted")
}
r.Revision = c.batchRevision
r.Name = "bad`name"
if _, err := s.ApplyShopEdit(r); err == nil {
t.Fatal("invalid name accepted")
}
r.Name = "valid"
r.Path = "itemshop/other.shp"
if _, err := s.ApplyShopEdit(r); err == nil {
t.Fatal("wrong path accepted")
}
}
func TestShopItemSearchScopeBeforePagination(t *testing.T) {
for _, disk := range []bool{false, true} {
c, _, _ := editFixture(t, editableShop)
if _, err := c.archive.AddFileText("npc/npc2.npc", "[name]\n`材料商人`", pvf.TypeScript); err != nil {
t.Fatal(err)
}
if disk {
index, _, err := openSQLiteArchiveIndex(c.archive)
if err != nil {
t.Fatal(err)
}
c.mu.Lock()
c.diskIndex = index
c.installDiskArchiveIndexesLocked(c.archive)
c.mu.Unlock()
}
c.startSearchIndex()
waitForSearchIndex(t, c)
exact, err := NewArchiveService(c).SearchItems("2", 0, 10)
if err != nil || len(exact.Hits) == 0 || exact.Hits[0].ID != "2" || exact.Hits[0].Name != "材料甲" {
t.Fatalf("exact match first: %#v, %v", exact, err)
}
cursor := 0
ids := []string{}
for page := 0; page < 10; page++ {
res, err := NewArchiveService(c).SearchItems("材料", cursor, 1)
if err != nil {
t.Fatal(err)
}
for _, hit := range res.Hits {
if hit.Category != SearchCategoryStackable {
t.Fatal("non-item result")
}
ids = append(ids, hit.ID)
}
if res.NextCursor < 0 {
break
}
cursor = res.NextCursor
}
if len(ids) != 2 {
t.Fatalf("disk=%v ids=%v", disk, ids)
}
}
}
func TestShopCategorizedTabCanBeRecreatedAfterLastDeletion(t *testing.T) {
text := "[sell info]\n[use category]\n`expert job`\n[tab]\n`last`\n[category entry]\n[id]\n1\n[item list]\n1\n[/item list]\n[/category entry]\n[/tab]\n[/sell info]"
c, s, r := editFixture(t, text)
r.Action = "delete-tab"
if _, err := s.ApplyShopEdit(r); err != nil {
t.Fatal(err)
}
r.Text, _ = c.archive.Text(r.FileIndex)
r.Revision = c.batchRevision
r.Action = "add-tab"
r.Name = "重新创建"
if _, err := s.ApplyShopEdit(r); err != nil {
t.Fatal(err)
}
saved, _ := c.archive.Text(r.FileIndex)
doc := parseShop(saved)
if len(doc.Tabs) != 1 || len(doc.Tabs[0].Groups) != 4 {
t.Fatalf("category groups=%#v", doc.Tabs)
}
}
func TestShopReadItemUsesDraftCosts(t *testing.T) {
c, s, _ := editFixture(t, editableShop)
i, _ := c.archive.Find("stackable/one.stk")
item, err := s.ReadItem("1", []ShopDraft{{FileIndex: i, Path: c.archive.Path(i), Text: "[price]\n42\n[need material]\n3 6\n[/need material]"}})
if err != nil {
t.Fatal(err)
}
if item.Costs[0].Quantity != "42" || item.Costs[1].ItemID != "3" {
t.Fatalf("draft costs=%#v", item.Costs)
}
}
func TestShopSearchExactIDPagination(t *testing.T) {
for _, disk := range []bool{false, true} {
c, _, _ := editFixture(t, editableShop)
if _, err := c.archive.AddFileText("stackable/12.stk", "[name]\n`other`", pvf.TypeScript); err != nil {
t.Fatal(err)
}
list, _ := c.archive.Find("stackable/stackable.lst")
text, _ := c.archive.Text(list)
if err := c.archive.SetText(list, text+"\n12 `12.stk`"); err != nil {
t.Fatal(err)
}
if err := c.setArchive(c.archive); err != nil {
t.Fatal(err)
}
if disk {
index, _, err := openSQLiteArchiveIndex(c.archive)
if err != nil {
t.Fatal(err)
}
c.mu.Lock()
c.diskIndex = index
c.installDiskArchiveIndexesLocked(c.archive)
c.mu.Unlock()
}
c.startSearchIndex()
waitForSearchIndex(t, c)
ids := []string{}
cursor := 0
for page := 0; page < 10; page++ {
result, err := NewArchiveService(c).SearchItems("2", cursor, 1)
if err != nil {
t.Fatal(err)
}
if len(result.Hits) > 1 {
t.Fatal("limit exceeded")
}
for _, hit := range result.Hits {
ids = append(ids, hit.ID)
}
if result.NextCursor < 0 {
break
}
if result.NextCursor <= cursor {
t.Fatal("cursor did not advance")
}
cursor = result.NextCursor
}
if len(ids) != 2 || ids[0] != "2" || ids[1] != "12" {
t.Fatalf("disk=%v ids=%v", disk, ids)
}
}
}