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

632 lines
23 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it } from "vitest";
import { getGUIProvider } from "../src/gui/registry";
import { createGUIModes, createWorldDropDraftState, createWorldDropSession } from "../src/gui/state";
import type { WorldDropDocument } from "../bindings/pvfine/services/models";
import {
batchAddItems,
batchDeleteItems,
calcItemPercentage,
calcLevelTotalWeight,
countLevelMatches,
countMatchingItems,
filterItems,
findFirstLevelWithMatch,
getLevelsInInterval,
isFilterCriteriaActive,
toEditableLevels,
toWorldDropLevels,
validateLevelInterval,
type WorldDropFilterCriteria,
} from "../src/gui/worldDropForm";
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason: Error) => void;
const promise = new Promise<T>((yes, no) => {
resolve = yes;
reject = no;
});
return { promise, resolve, reject };
}
describe("world drop GUI registry & session", () => {
it("首次从工具栏读取时,文档先返回不应被误判为表单修改", async () => {
const file = { index: 1, path: "etc/worlddrop.etc", text: "[world drop]\n1 0 100 5 -1", editable: true };
const session = createWorldDropSession(async () => ({
revision: 1,
levels: [{ level: 1, items: [{ id: 100, weight: 5, name: "测试道具" }] }],
}));
const draft = createWorldDropDraftState();
expect(draft.isDirty.value).toBe(false);
await session.load(file, "epoch1");
// 后端文档已到达,表单尚未同步;这不是用户编辑。
expect(session.document.value?.levels?.length).toBe(1);
expect(draft.isDirty.value).toBe(false);
draft.sync(session.document.value);
expect(draft.isDirty.value).toBe(false);
draft.editableLevels.value[0].items[0].weight = 9;
expect(draft.isDirty.value).toBe(true);
await session.load(file, "epoch1");
// 后续读取也不能擅自重置已编辑表单的比较基线。
expect(draft.isDirty.value).toBe(true);
});
it("GUI Provider 注册匹配 etc/worlddrop.etc 及其路径变体,排除其他文件", () => {
const file = { index: 1, path: "etc/worlddrop.etc", text: "", editable: true };
const provider = getGUIProvider(file);
expect(provider).not.toBeNull();
expect(provider?.id).toBe("world-drop");
expect(provider?.label).toBe("全局掉率");
expect(provider?.readOnly).toBe(false);
// 大小写与斜杠变体
expect(getGUIProvider({ ...file, path: "ETC/WORLDDROP.ETC" })?.id).toBe("world-drop");
expect(getGUIProvider({ ...file, path: "etc\\worlddrop.etc" })?.id).toBe("world-drop");
expect(getGUIProvider({ ...file, path: "/etc/worlddrop.etc" })?.id).toBe("world-drop");
// 不匹配其他文件
expect(getGUIProvider({ ...file, path: "etc/itemdropinfo_monster.etc" })).toBeNull();
expect(getGUIProvider({ ...file, path: "etc/worlddrop.txt" })).toBeNull();
});
it("Session 加载与生成代次保护:慢请求被新请求丢弃,不覆盖最新结果", async () => {
const file = { index: 1, path: "etc/worlddrop.etc", text: "text1", editable: true };
const firstDeferred = deferred<WorldDropDocument>();
const secondDeferred = deferred<WorldDropDocument>();
let callCount = 0;
const session = createWorldDropSession(async () => {
callCount++;
return callCount === 1 ? firstDeferred.promise : secondDeferred.promise;
});
const firstLoad = session.load(file, "epoch1");
expect(session.loading.value).toBe(true);
// 发起第二次加载
const secondLoad = session.load(file, "epoch1");
// 第二次先返回
secondDeferred.resolve({
revision: 2,
levels: [{ level: 85, items: [] }],
});
await secondLoad;
expect(session.document.value?.revision).toBe(2);
expect(session.selectedLevel.value).toBe(85);
// 第一次才返回,应当被忽略
firstDeferred.resolve({
revision: 1,
levels: [{ level: 1, items: [] }],
});
await firstLoad;
expect(session.document.value?.revision).toBe(2);
expect(session.selectedLevel.value).toBe(85);
});
it("Session 异常与空数据处理:保留上次成功状态,重试可恢复", async () => {
const file = { index: 1, path: "etc/worlddrop.etc", text: "", editable: true };
let shouldFail = false;
const session = createWorldDropSession(async () => {
if (shouldFail) throw new Error("解析失败:第 3 行缺少结束标记");
return {
revision: 1,
levels: [{ level: 10, items: [] }],
};
});
await session.load(file, "epoch1");
expect(session.document.value?.revision).toBe(1);
expect(session.error.value).toBe("");
// 下次加载失败
shouldFail = true;
await session.load(file, "epoch1");
expect(session.loading.value).toBe(false);
expect(session.error.value).toContain("缺少结束标记");
// 保留上一次成功加载的数据文档
expect(session.document.value?.revision).toBe(1);
// 恢复重试
shouldFail = false;
await session.load(file, "epoch1");
expect(session.error.value).toBe("");
expect(session.document.value?.revision).toBe(1);
});
it("Session.load 返回值:成功返回 true,被覆盖或异常返回 false", async () => {
const file = { index: 1, path: "etc/worlddrop.etc", text: "text1", editable: true };
const firstDeferred = deferred<WorldDropDocument>();
const secondDeferred = deferred<WorldDropDocument>();
let count = 0;
const session = createWorldDropSession(async () => {
count++;
if (count === 1) return firstDeferred.promise;
if (count === 2) return secondDeferred.promise;
throw new Error("异常测试");
});
const firstLoad = session.load(file, "epoch1");
const secondLoad = session.load(file, "epoch1");
secondDeferred.resolve({ revision: 2, levels: [] });
const secondResult = await secondLoad;
expect(secondResult).toBe(true);
firstDeferred.resolve({ revision: 1, levels: [] });
const firstResult = await firstLoad;
expect(firstResult).toBe(false);
// 失败情况
const thirdResult = await session.load(file, "epoch1");
expect(thirdResult).toBe(false);
});
it("GUI 模式守卫:阻止未保存修改的模式切换;确认放弃修改时重置表单并清除脏状态", async () => {
const modes = createGUIModes();
modes.set(1, "gui");
expect(modes.entries.get(1)?.mode).toBe("gui");
let isDirty = true;
let formResetCalled = false;
// 模拟确认框逻辑:用户确认放弃时重置表单并清除 dirty
const unregister = modes.registerGuard(1, async () => {
if (!isDirty) return true;
// 模拟用户点击【取消】
return false;
});
// 尝试切换到 text -> 被守卫拦截
const allowedFirst = await modes.requestMode(1, "text");
expect(allowedFirst).toBe(false);
expect(modes.entries.get(1)?.mode).toBe("gui");
expect(formResetCalled).toBe(false);
// 替换为用户点击【放弃修改】
unregister();
modes.registerGuard(1, async () => {
if (!isDirty) return true;
// 模拟用户点击【放弃修改并离开】
formResetCalled = true;
isDirty = false;
return true;
});
const allowedSecond = await modes.requestMode(1, "text");
expect(allowedSecond).toBe(true);
expect(modes.entries.get(1)?.mode).toBe("text");
expect(formResetCalled).toBe(true);
expect(isDirty).toBe(false);
});
it("不同窗格的 GUI 模式与守卫独立隔离", async () => {
const pane1Modes = createGUIModes();
const pane2Modes = createGUIModes();
pane1Modes.set(1, "gui");
pane2Modes.set(1, "text");
expect(pane1Modes.entries.get(1)?.mode).toBe("gui");
expect(pane2Modes.entries.get(1)?.mode).toBe("text");
// 在 pane1 上注册拦截守卫
pane1Modes.registerGuard(1, () => false);
// pane1 无法切回 text
expect(await pane1Modes.requestMode(1, "text")).toBe(false);
expect(pane1Modes.entries.get(1)?.mode).toBe("gui");
// pane2 没有守卫,可自由切换
expect(await pane2Modes.requestMode(1, "gui")).toBe(true);
expect(pane2Modes.entries.get(1)?.mode).toBe("gui");
expect(await pane2Modes.requestMode(1, "text")).toBe(true);
expect(pane2Modes.entries.get(1)?.mode).toBe("text");
});
it("标签切换与外部文本变动状态转移逻辑:切出再切回保留脏表单,文本冲突安全标示", () => {
// 模拟 WorldDropViewer 中的核心 watch 状态机行为
let loadedText = "version 1 text";
let loadedArchiveId = "1\u0000etc/worlddrop.etc\u0000epoch1";
let formDirty = true;
let staleConflict = false;
let reloadCount = 0;
function handleFileChange(active: boolean, index: number, path: string, text: string, epoch: string) {
if (!active) return;
const archiveId = `${index}\u0000${path}\u0000${epoch}`;
const identityChanged = loadedArchiveId !== "" && loadedArchiveId !== archiveId;
const textChanged = loadedText !== "" && loadedText !== text;
if (identityChanged) {
reloadCount++;
loadedArchiveId = archiveId;
loadedText = text;
formDirty = false;
staleConflict = false;
return;
}
if (textChanged) {
if (formDirty) {
staleConflict = true;
} else {
reloadCount++;
loadedText = text;
}
}
}
// 1. 用户编辑后成为 dirty,随后切到其他标签(active = false)
handleFileChange(false, 1, "etc/worlddrop.etc", "version 1 text", "epoch1");
expect(formDirty).toBe(true);
expect(reloadCount).toBe(0);
// 2. 切回该标签(active = true),文本与归档未变 -> 必须保留 dirty,绝不 reload
handleFileChange(true, 1, "etc/worlddrop.etc", "version 1 text", "epoch1");
expect(formDirty).toBe(true);
expect(reloadCount).toBe(0);
expect(staleConflict).toBe(false);
// 3. 此时外部在分屏中修改了文本(DSL 变动)
handleFileChange(true, 1, "etc/worlddrop.etc", "version 2 external text", "epoch1");
// 因存在未应用修改,必须标示 conflict 且保留 dirty 表单,禁止隐式 reload 冲掉用户编辑
expect(formDirty).toBe(true);
expect(reloadCount).toBe(0);
expect(staleConflict).toBe(true);
// 4. 用户手动点击重新加载并确认放弃后
reloadCount++;
loadedText = "version 2 external text";
formDirty = false;
staleConflict = false;
expect(formDirty).toBe(false);
expect(staleConflict).toBe(false);
// 5. 在非 dirty 状态下,外部文本更新会自动重新加载
handleFileChange(true, 1, "etc/worlddrop.etc", "version 3 text", "epoch1");
expect(reloadCount).toBe(2);
expect(staleConflict).toBe(false);
});
it("Reload 异步竞态防御:读取期间文本发生漂移时拒绝覆盖并标示冲突,且不丢弃脏表单", async () => {
// 模拟 WorldDropViewer.reload 内部防御机制
const readDeferred = deferred<WorldDropDocument>();
const file = { index: 1, path: "etc/worlddrop.etc", text: "text v1", editable: true };
const guiEpoch = 1;
let loadedText = "";
let loadedArchiveIdentity = "";
let staleConflict = false;
let syncCount = 0;
let isDirty = false;
async function reload(force = false): Promise<void> {
const userConfirmedDiscard = force;
if (isDirty && !userConfirmedDiscard) {
return;
}
// 请求发起时快照
const requestText = file.text;
const requestArchiveIdentity = `${file.index}\u0000${file.path}\u0000${guiEpoch}`;
await readDeferred.promise;
// 检查变动
const currentArchiveIdentity = `${file.index}\u0000${file.path}\u0000${guiEpoch}`;
const textChangedDuringLoad = file.text !== requestText;
const identityChangedDuringLoad = currentArchiveIdentity !== requestArchiveIdentity;
if (textChangedDuringLoad || identityChangedDuringLoad) {
staleConflict = true;
return;
}
if (isDirty && !userConfirmedDiscard) {
staleConflict = true;
return;
}
loadedText = requestText;
loadedArchiveIdentity = requestArchiveIdentity;
syncCount++;
staleConflict = false;
}
// 场景 A: 异步读取期间,外部 DSL 文本被修改为 text v2
const reloadPromiseA = reload(false);
// 模拟读取中途,用户在分屏编辑 DSL 改变了 file.text
file.text = "text v2 in split pane";
// 异步读取完成
readDeferred.resolve({ revision: 1, levels: [] });
await reloadPromiseA;
// 验证:检测到竞态漂移,绝不同步旧文档,绝不将 v2 标为已加载基线,标示冲突
expect(staleConflict).toBe(true);
expect(syncCount).toBe(0);
expect(loadedText).toBe(""); // 没有被错误更新为 text v2
// 场景 B: 异步读取期间,用户在界面中进行了编辑产生了 dirty 表单
const secondDeferred = deferred<WorldDropDocument>();
file.text = "text v2";
staleConflict = false;
isDirty = false;
async function secondReload(): Promise<void> {
const userConfirmedDiscard = false;
const requestText = file.text;
const requestArchiveIdentity = `${file.index}\u0000${file.path}\u0000${guiEpoch}`;
await secondDeferred.promise;
const currentArchiveIdentity = `${file.index}\u0000${file.path}\u0000${guiEpoch}`;
if (file.text !== requestText || currentArchiveIdentity !== requestArchiveIdentity) {
staleConflict = true;
return;
}
if (isDirty && !userConfirmedDiscard) {
// 保护脏表单不被静默覆盖
staleConflict = true;
return;
}
loadedText = requestText;
syncCount++;
}
const reloadPromiseB = secondReload();
// 异步在途期间,用户点击了添加道具/修改等级,表单变为 dirty
isDirty = true;
secondDeferred.resolve({ revision: 2, levels: [] });
await reloadPromiseB;
// 验证:保护 dirty 表单,未静默覆盖,且标示冲突
expect(isDirty).toBe(true);
expect(syncCount).toBe(0);
expect(staleConflict).toBe(true);
});
describe("全局掉率 GUI 后续特性集成测试 (等级只读、区间添加、批量删除、道具筛选)", () => {
it("区间选择必须限制在已有配置等级中,且仅对区间内已有等级生效", () => {
const docLevels = [
{ level: 1, items: [] },
{ level: 15, items: [] },
{ level: 85, items: [] },
];
const editable = toEditableLevels(docLevels);
const existingLevels = editable.map((l) => l.level);
// 非法等级值校验
expect(validateLevelInterval(5, 20, existingLevels)).toContain("Lv. 5 不存在");
expect(validateLevelInterval(15, 99, existingLevels)).toContain("Lv. 99 不存在");
expect(validateLevelInterval(85, 15, existingLevels)).toContain("不能大于");
// 合法区间
expect(validateLevelInterval(1, 85, existingLevels)).toBe("");
// 区间内已有等级
const targets = getLevelsInInterval(editable, 1, 15);
expect(targets.map((t) => t.level)).toEqual([1, 15]);
});
it("添加道具时区间批量追加:已存在同 ID 道具时直接追加新行", () => {
const docLevels = [
{ level: 1, items: [{ id: 1001, weight: 50, name: "原物品" }] },
{ level: 10, items: [] },
];
const editable = toEditableLevels(docLevels);
// 批量添加 1001,权重 80
const res = batchAddItems(editable, 1, 10, {
id: 1001,
weight: 80,
name: "新添加物品",
});
expect(res.addedCount).toBe(2);
expect(res.affectedLevels).toEqual([1, 10]);
// 等级 1 现在有 2 条记录,第一条为旧物品,第二条为追加行
expect(editable[0].items.length).toBe(2);
expect(editable[0].items[0]).toMatchObject({ id: 1001, weight: 50 });
expect(editable[0].items[1]).toMatchObject({ id: 1001, weight: 80 });
// 等级 10 现在有 1 条记录
expect(editable[1].items.length).toBe(1);
expect(editable[1].items[0]).toMatchObject({ id: 1001, weight: 80 });
});
it("批量删除:选定区间内完全移除匹配道具,保留空等级,0 匹配无变更", () => {
const docLevels = [
{
level: 1,
items: [
{ id: 2001, weight: 10, name: "X" },
{ id: 2001, weight: 20, name: "X2" },
{ id: 3001, weight: 30, name: "Y" },
],
},
{ level: 20, items: [{ id: 2001, weight: 40, name: "X" }] },
{ level: 85, items: [{ id: 2001, weight: 90, name: "X-outside" }] },
];
const editable = toEditableLevels(docLevels);
// 先查询 [1, 20] 内的匹配情况
const preview = countMatchingItems(editable, 1, 20, 2001);
expect(preview.matchingLevels).toEqual([1, 20]);
expect(preview.matchingRows).toBe(3);
// 执行批量删除
const result = batchDeleteItems(editable, 1, 20, 2001);
expect(result.removedCount).toBe(3);
expect(result.affectedLevels).toEqual([1, 20]);
// 等级 1 仅剩下 3001
expect(editable[0].items.map((i) => i.id)).toEqual([3001]);
// 等级 20 变为空等级(空等级结构保留,未被删除)
expect(editable[1].items.length).toBe(0);
expect(editable[1].level).toBe(20);
// 等级 85 在区间外,依然保留 2001
expect(editable[2].items.map((i) => i.id)).toEqual([2001]);
// 再次对不存在的道具批量删除:0 匹配,无变动
const zeroResult = batchDeleteItems(editable, 1, 20, 9999);
expect(zeroResult.removedCount).toBe(0);
expect(zeroResult.affectedLevels).toEqual([]);
});
it("视图筛选:仅展示匹配道具,但总权重与参考百分比始终基于等级内全部道具真实计算", () => {
const level = {
level: 1,
key: 1,
items: [
{ id: 10, weight: 100, name: "红药水", key: 1 },
{ id: 20, weight: 300, name: "蓝药水", key: 2 },
{ id: 30, weight: 600, name: "圣水", key: 3 },
],
};
// 全量总权重为 1000
const totalWeight = calcLevelTotalWeight(level.items);
expect(totalWeight).toBe(1000);
// 用户筛选 "红药水"
const filtered = filterItems(level.items, "红药水");
expect(filtered.length).toBe(1);
expect(filtered[0].id).toBe(10);
// 关键正确性:虽然视图只展示了红药水一条,但概率计算必须使用等级真实总权重 1000(占比 10.00%),而不是 100/100 = 100.00%
const percentage = calcItemPercentage(filtered[0].weight, totalWeight);
expect(percentage).toBe("10.00%");
// 若总权重为 0,展示 0.00% 而不是 NaN
const zeroLevelTotal = 0;
expect(calcItemPercentage(filtered[0].weight, zeroLevelTotal)).toBe("0.00%");
});
it("提交应用时即使界面处于筛选状态,快照仍完整保留所有未展示的道具行", () => {
const editable = toEditableLevels([
{
level: 10,
items: [
{ id: 101, weight: 50, name: "武器A" },
{ id: 102, weight: 60, name: "防具B" },
],
},
]);
// 界面处于筛选状态,只看到 "武器A"
const displayed = filterItems(editable[0].items, "武器");
expect(displayed.length).toBe(1);
// 导出提交快照(toWorldDropLevels)
const snapshot = toWorldDropLevels(editable);
expect(snapshot[0].items.length).toBe(2);
expect(snapshot[0].items.map((i) => i.id)).toEqual([101, 102]);
});
it("从物品库精准选取物品时:仅按 item.id 全等匹配,排除同名异 ID 和数字子串冲突", () => {
const docLevels = [
{
level: 1,
items: [
{ id: 10, weight: 100, name: "强化券" },
{ id: 100, weight: 200, name: "高级强化券" }, // 数字包含 10
{ id: 210, weight: 300, name: "特级强化券" }, // 数字包含 10
{ id: 999, weight: 400, name: "强化券" }, // 同名为 "强化券",但 ID 为 999
],
},
];
const editable = toEditableLevels(docLevels);
// 1. 精准选取 hit.id = 10(无论名称为何)
const exactFilter: WorldDropFilterCriteria = { exactItemId: 10 };
const matched = filterItems(editable[0].items, exactFilter);
// 必须且仅有一项:id 严格为 10
expect(matched.length).toBe(1);
expect(matched[0].id).toBe(10);
expect(matched[0].name).toBe("强化券");
// 确认同名异 ID (999) 和包含 10 的 (100, 210) 绝不出现
expect(matched.some((i) => i.id === 999)).toBe(false);
expect(matched.some((i) => i.id === 100)).toBe(false);
expect(matched.some((i) => i.id === 210)).toBe(false);
// 2. 确认全量提交快照依然不受精准筛选影响,保持全量行完整
const snapshot = toWorldDropLevels(editable);
expect(snapshot[0].items.length).toBe(4);
expect(snapshot[0].items.map((i) => i.id)).toEqual([10, 100, 210, 999]);
});
it("精准筛选与自由文本筛选独立建模且支持清除", () => {
const items = [
{ id: 50, weight: 10, name: "A", key: 1 },
{ id: 500, weight: 20, name: "A-500", key: 2 },
];
// 精准筛选状态
const exact: WorldDropFilterCriteria = { exactItemId: 50 };
expect(isFilterCriteriaActive(exact)).toBe(true);
expect(filterItems(items, exact).map((i) => i.id)).toEqual([50]);
// 清除精准筛选
const cleared: WorldDropFilterCriteria = { exactItemId: null, query: "" };
expect(isFilterCriteriaActive(cleared)).toBe(false);
expect(filterItems(items, cleared).map((i) => i.id)).toEqual([50, 500]);
// 自由文本筛选状态
const textSearch: WorldDropFilterCriteria = { query: "50" };
expect(isFilterCriteriaActive(textSearch)).toBe(true);
expect(filterItems(items, textSearch).map((i) => i.id)).toEqual([50, 500]);
});
it("精准选择物品时,若当前等级无匹配,导航优先跳转到首个有匹配的等级", () => {
const docLevels = [
{ level: 1, items: [{ id: 10, weight: 50, name: "药水" }] },
{ level: 10, items: [] }, // 空等级
{ level: 20, items: [{ id: 8888, weight: 100, name: "史诗装备" }] },
{ level: 85, items: [{ id: 8888, weight: 200, name: "史诗装备" }] },
];
const editable = toEditableLevels(docLevels);
// 模拟当前选中 Level 1
let selectedLevel: number = 1;
const targetExactId = 8888;
const criteria: WorldDropFilterCriteria = { exactItemId: targetExactId };
// 当前 Level 1 匹配数为 0
const currentLevelMatches = countLevelMatches(editable[0], criteria);
expect(currentLevelMatches).toBe(0);
// 执行导航偏好判定:找到首个有匹配的等级 (应该为 Level 20,而不是 Level 10 或 Level 85)
if (currentLevelMatches === 0) {
const firstMatchLevel = findFirstLevelWithMatch(editable, criteria);
if (firstMatchLevel !== null) {
selectedLevel = firstMatchLevel;
}
}
expect(selectedLevel).toBe(20);
// 若再次选择 8888,因当前等级 Level 20 已有匹配,保持当前 Level 20
const newLevelMatches = countLevelMatches(editable[2], criteria);
expect(newLevelMatches).toBe(1);
// 无需跳转
expect(selectedLevel).toBe(20);
});
});
});