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