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

211 lines
14 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { skillTreeEdge, skillTreeEdgeLayout, groupSkillTreeCells, skillTreeRowLevels, skillTreeCellNode, skillTreeDropTarget, moveSkillTreeCell, skillTreeCanvasHeight, addSkillTreeNode, removeSkillTreeNode, editSkillTreeLink } from "../src/gui/skillTree";
import type { SkillTreeNode } from "../bindings/pvfine/services/models";
const node = (x: number, y: number): SkillTreeNode => ({id: 1, sourceStart: 0, sourceClose: -1, nextTokens: [], positionTokens: [], x, y, name: "测试", explain: "", positionKind: "icon pos", position: [x, y], next: [], path: "", fileIndex: -1, icon: null, requiredLevel: null});
describe("技能树连线", () => {
it("端点与实际路径一致,同位置关联绕开卡片,仍可选中编辑", () => {
expect(skillTreeEdgeLayout(node(0, 0), node(94, 0)).end).toEqual({x: 90, y: 16});
expect(skillTreeEdgeLayout(node(0, 0), node(0, 134)).end).toEqual({x: 16, y: 131});
expect(skillTreeEdgeLayout(node(47, 67), node(47, 67))).toEqual({path: "M 81 83 H 91 V 57 H 63 V 64", end: {x: 63, y: 64}});
});
it("横向连线停在目标图标前,反向关系也保留箭头方向", () => {
expect(skillTreeEdge(node(0, 0), node(94, 0))).toBe("M 34 16 H 62 V 16 H 90");
expect(skillTreeEdge(node(94, 0), node(0, 0))).toBe("M 92 16 H 64 V 16 H 36");
});
it("纵向跨行连接保留像素偏移,并绕开图标中心", () => {
expect(skillTreeEdge(node(23, 0), node(47, 134))).toBe("M 39 40 V 85.5 H 63 V 131");
});
});
describe("同位置技能", () => {
it("保留坐标和文件顺序,同格技能可分别选择", () => {
const a = {...node(235, 938), id: 319};
const b = {...node(235, 938), id: 112};
const c = {...node(329, 938), id: 113};
const cells = groupSkillTreeCells([a, c, b]);
expect(cells.map(cell => cell.nodes.map(n => n.id))).toEqual([[319, 112], [113]]);
expect(cells[0]!.nodes[1]).toBe(b);
expect([b.x, b.y]).toEqual([235, 938]);
});
});
describe("技能树行等级", () => {
it("SP 按最左侧技能取学习等级,同坐标按文件顺序,不随所选技能变化", () => {
const nodes = [
{...node(94, 0), requiredLevel: 20},
{...node(23, 0), requiredLevel: 5},
{...node(23, 0), requiredLevel: 10},
{...node(0, 134), requiredLevel: 0},
{...node(0, 201), requiredLevel: null},
];
const levels = skillTreeRowLevels(nodes, "sp");
expect(levels.get(0)).toBe(5);
expect(levels.has(1)).toBe(false);
expect(levels.get(2)).toBe(0);
expect(levels.get(3)).toBeNull();
});
it("TP 不产生等级标注", () => {
expect(skillTreeRowLevels([{...node(0, 0), requiredLevel: 50}], "tp").size).toBe(0);
});
});
describe("同位置技能默认显示", () => {
it("默认取文件中最后一个技能;记忆有效则恢复,技能已移除则回退到最后一个", () => {
const cell = {key: "0:0", nodes: [{...node(0, 0), id: 0}, {...node(0, 0), id: 112}, {...node(0, 0), id: 319}]};
expect(skillTreeCellNode(cell).id).toBe(319);
expect(skillTreeCellNode(cell, 0).id).toBe(0);
expect(skillTreeCellNode(cell, 112).id).toBe(112);
expect(skillTreeCellNode(cell, 999).id).toBe(319);
});
});
describe("技能树拖拽", () => {
it("吸附47×67网格,拒绝边界、已占用位置及半格图标的交叠", () => {
const a = {...node(0, 0), sourceStart: 1};
const b = {...node(94, 67), sourceStart: 2};
const half = {...node(23, 134), sourceStart: 3};
const cell = {key: "0:0", nodes: [a]};
const nodes = [a, b, half];
expect(skillTreeDropTarget(nodes, cell, 46, 66, 564, 268)).toMatchObject({x: 47, y: 67, valid: true});
expect(skillTreeDropTarget(nodes, cell, 94, 67, 564, 268).valid).toBe(false);
expect(skillTreeDropTarget(nodes, cell, 0, 134, 564, 268).valid).toBe(false);
expect(skillTreeDropTarget(nodes, cell, 47, 134, 564, 268).valid).toBe(false);
expect(skillTreeDropTarget(nodes, cell, -47, 0, 564, 268).valid).toBe(false);
expect(skillTreeDropTarget(nodes, cell, 564, 0, 564, 268).valid).toBe(false);
expect(skillTreeDropTarget(nodes, cell, 0, 268, 564, 268).valid).toBe(false);
});
it("整组移动混合坐标,仅改数字,保留关联、CRLF、emoji及其他分支", () => {
const text = "// 😀\r\n[character job]\r\n`[swordman]` `none`\r\n[skill info]\r\n[index]\r\n1\r\n[cell pos]\r\n2\t3 // 坐标\r\n[next skill]\r\n9\r\n[/skill info]\r\n[skill info]\r\n[index]\r\n2\r\n[icon pos]\r\n94 201\r\n[/skill info]\r\n[/character job]\r\n[character job]\r\n`[mage]` `none`\r\n[skill info]\r\n[index]\r\n1\r\n[cell pos]\r\n2\t3\r\n[/skill info]\r\n[/character job]";
const normalized = text.replaceAll("\r\n", "\n");
const tokens = (raw: string, values: number[]) => {
const start = normalized.indexOf(raw);
return values.map((value, i) => ({value, start: start + (i ? String(values[0]).length + 1 : 0), end: start + (i ? String(values[0]).length + 1 : 0) + String(value).length, tokenType: 0}));
};
const a = {...node(94, 201), sourceStart: 1, positionKind: "cell pos", positionTokens: tokens("2\t3", [2, 3])};
const b = {...node(94, 201), sourceStart: 2, positionKind: "icon pos", positionTokens: tokens("94 201", [94, 201])};
const cell = {key: "94:201", nodes: [a, b]};
const result = moveSkillTreeCell(text, cell.nodes, cell, 47, 67, 564, 268);
expect(result).toBe(text.replace("2\t3 // 坐标", "1\t1 // 坐标").replace("94 201", "47 67"));
expect(() => moveSkillTreeCell(text.replace("94 201", "95 201"), cell.nodes, cell, 47, 67, 564, 268)).toThrow("已变化");
expect(() => moveSkillTreeCell(text, cell.nodes, cell, 23, 67, 564, 268)).toThrow("网格");
});
});
describe("技能树追加行", () => {
it("末尾始终有空行,移入后继续扩展,画布外下一行仍不可放置", () => {
const source = {...node(0, 201), sourceStart: 1};
const cell = {key: "0:201", nodes: [source]};
const height = skillTreeCanvasHeight([source]);
expect(height).toBe(335);
expect(skillTreeDropTarget([source], cell, 47, 268, 564, height).valid).toBe(true);
expect(skillTreeDropTarget([source], cell, 47, 335, 564, height).valid).toBe(false);
expect(skillTreeCanvasHeight([{...source, y: 268}])).toBe(402);
expect(skillTreeCanvasHeight([])).toBe(268);
});
it("像素坐标有纵向偏移时,新增行仍完整留空", () => {
const height = skillTreeCanvasHeight([node(23, 220)]);
expect(height).toBe(402);
expect(height - 67).toBeGreaterThan(220 + 56);
});
});
describe("点击空白格新增技能", () => {
it("只在当前分支结束前插入,保留CRLF、emoji、其他分支及原有注释", () => {
const text = "// 😀\r\n[character job]\r\n`[mage]` `none`\r\n// 保留\r\n[/character job]\r\n[character job]\r\n`[swordman]` `none`\r\n[/character job]";
const branch = {job: "[mage]", name: "none", nodes: [], insertOffset: text.replaceAll("\r\n", "\n").indexOf("[/character job]")};
const result = addSkillTreeNode(text, branch, 0, 94, 201, 564, 335);
expect(result).toBe(text.replace("// 保留\r\n", "// 保留\r\n\t[skill info]\r\n\t\t[index]\r\n\t\t\t0\r\n\t\t[cell pos]\r\n\t\t\t2\t3\r\n\t[/skill info]\r\n"));
});
it("拒绝重复ID、占用位置、缺失结束标签和失效插入位置", () => {
const text = "[character job]\n`[mage]` `none`\n[/character job]";
const existing = {...node(0, 0), id: 5, sourceStart: 20};
const branch = {job: "[mage]", name: "none", nodes: [existing], insertOffset: text.indexOf("[/character job]")};
expect(() => addSkillTreeNode(text, branch, 5, 47, 0, 564, 268)).toThrow("已包含");
expect(() => addSkillTreeNode(text, branch, 7, 0, 0, 564, 268)).toThrow("占用");
expect(() => addSkillTreeNode(text, {...branch, insertOffset: -1}, 7, 47, 0, 564, 268)).toThrow("结束标签");
expect(() => addSkillTreeNode("// 变化\n" + text, branch, 7, 47, 0, 564, 268)).toThrow("已变化");
});
});
describe("技能树删除", () => {
const fixture = () => {
const record = (id: number, next = "") => `[skill info]\n[index]\n${id}\n[cell pos]\n0 0\n${next}[/skill info]\n`;
const first = record(7, "// [/skill info] 😀\n");
const second = record(8, "[next skill]\n7 9 7 // 保留注释\n");
const other = record(7);
const text = `[character job]\n\`[swordman]\` \`none\`\n${first}${second}[/character job]\n[character job]\n\`[swordman]\` \`asura\`\n${other}[/character job]\n`;
const a = {...node(0, 0), id: 7, sourceStart: text.indexOf(first), sourceClose: text.indexOf("[/skill info]", text.indexOf("😀"))};
const linkStart = text.indexOf("7 9 7");
const b = {...node(0, 0), id: 8, sourceStart: text.indexOf(second), sourceClose: text.indexOf("[/skill info]", linkStart), next: [7, 9, 7], nextTokens: [0, 2, 4].map((offset, i) => ({value: i === 1 ? 9 : 7, start: linkStart + offset, end: linkStart + offset + 1, tokenType: 2}))};
return {text, first, second, a, b, branch: {job: "[swordman]", name: "none", insertOffset: -1, nodes: [a, b]}};
};
it("只移除选中记录及当前分支所有入边,保留重叠技能、注释、另一分支和CRLF", () => {
const {text, first, a, branch} = fixture();
const expected = text.replace(first, "").replace("7 9 7 //", " 9 //");
expect(removeSkillTreeNode(text, branch, a)).toBe(expected);
expect(removeSkillTreeNode(text.replaceAll("\n", "\r\n"), branch, a)).toBe(expected.replaceAll("\n", "\r\n"));
});
it("保留同行的其他文本、删除仅剩技能后允许空分支", () => {
const text = "[character job]\n`[swordman]` `none`\n[skill info][index]7[cell pos]0 0[/skill info] // 留下\n[/character job]";
const a = {...node(0, 0), id: 7, sourceStart: text.indexOf("[skill info]"), sourceClose: text.indexOf("[/skill info]")};
const branch = {job: "[swordman]", name: "none", insertOffset: -1, nodes: [a]};
expect(removeSkillTreeNode(text, branch, a)).toBe("[character job]\n`[swordman]` `none`\n // 留下\n[/character job]");
});
it("拒绝缺失关闭标签、过期关联及不属于分支的技能", () => {
const {text, a, branch} = fixture();
expect(() => removeSkillTreeNode(text, branch, {...a})).toThrow("技能已变化");
a.sourceClose = -1;
expect(() => removeSkillTreeNode(text, branch, a)).toThrow("不完整");
const fresh = fixture();
expect(() => removeSkillTreeNode(fresh.text.replace("7 9 7", "6 9 7"), fresh.branch, fresh.a)).toThrow("关联记录已变化");
fresh.b.nextTokens = [];
expect(() => removeSkillTreeNode(fresh.text, fresh.branch, fresh.a)).toThrow("关联记录不完整");
});
});
describe("技能树关联编辑", () => {
const fixture = () => {
const record = "[skill info]\n[index]\n1\n[cell pos]\n0 0\n[next skill]\n2 99 // 😀 顺序与注释保留\n[next skill]\n2\n[/skill info]";
const text = "// 😀\n[character job]\n`[swordman]` `none`\n" + record + "\n[/character job]\n";
const first = text.indexOf("2 99"), last = text.lastIndexOf("2\n");
const source = {...node(0, 0), sourceStart: text.indexOf("[skill info]"), sourceClose: text.indexOf("[/skill info]"), next: [2, 99, 2], nextTokens: [
{value: 2, start: first, end: first + 1, tokenType: 0},
{value: 99, start: first + 2, end: first + 4, tokenType: 0},
{value: 2, start: last, end: last + 1, tokenType: 0},
]};
const branch = {job: "[swordman]", name: "none", insertOffset: -1, nodes: [source, {...node(47, 0), id: 2}, {...node(94, 0), id: 3}]};
return {text, source, branch};
};
it("修改和删除按条目索引定位,保留重复关联、失效目标、注释与CRLF", () => {
const {text, source, branch} = fixture();
expect(editSkillTreeLink(text, branch, source, 2, 3)).toBe(text.replace("[next skill]\n2\n", "[next skill]\n3\n"));
expect(editSkillTreeLink(text, branch, source, 1, null)).toBe(text.replace("2 99 //", "2 //"));
expect(editSkillTreeLink(text.replaceAll("\n", "\r\n"), branch, source, 0, 3)).toBe(text.replace("2 99 //", "3 99 //").replaceAll("\n", "\r\n"));
});
it("新增追加到技能记录末尾,保留现有关联的顺序", () => {
const {text, source, branch} = fixture();
expect(editSkillTreeLink(text, branch, source, -1, 3)).toBe(text.replace("[/skill info]", "\t[next skill]\n\t\t3\n[/skill info]"));
const empty = {...source, next: [], nextTokens: []};
const emptyBranch = {...branch, nodes: [empty, ...branch.nodes.slice(1)]};
const blankText = text.replace(/2 99/g, " ").replace(/2\n/g, " \n");
expect(editSkillTreeLink(blankText, emptyBranch, empty, -1, 3)).toContain("\t[next skill]\n\t\t3\n[/skill info]");
});
it("没有 next skill 标签时可新增首条关联,且不改其他记录", () => {
const text = "[skill info][index]1[cell pos]0 0[/skill info]\n[skill info][index]2[cell pos]1 0[/skill info]";
const source = {...node(0, 0), sourceClose: text.indexOf("[/skill info]")};
const branch = {job: "[swordman]", name: "none", insertOffset: -1, nodes: [source, {...node(47, 0), id: 2}]};
expect(editSkillTreeLink(text, branch, source, -1, 2)).toBe(text.replace("0 0[/skill info]", "0 0\n\t[next skill]\n\t\t2\n[/skill info]"));
});
it("拒绝跨分支目标、自关联、重复新增、过期索引与过期文本", () => {
const {text, source, branch} = fixture();
expect(() => editSkillTreeLink(text, branch, source, -1, 2)).toThrow("已存在");
expect(() => editSkillTreeLink(text, branch, source, -1, 1)).toThrow("自身");
expect(() => editSkillTreeLink(text, branch, source, -1, 99)).toThrow("当前分支");
expect(() => editSkillTreeLink(text, branch, source, 3, null)).toThrow("位置已变化");
expect(() => editSkillTreeLink(text.replace("2 99", "3 99"), branch, source, 0, null)).toThrow("记录已变化");
source.sourceClose = -1;
expect(() => editSkillTreeLink(text, branch, source, -1, 3)).toThrow("不完整");
});
});