DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
hanfuit committed 2026-11-04 14:31:55 +08:00
commit 645b64fe21
4388 files changed
+1405457

No files matched your search

+214
View File
@@ -0,0 +1,214 @@
package services
import (
"bytes"
"encoding/binary"
"os"
"path/filepath"
"pvfine/internal/pvf"
"strings"
"testing"
"unicode/utf16"
)
const dropTestRow = "0 20 3015 1000000 200000 300000 400000 500000 1 2 3 4 5 0 99 -1 "
func TestIndependentDropStructure(t *testing.T) {
text := "# 😀\n[independent drop]\n" + dropTestRow + "0\n" + dropTestRow + "1\n[list]\n3176 20 3030 80\n[/list]\n" + dropTestRow + "5\n[list]\n100 1\n[/list]\n[replace list]\n[dungeon condition]\n1 20 1 2 300 100\n[/replace list]\n[/independent drop]\n[dungeon drop rate balance]\n1 2 3 4 5 6\n"
doc, err := parseIndependentDrop(text, false)
if err != nil {
t.Fatal(err)
}
if len(doc.Rows) != 3 || len(doc.Rows[1].Items) != 2 || !doc.Rows[2].HasExtensions || len(doc.Balance) != 6 || len(doc.Issues) != 1 {
t.Fatalf("bad structure: %+v", doc)
}
if doc.Rows[1].Fields[1].Value != 20 || doc.Rows[1].ListEnd <= doc.Rows[1].ListStart {
t.Fatal("bad fields")
}
a := pvf.New()
i, err := a.AddFileText(independentDropPath, text, pvf.TypeScript)
if err != nil {
t.Fatal(err)
}
c := NewCore()
c.archive = a
service := NewFileGUIService(c)
if _, err := service.ReadIndependentDrop(i, text); err != nil {
t.Fatal(err)
}
// The projection must not mutate the archive overlay.
if a.ModifiedCount() != 1 {
t.Fatal("read changed overlay")
}
}
func TestIndependentDropExternalAndMalformed(t *testing.T) {
doc, err := parseIndependentDrop("[independent drop]\n"+dropTestRow+"2\n[list]\n17\n[/list]\n[/independent drop]", false)
if err != nil || len(doc.Rows) != 1 || len(doc.Rows[0].Items[0].Fields) != 1 {
t.Fatalf("external: %v", err)
}
doc, err = parseIndependentDrop("[list]\n10 30\n20 70\n[/list]", true)
if err != nil || len(doc.Rows[0].Items) != 2 {
t.Fatalf("list: %v", err)
}
for _, text := range []string{"[independent drop]\n1 2\n[/independent drop]", "[independent drop]\n" + dropTestRow + "1\n[/independent drop]", "[independent drop]\n" + dropTestRow + "1\n[list]\n1\n[/list]\n[/independent drop]", "[independent drop]\n" + dropTestRow + "0", "[list]\n1 2 3\n[/list]"} {
if _, err := parseIndependentDrop(text, strings.HasPrefix(text, "[list]")); err == nil {
t.Errorf("accepted malformed input %q", text)
}
}
}
// Use the existing archive selection convention; no personal sample paths.
func TestIndependentDropReal(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)
}
defer a.Release()
i, ok := a.Find(independentDropPath)
if !ok {
t.Skip("该变体没有独立掉落文件")
}
text, err := a.Text(i)
if err != nil {
t.Fatal(err)
}
c := NewCore()
c.archive = a
doc, err := NewFileGUIService(c).ReadIndependentDrop(i, text)
if err != nil {
t.Fatal(err)
}
if len(doc.Rows) == 0 {
t.Fatal("empty records")
}
// Exercise the normal editor save path without touching the source archive.
token := doc.Rows[0].Fields[3]
units := utf16.Encode([]rune(strings.ReplaceAll(strings.ReplaceAll(text, "\r\n", "\n"), "\r", "\n")))
edited := string(utf16.Decode(units[:token.Start])) + "123456" + string(utf16.Decode(units[token.End:]))
original, err := a.RawBytes(i)
if err != nil {
t.Fatal(err)
}
expected := append([]byte(nil), original...)
// Native script tokens are five bytes; the first token is the section tag.
if len(expected) < 25 || expected[20] != 0 || int32(binary.LittleEndian.Uint32(expected[21:25])) != int32(token.Value) {
t.Fatal("unexpected script layout")
}
binary.LittleEndian.PutUint32(expected[21:25], 123456)
if err := a.SetText(i, edited); err != nil {
t.Fatal(err)
}
raw, err := a.RawBytes(i)
if err != nil || !bytes.Equal(raw, expected) {
t.Fatal("editing changed unrelated binary tokens", err)
}
// Also validate every registered external list through the same projection.
if li, ok := a.Find("etc/independentdrop.lst"); ok {
pairs, err := a.ListPairs(li)
if err != nil {
t.Fatal(err)
}
for _, pair := range pairs {
p := filepath.ToSlash(filepath.Join("etc", pair.Path))
idx, ok := a.Find(p)
if !ok {
t.Fatalf("missing external list %s", pair.ID)
}
source, err := a.Text(idx)
if err != nil {
t.Fatal(err)
}
if _, err := parseIndependentDrop(source, true); err != nil {
t.Fatalf("list %s: %v", pair.ID, err)
}
}
}
if os.Getenv("PVF_DROP_SAVE_ROUNDTRIP") == "1" {
output := filepath.Join(t.TempDir(), "independent-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()
index, ok := reopened.Find(independentDropPath)
if !ok {
t.Fatal("saved file missing")
}
raw, err := reopened.RawBytes(index)
if err != nil || !bytes.Equal(raw, expected) {
t.Fatal("save/reopen changed script tokens", err)
}
source, err := reopened.Text(index)
if err != nil {
t.Fatal(err)
}
after, err := parseIndependentDrop(source, false)
if err != nil || len(after.Rows) != len(doc.Rows) || after.Rows[0].Fields[3].Value != 123456 {
t.Fatal("save/reopen projection differs", err)
}
}
counts := map[float64]int{}
for _, r := range doc.Rows {
counts[r.Fields[16].Value]++
}
t.Logf("%s: %d records, modes %v, balance %d", a.ClientVersion(), len(doc.Rows), counts, len(doc.Balance))
}
func TestIndependentDropCommentBoundaries(t *testing.T) {
text := "[independent drop]\n" + dropTestRow + "1\n[list] # [/list]\n3176 20\n[/list]\n# [/independent drop]\n[/independent drop]"
doc, err := parseIndependentDrop(text, false)
if err != nil {
t.Fatal(err)
}
if doc.Rows[0].ListEnd != strings.LastIndex(text, "[/list]") || doc.BodyEnd != strings.LastIndex(text, "[/independent drop]") {
t.Fatal("comment mistaken for closing section")
}
}
func TestIndependentDropExternalService(t *testing.T) {
a := pvf.New()
for path, source := range map[string]string{
"etc/independentdrop.lst": "17 `IndependentDrop/shared.etc`",
"etc/independentdrop/shared.etc": "[list]\n3176 20\n3030 80\n[/list]",
independentDropPath: "[independent drop]\n" + dropTestRow + "2\n[list]\n17\n[/list]\n[/independent drop]",
"aicharacter/aicharacter.lst": "20 `example.aic`",
"aicharacter/example.aic": "[name]\n`测试 APC`",
} {
if _, err := a.AddFileText(path, source, pvf.TypeScript); err != nil {
t.Fatal(err)
}
}
c := NewCore()
c.archive = a
service := NewFileGUIService(c)
index, _ := a.Find(independentDropPath)
text, _ := a.Text(index)
doc, err := service.ReadIndependentDrop(index, text)
if err != nil {
t.Fatal(err)
}
if len(doc.Lists) != 1 || doc.Lists[0].FileIndex < 0 || doc.Lists[0].ID != "17" {
t.Fatal("external list not resolved")
}
idx := doc.Lists[0].FileIndex
source, _ := a.Text(idx)
list, err := service.ReadIndependentDrop(idx, source)
if err != nil || !list.IsList || len(list.Rows[0].Items) != 2 {
t.Fatal("external list unavailable", err)
}
apcText := strings.Replace(text, "0\t20", "1\t20", 1)
apc, err := service.ReadIndependentDrop(index, apcText)
if err != nil {
t.Fatal(err)
}
if apc.Rows[0].MonsterName != "测试 APC" {
t.Fatalf("APC name = %q", apc.Rows[0].MonsterName)
}
}