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

+454
View File
@@ -0,0 +1,454 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createPinia, setActivePinia } from "pinia";
import { nextTick, ref } from "vue";
const api = vi.hoisted(() => ({
GetFile: vi.fn(),
GetFileBasic: vi.fn(),
ResolveFiles: vi.fn(),
ApplyWorldDropEdit: vi.fn(),
showArchiveEditor: vi.fn(),
refreshInfo: vi.fn(),
SetText: vi.fn(),
GetAnnotations: vi.fn(),
Save: vi.fn(),
SaveAsDialog: vi.fn(),
}));
vi.mock("@wailsio/runtime", () => ({ Events: { On: vi.fn() } }));
vi.mock("../bindings/pvfine/services", () => ({
EditorService: {
GetFile: api.GetFile,
GetFileBasic: api.GetFileBasic,
SetText: api.SetText,
GetAnnotations: api.GetAnnotations,
Save: api.Save,
SaveAsDialog: api.SaveAsDialog,
},
ArchiveService: { ResolveFiles: api.ResolveFiles },
FileGUIService: { ApplyWorldDropEdit: api.ApplyWorldDropEdit },
}));
vi.mock("../src/stores/archive", () => ({
useArchiveStore: () => ({ refreshInfo: api.refreshInfo, open: true }),
}));
vi.mock("../src/stores/script", () => ({ useScriptStore: () => api }));
vi.mock("../src/stores/explorer", () => ({
useExplorerStore: () => ({
getFilePath: (index: number) => (index === 10 ? "etc/worlddrop.etc" : `file/${index}.txt`),
selectedKey: null,
refreshTreeTags: vi.fn(),
}),
}));
import { useEditorStore } from "../src/stores/editor";
describe("world drop entry & editor store integration", () => {
beforeEach(() => {
setActivePinia(createPinia());
vi.resetAllMocks();
api.GetFileBasic.mockImplementation((index) => api.GetFile(index));
});
it.each([false, true])("openUpgrade: 工具入口打开对应概率文件并复用已有草稿(增幅=%s)", async (amplify) => {
const path = amplify ? "etc/amplifyupgrade.etc" : "etc/upgrade.etc";
api.ResolveFiles.mockResolvedValue([{ name: path.split('/').at(-1), path, isDir: false, fileIndex: 30 }]);
api.GetFile.mockResolvedValue({ index: 30, path, text: "original", editable: true, dataType: 1, size: 8, tags: [], annotations: [], modified: false });
const store = useEditorStore();
await store.openFile(30);
store.updateContent(30, "概率编辑草稿");
expect(await store.openUpgrade(amplify)).toBe(true);
expect(api.ResolveFiles).toHaveBeenCalledWith([path]);
expect(store.tabs.filter(tab => tab.index === 30)).toHaveLength(1);
expect(store.activeTab?.text).toBe("概率编辑草稿");
expect(store.getGUIModes(store.activePaneId).entries.get(30)?.mode).toBe("gui");
});
it("openForge: 工具入口打开锻造 GUI 并保留已有草稿", async () => {
api.ResolveFiles.mockResolvedValue([{ fileIndex: 10, path: "etc/upgrade_separate.etc", isDir: false }]);
api.GetFile.mockResolvedValue({ index: 10, path: "etc/upgrade_separate.etc", text: "original", editable: true, dataType: 1, size: 10, tags: [], annotations: [], modified: false });
const store = useEditorStore();
await store.openFile(10);
const tab = store.tabs.find(t => t.index === 10)!;
tab.text = "forge draft";
expect(await store.openForge()).toBe(true);
expect(api.ResolveFiles).toHaveBeenCalledWith(["etc/upgrade_separate.etc"]);
expect(tab.text).toBe("forge draft");
expect(store.getGUIModes(store.activePaneId).entries.get(10)?.mode).toBe("gui");
expect(store.tabs).toHaveLength(1);
api.ResolveFiles.mockResolvedValue([]);
await expect(store.openForge()).rejects.toThrow("未找到锻造文件");
});
it("openUpgrade: 缺失文件时清晰报错", async () => {
api.ResolveFiles.mockResolvedValue([]);
const store = useEditorStore();
await expect(store.openUpgrade()).rejects.toThrow("未找到强化文件 etc/upgrade.etc");
await expect(store.openUpgrade(true)).rejects.toThrow("未找到增幅文件 etc/amplifyupgrade.etc");
});
it("saveFileTab: 保存指定技能,不受当前活动标签影响", async () => {
api.GetFile.mockImplementation((index: number) => Promise.resolve({
index, path: `skill/test/${index}.skl`, text: "original", editable: true,
dataType: 1, size: 8, tags: [], annotations: [], modified: false,
}));
api.SetText.mockResolvedValue(undefined);
api.GetAnnotations.mockResolvedValue([]);
const store = useEditorStore();
await store.openFile(20);
await store.openFile(21);
store.updateContent(20, "技能参数修改");
store.updateContent(21, "其他草稿");
expect(store.activeTab?.index).toBe(21);
expect(await store.saveFileTab(20)).toBe(true);
expect(api.SetText).toHaveBeenCalledExactlyOnceWith(20, "技能参数修改");
expect(store.tabs.find((tab) => tab.index === 20)?.original).toBe("技能参数修改");
expect(store.tabs.find((tab) => tab.index === 21)?.original).toBe("original");
expect(api.refreshInfo).toHaveBeenCalledOnce();
expect(await store.saveFileTab(20)).toBe(false);
expect(store.saving).toBe(false);
});
it("openWorldDrop: 归档中缺少 etc/worlddrop.etc 时明确抛错", async () => {
api.ResolveFiles.mockResolvedValue([]);
const store = useEditorStore();
await expect(store.openWorldDrop()).rejects.toThrow("未找到全局掉率文件 etc/worlddrop.etc");
});
it("openWorldDrop: 成功解析后打开标签并切换到 GUI 模式", async () => {
api.ResolveFiles.mockResolvedValue([
{ name: "worlddrop.etc", path: "etc/worlddrop.etc", isDir: false, fileIndex: 10 },
]);
api.GetFile.mockResolvedValue({
index: 10,
path: "etc/worlddrop.etc",
text: "[world drop]\n\t1\t0\n\t-1\n",
editable: true,
dataType: 1,
size: 30,
tags: [],
annotations: [],
modified: false,
});
const store = useEditorStore();
const result = await store.openWorldDrop();
expect(result).toBe(true);
// 标签应已打开且活动
expect(store.activeTab?.index).toBe(10);
expect(store.activeTab?.path).toBe("etc/worlddrop.etc");
// 目标窗格中的模式应为 gui
const modes = store.getGUIModes(store.activePaneId);
expect(modes.entries.get(10)?.mode).toBe("gui");
});
it("openWorldDrop: 已打开的标签会被复用并切换到 GUI 模式", async () => {
api.ResolveFiles.mockResolvedValue([
{ name: "worlddrop.etc", path: "etc/worlddrop.etc", isDir: false, fileIndex: 10 },
]);
api.GetFile.mockResolvedValue({
index: 10,
path: "etc/worlddrop.etc",
text: "[world drop]\n\t1\t0\n\t-1\n",
editable: true,
dataType: 1,
size: 30,
tags: [],
annotations: [],
modified: false,
});
const store = useEditorStore();
// 先以常规文本方式打开
await store.openFile(10);
const modes = store.getGUIModes(store.activePaneId);
expect(modes.entries.get(10)?.mode ?? "text").toBe("text");
// 点击全局掉率入口复用
await store.openWorldDrop();
expect(store.tabs.filter((t) => t.index === 10)).toHaveLength(1);
expect(modes.entries.get(10)?.mode).toBe("gui");
});
it("applyWorldDropEdit: 草稿文本不匹配时拒绝提交;成功后更新标签内容与 modified", async () => {
api.GetFile.mockResolvedValue({
index: 10,
path: "etc/worlddrop.etc",
text: "original text",
editable: true,
dataType: 1,
size: 30,
tags: [],
annotations: [],
modified: false,
});
const store = useEditorStore();
await store.openFile(10);
const tab = store.tabs.find((t) => t.index === 10)!;
// 草稿已被修改而请求带着旧文本
tab.text = "modified draft";
await expect(
store.applyWorldDropEdit({
fileIndex: 10,
path: "etc/worlddrop.etc",
text: "stale text",
revision: 1,
levels: [],
}),
).rejects.toThrow("全局掉率草稿已变化");
// 正常匹配提交
const newText = "[world drop]\n\t5\t0\n\t100\t50\n\t-1\n";
api.ApplyWorldDropEdit.mockResolvedValue({
revision: 2,
modifiedCount: 1,
files: [
{
fileIndex: 10,
path: "etc/worlddrop.etc",
beforeText: "modified draft",
text: newText,
},
],
});
api.GetFile.mockResolvedValue({
index: 10,
path: "etc/worlddrop.etc",
text: newText,
editable: true,
dataType: 1,
size: newText.length,
tags: [],
annotations: [],
modified: true,
});
const result = await store.applyWorldDropEdit({
fileIndex: 10,
path: "etc/worlddrop.etc",
text: "modified draft",
revision: 1,
levels: [],
});
expect(result.revision).toBe(2);
expect(tab.text).toBe(newText);
expect(tab.original).toBe(newText);
expect(tab.modified).toBe(true);
});
it("registerGUIDirtyChecker: GUI 未保存修改能够被 isDirty 与 dirtyCount 感知", async () => {
api.GetFile.mockResolvedValue({
index: 10,
path: "etc/worlddrop.etc",
text: "original text",
editable: true,
dataType: 1,
size: 30,
tags: [],
annotations: [],
modified: false,
});
const store = useEditorStore();
await store.openFile(10);
const tab = store.tabs.find((t) => t.index === 10)!;
expect(store.dirtyCount).toBe(0);
const guiDirty = ref(true);
const unregister = store.registerGUIDirtyChecker(10, () => guiDirty.value);
expect(store.dirtyCount).toBe(1);
guiDirty.value = false;
expect(store.dirtyCount).toBe(0);
unregister();
guiDirty.value = true;
expect(store.dirtyCount).toBe(0);
});
it("registerGUIDirtyChecker: 支持多分屏独立注册,任意窗格有修改即判定为 dirty,注销一个不影响其他", async () => {
api.GetFile.mockResolvedValue({
index: 10,
path: "etc/worlddrop.etc",
text: "original text",
editable: true,
dataType: 1,
size: 30,
tags: [],
annotations: [],
modified: false,
});
const store = useEditorStore();
await store.openFile(10);
expect(store.dirtyCount).toBe(0);
const pane1Dirty = ref(false);
const pane2Dirty = ref(false);
const unreg1 = store.registerGUIDirtyChecker(10, () => pane1Dirty.value);
const unreg2 = store.registerGUIDirtyChecker(10, () => pane2Dirty.value);
// 此时两个都为 false
expect(store.hasGUIDirty(10)).toBe(false);
expect(store.dirtyCount).toBe(0);
// 窗格 1 修改
pane1Dirty.value = true;
expect(store.hasGUIDirty(10)).toBe(true);
expect(store.dirtyCount).toBe(1);
// 窗格 2 也修改
pane2Dirty.value = true;
expect(store.hasGUIDirty(10)).toBe(true);
// 窗格 1 恢复,但窗格 2 仍然 dirty
pane1Dirty.value = false;
expect(store.hasGUIDirty(10)).toBe(true);
expect(store.dirtyCount).toBe(1);
// 窗格 2 恢复
pane2Dirty.value = false;
expect(store.hasGUIDirty(10)).toBe(false);
expect(store.dirtyCount).toBe(0);
// 窗格 1 再次 dirty,然后注销窗格 2,窗格 1 仍生效
pane1Dirty.value = true;
unreg2();
expect(store.hasGUIDirty(10)).toBe(true);
// 注销窗格 1
unreg1();
expect(store.hasGUIDirty(10)).toBe(false);
});
it("save / saveAs / saveActiveTab: 阻止存在 GUI 未应用修改时的普通保存,并给出明确提示;草稿保存保持正常", async () => {
api.GetFile.mockResolvedValue({
index: 10,
path: "etc/worlddrop.etc",
text: "original text",
editable: true,
dataType: 1,
size: 30,
tags: [],
annotations: [],
modified: false,
});
api.SetText.mockResolvedValue(undefined);
api.GetAnnotations.mockResolvedValue([]);
api.Save.mockResolvedValue({ files: [] });
api.SaveAsDialog.mockResolvedValue("/path/to/new.pvf");
const store = useEditorStore();
await store.openFile(10);
const tab = store.tabs.find((t) => t.index === 10)!;
// 仅在 GUI 中有未应用修改(tab.text === tab.original)
const guiDirty = ref(true);
const unreg = store.registerGUIDirtyChecker(10, () => guiDirty.value);
expect(store.hasGUIOnlyDirty(tab)).toBe(true);
// 尝试 saveActiveTab
await expect(store.saveActiveTab()).rejects.toThrow(
"当前文件存在未应用的界面修改,请先在界面中点击【应用修改】后再保存",
);
expect(api.SetText).not.toHaveBeenCalled();
// 尝试 save
await expect(store.save()).rejects.toThrow(
"存在未应用的界面修改,请先在界面中点击【应用修改】后再保存",
);
expect(api.Save).not.toHaveBeenCalled();
// 尝试 saveAs
await expect(store.saveAs()).rejects.toThrow(
"存在未应用的界面修改,请先在界面中点击【应用修改】后再保存",
);
expect(api.SaveAsDialog).not.toHaveBeenCalled();
// 取消 GUI dirty 后,如果仅有普通文本草稿修改,正常保存成功
unreg();
expect(store.hasGUIOnlyDirty(tab)).toBe(false);
tab.text = "edited text draft";
expect(store.isTextDirty(tab)).toBe(true);
const activeSaved = await store.saveActiveTab();
expect(activeSaved).toBe(true);
expect(api.SetText).toHaveBeenCalledWith(10, "edited text draft");
tab.text = "another draft";
const saveResult = await store.save();
expect(saveResult).toBeDefined();
expect(api.Save).toHaveBeenCalled();
});
it("save / saveAs / saveActiveTab: 文本草稿与 GUI 表单同时 dirty 时,同样必须阻止保存并禁止静默遗留 GUI 修改", async () => {
api.GetFile.mockResolvedValue({
index: 10,
path: "etc/worlddrop.etc",
text: "original text",
editable: true,
dataType: 1,
size: 30,
tags: [],
annotations: [],
modified: false,
});
api.SetText.mockResolvedValue(undefined);
api.GetAnnotations.mockResolvedValue([]);
api.Save.mockResolvedValue({ files: [] });
api.SaveAsDialog.mockResolvedValue("/path/to/new.pvf");
const store = useEditorStore();
await store.openFile(10);
const tab = store.tabs.find((t) => t.index === 10)!;
// 模拟同时修改了 DSL 文本与 GUI 表单
tab.text = "modified text in dsl";
expect(store.isTextDirty(tab)).toBe(true);
const guiDirty = ref(true);
const unreg = store.registerGUIDirtyChecker(10, () => guiDirty.value);
expect(store.hasGUIDirty(tab.index)).toBe(true);
// hasGUIOnlyDirty 为 false(因为文本也是脏的),但仍有 GUI 脏数据待应用
expect(store.hasGUIOnlyDirty(tab)).toBe(false);
// 1. saveActiveTab 必须拦截,禁止只保存 DSL 文本而静默遗留 GUI 修改
await expect(store.saveActiveTab()).rejects.toThrow(
"当前文件存在未应用的界面修改,请先在界面中点击【应用修改】后再保存",
);
expect(api.SetText).not.toHaveBeenCalled();
// 2. save 必须拦截
await expect(store.save()).rejects.toThrow(
"存在未应用的界面修改,请先在界面中点击【应用修改】后再保存",
);
expect(api.Save).not.toHaveBeenCalled();
// 3. saveAs 必须拦截
await expect(store.saveAs()).rejects.toThrow(
"存在未应用的界面修改,请先在界面中点击【应用修改】后再保存",
);
expect(api.SaveAsDialog).not.toHaveBeenCalled();
// 4. 用户应用或放弃 GUI 修改后(GUI 不再 dirty),普通的文本草稿保存才能顺利执行
guiDirty.value = false;
expect(store.hasGUIDirty(tab.index)).toBe(false);
expect(store.isTextDirty(tab)).toBe(true);
const saved = await store.saveActiveTab();
expect(saved).toBe(true);
expect(api.SetText).toHaveBeenCalledWith(10, "modified text in dsl");
unreg();
});
});