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

+236
View File
@@ -0,0 +1,236 @@
import { parseItem, type ItemSection, type ItemToken } from './item';
export type CeraLayout = 'item' | 'avatar' | 'package' | 'external';
export interface CeraSchema { label: string; tab: string; layout: CeraLayout; width: number; name: number; gold: number; cash: number; date: number; quantity?: number; productId?: number; itemId?: number; readOnly?: boolean; stringFields?: number[]; defaults?: Record<number, string> }
const item = (label: string, tab: string): CeraSchema => ({ label, tab, layout: 'item', width: 14, name: 8, gold: 3, cash: 5, date: 12, defaults: { 11: '-1', 13: '-1' } });
export const ceraSchemas: Record<string, CeraSchema> = {
'avatar': { label: '可交易时装', tab: 'avatar', layout: 'avatar', width: 13, name: -1, gold: 3, cash: 5, date: 12, defaults: { 2: '4', 10: '-1', 11: '-1' } },
'dont trade avatar': { label: '不可交易时装', tab: 'avatar', layout: 'avatar', width: 14, name: -1, gold: 3, cash: 5, date: 13, defaults: { 2: '4', 10: '-1', 11: '-1', 12: '-1' } },
'avatar dye': item('染色道具', 'avatar'),
'item event': item('活动商品', 'event'),
'item second': item('消耗道具', 'item'),
'item': item('常规道具', 'item'),
'item mod or ext': item('仓库与扩展', 'item'),
'item period or contract': item('期限与契约', 'item'),
'creature': item('宠物', 'item'),
'item etc': item('其他道具', 'item'),
'premium': item('高级商品', 'item'),
'package related': item('关联礼包', 'package'),
'package': { label: '礼包', tab: 'package', layout: 'package', width: 13, name: 7, gold: 2, cash: 4, date: 12, defaults: { 10: '-1', 11: '-1' } },
};
const legacyItem = (label: string, tab = 'item'): CeraSchema => ({ label, tab, layout: 'item', width: 9, name: 6, gold: 3, cash: 5, date: -1 });
const legacyPackage = (label: string, tab = 'package'): CeraSchema => ({ label, tab, layout: 'package', width: 11, name: 5, gold: 2, cash: 4, date: -1, defaults: { 8: '-1', 9: '-1' } });
export const legacyCeraSchemas: Record<string, CeraSchema> = {
// The six-field avatar table contains period/type flags, not a confirmed price column.
avatar: { label: '时装', tab: 'avatar', layout: 'avatar', width: 6, name: -1, gold: -1, cash: -1, date: -1, defaults: { 2: '1', 5: '-1' } },
coin: legacyItem('复活币'),
item: legacyItem('常规道具'),
material: legacyItem('材料'),
recoveryitem: legacyItem('恢复道具'),
premium: legacyItem('契约与服务'),
'selectable character premium': { ...legacyItem('魔王之契约'), quantity: -1, gold: -1, defaults: { 2: '-1', 3: '7', 8: '-1' } },
visual: { ...legacyItem('名称装饰'), width: 8, quantity: -1, gold: -1, defaults: { 2: '-1', 3: '7' } },
creature: legacyItem('宠物'),
'community package': legacyPackage('结婚礼包', 'community'),
'community item': legacyItem('社区道具', 'community'),
'regular package': legacyPackage('常规礼包'),
package: legacyPackage('礼包'),
'charac premium package': { label: '角色服务礼包', tab: 'package', layout: 'package', width: 9, name: 3, gold: -1, cash: 2, date: -1, defaults: { 6: '1' } },
derivative: { label: '周边商品', tab: 'derivative', layout: 'external', width: 7, name: 1, gold: -1, cash: 2, date: -1, productId: -1, itemId: 3, readOnly: true, stringFields: [0, 1] },
};
const twItem = (label: string, tab = 'item'): CeraSchema => ({ label, tab, layout: 'item', width: 10, name: 6, gold: 3, cash: 5, date: -1, defaults: { 9: '-1' } });
export const twCeraSchemas: Record<string, CeraSchema> = {
avatar: { label: '可交易时装', tab: 'avatar', layout: 'avatar', width: 7, name: -1, gold: -1, cash: -1, date: -1, defaults: { 2: '1', 5: '-1', 6: '-1' } },
'dont trade avatar': { label: '不可交易时装', tab: 'avatar', layout: 'avatar', width: 8, name: -1, gold: -1, cash: -1, date: -1, defaults: { 2: '4', 5: '-1', 6: '-1', 7: '-1' } },
coin: twItem('复活币'),
item: twItem('常规道具'),
creature: twItem('宠物', 'creature'),
premium: twItem('契约与服务'),
package: { label: '礼包', tab: 'package', layout: 'package', width: 10, name: 5, gold: 2, cash: 4, date: -1, defaults: { 8: '-1', 9: '-1' } },
};
export function ceraQuantityIndex(schema: CeraSchema): number { return schema.quantity ?? (schema.layout === 'item' ? 2 : -1); }
export const ceraTabLabels: Record<string, string> = { event: '活动', mileage: '积分', hotandnew: '热卖与新品', package: '礼包', avatar: '时装', item: '道具', community: '社区', derivative: '周边', creature: '宠物', exit: '退出' };
export interface CeraProduct { section: string; row: number; fields: ItemToken[]; id: string; itemId: string; name: string; gold: string; cash: string; date: string; quantity: string }
export interface CeraDocument { family: 'legacy' | 'modern' | 'tw'; schemas: Record<string, CeraSchema>; references: Record<string, { width: number; index: number }>; sections: ItemSection[]; products: CeraProduct[]; tabs: string[]; hot: Set<string>; mileage: Set<string>; issues: string[]; blocked: Set<string> }
const refWidths: Record<string, number> = {
'purchasing limit': 8, 'push cart limit': 1, 'gift disallowance': 1, 'gift only': 1,
'not stackable buy': 1, 'immediately adaptive product': 1, 'hotnew sell ipg': 2,
'default promotion page': 4, 'specific product mileage': 2, 'hide purchased limit item': 1,
};
const integer = (value: string, min = 0) => /^[+-]?\d+$/.test(value) && Number(value) >= min && Number(value) <= 2147483647;
const canonical = (value: string) => String(Number(value));
function schemaKinds(schema: CeraSchema) {
return Array.from({ length: schema.width }, (_, i) => (schema.stringFields ?? [schema.name, schema.date]).includes(i) ? 'string' : 'integer');
}
export function readCeraShop(text: string): CeraDocument {
const sections = parseItem(text, {
'shop button and sequence': 'shop button list and sequence',
});
const matchesSchema = (section: ItemSection, schema: CeraSchema | undefined): boolean => {
if (!schema || !section.paired || section.nested || section.tokens.length % schema.width) return false;
const kinds = schemaKinds(schema);
return section.tokens.every((t, i) => t.kind === kinds[i % schema.width] && (t.kind !== 'integer' || integer(t.value, -2147483648)));
};
const isTW = sections.some(section => section.tokens.length > 0 && matchesSchema(section, twCeraSchemas[section.name]) && !matchesSchema(section, legacyCeraSchemas[section.name]) && !matchesSchema(section, ceraSchemas[section.name]));
const family = isTW ? 'tw' : sections.some(section => section.tokens.length > 0 && matchesSchema(section, legacyCeraSchemas[section.name]) && !matchesSchema(section, ceraSchemas[section.name])) ? 'legacy' : 'modern';
const preferred = family === 'tw' ? twCeraSchemas : family === 'legacy' ? legacyCeraSchemas : ceraSchemas;
const candidates = [preferred, ceraSchemas, legacyCeraSchemas, twCeraSchemas];
const references = Object.fromEntries(Object.entries(refWidths).map(([name, width]) => [name, { width, index: 0 }]));
if (family === 'legacy') Object.assign(references, { 'default promotion page': { width: 2, index: 0 }, 'buy restrict item': { width: 4, index: 1 }, 'btn control': { width: 4, index: 0 } });
const doc: CeraDocument = { family, schemas: { ...preferred }, references, sections, products: [], tabs: [], hot: new Set(), mileage: new Set(), issues: [], blocked: new Set() };
for (const name of new Set(candidates.flatMap(schemas => Object.keys(schemas)))) {
const matches = sections.filter(s => s.name === name);
if (!matches.length) continue;
const section = matches[0]!;
if (!section.tokens.length && !preferred[name]) continue;
const options = candidates.map(schemas => schemas[name]).filter((schema): schema is CeraSchema => !!schema);
const schema = options.find(candidate => matchesSchema(section, candidate)) ?? options[0]!;
doc.schemas[name] = schema;
if (matches.length > 1 || !matchesSchema(section, schema)) {
doc.blocked.add(name); doc.issues.push(`[${name}] 的记录布局无法识别,请在文本模式编辑;该分类已锁定。`); continue;
}
for (let i = 0; i < section.tokens.length; i += schema.width) {
const fields = section.tokens.slice(i, i + schema.width);
const value = (index: number) => fields[index]?.value ?? '';
doc.products.push({ section: name, row: i / schema.width, fields, id: value(schema.productId ?? 0), itemId: value(schema.itemId ?? 1),
name: value(schema.name), gold: value(schema.gold), cash: value(schema.cash), date: value(schema.date), quantity: value(ceraQuantityIndex(schema)) });
}
}
const seen = new Set<string>();
for (const product of doc.products) {
if (Number(product.id) <= 0 || doc.schemas[product.section]!.readOnly) continue;
if (seen.has(canonical(product.id))) doc.issues.push(`商品编号 ${product.id} 重复,请先在文本模式修正。`);
seen.add(canonical(product.id));
}
const sequence = sections.filter(s => s.name === 'shop button list and sequence');
const order = sequence[0]?.tokens.map(t => t.value) ?? ['event', 'mileage', 'hotandnew', 'package', 'avatar', 'item', 'exit'];
if (sequence.length > 1) doc.issues.push('商城按钮顺序重复,使用第一组配置。');
doc.tabs = [...new Set(order)];
// Keep every product accessible when a script omits its category button.
for (const tab of new Set(Object.values(doc.schemas).map(schema => schema.tab))) if (doc.products.some(p => doc.schemas[p.section]!.tab === tab) && !doc.tabs.includes(tab)) doc.tabs.push(tab);
for (const [name, target] of [['hotnew sell ipg', doc.hot], ['specific product mileage', doc.mileage]] as const) {
for (const section of sections.filter(s => s.name === name)) {
if (!section.nested && section.tokens.length % 2 === 0) for (let i = 0; i < section.tokens.length; i += 2) target.add(canonical(section.tokens[i]!.value));
}
}
return doc;
}
export function ceraTabProducts(doc: CeraDocument, tab: string): CeraProduct[] {
if (tab === 'hotandnew' || tab === 'mileage') {
const index = new Map(doc.products.filter(p => Number(p.id) > 0).map(p => [canonical(p.id), p]));
return [...(tab === 'hotandnew' ? doc.hot : doc.mileage)].flatMap(id => index.has(id) ? [index.get(id)!] : []);
}
return doc.products.filter(p => doc.schemas[p.section]!.tab === tab);
}
export function nextCeraID(doc: CeraDocument): string {
if (doc.blocked.size) throw new Error('存在未识别的商品分类,无法保证自动编号不重复,请先修正分类布局');
let next = doc.products.reduce((max, p) => Math.max(max, Number(p.id) || 0), doc.family === 'tw' ? 8999 : doc.family === 'legacy' ? 99999 : 2999999) + 1;
const reserved = new Set<string>();
for (const section of doc.sections) {
const spec = doc.references[section.name];
if (spec) for (let i = 0; i < section.tokens.length; i += spec.width) if (section.tokens[i + spec.index]) reserved.add(canonical(section.tokens[i + spec.index]!.value));
}
while (reserved.has(String(next))) next++;
if (next > 2147483647) throw new Error('商品编号已达到 32 位整数上限');
return String(next);
}
export function newCeraValues(doc: CeraDocument, name: string): string[] {
const schema = doc.schemas[name];
if (!schema || schema.readOnly || doc.blocked.has(name)) throw new Error('该商品分类不能编辑');
const values: string[] = schemaKinds(schema).map(kind => kind === 'string' ? '' : '0');
values[0] = nextCeraID(doc); values[1] = '';
const quantity = ceraQuantityIndex(schema);
if (quantity >= 0) values[quantity] = '1';
for (const [index, value] of Object.entries(schema.defaults ?? {})) values[Number(index)] = value;
return values;
}
export function ceraFieldLabel(schema: CeraSchema, index: number): string {
if (index === 0) return '商品编号';
if (index === 1) return '物品编号';
if (index === schema.name) return '商品名称';
if (index === schema.gold) return '金币价格';
if (index === schema.cash) return '点券价格';
if (index === schema.date) return '日期参数';
if (index === ceraQuantityIndex(schema)) return '购买数量';
return `参数 ${index + 1}`;
}
export function ceraDate(value: string): string {
if (!/^\d{8}$/.test(value)) return value;
return `${value.slice(0, 4)}-${value.slice(4, 6)}-${value.slice(6, 8)}`;
}
interface Patch { start: number; end: number; value: string }
function formatValue(value: string, token: Pick<ItemToken, 'kind' | 'marker'>): string {
if (token.kind === 'string') {
const quoted = '`' + value.replaceAll('`', '``') + '`';
return token.marker ? `{${token.marker}=${quoted}}` : quoted;
}
if (token.kind !== 'integer' || !integer(value, -2147483648)) throw new Error('参数必须为 32 位整数');
return value;
}
function patches(text: string, changes: Patch[]): string {
let result = text, last = text.length;
for (const change of changes.sort((a, b) => b.start - a.start)) {
if (change.start < 0 || change.end < change.start || change.end > last) throw new Error('编辑位置重叠,请重新打开表单');
result = result.slice(0, change.start) + change.value + result.slice(change.end); last = change.start;
}
return result;
}
// Only known product-ID references are rewritten. Item IDs and unknown sections remain untouched.
function referencePatches(doc: CeraDocument, oldID: string, nextID: string | null): Patch[] {
const result: Patch[] = [];
for (const section of doc.sections) {
const spec = doc.references[section.name];
if (spec) {
const { width, index } = spec;
if (section.nested || section.tokens.length % width || section.tokens.some(t => t.kind !== 'integer')) throw new Error(`[${section.name}] 结构异常,无法同步商品编号`);
for (let i = 0; i < section.tokens.length; i += width) {
const token = section.tokens[i + index]!;
if (canonical(token.value) !== canonical(oldID)) continue;
result.push({ start: nextID === null ? section.tokens[i]!.start : token.start, end: nextID === null ? section.tokens[i + width - 1]!.end : token.end, value: nextID ?? '' });
}
}
if (section.name === 'storage' || section.name === 'visible storage') {
for (const child of section.children) {
if (!['cargo 1', 'cargo 2', 'cargo account'].includes(child.name) || child.tokens.length !== 2 || child.tokens.some(t => t.kind !== 'integer')) throw new Error(`[${section.name}] 仓库映射异常,无法同步商品编号`);
const token = child.tokens[section.name === 'storage' ? 0 : 1]!;
if (canonical(token.value) !== canonical(oldID)) continue;
result.push(nextID === null ? { start: child.start, end: child.end, value: '' } : { start: token.start, end: token.end, value: nextID });
}
}
}
return result;
}
export function changeCeraProduct(text: string, action: 'add' | 'edit' | 'delete', sectionName: string, values: string[], target?: CeraProduct): string {
const doc = readCeraShop(text), schema = doc.schemas[sectionName];
if (!schema || schema.readOnly || doc.blocked.has(sectionName)) throw new Error('商品分类结构异常,不能编辑');
const section = doc.sections.find(s => s.name === sectionName);
const current = target && doc.products.find(p => p.section === target.section && p.row === target.row);
if (action !== 'add' && (!target || !current || current.fields.some((t, i) => t.start !== target.fields[i]?.start || t.end !== target.fields[i]?.end || t.value !== target.fields[i]?.value))) throw new Error('商品数据已变化,请重新打开表单');
if (current && current.section !== sectionName) throw new Error('不能直接改变已有商品的记录布局');
if (current && Number(current.id) > 0 && doc.products.filter(p => canonical(p.id) === canonical(current.id)).length > 1) throw new Error('商品编号重复,请先在文本模式修正');
const changes: Patch[] = [];
if (action === 'delete') {
if (doc.blocked.size) throw new Error('存在未识别的商品分类,无法安全删除商品及其引用');
changes.push({ start: current!.fields[0]!.start, end: current!.fields.at(-1)!.end, value: '' }, ...(Number(current!.id) > 0 ? referencePatches(doc, current!.id, null) : []));
} else {
if (values.length !== schema.width) throw new Error('商品字段数量不匹配');
for (const i of [0, 1]) if (!integer(values[i]!, 1) && !(current && values[i] === current.fields[i]!.value && Number(values[i]) === -1)) throw new Error('商品和物品编号必须为正整数');
for (const i of [schema.gold, schema.cash]) if (i >= 0 && !integer(values[i]!, 0)) throw new Error('价格必须为非负整数');
const quantity = ceraQuantityIndex(schema);
if (quantity >= 0 && !integer(values[quantity]!, 1)) throw new Error('购买数量必须为正整数');
if (Number(values[0]) > 0 && doc.products.some(p => p !== current && canonical(p.id) === canonical(values[0]!))) throw new Error('商品编号已存在');
if (action === 'add' && doc.blocked.size) throw new Error('存在未识别的商品分类,无法确认编号唯一性');
if (current && canonical(current.id) !== canonical(values[0]!) && doc.blocked.size) throw new Error('存在未识别的商品分类,无法安全修改商品编号');
const kinds = schemaKinds(schema);
const formatted = values.map((v, i) => formatValue(v, current?.fields[i] ?? { kind: kinds[i]! as 'string' | 'integer' }));
if (current) {
for (let i = 0; i < values.length; i++) if (values[i] !== current.fields[i]!.value) changes.push({ start: current.fields[i]!.start, end: current.fields[i]!.end, value: formatted[i]! });
if (Number(current.id) > 0 && canonical(current.id) !== canonical(values[0]!)) changes.push(...referencePatches(doc, current.id, values[0]!));
} else {
const eol = text.includes('\r\n') ? '\r\n' : text.includes('\r') ? '\r' : '\n';
const row = `${eol}\t${formatted.join('\t')}${eol}`;
changes.push(section ? { start: section.closeStart, end: section.closeStart, value: row } : { start: text.length, end: text.length, value: `${eol}[${sectionName}]${row}[/${sectionName}]${eol}` });
}
}
return patches(text, changes);
}
+84
View File
@@ -0,0 +1,84 @@
import { parseItem, type ItemSection, type ItemToken } from './item';
export interface CraneItem {
section: ItemSection;
fields: [ItemToken, ItemToken, ItemToken, ItemToken];
}
export interface CraneDocument {
items: CraneItem[];
material: ItemToken[];
}
export interface CraneValues { id: number; count: number; viewRatio: number; pickRatio: number }
const numeric = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i;
function numbers(section: ItemSection, count: number): ItemToken[] {
if (section.paired || section.children.length || section.tokens.length !== count || section.tokens.some(t => t.kind === 'string' || !numeric.test(t.value) || !Number.isFinite(Number(t.value)))) {
throw new Error(`[${section.name}] 参数无法识别,请先在 DSL 中修复`);
}
return section.tokens;
}
export function craneInteger(value: number | null): number {
if (value === null || !Number.isInteger(value) || value < 0 || value > 2147483647) throw new Error('请输入 0~2147483647 的整数');
return value;
}
export function validateCraneValues(values: CraneValues) {
craneInteger(values.id); craneInteger(values.count);
if (!Number.isFinite(values.viewRatio) || values.viewRatio < 0 || values.viewRatio > 3.4028234663852886e38) throw new Error('视野比率需要为非负有限数值');
if (!Number.isFinite(values.pickRatio) || values.pickRatio < 0 || values.pickRatio > 100) throw new Error('抓取几率需要在 0~100 之间');
}
export function parseCraneMinigame(text: string): CraneDocument {
const sections = parseItem(text);
const items = sections.filter(s => s.name === 'item').map(section => {
if (!section.paired || section.tokens.length !== 1 || section.tokens[0]!.kind !== 'integer') throw new Error('[item] 需要物品 ID 和闭合标签,请先在 DSL 中修复');
const fields: ItemToken[] = [section.tokens[0]!];
for (const name of ['cnt', 'viewratio', 'pickratio']) {
const matches = section.children.filter(s => s.name === name);
if (matches.length !== 1) throw new Error(`[item] 中 [${name}] 缺失或重复,请先在 DSL 中修复`);
fields.push(numbers(matches[0]!, 1)[0]!);
}
if (fields[1]!.kind !== 'integer') throw new Error('[cnt] 需要整数数量');
validateCraneValues({ id: Number(fields[0]!.value), count: Number(fields[1]!.value), viewRatio: Number(fields[2]!.value), pickRatio: Number(fields[3]!.value) });
return { section, fields: fields as CraneItem['fields'] };
});
const materials = sections.filter(s => s.name === 'material');
if (materials.length > 1) throw new Error('[material] 重复,请先在 DSL 中修复');
const material = materials.length ? numbers(materials[0]!, 2) : [];
material.forEach(t => { if (t.kind !== 'integer') throw new Error('[material] 需要整数物品 ID 和数量'); craneInteger(Number(t.value)); });
return { items, material };
}
const floatText = (value: number) => /[.e]/i.test(String(value)) ? String(value) : `${value}.0`;
function patch(text: string, fields: ItemToken[], values: number[]): string {
return fields.map((token, i) => ({ token, value: values[i]! })).sort((a, b) => b.token.start - a.token.start).reduce((source, { token, value }) => {
if (Number(token.value) === value) return source;
// Keep floating-point fields floating-point even when the new value is whole.
const replacement = /[.e]/i.test(token.value) ? floatText(value) : String(value);
return source.slice(0, token.start) + replacement + source.slice(token.end);
}, text);
}
export function editCraneItem(text: string, index: number, values: CraneValues): string {
validateCraneValues(values);
const item = parseCraneMinigame(text).items[index];
if (!item) throw new Error('奖品位置已失效');
return patch(text, item.fields, [values.id, values.count, values.viewRatio, values.pickRatio]);
}
const newline = (text: string) => text.includes('\r\n') ? '\r\n' : text.includes('\r') ? '\r' : '\n';
export function addCraneItem(text: string, values: CraneValues): string {
validateCraneValues(values);
const doc = parseCraneMinigame(text), eol = newline(text);
const block = ['[item]', `\t${values.id}`, '\t[cnt]', `\t\t${values.count}`, '\t[viewRatio]', `\t\t${floatText(values.viewRatio)}`, '\t[pickRatio]', `\t\t${values.pickRatio}`, '[/item]'].join(eol);
const position = doc.items.at(-1)?.section.end ?? parseItem(text).find(s => s.name === 'material')?.start ?? text.length;
return text.slice(0, position) + eol + block + eol + text.slice(position);
}
export function removeCraneItem(text: string, index: number): string {
const item = parseCraneMinigame(text).items[index];
if (!item) throw new Error('奖品位置已失效');
if (item.section.children.some(s => !['cnt', 'viewratio', 'pickratio'].includes(s.name))) throw new Error('该奖品包含其他字段,请在 DSL 中删除,以确认处理这些字段');
return text.slice(0, item.section.start) + text.slice(item.section.end);
}
export function editCraneMaterial(text: string, id: number, count: number): string {
craneInteger(id); craneInteger(count);
const doc = parseCraneMinigame(text);
if (doc.material.length) return patch(text, doc.material, [id, count]);
const eol = newline(text);
return text + eol + `[material]${eol}\t${id}\t${count}${eol}`;
}
+24
View File
@@ -0,0 +1,24 @@
/** Deterministic visual approximation; does not execute ACT logic or combat. */
export interface ParticleMotion { life: number; frequency: number; maximum: number; maximumTime: number; gravity: number; position: number[]; area: number[]; alpha: number[]; velocity: number[]; acceleration: number[] }
export function effectFrameIndex(delays: number[], elapsed: number, loop: boolean): number {
if (!delays.length) return -1;
const safe = delays.map(delay => Math.max(16, Math.min(Number.isFinite(delay) ? delay : 100, 10000)));
const duration = safe.reduce((sum, delay) => sum + delay, 0);
let offset = loop ? Math.max(0, elapsed) % duration : Math.min(Math.max(0, elapsed), duration - 1);
for (let i = 0; i < safe.length; i++) { offset -= safe[i]!; if (offset < 0) return i; }
return safe.length - 1;
}
export function particleInstances(meta: ParticleMotion, elapsed: number): { x: number; y: number; alpha: number; age: number }[] {
const life = Math.max(50, Math.min(meta.life, 60000)), frequency = meta.frequency > 0 ? Math.max(16, meta.frequency) : life;
const count = Math.max(1, Math.min(64, meta.maximum)), cycle = Math.max(life + frequency * count, meta.maximumTime || 0);
const time = Math.max(0, elapsed) % cycle;
const result: { x: number; y: number; alpha: number; age: number }[] = [];
for (let i = 0; i < count; i++) {
const age = time - i * frequency;
if (age < 0 || age >= life || (meta.maximumTime > 0 && i * frequency > meta.maximumTime)) continue;
const seconds = age / 1000, ratio = age / life;
const offset = (axis: number) => (meta.position[axis] ?? 0) + (meta.area[axis] ?? 0) * (Math.sin(i * 127.1 + axis * 311.7) * .5) + (meta.velocity[axis] ?? 0) * seconds + .5 * (meta.acceleration[axis] ?? 0) * seconds * seconds;
result.push({ x: offset(0), y: offset(1) - offset(2) + .5 * meta.gravity * seconds * seconds, alpha: Math.max(0, Math.min(1, ((meta.alpha[0] ?? 100) + ((meta.alpha[1] ?? 100) - (meta.alpha[0] ?? 100)) * ratio) / 100)), age });
}
return result;
}
+27
View File
@@ -0,0 +1,27 @@
import type { UpgradeRow } from "../../bindings/pvfine/services/models";
import { editSkillNumbers } from "./skill";
export type ForgeScope = "table" | "grades" | "maximum";
export function editForge(text: string, rows: UpgradeRow[], scope: ForgeScope, changes: { column: number; value: number }[], maximumLevel: number): string {
if (!rows.length || !changes.length) throw new Error("请选择等级和修改项");
const patches = changes.flatMap(({ column, value }) => {
const valid = scope === "table" ? [2, 3] : scope === "grades" ? [0, 1] : [0];
const limit = scope === "maximum" ? maximumLevel : scope === "table" && column === 2 ? 100 : 2147483647;
if (!valid.includes(column) || !Number.isFinite(value) || value < (scope === "maximum" ? 1 : 0) || value > limit || (scope !== "table" && !Number.isInteger(value))) throw new Error("数值超出允许范围");
return rows.map(row => {
const token = row.fields?.[column];
if (!token) throw new Error("数据已失效");
const operand = scope === "table" && column === 2 ? Math.round(value * 100) : value;
// Preserve the original numeric type; integer multipliers cannot represent fractions.
if (token.tokenType === 0 && !Number.isInteger(operand)) throw new Error("该字段在源文件中为整数,不能写入小数");
return { token, value: operand };
});
}).sort((a, b) => b.token.start - a.token.start);
let result = text;
let previous = Infinity;
for (const patch of patches) {
if (patch.token.end > previous) throw new Error("数据位置重叠");
result = editSkillNumbers(result, [patch.token], "set", patch.value);
previous = patch.token.start;
}
return result;
}
+78
View File
@@ -0,0 +1,78 @@
import type { IndependentDropItem, IndependentDropRow, SkillNumber } from "../../bindings/pvfine/services/models";
import { editSkillNumbers } from "./skill";
export const independentDropModes = [{ label: "直接物品", value: 0 }, { label: "内嵌列表", value: 1 }, { label: "外部列表", value: 2 }];
export function dropModeLabel(mode: number): string { return independentDropModes.find(option => option.value === mode)?.label ?? `扩展模式 ${mode}`; }
export function sourceOffset(text: string, offset: number): number {
if (!Number.isInteger(offset) || offset < 0) throw new Error("数据位置无效");
let count = 0;
for (let i = 0; i <= text.length; i++) {
if (count === offset) return i;
if (text[i] === "\r" && text[i + 1] === "\n") i++;
count++;
}
throw new Error("数据位置已失效");
}
export function replaceDropSpan(text: string, start: number, end: number, value: string): string {
if (end < start) throw new Error("数据范围无效");
const a = sourceOffset(text, start), b = sourceOffset(text, end);
return text.slice(0, a) + value + text.slice(b);
}
export function dropListText(items: { id: number; weight: number }[]): string {
return items.map(item => {
if (!Number.isInteger(item.id) || item.id < 0 || item.id > 2147483647 || !Number.isInteger(item.weight) || item.weight < 0 || item.weight > 2147483647) throw new Error("物品 ID 和权重必须为非负的 32 位整数");
return `\t\t${item.id}\t${item.weight}`;
}).join("\n");
}
export function dropRecordText(values: number[], items: { id: number; weight: number }[], listID: number): string {
if (values.length !== 17 || values.some(n => !Number.isInteger(n) || n < -2147483648 || n > 2147483647)) throw new Error("记录需要 17 个 32 位整数");
if (![0, 1].includes(values[0]!)) throw new Error("请选择怪物或 APC");
if (!values.slice(3,8).every(n => n >= 0 && n <= 1000000) || values.slice(8,15).some(n => n < 0)) throw new Error("概率、数量或等级超出范围");
if (values[14]! > 0 && values[13]! > values[14]!) throw new Error("最低等级不能超过最高等级");
const mode = values[16];
if (![0,1,2].includes(mode!)) throw new Error("不能新建未知掉落模式");
const fields = [...values]; if (mode !== 0) fields[2] = 0;
let text = fields.join("\t");
if (mode === 1) text += `\n\t[list]\n${dropListText(items)}\n\t[/list]`;
if (mode === 2) {
if (!Number.isInteger(listID) || listID < 0 || listID > 2147483647) throw new Error("请输入有效列表 ID");
text += `\n\t[list]\n\t\t${listID}\n\t[/list]`;
}
return text + "\n\t";
}
export function editDropFields(text: string, row: IndependentDropRow, changes: { column: number; value: number }[]): string {
const fields = row.fields ?? [];
const patches = changes.map(change => {
const token = fields[change.column];
if (!token || !Number.isInteger(change.column) || change.column < 0 || change.column > 15 || !Number.isInteger(change.value)) throw new Error("字段无效");
if (change.value < -2147483648 || change.value > 2147483647) throw new Error("整数超出范围");
if (change.column === 0 && ![0,1].includes(change.value)) throw new Error("请选择怪物或 APC");
if (change.column >= 3 && change.column <= 7 && (fields[16]?.value ?? 0) > 2) throw new Error("扩展模式的概率含义未确认,请使用文本模式编辑");
if (change.column >= 3 && change.column <= 7 && (change.value < 0 || change.value > 1000000)) throw new Error("概率必须为 0~100%");
if (change.column >= 8 && change.column <= 14 && change.value < 0) throw new Error("数量和等级不能为负数");
return {token,value:change.value};
}).sort((a,b) => b.token.start - a.token.start);
const min = changes.find(c=>c.column===13)?.value ?? fields[13]?.value ?? 0;
const max = changes.find(c=>c.column===14)?.value ?? fields[14]?.value ?? 0;
if (changes.some(c=>c.column===13 || c.column===14) && max > 0 && min > max) throw new Error("最低等级不能超过最高等级");
return applyDropPatches(text, patches);
}
export function applyDropPatches(text: string, patches: {token: SkillNumber; value: number}[]): string {
let previous = Infinity, result = text;
for (const patch of [...patches].sort((a,b)=>b.token.start-a.token.start)) {
if (patch.token.end > previous) throw new Error("数据位置重叠");
result = editSkillNumbers(result,[patch.token],"set",patch.value); previous=patch.token.start;
}
return result;
}
export function editableDropItems(items: IndependentDropItem[]): { id: number; weight: number }[] {
return items.filter(item=>item.fields?.length===2).map(item=>({id:item.fields![0]!.value,weight:item.fields![1]!.value}));
}
export function convertDropRecord(text: string, row: IndependentDropRow, values: number[], items: {id:number;weight:number}[], listID: number): string {
if (row.hasExtensions || ![0,1,2].includes(row.fields?.[16]?.value ?? -1)) throw new Error("扩展记录不能转换掉落模式");
const content=dropRecordText(values,items,listID).trimEnd();
let result=text;
if(row.listStart>=0) result=replaceDropSpan(result,row.listTagStart,row.listEnd+7,"");
return replaceDropSpan(result,row.start,row.fields![16]!.end,content);
}
+146
View File
@@ -0,0 +1,146 @@
/** Item forms patch token ranges; nested effects and unknown sections stay intact. */
import { questReference } from './quest';
export type ItemKind = 'string' | 'integer';
export interface ItemToken { start: number; end: number; value: string; kind: ItemKind | 'unsupported'; marker?: string }
export interface ItemSection { name: string; start: number; body: number; end: number; closeStart: number; paired: boolean; children: ItemSection[]; tokens: ItemToken[]; nested: boolean }
export interface ItemField { name: string; label: string; kinds: ItemKind[]; counts?: number[]; repeat?: ItemKind; multiline?: boolean; equ?: boolean; stk?: boolean }
export const itemGroups: { label: string; fields: ItemField[] }[] = [
{ label: '基础信息', fields: [
{ name: 'name', label: '名称', kinds: ['string'] },
{ name: 'name2', label: '附加名称', kinds: ['string'] },
{ name: 'rarity', label: '稀有度', kinds: ['integer'] },
{ name: 'minimum level', label: '最低使用等级', kinds: ['integer'] },
{ name: 'attach type', label: '绑定类型', kinds: ['string'] },
{ name: 'equipment type', label: '装备类型与编号', kinds: ['string', 'integer'], counts: [1, 2], equ: true },
{ name: 'stackable type', label: '道具类型与编号', kinds: ['string', 'integer'], counts: [1, 2], stk: true },
{ name: 'usable job', label: '可用职业', kinds: ['string'], repeat: 'string' },
{ name: 'price', label: '出售价格', kinds: ['integer'] },
{ name: 'weight', label: '重量(原始值)', kinds: ['integer'] },
{ name: 'durability', label: '耐久度', kinds: ['integer'], equ: true },
{ name: 'stack limit', label: '堆叠上限', kinds: ['integer'], stk: true },
] },
{ label: '装备属性', fields: [
...[['physical attack', '力量'], ['magical attack', '智力'], ['physical defense', '体力'], ['magical defense', '精神'],
['equipment physical attack', '物理攻击力'], ['equipment magical attack', '魔法攻击力'], ['separate attack', '独立攻击力'],
['equipment physical defense', '物理防御力'], ['equipment magical defense', '魔法防御力'],
['HP MAX', 'HP 上限'], ['MP MAX', 'MP 上限']].map(([name, label]) => ({ name: name!, label: label!, kinds: ['integer'] as ItemKind[], repeat: 'integer' as const, counts: [1, 2, 4], equ: true })),
...[['attack speed', '攻击速度'], ['cast speed', '施放速度'], ['move speed', '移动速度'],
['physical critical hit', '物理暴击'], ['magical critical hit', '魔法暴击'], ['anti evil', '抗魔值']].map(([name, label]) => ({ name: name!, label: label!, kinds: ['integer'] as ItemKind[], equ: true })),
] },
{ label: '说明文本', fields: [
...[['basic explain', '基础说明'], ['detail explain', '详细说明'], ['flavor text', '背景描述'], ['explain', '道具说明']].map(([name, label]) => ({ name: name!, label: label!, kinds: ['string'] as ItemKind[], multiline: true })),
] },
{ label: '图像引用', fields: [
{ name: 'icon', label: '物品图标', kinds: ['string', 'integer'] },
{ name: 'field image', label: '地面图像', kinds: ['string', 'integer'] },
] },
];
export function parseItem(text: string, closingAliases: Readonly<Record<string, string>> = {}): ItemSection[] {
const lex: (ItemToken & { tag?: string })[] = [];
let i = 0;
const quoted = () => {
const start = ++i;
while (i < text.length) {
if (text[i] !== '`') { i++; continue; }
if (text[i + 1] === '`') { i += 2; continue; }
return text.slice(start, i++).replaceAll('``', '`');
}
throw new Error('文本未闭合,请先在 DSL 中修复');
};
while (i < text.length) {
if (/\s/.test(text[i]!)) { i++; continue; }
if (text[i] === '#' || text.startsWith('//', i)) {
while (i < text.length && !/[\r\n]/.test(text[i]!)) i++;
continue;
}
if (text.startsWith('/*', i)) {
const end = text.indexOf('*/', i + 2);
if (end < 0) throw new Error('注释未闭合,请先在 DSL 中修复');
i = end + 2;
continue;
}
const start = i;
let value = '', kind: ItemToken['kind'] = 'unsupported', tag: string | undefined, marker: string | undefined;
if (text[i] === '`') { value = quoted(); kind = 'string'; }
else if (text[i] === '[') {
const end = text.indexOf(']', i);
if (end < 0) throw new Error('标签未闭合,请先在 DSL 中修复');
tag = text.slice(++i, end).trim().toLowerCase();
// Known format-specific aliases affect matching only; source offsets and text stay exact.
if (tag.startsWith('/')) tag = '/' + (closingAliases[tag.slice(1).trim()] ?? tag.slice(1).trim());
value = tag; i = end + 1;
} else if (text[i] === '{') {
i++;
while (i < text.length && text[i] !== '}') { if (text[i] === '`') quoted(); else i++; }
if (i >= text.length) throw new Error('类型标记未闭合,请先在 DSL 中修复');
i++; value = text.slice(start, i);
const match = /^\{(5|7|8|10)=\s*`((?:[^`]|``)*)`\s*\}$/.exec(value);
if (match) { marker = match[1]; value = match[2]!.replaceAll('``', '`'); kind = 'string'; }
} else {
while (i < text.length && !/[\s`\[#{]/.test(text[i]!) && !text.startsWith('//', i) && !text.startsWith('/*', i)) i++;
value = text.slice(start, i);
if (/^[+-]?\d+$/.test(value)) kind = 'integer';
}
lex.push({ start, end: i, value, kind, marker, tag });
}
// Preserve container boundaries even when a required closing tag is missing.
const paired = new Set(['if', 'then', 'multiple then', 'booster info', 'booster select category',
...lex.filter(t => t.tag?.startsWith('/')).map(t => t.tag!.slice(1).trim())]);
const sections: ItemSection[] = [], stack: ItemSection[] = [];
let current: ItemSection | undefined;
for (const token of lex) {
if (token.tag !== undefined) {
if (current && current !== stack.at(-1)) current.end = token.start;
if (token.tag.startsWith('/')) {
const parent = stack.pop();
if (parent?.name !== token.tag.slice(1).trim()) throw new Error('闭合标签不匹配,请先在 DSL 中修复');
parent.closeStart = token.start;
parent.end = token.end;
current = stack.at(-1);
} else {
const node: ItemSection = { name: token.tag, start: token.start, body: token.end, end: text.length,
closeStart: text.length, paired: paired.has(token.tag), children: [], tokens: [], nested: false };
const parent = stack.at(-1);
if (parent) { parent.children.push(node); parent.nested = true; }
else sections.push(node);
if (node.paired) stack.push(node);
current = node;
}
} else current?.tokens.push(token);
}
if (stack.length) throw new Error('容器标签未闭合,请先在 DSL 中修复');
return sections;
}
export function itemSection(sections: ItemSection[], name: string) { return sections.find(s => s.name === name.toLowerCase()); }
export function itemFieldIssue(sections: ItemSection[], field: ItemField): string {
const matches = sections.filter(s => s.name === field.name.toLowerCase()), section = matches[0];
if (matches.length > 1) return '存在重复字段,请在 DSL 中编辑';
if (!section) return '';
if (section.nested) return '包含嵌套结构,请在 DSL 中编辑';
if (!(field.counts ?? [field.kinds.length]).includes(section.tokens.length) && !(!field.counts && field.repeat && section.tokens.length >= field.kinds.length)) return '参数数量无法识别,请在 DSL 中编辑';
if (section.tokens.some((t, i) => t.kind !== (field.kinds[i] ?? field.repeat))) return '包含特殊类型,请在 DSL 中编辑';
if (section.tokens.some(t => t.kind === 'string' && questReference(t.value))) return '字符串表引用,请在 DSL 中使用修改译文';
return '';
}
export function editItem(text: string, field: ItemField, values: string[]): string {
const sections = parseItem(text), issue = itemFieldIssue(sections, field);
if (issue) throw new Error(issue);
if (!(field.counts ?? [field.kinds.length]).includes(values.length) && !(!field.counts && field.repeat && values.length >= field.kinds.length)) throw new Error('参数数量不匹配');
const formatted = values.map((value, i) => {
if ((field.kinds[i] ?? field.repeat) === 'string') return '`' + value.replaceAll('`', '``') + '`';
if (!/^[+-]?\d+$/.test(value) || Number(value) < -2147483648 || Number(value) > 2147483647) throw new Error('请输入 32 位范围内的整数');
return value;
});
const section = itemSection(sections, field.name);
const newline = text.includes('\r\n') ? '\r\n' : '\n';
if (!section) return text + (text && !text.endsWith('\n') ? newline : '') + `[${field.name}]${newline}\t${formatted.join('\t')}${newline}`;
if (section.tokens.length !== values.length) throw new Error('已有字段的参数数量不能在基础编辑中改变');
let result = text;
for (let i = section.tokens.length - 1; i >= 0; i--) {
const token = section.tokens[i]!;
if (token.value === values[i]) continue;
const replacement = token.marker ? `{${token.marker}=${formatted[i]}}` : formatted[i]!;
result = result.slice(0, token.start) + replacement + result.slice(token.end);
}
return result;
}
+253
View File
@@ -0,0 +1,253 @@
import { itemFieldIssue, itemSection, parseItem, type ItemSection } from './item';
export interface BoosterRow { sourceIndex?: number; poolIndex?: number; id: string; quantity: string; weight: string; avatarParams?: [string, string] }
export interface BoosterPool { drawCount: string; source: ItemSection }
export interface BoosterGroup { coordinates: string[]; rows: BoosterRow[]; pools?: BoosterPool[]; equipmentUpgrade?: string; source?: ItemSection }
export interface BoosterDocument {
mode: 'booster' | 'selection' | null; issue: string; groups: BoosterGroup[];
selection: string; dimensions: string[]; names: string[]; sections: ItemSection[];
}
const integer = (value: string, minimum = 0) => /^\d+$/.test(value) && Number(value) >= minimum && Number(value) <= 2147483647;
function numbers(section: ItemSection, count?: number) {
if (section.nested || section.tokens.some(t => t.kind !== 'integer') || count !== undefined && section.tokens.length !== count) throw new Error(`[${section.name}] 参数结构无法识别,请在 DSL 中编辑`);
return section.tokens.map(t => t.value);
}
function single(sections: ItemSection[], name: string): ItemSection {
const matches = sections.filter(s => s.name === name);
if (matches.length !== 1) throw new Error(`[${name}] 缺失或重复,请在 DSL 中编辑`);
return matches[0]!;
}
function selectionContents(group?: BoosterGroup): ItemSection | undefined {
return group?.source?.children.find(child=>child.name!=='booster equipment upgrade');
}
function selectionTag(doc: BoosterDocument, group?: BoosterGroup): string {
return selectionContents(group)?.name ?? doc.groups.map(selectionContents).find(Boolean)?.name ?? 'etc';
}
export function boosterRewardName(group: BoosterGroup | undefined, name: string): string {
return group?.equipmentUpgrade===undefined ? name : `+${group.equipmentUpgrade} ${name}`;
}
function selectionValues(row: BoosterRow, tag: string): string[] {
return tag==='avatar' ? [row.id,row.quantity,...row.avatarParams!] : [row.id,row.quantity];
}
export function newBoosterRow(doc: BoosterDocument, group?: BoosterGroup, poolIndex=0): BoosterRow {
const row: BoosterRow = {id:'',quantity:'1',weight:'1000'};
if(doc.mode==='booster')row.poolIndex=poolIndex;
if (doc.mode==='selection' && selectionTag(doc,group)==='avatar') {
const reference=group?.rows.find(r=>r.avatarParams)?.avatarParams ?? doc.groups.flatMap(g=>g.rows).find(r=>r.avatarParams)?.avatarParams;
row.avatarParams=reference ? [...reference] : ['',''];
}
return row;
}
export function readBooster(text: string): BoosterDocument {
const doc: BoosterDocument = { mode: null, issue: '', groups: [], selection: '1', dimensions: [], names: [], sections: [] };
try {
const sections = parseItem(text); doc.sections = sections;
const type = itemSection(sections, 'stackable type');
const mode = type?.tokens[0]?.value;
if (mode !== '[booster]' && mode !== '[booster selection]') return doc;
doc.mode = mode === '[booster]' ? 'booster' : 'selection';
const typeIssue = itemFieldIssue(sections, { name:'stackable type',label:'',kinds:['string','integer'],counts:[1,2] });
if (typeIssue) throw new Error(typeIssue);
if (doc.mode === 'booster') {
const info = single(sections, 'booster info');
if (!info.paired || info.tokens.length) throw new Error('礼包内容结构无法识别,请在 DSL 中编辑');
const rows: BoosterRow[] = [];
const pools:BoosterPool[]=[];
for (const child of info.children) {
if (!['etc','stackable','cera'].includes(child.name) || !child.paired) throw new Error('包含特殊礼包奖励,请在 DSL 中编辑');
const values = numbers(child);
if (values.length<4 || (values.length-1)%3) throw new Error(`[${child.name}] 需包含随机获得项数及成组的物品 ID、权重、数量,请在 DSL 中编辑`);
const poolIndex=pools.length;
pools.push({drawCount:values[0]!,source:child});
for(let i=1;i<values.length;i+=3) rows.push({sourceIndex:rows.length,poolIndex,id:values[i]!,weight:values[i+1]!,quantity:values[i+2]!});
}
doc.groups = [{ coordinates: [], rows, pools, source: info }];
} else {
doc.selection = numbers(single(sections,'booster selection num'),1)[0]!;
doc.dimensions = numbers(single(sections,'booster category num'));
const [dims,n,m] = doc.dimensions.map(Number);
if (!(dims === 0 && doc.dimensions.length === 1 || dims === 1 && doc.dimensions.length === 2 && n! >= 0 || dims === 2 && doc.dimensions.length === 3 && n! > 0 && m! > 0)) throw new Error('自选礼包分类维度无法识别,请在 DSL 中编辑');
const blocks = sections.filter(s => s.name === 'booster select category');
if (blocks.length !== (dims === 0 ? 1 : dims === 1 ? Math.max(n!,1) : n!*m!)) throw new Error('分类数量与礼包内容不匹配,请在 DSL 中编辑');
const used = new Set<string>();
doc.groups = blocks.map(block => {
if (!block.paired || block.tokens.length !== 2 || block.tokens.some(t => t.kind !== 'integer')) throw new Error('包含特殊自选礼包配置,请在 DSL 中编辑');
const coordinates = block.tokens.map(t => t.value), [x,y] = coordinates.map(Number);
const key = coordinates.join(':');
if (used.has(key) || x! < 0 || y! < 0 || x! >= (dims === 0 ? 1 : Math.max(n!,1)) || y! >= (dims === 2 ? m! : 1)) throw new Error('分类坐标重复或超出范围,请在 DSL 中编辑');
used.add(key);
const upgrades=block.children.filter(child=>child.name==='booster equipment upgrade');
const rewards=block.children.filter(child=>child.name!=='booster equipment upgrade');
if(upgrades.length>1 || rewards.length>1)throw new Error('包含重复强化等级或特殊自选礼包配置,请在 DSL 中编辑');
const upgrade=upgrades[0];
const equipmentUpgrade=upgrade?numbers(upgrade,1)[0]:undefined;
if(equipmentUpgrade!==undefined && !integer(equipmentUpgrade))throw new Error('装备强化等级必须为有效的非负整数');
const contents = rewards[0];
if (contents && (!['etc','stackable','equipment','avatar'].includes(contents.name) || !contents.paired)) throw new Error('包含特殊自选奖励,请在 DSL 中编辑');
const values = contents ? numbers(contents) : [];
const stride=contents?.name==='avatar'?4:2;
if (values.length % stride) throw new Error(stride===4?'avatar 奖励必须包含 ID、数量及两个附加参数,请在 DSL 中编辑':'奖励 ID 和数量未配对,请在 DSL 中编辑');
const rows: BoosterRow[] = [];
for (let i=0;i<values.length;i+=stride) {
const row:BoosterRow={sourceIndex:i/stride,id:values[i]!,quantity:values[i+1]!,weight:''};
if(stride===4)row.avatarParams=[values[i+2]!,values[i+3]!];
rows.push(row);
}
return {coordinates, rows, equipmentUpgrade, source:block};
});
if (isFlatBoosterSelection(doc) && !sections.some(s=>s.name==='booster category name')) return doc;
const names = single(sections,'booster category name');
if (names.nested || names.tokens.some(t=>t.kind !== 'string')) throw new Error('分类名称结构无法识别,请在 DSL 中编辑');
doc.names = names.tokens.map(t=>t.value);
const expected = dims === 0 ? 1 : dims === 1 ? 1 + Math.max(n!,1) : 2 + n! + n!*m!;
// A rectangular two-level menu may end with empty slots whose names are omitted.
let minimum = dims === 1 && n === 0 ? 1 : expected;
if (dims === 2) {
minimum = 2+n!;
for (const group of doc.groups) {
if (!group.rows.length) continue;
const [x,y] = group.coordinates.map(Number);
minimum = Math.max(minimum,2+n!+x!*m!+y!+1);
}
}
if (doc.names.length < minimum || doc.names.length > expected) throw new Error('分类名称数量与维度不匹配,请在 DSL 中编辑');
}
} catch(e) { doc.issue = String(e); }
return doc;
}
export function isFlatBoosterSelection(doc: BoosterDocument): boolean {
return doc.mode==='selection' && (doc.dimensions[0]==='0' || doc.dimensions[0]==='1' && Number(doc.dimensions[1])===0);
}
export function boosterGroupLabel(doc: BoosterDocument, group: BoosterGroup): string {
if (doc.mode === 'booster') return '礼包奖励';
if (isFlatBoosterSelection(doc)) return '可选物品';
const [x,y] = group.coordinates.map(Number);
if (doc.dimensions[0] === '1') return doc.names[1+x!] || `分类 ${x!+1}`;
const n = Number(doc.dimensions[1]), m = Number(doc.dimensions[2]);
return `${doc.names[1+x!] || `分类 ${x!+1}`} / ${doc.names[2+n+x!*m+y!] || `子分类 ${y!+1}`}`;
}
interface Patch { start:number;end:number;value:string }
function patchValues(section: ItemSection, values: string[], kind:'integer'|'string', newline:string): Patch[] {
const formatted = values.map(v=>kind==='string'?'`'+v.replaceAll('`','``')+'`':v);
const patches: Patch[] = [];
section.tokens.forEach((t,i)=> {
if (values[i]===t.value) return;
const value = formatted[i];
patches.push({start:t.start,end:t.end,value:value===undefined?'':t.marker?`{${t.marker}=${value}}`:value});
});
if (values.length>section.tokens.length) {
const last=section.tokens.at(-1);
patches.push({start:last?.end??section.body,end:last?.end??section.body,value:(last?'\t':newline+'\t')+formatted.slice(section.tokens.length).join('\t')});
}
return patches;
}
export function editBooster(text: string, draft: BoosterDocument): string {
const original = readBooster(text);
if (original.issue || !original.mode || draft.mode !== original.mode) throw new Error(original.issue || '礼包类型已变化');
const extra=draft.groups.length-original.groups.length;
const adding=extra>0 && original.mode==='selection' && original.dimensions[0]==='1' && Number(original.dimensions[1])>0;
if (extra!==0 && !adding || !adding && draft.dimensions.join()!==original.dimensions.join()) throw new Error('基础礼包编辑保留原分类结构');
if (adding && (draft.dimensions[0]!=='1' || Number(draft.dimensions[1])!==draft.groups.length || draft.dimensions.length!==2)) throw new Error('新增分类数量不匹配');
const newline=text.includes('\r\n')?'\r\n':'\n', patches:Patch[]=[];
const selectionRewardTag=selectionTag(original);
let additions='';
for (let g=0;g<draft.groups.length;g++) {
const next=draft.groups[g]!, old=original.groups[g]!;
const rewardTag=selectionTag(original,old);
if (old && next.equipmentUpgrade!==old.equipmentUpgrade || !old && next.equipmentUpgrade!==undefined)throw new Error('装备强化等级已变化,请通过 DSL 修改');
if (old && next.coordinates.join()!==old.coordinates.join()) throw new Error('分类坐标已变化');
for (const row of next.rows) {
if (draft.mode==='selection' && rewardTag==='avatar' && (row.avatarParams?.length!==2 || row.avatarParams.some(value=>! /^-?\d+$/.test(value) || Number(value)<-2147483648 || Number(value)>2147483647))) throw new Error('avatar 的两个附加参数必须是有效整数');
if (!integer(row.id,1) || !integer(row.quantity,1) || draft.mode==='booster' && !integer(row.weight)) throw new Error('奖励 ID、数量必须是有效的正整数,权重需为非负整数');
}
if (!old) {
if (next.coordinates.join(':')!==`${g}:0`) throw new Error('新增分类坐标不匹配');
additions+=`[booster select category]${newline}\t${g}\t0${newline}\t[${selectionRewardTag}]${newline}\t\t${next.rows.flatMap(row=>selectionValues(row,rewardTag)).join('\t')}${newline}\t[/${selectionRewardTag}]${newline}[/booster select category]${newline}`;
continue;
}
const seen = new Set<number>();
for (const row of next.rows) {
if (row.sourceIndex === undefined) continue;
if (!Number.isInteger(row.sourceIndex) || row.sourceIndex < 0 || row.sourceIndex >= old.rows.length || seen.has(row.sourceIndex)) throw new Error('奖励位置已变化,请重新打开编辑');
seen.add(row.sourceIndex);
}
if (draft.mode==='booster') {
const pools=next.pools??[],oldPools=old.pools??[];
if(pools.length!==oldPools.length)throw new Error('随机奖励池结构已变化,请重新打开编辑');
if(pools.some(pool=>!integer(pool.drawCount,1)))throw new Error('随机获得项数必须为有效的正整数');
for(const row of next.rows) {
if(!Number.isInteger(row.poolIndex??0) || (row.poolIndex??0)<0 || (row.poolIndex??0)>=pools.length)throw new Error('奖励所属随机池无效');
if(row.sourceIndex!==undefined && row.poolIndex!==old.rows[row.sourceIndex]!.poolIndex)throw new Error('已有奖励不能跨随机池移动');
}
let offset=0;
for(let poolIndex=0;poolIndex<oldPools.length;poolIndex++) {
const child=oldPools[poolIndex]!.source,count=(child.tokens.length-1)/3;
const retained=next.rows.filter(row=>row.sourceIndex!==undefined && row.sourceIndex>=offset && row.sourceIndex<offset+count);
const added=next.rows.filter(row=>row.sourceIndex===undefined && (row.poolIndex??0)===poolIndex);
if(!retained.length&&!added.length)patches.push({start:child.start,end:child.end,value:''});
else {
patches.push(...patchValues({...child,tokens:child.tokens.slice(0,1)},[pools[poolIndex]!.drawCount],'integer',newline));
for(let i=0;i<count;i++) {
const row=retained.find(row=>row.sourceIndex===offset+i);
patches.push(...patchValues({...child,tokens:child.tokens.slice(1+i*3,1+(i+1)*3)},row?[row.id,row.weight,row.quantity]:[],'integer',newline));
}
if(added.length)patches.push({start:child.closeStart,end:child.closeStart,value:newline+'\t\t'+added.flatMap(row=>[row.id,row.weight,row.quantity]).join('\t')+newline});
}
offset+=count;
}
} else {
const contents=selectionContents(old);
if (draft.mode==='selection' && draft.dimensions[0]==='2' && next.rows.length) {
const [x,y]=next.coordinates.map(Number),n=Number(draft.dimensions[1]),m=Number(draft.dimensions[2]);
if (!draft.names[2+n+x!*m+y!]?.trim()) throw new Error('该空分类没有名称,请先补充分类名称');
}
if (!contents) {
if (next.rows.length) {
const tag=selectionRewardTag;
const position=old.source!.closeStart;
patches.push({start:position,end:position,value:`\t[${tag}]${newline}\t\t${next.rows.flatMap(row=>selectionValues(row,rewardTag)).join('\t')}${newline}\t[/${tag}]${newline}`});
}
continue;
}
old.rows.forEach((_,i)=> {
const row=next.rows.find(row=>row.sourceIndex===i);
patches.push(...patchValues({...contents,tokens:contents.tokens.slice(i*(rewardTag==='avatar'?4:2),(i+1)*(rewardTag==='avatar'?4:2))},row?selectionValues(row,rewardTag):[],'integer',newline));
});
const added=next.rows.filter(row=>row.sourceIndex===undefined).flatMap(row=>selectionValues(row,rewardTag));
if(added.length)patches.push({start:contents.closeStart,end:contents.closeStart,value:newline+'\t'+added.join('\t')+newline});
}
}
if (draft.mode==='selection') {
if (!integer(draft.selection,1)) throw new Error('可选数量必须为正整数');
if (draft.names.length!==original.names.length+extra || draft.names.some((name,i)=>!name.trim() && name!==original.names[i] && !(i===0 && isFlatBoosterSelection(draft)))) throw new Error('分类名称不能为空,且需保留原分类数量');
patches.push(...patchValues(single(original.sections,'booster selection num'),[draft.selection],'integer',newline));
const names=itemSection(original.sections,'booster category name');
if (names) {
if (additions) {
patches.push({start:names.start,end:names.start,value:additions});
patches.push(...patchValues(single(original.sections,'booster category num'),draft.dimensions,'integer',newline));
}
if (names.tokens.some((t,i)=>/^<\s*\d+\s*::/.test(t.value) && draft.names[i]!==t.value)) throw new Error('分类名称为字符串表引用,请通过 DSL 修改译文');
patches.push(...patchValues(names,draft.names,'string',newline));
}
}
let result=text;
for (const p of patches.sort((a,b)=>b.start-a.start || b.end-a.end)) result=result.slice(0,p.start)+p.value+result.slice(p.end);
return result;
}
export function addBoosterCategory(draft: BoosterDocument): void {
if (draft.mode!=='selection' || draft.dimensions[0]!=='1' || Number(draft.dimensions[1])<1) throw new Error('仅单层分类支持新增分类');
const index=draft.groups.length;
draft.groups.push({coordinates:[String(index),'0'],rows:[newBoosterRow(draft)]});
draft.names.push(`类别 ${index+1}`);
draft.dimensions[1]=String(index+1);
}
export type ItemTemplate = 'material'|'booster'|'selection'|'weapon'|'armor';
export function itemTemplate(kind: ItemTemplate): string {
let text='#PVF_File\n[name]\n`新物品`\n[rarity]\n0\n[minimum level]\n1\n';
if (kind==='weapon'||kind==='armor') return text+`[equipment type]\n\t\`[${kind}]\`\t0\n[attach type]\n\t\`[free]\`\n`;
text+=`[stackable type]\n\t\`[${kind==='selection'?'booster selection':kind==='booster'?'booster':'material'}]\`\t0\n[stack limit]\n9999\n`;
if (kind==='booster') text+='[booster info]\n[etc]\n1 1 1000 1\n[/etc]\n[/booster info]\n';
if (kind==='selection') text+='[booster category num]\n1 1\n[booster selection num]\n1\n[booster select category]\n0 0\n[etc]\n1 1\n[/etc]\n[/booster select category]\n[booster category name]\n`请选择。` `类别 1`\n[/booster category name]\n';
return text;
}
+168
View File
@@ -0,0 +1,168 @@
import { editBooster, isFlatBoosterSelection, readBooster, type BoosterDocument, type BoosterGroup, type BoosterRow } from './itemBooster';
import type { ItemSection, ItemToken } from './item';
export interface BoosterCategoryAction {
kind: 'add-category' | 'rename-category' | 'delete-category';
level: 0 | 1;
parent?: number;
index?: number;
name?: string;
}
export type BoosterInlineAction = BoosterCategoryAction |
{ kind: 'add-item' | 'edit-item' | 'delete-item'; group: number; index?: number; row?: BoosterRow } |
{ kind: 'settings'; selection?: string; pool?: number; drawCount?: string };
interface Patch { start: number; end: number; value: string }
interface Name { value: string; raw: string; token?: ItemToken }
interface Cell { name: Name; group: BoosterGroup }
interface Category { name: Name; cells: Cell[] }
const quoted = (value: string) => '`' + value.replaceAll('`', '``') + '`';
const name = (value: string): Name => ({ value, raw: quoted(value) });
const emptyGroup = (): BoosterGroup => ({ coordinates: [], rows: [] });
const occupied = (cell: Cell) => !!cell.name.value.trim() || !!cell.group.rows.length || cell.group.equipmentUpgrade !== undefined;
function apply(text: string, patches: Patch[]): string {
for (const patch of patches.sort((a,b)=>b.start-a.start || b.end-a.end)) text=text.slice(0,patch.start)+patch.value+text.slice(patch.end);
const result=readBooster(text);
if(result.issue)throw new Error(result.issue);
return text;
}
function snapshot(text: string): BoosterDocument {
const doc=readBooster(text);
if(doc.issue || !doc.mode)throw new Error(doc.issue || '当前文件不是礼包');
return doc;
}
function numbers(section: ItemSection, values: string[], newline: string): Patch[] {
const patches: Patch[]=[];
section.tokens.forEach((token,i)=>{if(token.value!==values[i])patches.push({start:token.start,end:token.end,value:values[i]??''});});
if(values.length>section.tokens.length){
const position=section.tokens.at(-1)?.end??section.body;
patches.push({start:position,end:position,value:(section.tokens.length?'\t':newline+'\t')+values.slice(section.tokens.length).join('\t')});
}
return patches;
}
/** Category changes move only coordinates and name tokens; retained reward blocks stay verbatim. */
export function changeBoosterCategory(text: string, action: BoosterCategoryAction): string {
const doc=snapshot(text);
if(doc.mode!=='selection')throw new Error('随机礼盒没有自选分类');
const flat=isFlatBoosterSelection(doc),two=doc.dimensions[0]==='2';
if(action.level===1&&!two)throw new Error('当前礼包没有二级分类');
if(action.kind!=='add-category'&&flat)throw new Error('当前礼包没有分类');
if(action.kind!=='delete-category'&&!action.name?.trim())throw new Error('分类名称不能为空');
const newline=text.includes('\r\n')?'\r\n':'\n';
const namesSection=doc.sections.find(section=>section.name==='booster category name');
const atom=(index:number):Name=>{
const token=namesSection?.tokens[index];
return token?{value:token.value,raw:text.slice(token.start,token.end),token}:name('');
};
const prompt=namesSection?atom(0):name('请选择。');
const n=flat?1:Number(doc.dimensions[1]),width=two?Number(doc.dimensions[2]):1;
const secondPrompt=two?atom(1+n):name('请选择物品。');
const parents:Category[]=Array.from({length:n},(_,x)=>({
name:flat?(doc.names[1]?atom(1):name('默认分类')):atom(1+x),
cells:Array.from({length:width},(_,y)=>({name:two?atom(2+n+x*width+y):name(''),group:doc.groups.find(g=>Number(g.coordinates[0])===x&&Number(g.coordinates[1])===y)!})),
}));
const blankCell=():Cell=>({name:name(''),group:emptyGroup()});
const rename=(old:Name,value:string):Name=>{
if(old.value===value)return old;
if(/^<\s*\d+\s*::/.test(old.value))throw new Error('分类名称为字符串表引用,请通过 DSL 修改译文');
return name(value);
};
if(action.level===0){
if(action.kind==='add-category'){
if(flat&&!parents[0]!.cells[0]!.group.rows.length){parents[0]!.name=name(action.name!);}
else parents.push({name:name(action.name!),cells:Array.from({length:width},blankCell)});
}else{
const index=action.index;
if(index===undefined||!Number.isInteger(index)||!parents[index])throw new Error('分类已变化,请重新操作');
if(action.kind==='delete-category')parents.splice(index,1);
else parents[index]!.name=rename(parents[index]!.name,action.name!);
}
}else{
const parent=parents[action.parent??-1];
if(!parent)throw new Error('父分类已变化,请重新操作');
if(action.kind==='add-category'){
let index=parent.cells.findIndex(cell=>!occupied(cell));
if(index<0){index=parent.cells.length;parents.forEach(p=>p.cells.push(blankCell()));}
parent.cells[index]={name:name(action.name!),group:parent.cells[index]!.group};
}else{
const index=action.index;
if(index===undefined||!Number.isInteger(index)||!parent.cells[index])throw new Error('子分类已变化,请重新操作');
if(action.kind==='rename-category')parent.cells[index]!.name=rename(parent.cells[index]!.name,action.name!);
else {parent.cells.splice(index,1);parent.cells.push(blankCell());}
}
}
let dims:string[],outputNames:Name[],groups:BoosterGroup[]=[];
if(!parents.length){
dims=['0'];outputNames=[prompt];groups=[{coordinates:['0','0'],rows:[]}];
}else if(two){
// Each parent has its own logical children; the file stores a rectangular grid.
const m=action.kind==='delete-category'?Math.max(1,...parents.map(p=>p.cells.reduce((last,cell,i)=>occupied(cell)?i+1:last,0))):parents[0]!.cells.length;
dims=['2',String(parents.length),String(m)];
outputNames=[prompt,...parents.map(p=>p.name),secondPrompt];
parents.forEach((p,x)=>{
for(let y=0;y<m;y++){
const cell=p.cells[y]??blankCell();
groups.push({...cell.group,coordinates:[String(x),String(y)]});outputNames.push(cell.name);
}
});
const minimum=2+parents.length;
while(outputNames.length>minimum&&!outputNames.at(-1)!.token&&!outputNames.at(-1)!.value)outputNames.pop();
}else{
dims=['1',String(parents.length)];outputNames=[prompt,...parents.map(p=>p.name)];
groups=parents.map((p,x)=>({...p.cells[0]!.group,coordinates:[String(x),'0']}));
}
const patches=numbers(doc.sections.find(s=>s.name==='booster category num')!,dims,newline);
const retained=new Set(groups.flatMap(group=>group.source?[group.source.start]:[]));
for(const group of doc.groups)if(!retained.has(group.source!.start))patches.push({start:group.source!.start,end:group.source!.end,value:''});
let additions='';
for(const group of groups){
if(group.source)patches.push(...numbers(group.source,group.coordinates,newline));
else additions+=`[booster select category]${newline}\t${group.coordinates.join('\t')}${newline}[/booster select category]${newline}`;
}
if(namesSection){
namesSection.tokens.forEach((token,i)=>{
const next=outputNames[i],raw=next?.raw??'';
if(raw!==text.slice(token.start,token.end))patches.push({start:token.start,end:token.end,value:raw});
});
if(outputNames.length>namesSection.tokens.length)patches.push({start:namesSection.closeStart,end:namesSection.closeStart,value:newline+'\t'+outputNames.slice(namesSection.tokens.length).map(n=>n.raw).join('\t')+newline});
if(additions)patches.push({start:namesSection.start,end:namesSection.start,value:additions});
}else{
additions+=`[booster category name]${newline}\t${outputNames.map(n=>n.raw).join('\t')}${newline}[/booster category name]${newline}`;
const position=doc.groups.at(-1)!.source!.end;
patches.push({start:position,end:position,value:newline+additions});
}
return apply(text,patches);
}
export function applyBoosterInlineAction(text: string, action: BoosterInlineAction): string {
if('level' in action)return changeBoosterCategory(text,action);
const draft=snapshot(text);
if(action.kind==='settings'){
if(action.selection!==undefined){if(draft.mode!=='selection')throw new Error('当前礼包不是自选礼包');draft.selection=action.selection;}
if(action.drawCount!==undefined){
const pool=draft.groups[0]?.pools?.[action.pool??-1];
if(!pool)throw new Error('奖励池已变化,请重新操作');
pool.drawCount=action.drawCount;
}
}else{
const group=draft.groups[action.group];
if(!group)throw new Error('分类已变化,请重新操作');
if(action.kind==='add-item'){
if(!action.row)throw new Error('请选择物品');
const row={...action.row,sourceIndex:undefined};
// An empty random box has no pool yet. The first candidate creates one draw.
if(draft.mode==='booster'&&!group.pools?.length){
if(!/^\d+$/.test(row.id)||Number(row.id)<1||Number(row.id)>2147483647||!/^\d+$/.test(row.quantity)||Number(row.quantity)<1||Number(row.quantity)>2147483647||!/^\d+$/.test(row.weight)||Number(row.weight)>2147483647)throw new Error('物品 ID、数量及权重无效');
const newline=text.includes('\r\n')?'\r\n':'\n',position=group.source!.closeStart;
return apply(text,[{start:position,end:position,value:`\t[etc]${newline}\t\t1\t${row.id}\t${row.weight}\t${row.quantity}${newline}\t[/etc]${newline}`}]);
}
group.rows.push(row);
}else{
const row=group.rows[action.index??-1];
if(!row)throw new Error('物品已变化,请重新操作');
if(action.kind==='delete-item')group.rows.splice(action.index!,1);
else {if(!action.row)throw new Error('请选择物品');group.rows[action.index!]={...action.row,sourceIndex:row.sourceIndex,poolIndex:row.poolIndex};}
}
}
return editBooster(text,draft);
}
+36
View File
@@ -0,0 +1,36 @@
import { ref } from 'vue';
/** One read per distinct ID, with stale responses isolated from the visible archive. */
export function createItemNameSession(read: (ids: string[]) => PromiseLike<Record<string, string | undefined> | null>) {
const names = ref<Record<string, string>>({});
const loading = ref(false);
const error = ref('');
let generation = 0;
function invalidate(clear = false) {
generation++;
loading.value = false;
error.value = '';
if (clear) names.value = {};
}
async function load(values: string[]) {
const ids = [...new Set(values.map(value => value.trim()).filter(value => /^\d+$/.test(value)))];
const request = ++generation;
names.value = Object.fromEntries(ids.filter(id => Object.hasOwn(names.value, id)).map(id => [id, names.value[id]!]));
error.value = '';
loading.value = ids.length > 0;
if (!ids.length) return;
try {
const result = await read(ids);
if (request !== generation) return;
names.value = Object.fromEntries(ids.map(id => [id, result?.[id] ?? '']));
} catch (cause) {
if (request === generation) error.value = String(cause);
} finally {
if (request === generation) loading.value = false;
}
}
return { names, loading, error, load, invalidate };
}
+19
View File
@@ -0,0 +1,19 @@
import { ref } from 'vue';
import type { SkillTreeSkill } from '../../bindings/pvfine/services/models';
/** Skill IDs are job-local. Never reuse a result across archive epochs. */
export function createItemSkillCatalog(read:(job:string)=>PromiseLike<SkillTreeSkill[]|null>){
const skills=ref<Record<string,SkillTreeSkill[]>>({}),errors=ref<Record<string,string>>({}),loading=ref(false);
let generation=0;
function invalidate(clear=false){generation++;loading.value=false;errors.value={};if(clear)skills.value={};}
async function load(jobs:string[]){
const wanted=[...new Set(jobs.filter(job=>/^\[[a-z][a-z0-9 _-]*\]$/i.test(job)&&job!=='[all]'))].filter(job=>!Object.hasOwn(skills.value,job));
const request=++generation;errors.value={};loading.value=wanted.length>0;
await Promise.allSettled(wanted.map(async job=>{
try{const result=await read(job);if(request===generation)skills.value={...skills.value,[job]:result??[]};}
catch(cause){if(request===generation)errors.value={...errors.value,[job]:String(cause)};}
}));
if(request===generation)loading.value=false;
}
return{skills,errors,loading,load,invalidate};
}
+158
View File
@@ -0,0 +1,158 @@
import type { SkillDataSelection } from './skill';
import { parseItem, type ItemSection, type ItemToken } from './item';
export type ItemSkillEffect =
{ kind:'level';job:string;skill:string;value:string } |
{ kind:'data';job:string;skill:string;scope:string;data:string;index:string;operator:string;value:string } |
{ kind:'range';job:string;growType:string;group:string;lower:string;upper:string;value:string;phase:string;extra:string[] };
export interface ItemSkillEntry { effect:ItemSkillEffect; section:ItemSection; tokens:ItemToken[]; container?:ItemSection; range?:RangeSource }
interface RangeSource { job:ItemSection;group:ItemSection;phase:ItemSection;lower:ItemToken;upper:ItemToken;value:ItemToken;growType:ItemToken;condition:ItemSection;extra?:ItemSection }
export interface ItemSkillEffects { entries:ItemSkillEntry[];issues:string[];sections:ItemSection[] }
export const itemSkillDataKinds=[['[level]','等级数据'],['[static]','静态数据'],['[cooltime]','冷却时间'],['[mp]','MP 消耗'],['[skill consume item]','技能道具消耗'],['[casting time]','施放时间']].map(([value,label])=>({value:value!,label:label!}));
const tag=(value:string)=>/^\[[a-z][a-z0-9 _-]*\]$/i.test(value);
const integer=(value:string,minimum=-2147483648)=>/^[+-]?\d+$/.test(value)&&Number(value)>=minimum&&Number(value)<=2147483647;
const number=(value:string)=>/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i.test(value)&&Number.isFinite(Number(value))&&Math.abs(Number(value))<=3.4028234663852886e38;
const quote=(value:string)=>'`'+value.replaceAll('`','``')+'`';
const signed=(value:string)=>Number(value)>0?'+'+value:value;
export function itemSkillEffectSummary(effect:ItemSkillEffect):string {
if(effect.kind==='level')return `技能等级 ${signed(effect.value)}`;
if(effect.kind==='range')return `Lv${effect.lower}–${effect.upper} · 技能等级 ${signed(effect.value)}`;
return `${itemSkillDataKinds.find(k=>k.value===effect.data)?.label??effect.data} [${effect.index}] ${effect.operator} ${effect.value} · ${effect.scope}`;
}
export function defaultItemSkillEffect(kind:ItemSkillEffect['kind'],job=''):ItemSkillEffect {
if(kind==='level')return{kind,job,skill:'',value:'1'};
if(kind==='data')return{kind,job,skill:'',scope:'[dungeon type]',data:'[level]',index:'0',operator:'%',value:'0'};
return{kind,job:'[all]',growType:'0',group:'[all]',lower:'1',upper:'85',value:'1',phase:'[all]',extra:['[except for ex skill]']};
}
function validate(effect:ItemSkillEffect){
if(!tag(effect.job))throw new Error('职业必须为有效标签,例如 [swordman]');
if(effect.kind==='data'?!number(effect.value):!integer(effect.value))throw new Error('技能数值无效:等级加成需为整数,数据修改支持整数或小数');
if(effect.kind==='range'){
if(!integer(effect.growType,0)||!integer(effect.lower,0)||!integer(effect.upper,0)||Number(effect.lower)>Number(effect.upper))throw new Error('成长类型和等级范围无效,等级下限不能大于上限');
if(!tag(effect.group)||!tag(effect.phase)||effect.extra.some(value=>!tag(value)))throw new Error('技能组、阶段和额外条件必须为有效标签');
}else{
if(!integer(effect.skill,0))throw new Error('技能 ID 必须为有效的非负整数');
if(effect.kind==='data'&&(!tag(effect.scope)||!tag(effect.data)||!integer(effect.index,0)||!['+','-','*','%'].includes(effect.operator)))throw new Error('技能数据作用范围、数据类型、序号或操作符无效');
}
}
function leaf(section:ItemSection|undefined,name:string,count:number):ItemSection {
if(!section||section.name!==name||section.paired||section.nested||section.tokens.length!==count)throw new Error('区间技能结构无法识别');
return section;
}
function rangeEntry(section:ItemSection,container:ItemSection):ItemSkillEntry {
if(!section.paired||section.tokens.length||section.children.length!==5)throw new Error('区间技能包含特殊字段,请在 DSL 中编辑');
const [grow,job,condition,phase,phaseValue]=section.children;
leaf(grow,'item growtype',0);leaf(job,job!.name,1);leaf(phase,'skill phase',0);leaf(phaseValue,phaseValue!.name,0);
if(!condition?.paired||condition.name!=='skill apply condition'||condition.tokens.length||![5,6].includes(condition.children.length))throw new Error('区间技能条件结构无法识别');
const [group,groupValue,lower,upper,value,extra]=condition.children;
leaf(group,'skill group',0);leaf(groupValue,groupValue!.name,0);leaf(lower,'lower bound level',1);leaf(upper,'upper bound level',1);leaf(value,'value',1);
if(extra&&(!extra.paired||extra.name!=='extra condition'||extra.tokens.length||extra.children.some(child=>child.paired||child.nested||child.tokens.length)))throw new Error('区间技能额外条件结构无法识别');
const numeric=[job!.tokens[0]!,lower!.tokens[0]!,upper!.tokens[0]!,value!.tokens[0]!];
if(numeric.some(token=>token.kind!=='integer'))throw new Error('区间技能参数需为整数');
const effect:ItemSkillEffect={kind:'range',job:`[${job!.name}]`,growType:numeric[0]!.value,group:`[${groupValue!.name}]`,lower:numeric[1]!.value,upper:numeric[2]!.value,value:numeric[3]!.value,phase:`[${phaseValue!.name}]`,extra:extra?.children.map(child=>`[${child.name}]`)??[]};
validate(effect);
return{effect,section,container,tokens:[],range:{job:job!,group:groupValue!,phase:phaseValue!,lower:numeric[1]!,upper:numeric[2]!,value:numeric[3]!,growType:numeric[0]!,condition,extra}};
}
export function readItemSkillEffects(text:string):ItemSkillEffects {
const doc:ItemSkillEffects={entries:[],issues:[],sections:[]};
try{
doc.sections=parseItem(text);
for(const section of doc.sections){
if(section.name==='all skill item container'){
if(!section.paired||section.tokens.length){doc.issues.push('[all skill item container] 结构无法识别');continue;}
for(const child of section.children){
if(child.name!=='all skill item'){doc.issues.push(`技能区间包含 [${child.name}],保留原文`);continue;}
try{doc.entries.push(rangeEntry(child,section));}catch(cause){doc.issues.push(String(cause));}
}
}else if(section.name==='skill levelup'||section.name==='skill data up'){
try{
const width=section.name==='skill levelup'?3:7;
if(!section.paired||section.nested||section.tokens.length%width)throw new Error(`[${section.name}] 参数未完整分组,请在 DSL 中编辑`);
const entries:ItemSkillEntry[]=[];
for(let i=0;i<section.tokens.length;i+=width){
const tokens=section.tokens.slice(i,i+width),values=tokens.map(token=>token.value);
const strings=width===3?[0]:[0,2,3,5];
if(strings.some(index=>tokens[index]!.kind!=='string')||tokens.some((token,index)=>!strings.includes(index)&&token.kind!=='integer'&&!(index===6&&number(token.value))))throw new Error(`[${section.name}] 参数类型无法识别,请在 DSL 中编辑`);
const effect:ItemSkillEffect=width===3?{kind:'level',job:values[0]!,skill:values[1]!,value:values[2]!}:{kind:'data',job:values[0]!,skill:values[1]!,scope:values[2]!,data:values[3]!,index:values[4]!,operator:values[5]!,value:values[6]!};
validate(effect);entries.push({effect,section,tokens});
}
doc.entries.push(...entries);
}catch(cause){doc.issues.push(String(cause));}
}
}
}catch(cause){doc.issues.push(String(cause));}
return doc;
}
interface Patch {start:number;end:number;value:string}
function tokenPatch(token:ItemToken,value:string,string=false):Patch|undefined {
if(token.value===value)return;
const formatted=string?quote(value):value;
return{start:token.start,end:token.end,value:token.marker?`{${token.marker}=${formatted}}`:formatted};
}
function values(effect:Exclude<ItemSkillEffect,{kind:'range'}>):string[]{
return effect.kind==='level'?[effect.job,effect.skill,effect.value]:[effect.job,effect.skill,effect.scope,effect.data,effect.index,effect.operator,effect.value];
}
function rangeText(effect:Extract<ItemSkillEffect,{kind:'range'}>,newline:string):string {
return ['[all skill item]','\t[item growtype]',`\t\t${effect.job}`,`\t\t${effect.growType}`,'\t[skill apply condition]','\t\t[skill group]',`\t\t\t${effect.group}`,'\t\t[lower bound level]',`\t\t\t${effect.lower}`,'\t\t[upper bound level]',`\t\t\t${effect.upper}`,'\t\t[value]',`\t\t\t${effect.value}`,...(effect.extra.length?['\t\t[extra condition]',...effect.extra.map(value=>'\t\t\t'+value),'\t\t[/extra condition]']:[]),'\t[/skill apply condition]','\t[skill phase]',`\t\t${effect.phase}`,'[/all skill item]'].join(newline)+newline;
}
export function editItemSkillEffect(text:string,action:{kind:'add';effect:ItemSkillEffect}|{kind:'edit';entry:number;effect:ItemSkillEffect}|{kind:'delete';entry:number}):string {
const doc=readItemSkillEffects(text),patches:Patch[]=[],newline=text.includes('\r\n')?'\r\n':'\n';
// A broken root parse has no trustworthy insertion positions.
if(!doc.sections.length&&doc.issues.length)throw new Error(doc.issues[0]);
if(action.kind==='add'){
validate(action.effect);
const effect=action.effect;
if(effect.kind==='range'){
const container=doc.sections.filter(s=>s.name==='all skill item container'&&s.paired&&!s.tokens.length).at(-1);
const block=rangeText(effect,newline);
if(container)patches.push({start:container.closeStart,end:container.closeStart,value:newline+block});
else patches.push({start:text.length,end:text.length,value:(text.endsWith(newline)?'':newline)+`[all skill item container]${newline}${block}[/all skill item container]${newline}`});
}else{
const name=effect.kind==='level'?'skill levelup':'skill data up';
const host=doc.sections.filter(s=>s.name===name&&s.paired&&!s.nested&&(s.tokens.length===0||doc.entries.some(entry=>entry.section===s))).at(-1);
const strings=effect.kind==='level'?[0]:[0,2,3,5];
const row='\t'+values(effect).map((v,i)=>strings.includes(i)?quote(v):v).join('\t')+newline;
if(host)patches.push({start:host.closeStart,end:host.closeStart,value:newline+row});
else patches.push({start:text.length,end:text.length,value:(text.endsWith(newline)?'':newline)+`[${name}]${newline}${row}[/${name}]${newline}`});
}
}else{
const entry=doc.entries[action.entry];
if(!entry)throw new Error('技能增强条目已变化,请重新编辑');
if(action.kind==='delete'){
if(entry.effect.kind==='range'){
const target=entry.container!.children.length===1?entry.container!:entry.section;
patches.push({start:target.start,end:target.end,value:''});
}else if(entry.section.tokens.length===entry.tokens.length)patches.push({start:entry.section.start,end:entry.section.end,value:''});
else entry.tokens.forEach(token=>patches.push({start:token.start,end:token.end,value:''}));
}else{
const effect=action.effect;validate(effect);
if(effect.kind!==entry.effect.kind)throw new Error('编辑时不能改变技能增强类型,请新增对应条目');
if(effect.kind==='range'&&entry.effect.kind==='range'){
const source=entry.range!;
for(const [section,value] of [[source.job,effect.job],[source.group,effect.group],[source.phase,effect.phase]] as [ItemSection,string][]){
if(`[${section.name}]`!==value)patches.push({start:section.start,end:section.body,value});
}
for(const [token,value] of [[source.lower,effect.lower],[source.upper,effect.upper],[source.value,effect.value],[source.growType,effect.growType]] as [ItemToken,string][]){const patch=tokenPatch(token,value);if(patch)patches.push(patch);}
if(effect.extra.join()!==entry.effect.extra.join()){
const raw=effect.extra.length?`[extra condition]${newline}${effect.extra.map(value=>'\t\t'+value).join(newline)}${newline}[/extra condition]${newline}`:'';
patches.push({start:source.extra?.start??source.condition.closeStart,end:source.extra?.end??source.condition.closeStart,value:raw});
}
}else if(effect.kind!=='range'){
const strings=effect.kind==='level'?[0]:[0,2,3,5];
values(effect).forEach((value,i)=>{const patch=tokenPatch(entry.tokens[i]!,value,strings.includes(i));if(patch)patches.push(patch);});
}
}
}
for(const patch of patches.sort((a,b)=>b.start-a.start||b.end-a.end))text=text.slice(0,patch.start)+patch.value+text.slice(patch.end);
// Parse again so insertion can never introduce an unclosed or mismatched block.
parseItem(text);
return text;
}
/** Copy source identity only; preview multipliers and levels do not change equipment operands. */
export function applySkillDataSelection(effect: Extract<ItemSkillEffect, { kind: 'data' }>, selection: SkillDataSelection & { job: string; skill: string }): Extract<ItemSkillEffect, { kind: 'data' }> {
if (!Number.isInteger(selection.index) || selection.index < 0) throw new Error('技能数据索引无效');
const next = { ...effect, job: selection.job, skill: selection.skill, data: selection.dynamic ? '[level]' : '[static]', index: String(selection.index) };
validate(next);
return next;
}
+218
View File
@@ -0,0 +1,218 @@
import { parseItem, type ItemSection, type ItemToken } from './item';
export type EffectResourceKind = 'object' | 'particle' | 'appendage' | 'monster' | 'apc';
export interface TriggerParameter { label: string; kind: 'integer' | 'number' | 'string'; default: string; resource?: EffectResourceKind }
export interface TriggerClauseSpec { name: string; label: string; scope: 'if' | 'then'; params: TriggerParameter[]; paired?: boolean; repeat?: boolean; counts?: number[] }
const n = (label: string, value = '0'): TriggerParameter => ({ label, kind: 'integer', default: value });
const s = (label: string, value: string): TriggerParameter => ({ label, kind: 'string', default: value });
const resource = (label: string, kind: EffectResourceKind): TriggerParameter => ({ ...s(label, ''), kind: kind === 'particle' ? 'string' : 'integer', resource: kind });
const spec = (scope: 'if' | 'then', name: string, label: string, params: TriggerParameter[], extra: Partial<TriggerClauseSpec> = {}): TriggerClauseSpec => ({ scope, name, label, params, ...extra });
export const triggerClauses: TriggerClauseSpec[] = [
...['attack success', 'event attack success', 'hit', 'casting', 'set my state', 'use command'].map((name, i) => spec('if', name, ['攻击命中', '攻击命中事件', '被攻击', '施放技能', '检测自身状态', '装备操作键'][i]!, [n('启用', '1')])),
...['my state', 'my attack state', 'target state', 'my active status', 'target active status', 'target type', 'target grade', 'element'].map((name, i) => spec('if', name, ['自身动作状态', '自身攻击状态', '目标动作状态', '自身异常状态', '目标异常状态', '目标种族', '目标品级', '属性条件'][i]!, [s('类型', ['attack', 'attack', 'down', 'bleeding', 'poison', 'human', 'boss', 'fire'][i]!)])),
...['use skill', 'event use skill'].map(name => spec('if', name, name === 'use skill' ? '使用指定技能' : '使用技能事件', [s('职业(原始 token)', 'swordman'), n('技能 ID')], { paired: true, repeat: true })),
spec('if', 'time', '周期检测', [n('间隔(毫秒)', '1000'), n('启用', '1'), n('延迟(毫秒)', '0')], { counts: [2, 3] }),
...['cooltime', 'start cooltime'].map(name => spec('if', name, name === 'cooltime' ? '触发冷却' : '装备起始冷却', [n('时间(毫秒)', '1000')])),
...['my appendage', 'not my appendage', 'my appendage count'].map((name, i) => spec('if', name, ['自身有附加物', '自身无附加物', '附加物层数'][i]!, [resource('附加物 ID', 'appendage'), ...(i === 2 ? [n('层数', '1')] : [])])),
spec('if', 'stat change', 'HP / MP 阈值', [s('属性', 'hp'), s('比较符', '>'), s('单位', '%'), n('阈值', '0')]),
...['distance', 'my prev state', 'attack condition', 'attack type'].map((name, i) => spec('if', name, ['目标距离', '自身上一状态', '攻击条件', '伤害类型'][i]!, [s('类型', ['far', 'dash', 'counter', 'physical'][i]!)])),
...['combo', 'after attack', 'revive'].map((name, i) => spec('if', name, ['连击数', '攻击后', '复活'][i]!, [n('数值', i === 0 ? '30' : '1')])),
spec('if', 'keep my state', '持续保持状态', [s('状态', 'stand'), n('毫秒', '10000')]),
spec('if', 'check equipment rarity', '装备品级条件', [s('部位', 'weapon'), s('比较符', '='), n('品级', '2')]),
spec('if', 'monster exists', '场上怪物数量', [resource('怪物 ID', 'monster'), s('比较符', '<'), n('数量', '1')]),
spec('if', 'passive object exists', '场上被动对象条件', [resource('对象 ID', 'object'), s('比较符', '<'), n('时间(毫秒)', '3000')]),
spec('if', 'check damage', '单次伤害条件', [s('单位', '%'), n('数值', '10')]),
spec('if', 'party count', '队伍人数', [s('比较符', '='), n('人数', '1')]),
spec('then', 'target', '动作目标', [s('目标', 'myself'), n('范围 / 参数', '-1')]),
...['probability', 'then probability'].map(name => spec('then', name, name === 'probability' ? '触发概率' : '分支概率', [{ ...n('概率(原始值)', '100'), kind: 'number' }])),
...['duration', 'equipment duration'].map(name => spec('then', name, name === 'duration' ? '持续时间' : '装备效果持续时间', [n('时间(毫秒)', '3000')])),
spec('then', 'passive object', '生成被动对象', [resource('对象 ID', 'object'), n('X'), n('Y'), n('Z'), n('等级', '80'), n('跟随参数'), resource('粒子路径(可留空)', 'particle')], { paired: true, repeat: true }),
...['stat', 'stat by condition'].map(name => spec('then', name, name === 'stat' ? '属性加成' : '条件属性加成', [s('属性', 'attack speed'), s('单位', '+'), n('数值', '10')], { repeat: true })),
spec('then', 'add absolute damage', '追加伤害', [s('属性', 'all'), s('运算符', '%'), n('数值', '10')]),
...['unique increase damage', 'unique increase critical damage'].map(name => spec('then', name, name === 'unique increase damage' ? '独特增伤' : '独特暴伤', [n('索引'), s('运算符', '%'), n('数值', '10')], { repeat: true })),
...['appendage', 'remove appendage', 'appendage unique'].map((name, i) => spec('then', name, ['施加附加物', '移除附加物', '施加唯一附加物'][i]!, [resource('附加物 ID', 'appendage')], { repeat: true })),
spec('then', 'consume item', '消耗道具', [n('道具 ID'), n('数量', '1')], { repeat: true }),
spec('then', 'restore', '恢复 HP / MP', [s('资源', 'hp'), s('单位', '%'), n('数值', '10'), n('恢复时间(毫秒)')]),
spec('then', 'active status', '异常状态', [s('状态', 'stun'), n('等级', '50'), n('参数 2'), n('参数 3'), n('参数 4'), n('参数 5')], { counts: [2, 3, 4, 6] }),
spec('then', 'buff', '增益状态', [s('类型', 'superarmor'), n('等级', '1')], { counts: [1, 2] }),
spec('then', 'weakness', '弱化效果', [n('数值', '75'), n('第二值', '10')], { counts: [1, 2] }),
spec('then', 'elemental weapon', '赋予武器属性', [s('属性', 'light')]),
spec('then', 'add absolute defense percent', '追加防御百分比', [s('属性', 'all'), n('数值', '10')]),
...['summon monster', 'summon apc'].map(name => spec('then', name, name === 'summon monster' ? '召唤怪物' : '召唤 APC', [resource('召唤 ID', name === 'summon monster' ? 'monster' : 'apc'), n('等级', '-1'), n('数量', '1')])),
spec('then', 'speech on', '头顶台词', [n('参数'), n('显示时间(毫秒)', '2000'), s('台词', '')], { paired: true }),
spec('then', 'perform skill', '执行技能', [s('职业', 'swordman'), n('技能 ID'), n('等级', '1'), n('参数')]),
spec('then', 'apply status in range', '范围内应用状态', [s('状态种类', 'hp'), s('数值单位', '%')]),
spec('then', 'stat change by stat', '按属性改变属性', [s('目标', 'myself'), n('半径', '-1'), s('属性', 'hp max'), n('数值', '4000')]),
spec('then', 'appendage control', '附加物控制', [s('属性名', 'add condition increase damage'), s('类型', 'string'), n('索引', '3'), n('数值', '5')]),
];
export interface ItemTrigger { condition: ItemSection; response: ItemSection; branches: ItemSection[]; multiple: boolean }
export function readItemTriggers(text: string): { triggers: ItemTrigger[]; issues: string[] } {
const triggers: ItemTrigger[] = [], issues: string[] = [];
try {
const sections = parseItem(text);
for (let i = 0; i < sections.length; i++) {
const condition = sections[i]!;
if (condition.name === 'if') {
const response = sections[i + 1];
if (!response || !['then', 'multiple then'].includes(response.name)) { issues.push('[if] 后缺少相邻的 [then] 或 [multiple then],请在 DSL 中修复'); continue; }
const multiple = response.name === 'multiple then', branches = multiple ? response.children.filter(c => c.name === 'then') : [response];
if (!branches.length || condition.tokens.length || response.tokens.length || (multiple && response.children.some(c => c.name !== 'then'))) { issues.push('触发器包含无法识别的容器内容,请在 DSL 中编辑'); i++; continue; }
triggers.push({ condition, response, branches, multiple }); i++;
} else if (condition.name === 'then' || condition.name === 'multiple then') issues.push(`存在未配对的 [${condition.name}],请在 DSL 中修复`);
}
} catch (cause) { issues.push(String(cause)); }
return { triggers, issues };
}
export function triggerClauseSpec(scope: 'if' | 'then', section: ItemSection): TriggerClauseSpec | undefined {
return triggerClauses.find(c => c.scope === scope && c.name === section.name);
}
function validValue(value: string, parameter: TriggerParameter): boolean {
if (parameter.kind === 'string') return true;
if (parameter.kind === 'integer') return /^[+-]?\d+$/.test(value) && Number(value) >= -2147483648 && Number(value) <= 2147483647 && (!parameter.resource || Number(value) >= 0);
return /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i.test(value) && Number.isFinite(Number(value)) && Math.abs(Number(value)) <= 3.4028234663852886e38;
}
function validateRows(spec: TriggerClauseSpec, rows: string[][]) {
if (!rows.length || (!spec.repeat && rows.length !== 1)) throw new Error('参数记录数量无效');
for (const row of rows) {
if (!(spec.counts ?? [spec.params.length]).includes(row.length) || row.some((v, i) => !validValue(v, spec.params[i]!))) throw new Error('参数数量或类型无效,数值需在 32 位范围内');
}
}
export function triggerClauseRows(section: ItemSection, spec: TriggerClauseSpec): string[][] | null {
if (section.nested || section.paired !== !!spec.paired) return null;
const width = spec.repeat ? spec.params.length : section.tokens.length;
if (!width || section.tokens.length % width) return null;
const rows: string[][] = [];
for (let i = 0; i < section.tokens.length; i += width) {
const tokens = section.tokens.slice(i, i + width);
if (tokens.some((token, index) => spec.params[index]?.kind === 'string' ? token.kind !== 'string' : token.kind !== 'integer' && !(spec.params[index]?.kind === 'number' && validValue(token.value, spec.params[index]!)))) return null;
rows.push(tokens.map(t => t.value));
}
try { validateRows(spec, rows); return rows; } catch { return null; }
}
const quote = (value: string) => '`' + value.replaceAll('`', '``') + '`';
const format = (value: string, param: TriggerParameter) => param.kind === 'string' ? quote(value) : value;
export function triggerClauseText(spec: TriggerClauseSpec, rows: string[][], newline = '\n'): string {
validateRows(spec, rows);
return `[${spec.name}]${newline}${rows.map(row => '\t' + row.map((value, i) => format(value, spec.params[i]!)).join('\t')).join(newline)}${newline}${spec.paired ? `[/${spec.name}]${newline}` : ''}`;
}
export type TriggerEdit =
{ kind: 'add-trigger'; condition: string; rows: string[][]; multiple: boolean } |
{ kind: 'delete-trigger'; trigger: number } |
{ kind: 'add-branch'; trigger: number } |
{ kind: 'delete-branch'; trigger: number; branch: number } |
{ kind: 'add-clause'; trigger: number; branch?: number; name: string; rows: string[][] } |
{ kind: 'edit-clause'; trigger: number; branch?: number; clause: number; rows: string[][] } |
{ kind: 'delete-row'; trigger: number; branch?: number; clause: number; row: number } |
{ kind: 'delete-clause'; trigger: number; branch?: number; clause: number };
interface Patch { start: number; end: number; value: string }
/** Adjust syntax indentation only; multiline string contents and token spelling stay exact. */
export function formatItemTriggerBlock(text: string): string {
const patches = new Map<number, Patch>();
function indent(position: number, depth: number) {
const start = Math.max(text.lastIndexOf('\n', position - 1), text.lastIndexOf('\r', position - 1)) + 1;
const prefix = text.slice(start, position);
if (!/^[ \t]*$/.test(prefix)) return; // Inline syntax stays inline.
const value = '\t'.repeat(depth);
if (prefix !== value) patches.set(start, { start, end: position, value });
}
function visit(section: ItemSection, depth: number) {
indent(section.start, depth);
for (const token of section.tokens) indent(token.start, depth + 1);
for (const child of section.children) visit(child, depth + 1);
if (section.paired) indent(section.closeStart, depth);
}
for (const section of parseItem(text)) visit(section, 0);
for (const patch of [...patches.values()].sort((a, b) => b.start - a.start)) text = text.slice(0, patch.start) + patch.value + text.slice(patch.end);
return text;
}
/** Apply a single operation to exact source spans; never serialize the trigger tree. */
export function editItemTrigger(text: string, action: TriggerEdit): string {
const sections = parseItem(text);
const hasCommand = (before: number) => sections.some(section => section.name === 'command' && section.start < before && section.tokens.length > 0);
const doc = readItemTriggers(text), newline = text.includes('\r\n') ? '\r\n' : '\n', patches: Patch[] = [];
const insert = (position: number, value: string) => patches.push({ start: position, end: position, value });
const insertBeforeClose = (section: ItemSection, value: string) => {
const lineStart = text.lastIndexOf('\n', section.closeStart - 1) + 1;
if (/^[ \t]*$/.test(text.slice(lineStart, section.closeStart))) insert(lineStart, value);
else insert(section.closeStart, newline + value);
};
const remove = (section: ItemSection) => patches.push({ start: section.start, end: section.paired ? section.end : section.tokens.at(-1)?.end ?? section.body, value: '' });
if (action.kind === 'add-trigger') {
if (doc.issues.length) throw new Error('请先修复现有触发器配对,再新增触发器');
const spec = triggerClauses.find(c => c.scope === 'if' && c.name === action.condition);
if (!spec) throw new Error('触发条件无效');
// Command requires a version-specific binary token; do not synthesize it.
if (spec.name === 'use command' && !hasCommand(text.length)) throw new Error('操作键需要与客户端匹配的 [command] 数据,请复制已有触发器后在 DSL 中配置');
const body = triggerClauseText(spec, action.rows, newline);
const target = triggerClauseText(triggerClauses.find(c => c.scope === 'then' && c.name === 'target')!, [['myself', '-1']], newline);
const branch = `[then]${newline}${target}${action.multiple ? `[then probability]${newline}\t100.0${newline}` : ''}[/then]${newline}`;
insert(text.length, (text && !text.endsWith('\n') ? newline : '') + `[if]${newline}${body}[/if]${newline}` + (action.multiple ? `[multiple then]${newline}${branch}[/multiple then]${newline}` : branch));
} else {
const trigger = doc.triggers[action.trigger];
if (!trigger) throw new Error('触发器已变化,请重新编辑');
if (action.kind === 'delete-trigger') { remove(trigger.condition); remove(trigger.response); }
else if (action.kind === 'add-branch') {
const target = triggerClauseText(triggerClauses.find(c => c.name === 'target' && c.scope === 'then')!, [['myself', '-1']], newline);
const branch = `[then]${newline}${target}[then probability]${newline}\t0.0${newline}[/then]${newline}`;
if (trigger.multiple) insertBeforeClose(trigger.response, branch);
else {
insert(trigger.response.start, `[multiple then]${newline}`);
if (!trigger.response.children.some(child => child.name === 'then probability')) insertBeforeClose(trigger.response, `[then probability]${newline}\t100.0${newline}`);
insert(trigger.response.end, newline + branch + `[/multiple then]${newline}`);
}
} else if (action.kind === 'delete-branch') {
if (trigger.branches.length <= 1) throw new Error('至少保留一个动作分支;移除最后分支请删除整个触发器');
const branch = trigger.branches[action.branch]; if (!branch) throw new Error('分支已变化'); remove(branch);
} else {
const scope = action.branch === undefined ? 'if' : 'then';
const host = scope === 'if' ? trigger.condition : trigger.branches[action.branch!];
if (!host) throw new Error('分支已变化');
if (action.kind === 'add-clause') {
const spec = triggerClauses.find(c => c.scope === scope && c.name === action.name);
if (!spec) throw new Error('不支持的触发器字段');
if (spec.name === 'then probability' && host.children.some(child => child.name === spec.name)) throw new Error('分支已有概率,请编辑现有设置');
if (spec.name === 'use command' && !hasCommand(trigger.condition.start)) throw new Error('操作键需要客户端对应的 [command] 数据,请在 DSL 中配置');
insertBeforeClose(host, triggerClauseText(spec, action.rows, newline));
} else {
const section = host.children[action.clause], spec = section && triggerClauseSpec(scope, section);
if (!section || !spec) throw new Error('特殊字段请在 DSL 中编辑');
const rows = triggerClauseRows(section, spec);
if (!rows) throw new Error('特殊参数结构请在 DSL 中编辑');
if (action.kind === 'delete-clause') {
if (scope === 'if' && host.children.length <= 1) throw new Error('至少保留一个触发条件;移除最后条件请删除整个触发器');
remove(section);
}
else if (action.kind === 'delete-row') {
if (!spec.repeat || !rows[action.row]) throw new Error('参数记录已变化');
if (rows.length === 1) remove(section);
else section.tokens.slice(action.row * spec.params.length, (action.row + 1) * spec.params.length).forEach(token => patches.push({ start: token.start, end: token.end, value: '' }));
}
else {
validateRows(spec, action.rows);
if (rows.length !== action.rows.length || rows.some((r, i) => r.length !== action.rows[i]!.length)) {
// Preserve every existing row and comment; only appending rows is safe.
if (!spec.repeat || action.rows.length < rows.length || rows.some((r, i) => r.length !== action.rows[i]!.length)) throw new Error('已有记录不可直接改变参数数量;请分开删除或新增字段');
insert(section.paired ? section.closeStart : section.tokens.at(-1)!.end, newline + action.rows.slice(rows.length).map(row => '\t' + row.map((v, i) => format(v, spec.params[i]!)).join('\t')).join(newline) + newline);
}
section.tokens.forEach((token: ItemToken, i) => {
const row = Math.floor(i / rows[0]!.length), column = i % rows[0]!.length, value = action.rows[row]![column]!;
if (value === token.value) return;
const raw = format(value, spec.params[column]!);
patches.push({ start: token.start, end: token.end, value: token.marker ? `{${token.marker}=${raw}}` : raw });
});
}
}
}
}
for (const patch of patches.sort((a, b) => b.start - a.start || b.end - a.end)) text = text.slice(0, patch.start) + patch.value + text.slice(patch.end);
parseItem(text);
if (patches.length && action.kind !== 'delete-trigger') {
const updated = readItemTriggers(text).triggers;
const trigger = action.kind === 'add-trigger' ? updated.at(-1) : updated[action.trigger];
if (trigger) {
const start = trigger.condition.start, end = trigger.response.end;
text = text.slice(0, start) + formatItemTriggerBlock(text.slice(start, end)) + text.slice(end);
}
}
return text;
}
+42
View File
@@ -0,0 +1,42 @@
import type { SearchHit, SearchResult } from "../../bindings/pvfine/services/models";
import type { GUIProvider } from "./types";
export interface GUISearchCursor { queryIndex: number; offset: number }
export function guiPathMatches(provider: GUIProvider, path: string): boolean {
return provider.matches({ index: 0, path, text: "", editable: true });
}
export function newGUIFilePath(provider: GUIProvider, raw: string): string {
const path = raw.trim().replaceAll("\\", "/").replace(/^\/+|\/+$/g, "");
if (!path || path.split("/").some(part => !part || part === "." || part === "..")) {
throw new Error("路径无效:不能为空,且不能包含空段、. 或 ..");
}
if (!guiPathMatches(provider, path)) throw new Error(`请选择支持的文件类型或路径:${provider.launcher.fileTypes}`);
return path;
}
/** 先按类型搜索;关键词搜索过滤后继续翻页,避免无关结果遮住目标文件。 */
export async function searchGUIFiles(
provider: GUIProvider,
query: string,
cursor: GUISearchCursor,
search: (query: string, cursor: number, limit: number) => Promise<SearchResult | null>,
isCurrent: () => boolean,
): Promise<{ hits: SearchHit[]; next: GUISearchCursor | null }> {
const queries = query.trim() ? [query.trim()] : provider.launcher.queries;
const next = { ...cursor };
const hits = new Map<number, SearchHit>();
// 限制单次扫描量,仍可通过“继续搜索”访问剩余结果。
for (let page = 0; page < 10 && next.queryIndex < queries.length; page++) {
const result = await search(queries[next.queryIndex]!, next.offset, 100);
if (!isCurrent()) return { hits: [], next: null };
for (const hit of result?.hits ?? []) {
if (hit && guiPathMatches(provider, hit.path)) hits.set(hit.fileIndex, hit);
}
if ((result?.nextCursor ?? -1) < 0) { next.queryIndex++; next.offset = 0; }
else next.offset = result!.nextCursor;
if (hits.size) break;
}
return { hits: [...hits.values()], next: next.queryIndex < queries.length ? next : null };
}
+158
View File
@@ -0,0 +1,158 @@
/** Quest fields use source ranges so unrelated sections and comments survive edits. */
import type { EditorAnnotation } from '../../bindings/pvfine/services/models';
export interface QuestReference { tableIndex: number; key: string }
export interface QuestToken { start: number; end: number; value: string; kind: 'string' | 'integer' | 'unsupported'; markerType?: number; reference?: QuestReference }
export function questReference(value: string): QuestReference | undefined {
const match = /^<\s*(\d+)\s*::([^<>]+)>$/.exec(value);
if (!match || Number(match[1]) > 2147483647 || !match[2]!.trim()) return;
return { tableIndex: Number(match[1]), key: match[2]!.trim() };
}
export function questReferenceAnnotation(reference: QuestReference, annotations: EditorAnnotation[]): EditorAnnotation | undefined {
return annotations.find((a) => a.placeholder?.tableIndex === reference.tableIndex && a.placeholder.key === reference.key);
}
export function questAnnotationText(annotation: EditorAnnotation): string {
if (annotation.placeholder?.missing) return '';
const suffix = '(未翻译)';
return annotation.placeholder?.fallback && annotation.title.endsWith(suffix) ? annotation.title.slice(0, -suffix.length) : annotation.title;
}
export function questDisplayValue(doc: QuestDocument, name: string, annotations: EditorAnnotation[]): string {
const token = questField(doc, name)?.tokens[0];
if (!token) return '';
const annotation = token.reference && questReferenceAnnotation(token.reference, annotations);
return annotation ? questAnnotationText(annotation) : token.value;
}
export interface QuestSection { name: string; start: number; body: number; end: number; paired: boolean; tokens: QuestToken[]; issue: string }
export interface QuestDocument { sections: QuestSection[] }
export type QuestTargetKind = 'dungeon' | 'monster' | 'npc';
export interface QuestCondition { value: string; label: string; type: string; subType: number; fields: string[]; repeat?: boolean; queries?: Record<number, QuestTargetKind> }
export const questConditions: QuestCondition[] = [
{ value: 'hunt', label: '杀怪', type: '[hunt monster]', subType: -1, fields: ['副本 ID', '难度', '怪物 ID', '数量'], repeat: true, queries: { 0: 'dungeon', 2: 'monster' } },
{ value: 'map', label: '通关房间', type: '[clear map]', subType: -1, fields: ['地图 ID'] },
{ value: 'seeking', label: '收集物品', type: '[seeking]', subType: -1, fields: ['物品 ID', '数量'], repeat: true },
{ value: 'npc', label: '对话', type: '[meet npc]', subType: -1, fields: ['NPC ID'], queries: { 0: 'npc' } },
{ value: 'clear', label: '通关副本', type: '[condition under clear]', subType: 6, fields: ['副本 ID', '难度'], queries: { 0: 'dungeon' } },
{ value: 'rooms', label: '通关房间数量', type: '[condition under clear]', subType: 11, fields: ['副本 ID', '难度', '房间数量'] },
{ value: 'time', label: '限时通关', type: '[condition under clear]', subType: 0, fields: ['副本 ID', '难度', '时间(秒)'], queries: { 0: 'dungeon' } },
{ value: 'party', label: '组队通关', type: '[condition under clear]', subType: 5, fields: ['副本 ID', '难度', '人数'], queries: { 0: 'dungeon' } },
];
export const questRewards = [
{ value: '[item]', label: '道具', fields: ['物品 ID', '数量'], repeat: true },
{ value: '[title]', label: '称号', fields: ['称号 ID', '数量'], repeat: false },
{ value: '[grow type]', label: '转职', fields: ['转职 ID'], repeat: false },
{ value: '[awakening type]', label: '觉醒', fields: ['觉醒次数'], repeat: false },
];
export const questGrades = [
{ label: '每日任务', value: '[daily]' }, { label: '重复任务', value: '[normaly repeat]' },
{ label: '普通任务', value: '[common unique]' }, { label: '成就任务', value: '[achievement]' }, { label: '主线任务', value: '[epic]' },
];
export function parseQuest(text: string): QuestDocument {
const sections: QuestSection[] = [];
const open: QuestSection[] = [];
let current: QuestSection | undefined;
let i = 0;
function stringEnd(start: number): number {
let p = start + 1;
while (p < text.length) {
if (text[p++] !== '`') continue;
if (text[p] === '`') { p++; continue; }
return p;
}
throw new Error('存在未闭合的文本,请先在 DSL 中修复');
}
while (i < text.length) {
if (/\s/.test(text[i]!)) { i++; continue; }
if (text[i] === '#') { const end = text.indexOf('\n', i); i = end < 0 ? text.length : end; continue; }
const start = i;
if (text[i] === '[') {
const end = text.indexOf(']', i);
if (end < 0) throw new Error('存在未闭合的字段标签,请先在 DSL 中修复');
const name = text.slice(i + 1, end).trim();
if (name.startsWith('/')) {
const match = open.findLastIndex(s => s.name === name.slice(1).trim());
if (match < 0) throw new Error('字段闭合或嵌套结构无法识别,请在 DSL 中编辑');
const section = open[match]!;
section.end = i;
section.paired = true;
const position = sections.indexOf(section);
// Preserve nested blocks as opaque, read-only root fields. Their
// children must never shadow the quest's name, type or reward fields.
if (position < sections.length - 1) {
section.issue = '包含嵌套字段,请在 DSL 中编辑';
sections.splice(position + 1);
}
open.splice(match);
current = undefined;
} else {
if (current) current.end = i;
current = { name, start, body: end + 1, end: text.length, paired: false, tokens: [], issue: '' };
sections.push(current);
open.push(current);
}
i = end + 1; continue;
}
let kind: QuestToken['kind'] = 'unsupported';
let markerType: number | undefined;
let value: string;
if (text[i] === '`') {
i = stringEnd(i); kind = 'string'; value = text.slice(start + 1, i - 1).replaceAll('``', '`');
} else if (text[i] === '{') {
i++;
while (i < text.length && text[i] !== '}') { i = text[i] === '`' ? stringEnd(i) : i + 1; }
if (i >= text.length) throw new Error('存在未闭合的类型标记,请先在 DSL 中修复');
i++; value = text.slice(start, i);
const marker = /^\{(5|7|8|10)=\s*`((?:[^`]|``)*)`\s*\}$/.exec(value);
if (marker) { kind = 'string'; markerType = Number(marker[1]); value = marker[2]!.replaceAll('``', '`'); }
} else {
while (i < text.length && !/[\s`\[{#]/.test(text[i]!)) i++;
value = text.slice(start, i);
if (/^[+-]?\d+$/.test(value)) kind = 'integer';
}
current?.tokens.push({ start, end: i, value, kind, markerType, reference: kind === 'string' ? questReference(value) : undefined });
}
for (const section of sections) {
if (section.issue) continue;
if (sections.filter((s) => s.name === section.name).length > 1) section.issue = '存在重复字段,请在 DSL 中编辑';
else if (section.tokens.some((t) => t.kind === 'unsupported')) section.issue = '包含字符串引用或特殊类型,请在 DSL 中编辑';
}
return { sections };
}
export function questField(doc: QuestDocument, name: string): QuestSection | undefined { return doc.sections.find((s) => s.name === name); }
export function questValues(doc: QuestDocument, name: string): string[] { return questField(doc, name)?.tokens.map((t) => t.value) ?? []; }
export interface QuestChange { name: string; values: string[]; kind: 'string' | 'integer'; paired?: boolean }
export function editQuest(text: string, changes: QuestChange[]): string {
const doc = parseQuest(text);
const newline = text.includes('\r\n') ? '\r\n' : '\n';
const patches: { start: number; end: number; value: string }[] = [];
let additions = '';
const seen = new Set<string>();
for (const change of changes) {
if (seen.has(change.name)) throw new Error('一次操作不能重复修改同一字段');
seen.add(change.name);
const section = questField(doc, change.name);
if (section?.issue) throw new Error(`[${change.name}] ${section.issue}`);
if (section?.tokens.some((t) => t.reference)) throw new Error(`[${change.name}] 请通过修改译文编辑字符串引用`);
if (section?.tokens.some((t) => t.kind !== change.kind)) throw new Error(`[${change.name}] 数据类型不匹配,请在 DSL 中编辑`);
const values = change.values.map((v) => {
if (change.kind === 'string') return '`' + v.replaceAll('`', '``') + '`';
if (!/^[+-]?\d+$/.test(v) || Number(v) < -2147483648 || Number(v) > 2147483647) throw new Error('请输入 32 位范围内的整数');
return v;
});
if (!section) {
if (values.length) additions += `${newline}[${change.name}]${newline}\t${values.join('\t')}${newline}${change.paired ? `[/${change.name}]${newline}` : ''}`;
continue;
}
section.tokens.forEach((token, index) => {
if (change.values[index] === token.value) return;
const formatted = values[index];
const value = formatted === undefined ? '' : token.markerType === undefined ? formatted : `{${token.markerType}=${formatted}}`;
if (text.slice(token.start, token.end) !== value) patches.push({ start: token.start, end: token.end, value });
});
if (values.length > section.tokens.length) {
const position = section.tokens.at(-1)?.end ?? section.body;
patches.push({ start: position, end: position, value: `${section.tokens.length ? '\t' : newline + '\t'}${values.slice(section.tokens.length).join('\t')}${section.tokens.length ? '' : newline}` });
}
}
let result = text;
for (const patch of patches.sort((a, b) => b.start - a.start || b.end - a.end)) result = result.slice(0, patch.start) + patch.value + result.slice(patch.end);
return result + (additions ? (result.endsWith('\n') || !result ? '' : newline) + additions : '');
}
+101
View File
@@ -0,0 +1,101 @@
import { defineAsyncComponent } from "vue";
import type { GUIFile, GUIProvider } from "./types";
function normalizePath(path: string): string {
return path.replaceAll("\\", "/").replace(/^\/+|\/+$/g, "").toLowerCase();
}
export const guiProviders: readonly GUIProvider[] = [
{
id: "shop",
label: "商店",
launcher: { fileTypes: ".shp", paths: ["npc/shop/new.shp"], queries: ["*.shp"] },
readOnly: false,
matches: (file) => file.path.toLowerCase().endsWith(".shp"),
component: defineAsyncComponent(() => import("../components/gui/ShopViewer.vue")),
},
{
id: "world-drop",
label: "全局掉率",
launcher: { fileTypes: "etc/worlddrop.etc、etc/(r)worlddrop.etc", paths: ["etc/worlddrop.etc", "etc/(r)worlddrop.etc"], queries: ["*worlddrop.etc"] },
readOnly: false,
matches: (file) => ["etc/worlddrop.etc", "etc/(r)worlddrop.etc"].includes(normalizePath(file.path)),
component: defineAsyncComponent(() => import("../components/gui/WorldDropViewer.vue")),
},
{
id: "skill",
label: "技能",
launcher: { fileTypes: ".skl", paths: ["skill/new.skl"], queries: ["*.skl"] },
readOnly: false,
matches: (file) => file.path.toLowerCase().endsWith(".skl"),
component: defineAsyncComponent(() => import("../components/gui/SkillViewer.vue")),
},
{
id: "quest",
label: "任务",
launcher: { fileTypes: ".qst", paths: ["quest/new.qst"], queries: ["*.qst"] },
readOnly: false,
matches: (file) => file.path.toLowerCase().endsWith(".qst"),
component: defineAsyncComponent(() => import("../components/gui/QuestViewer.vue")),
},
{
id: "upgrade",
label: "强化/增幅",
launcher: { fileTypes: "etc/upgrade.etc、etc/amplifyupgrade.etc", paths: ["etc/upgrade.etc", "etc/amplifyupgrade.etc"], queries: ["etc/upgrade.etc", "etc/amplifyupgrade.etc"] },
readOnly: false,
matches: (file) => ["etc/upgrade.etc", "etc/amplifyupgrade.etc"].includes(normalizePath(file.path)),
component: defineAsyncComponent(() => import("../components/gui/UpgradeViewer.vue")),
},
{
id: "forge",
label: "锻造",
launcher: { fileTypes: "etc/upgrade_separate.etc", paths: ["etc/upgrade_separate.etc"], queries: ["etc/upgrade_separate.etc"] },
readOnly: false,
matches: (file) => normalizePath(file.path) === "etc/upgrade_separate.etc",
component: defineAsyncComponent(() => import("../components/gui/ForgeViewer.vue")),
},
{
id: "item",
label: "物品",
launcher: { fileTypes: ".equ、.stk", paths: ["equipment/new.equ", "stackable/new.stk"], queries: ["*.equ", "*.stk"] },
readOnly: false,
matches: (file) => /\.(equ|stk)$/i.test(file.path),
component: defineAsyncComponent(() => import("../components/gui/ItemViewer.vue")),
},
{
id: "independent-drop",
label: "独立掉落",
launcher: { fileTypes: "etc/independent_drop.etc、etc/independentdrop/*.etc", paths: ["etc/independent_drop.etc"], queries: ["etc/independent_drop.etc", "etc/independentdrop/"] },
readOnly: false,
matches: (file) => normalizePath(file.path) === "etc/independent_drop.etc" || /^etc\/independentdrop\/.+\.etc$/.test(normalizePath(file.path)),
component: defineAsyncComponent(() => import("../components/gui/IndependentDropViewer.vue")),
},
{
id: "vending-machine",
label: "抽奖机",
launcher: { fileTypes: ".vm(etc/vendingmachine.lst)", paths: ["etc/vendingmachine/new.vm"], queries: ["*.vm"] },
readOnly: false,
matches: (file) => file.path.toLowerCase().endsWith(".vm"),
component: defineAsyncComponent(() => import("../components/gui/VendingMachineViewer.vue")),
},
{
id: "crane-minigame",
label: "娃娃机",
launcher: { fileTypes: "etc/craneminigameitem.etc", paths: ["etc/craneminigameitem.etc"], queries: ["etc/craneminigameitem.etc"] },
readOnly: false,
matches: (file) => normalizePath(file.path) === "etc/craneminigameitem.etc",
component: defineAsyncComponent(() => import("../components/gui/CraneMinigameViewer.vue")),
},
{
id: "cera-shop",
label: "商城",
launcher: { fileTypes: "etc/(r)cerashop.etc", paths: ["etc/(r)cerashop.etc"], queries: ["etc/*cerashop*.etc"] },
readOnly: false,
matches: (file) => /^etc\/(?:\([rf]\))?cerashop(?:_first|_devtest)?\.etc$/.test(normalizePath(file.path)),
component: defineAsyncComponent(() => import("../components/gui/CeraShopViewer.vue")),
},
];
export function getGUIProvider(file: GUIFile): GUIProvider | null {
return guiProviders.find((provider) => provider.matches(file)) ?? null;
}
+18
View File
@@ -0,0 +1,18 @@
import type { ShopItem, ShopMaterialInput } from "../../bindings/pvfine/services/models";
export interface MaterialRow { key: number; itemId: string; quantity: string; name: string }
export interface ShopCostForm { gold: string; materials: MaterialRow[] }
let sequence = 0;
export function materialRow(itemId = "", quantity = "1", name = ""): MaterialRow { return { key: ++sequence, itemId, quantity, name }; }
export function costForm(item?: ShopItem | null): ShopCostForm {
return {
gold: item?.costs?.find((cost) => cost.kind === "gold")?.quantity ?? "",
materials: (item?.costs ?? []).filter((cost) => cost.kind === "material").map((cost) => materialRow(cost.itemId, cost.quantity, cost.name)),
};
}
export function integerError(value: string): boolean { return !/^\d+$/.test(value) || Number(value) > 2147483647; }
export function validateCosts(form: ShopCostForm, gold: boolean, materials: boolean): string {
if (gold && form.gold !== "" && integerError(form.gold)) return "金币价格必须为 0–2147483647 的整数,或留空取消金币价格";
if (materials && form.materials.some((row) => integerError(row.itemId) || integerError(row.quantity))) return "每条兑换道具必须选择物品,并填写 0–2147483647 的整数数量";
return "";
}
export function materialInputs(form: ShopCostForm): ShopMaterialInput[] { return form.materials.map(({ itemId, quantity }) => ({ itemId, quantity })); }
+88
View File
@@ -0,0 +1,88 @@
import type { SkillMode, SkillNumber, SkillProperty } from "../../bindings/pvfine/services/models";
export interface SkillPreviewPart {
text: string;
binding?: { dynamic: boolean; index: number; multiplier: number };
token?: SkillNumber;
}
export function skillPreview(property: SkillProperty, mode: SkillMode, level: number): SkillPreviewPart[] {
// ReadSkill resolves and normalizes template aliases / escaped line breaks.
// Rendering therefore depends on template semantics, never PVF variants.
const parts: SkillPreviewPart[] = [];
const template = property.template.replaceAll("%%", "%");
const placeholders = /<int>|<float(\d*)>/g;
let end = 0;
let index = 0;
for (const match of template.matchAll(placeholders)) {
const start = match.index!;
if (start > end) parts.push({ text: template.slice(end, start) });
const binding = property.bindings?.[index++];
const token = binding?.dynamic ? mode.levels?.[level - 1]?.[binding.index] : mode.static?.[binding?.index ?? -1];
const value = token && binding ? token.value * binding.multiplier : NaN;
const precision = match[0] === "<int>" ? -1 : Number(match[1] || 0);
const text = Number.isFinite(value)
? precision === -1 ? String(Math.trunc(value)) : value.toFixed(Math.min(precision, 20))
: "(无对应数据)";
parts.push({ text, binding, token });
end = start + match[0].length;
}
if (end < template.length) parts.push({ text: template.slice(end) });
return parts;
}
export type SkillOperation = "set" | "add" | "multiply";
/** Atomic source patches: validate every target and number before changing text. */
export function editSkillNumbers(text: string, tokens: SkillNumber[], operation: SkillOperation, operand: number): string {
if (!Number.isFinite(operand) || !tokens.length) throw new Error("请输入有效数值");
// ParseScriptView counts CRLF as one UTF-16 unit. Translate positions back
// to the draft so its original line endings remain untouched.
const offsets: number[] = [0];
for (let i = 0; i < text.length; i++) {
if (text[i] === "\r" && text[i + 1] === "\n") i++;
offsets.push(i + 1);
}
const patches = tokens.map((token) => {
const value = operation === "set" ? operand : operation === "add" ? token.value + operand : token.value * operand;
if (!Number.isFinite(value)) throw new Error("运算结果超出数值范围");
if (token.start < 0 || token.end <= token.start || token.end >= offsets.length) throw new Error("数据位置已失效,请重新加载");
const start = offsets[token.start]!;
const end = offsets[token.end]!;
const original = Number(text.slice(start, end));
if (token.tokenType !== 0 && token.tokenType !== 2) throw new Error("不支持的数值类型");
if (token.tokenType === 0 ? original !== token.value : Math.fround(original) !== Math.fround(token.value)) throw new Error("技能草稿已变化,请重新打开表单");
let formatted: string;
if (token.tokenType === 0) {
const integer = Math.round(value);
if (integer < -2147483648 || integer > 2147483647) throw new Error("整数超出 32 位范围");
formatted = String(integer);
} else {
const float = Math.fround(value);
if (!Number.isFinite(float)) throw new Error("浮点数超出 32 位范围");
formatted = String(float);
if (!/[.eE]/.test(formatted)) formatted += ".0";
}
return { start, end, text: formatted };
}).sort((a, b) => b.start - a.start);
let result = text;
let previous = text.length;
for (const patch of patches) {
if (patch.end > previous) throw new Error("数据位置重叠");
result = result.slice(0, patch.start) + patch.text + result.slice(patch.end);
previous = patch.start;
}
return result;
}
export const skillJobNames: Record<string, string> = {
swordman: "鬼剑士(男)", atswordman: "鬼剑士(女)", fighter: "格斗家(女)", atfighter: "格斗家(男)",
gunner: "神枪手(男)", atgunner: "神枪手(女)", mage: "魔法师(女)", atmage: "魔法师(男)",
priest: "圣职者(男)", atpriest: "圣职者(女)", thief: "暗夜使者", knight: "守护者", demoniclancer: "魔枪士",
creator: "缔造者", darkknight: "黑暗武士", common: "通用",
creatormage: "缔造者", demonicswordman: "黑暗武士", gunblader: "枪剑士",
archer: "弓箭手",
};
/** A source index, independent of preview level and display multiplier. */
export interface SkillDataSelection { dynamic: boolean; index: number; mode: string }
+220
View File
@@ -0,0 +1,220 @@
import { editSkillNumbers } from "./skill";
import type { SkillTreeNode, SkillTreeBranch } from "../../bindings/pvfine/services/models";
export const skillBranchNames: Record<string, string> = {
none: "未转职", weaponmaster: "剑魂", soulbringer: "鬼泣", berserker: "狂战士", asura: "阿修罗", swordghost: "剑影",
ranger: "漫游枪手", launcher: "枪炮师", mechanic: "机械师", spitfire: "弹药专家", assault: "合金战士",
nenmaster: "气功师", striker: "散打", streetfighter: "街霸", grappler: "柔道家",
elementalmaster: "元素师", summoner: "召唤师", battlemage: "战斗法师", witch: "魔道学者", enchantress: "小魔女",
crusader: "圣骑士", infighter: "蓝拳圣使", exorcist: "驱魔师", avenger: "复仇者", inquisitor: "异端审判者", shaman: "巫女", mistress: "诱魔者",
rogue: "刺客", necromancer: "死灵术士", kunoichi: "忍者", shadowdancer: "影舞者",
muse: "缪斯", traveler: "旅人", hunter: "猎人", vigilante: "妖护使",
elementalbomber: "元素爆破师", glacialmaster: "冰结师", bloodmage: "血法师", swiftmaster: "逐风者", dimensionwalker: "次元行者",
miko: "巫女", temptress: "诱魔者", demonslayer: "契魔者", vegabond: "流浪武士", blade: "刃影",
duelist: "决战者", vanguard: "征战者", dragonianlancer: "狩猎者", darklancer: "暗枪士",
hitman: "暗刃", agent: "特工", troubleshooter: "战线佣兵", specialist: "源能专家",
elvenknight: "精灵骑士", chaos: "混沌魔灵", paladin: "帕拉丁", dragonknight: "龙骑士",
swordmaster: "驭剑士", darktempler: "暗殿骑士", demonicslayer: "契魔者", vagabond: "流浪武士", spectre: "刃影",
};
// Use the edge facing the destination, keeping arrows outside the 32px icon.
export function skillTreeEdgeLayout(source: SkillTreeNode, target: SkillTreeNode): {path: string; end: {x: number; y: number}} {
const sx = source.x + 16, sy = source.y + 16;
const tx = target.x + 16, ty = target.y + 16;
if (source.x === target.x && source.y === target.y) {
return {path: `M ${source.x + 34} ${sy} H ${source.x + 44} V ${source.y - 10} H ${tx} V ${target.y - 3}`, end: {x: tx, y: target.y - 3}};
}
if (Math.abs(tx - sx) > Math.abs(ty - sy)) {
const direction = Math.sign(tx - sx);
const start = sx + direction * 18, end = tx - direction * 20;
const mid = (start + end) / 2;
return {path: `M ${start} ${sy} H ${mid} V ${ty} H ${end}`, end: {x: end, y: ty}};
}
const direction = Math.sign(ty - sy) || 1;
const start = sy + direction * 24, end = ty - direction * 19;
const mid = (start + end) / 2;
return {path: `M ${sx} ${start} V ${mid} H ${tx} V ${end}`, end: {x: tx, y: end}};
}
export function skillTreeEdge(source: SkillTreeNode, target: SkillTreeNode): string {
return skillTreeEdgeLayout(source, target).path;
}
export interface SkillTreeCell {
key: string;
nodes: SkillTreeNode[];
}
/** Keep source order and coordinates; overlapping skills remain individually selectable. */
export function groupSkillTreeCells(nodes: SkillTreeNode[]): SkillTreeCell[] {
const cells = new Map<string, SkillTreeCell>();
for (const node of nodes) {
const key = `${node.x}:${node.y}`;
let cell = cells.get(key);
if (!cell) { cell = {key, nodes: []}; cells.set(key, cell); }
cell.nodes.push(node);
}
return [...cells.values()];
}
/** Row labels use the leftmost skill, with source order breaking position ties. */
export function skillTreeRowLevels(nodes: SkillTreeNode[], kind: string): Map<number, number | null> {
const first = new Map<number, SkillTreeNode>();
if (kind !== "sp") return new Map();
for (const node of nodes) {
const row = Math.floor(node.y / 67);
const previous = first.get(row);
if (!previous || node.x < previous.x) first.set(row, node);
}
return new Map([...first].map(([row, node]) => [row, node.requiredLevel]));
}
/** Prefer the last source entry unless this cell has a remembered selection. */
export function skillTreeCellNode(cell: SkillTreeCell, rememberedID?: number): SkillTreeNode {
return cell.nodes.find(node => node.id === rememberedID) ?? cell.nodes[cell.nodes.length - 1]!;
}
export function skillTreeCellSelectionKey(path: string, job: string | null, kind: string, branch: string | null, cellKey: string): string {
return JSON.stringify([path.toLowerCase(), job, kind, branch, cellKey]);
}
export interface SkillTreeDropTarget {
x: number;
y: number;
valid: boolean;
reason: string;
}
/** Snap the whole source cell; half-cell icons also participate in collisions. */
export function skillTreeDropTarget(nodes: SkillTreeNode[], cell: SkillTreeCell, x: number, y: number, width: number, height: number): SkillTreeDropTarget {
const target = {x: Math.round(x / 47) * 47, y: Math.round(y / 67) * 67, valid: false, reason: ""};
if (![x, y, width, height].every(Number.isFinite) || target.x < 0 || target.y < 0 || target.x + 34 > width || target.y + 56 > height) {
return {...target, reason: "超出当前画布"};
}
const source = new Set(cell.nodes.map(node => node.sourceStart));
if (nodes.some(node => !source.has(node.sourceStart) && node.x < target.x + 34 && node.x + 34 > target.x && node.y < target.y + 56 && node.y + 56 > target.y)) {
return {...target, reason: "目标位置已被其他技能占用"};
}
return {...target, valid: true};
}
export function moveSkillTreeCell(text: string, nodes: SkillTreeNode[], cell: SkillTreeCell, x: number, y: number, width: number, height: number): string {
const target = skillTreeDropTarget(nodes, cell, x, y, width, height);
if (!target.valid || target.x !== x || target.y !== y) throw new Error(target.reason || "目标必须位于47×67网格上");
const patches = cell.nodes.flatMap(node => {
if (node.positionTokens?.length !== 2 || !["cell pos", "icon pos"].includes(node.positionKind)) throw new Error("坐标记录不完整,请先在文本中修正");
const values = node.positionKind === "cell pos" ? [x / 47, y / 67] : [x, y];
return node.positionTokens.map((token, i) => ({token, value: values[i]!}));
}).sort((a, b) => b.token.start - a.token.start);
// Patch right to left so the original UTF-16 offsets stay valid. The shared
// number editor checks every old value and preserves CRLF and other fields.
let result = text;
for (const patch of patches) result = editSkillNumbers(result, [patch.token], "set", patch.value);
return result;
}
/** Reserve a blank grid row after all occupied rows, including pixel offsets. */
export function skillTreeCanvasHeight(nodes: SkillTreeNode[]): number {
const occupiedRows = Math.ceil(Math.max(0, ...nodes.map(node => node.y + 67)) / 67);
return Math.max(4, occupiedRows + 1) * 67;
}
/** Insert only one skill record before the lexer's matching branch close tag. */
export function addSkillTreeNode(text: string, branch: SkillTreeBranch, id: number, x: number, y: number, width: number, height: number): string {
if (!Number.isInteger(id) || id < 0 || id > 2147483647) throw new Error("技能ID无效");
if (branch.nodes?.some(node => node.id === id)) throw new Error("当前分支已包含此技能");
const target = skillTreeDropTarget(branch.nodes ?? [], {key: "", nodes: []}, x, y, width, height);
if (!target.valid || x !== target.x || y !== target.y) throw new Error(target.reason || "目标必须位于47×67网格上");
if (!Number.isInteger(branch.insertOffset) || branch.insertOffset < 0) throw new Error("分支缺少结束标签,请先修正文本");
let offset = 0;
for (let unit = 0; unit < branch.insertOffset && offset < text.length; unit++, offset++) {
if (text[offset] === "\r" && text[offset + 1] === "\n") offset++;
}
if (!/^\[\s*\/\s*character job\s*\]/i.test(text.slice(offset))) throw new Error("分支位置已变化,请重新读取");
const newline = text.includes("\r\n") ? "\r\n" : text.includes("\r") ? "\r" : "\n";
const lineStart = Math.max(text.lastIndexOf("\n", offset - 1), text.lastIndexOf("\r", offset - 1)) + 1;
const prefix = text.slice(lineStart, offset);
const ownLine = /^[ \t]*$/.test(prefix);
const indent = ownLine ? prefix : "";
const insertion = ownLine ? lineStart : offset;
const record = ["[skill info]", "\t[index]", `\t\t${id}`, "\t[cell pos]", `\t\t${x / 47}\t${y / 67}`, "[/skill info]"].map(line => `${indent}\t${line}`).join(newline) + newline;
return text.slice(0, insertion) + (ownLine ? "" : newline) + record + text.slice(insertion);
}
/** Remove one source record and incoming links in this branch, preserving other text. */
export function removeSkillTreeNode(text: string, branch: SkillTreeBranch, node: SkillTreeNode): string {
if (!branch.nodes?.includes(node)) throw new Error("技能已变化,请重新选择");
const rawOffset = (offset: number) => skillTreeRawOffset(text, offset);
let start = rawOffset(node.sourceStart), end = rawOffset(node.sourceClose);
const closing = /^\[\s*\/\s*skill info\s*\]/i.exec(text.slice(end));
if (end <= start || !/^\[\s*skill info\s*\]/i.test(text.slice(start)) || !closing) throw new Error("技能位置已变化,请重新读取");
end += closing[0].length;
const lineStart = Math.max(text.lastIndexOf("\n", start - 1), text.lastIndexOf("\r", start - 1)) + 1;
const lineSuffix = /^[ \t]*(?:\r\n|\r|\n|$)/.exec(text.slice(end));
if (/^[ \t]*$/.test(text.slice(lineStart, start)) && lineSuffix) {
start = lineStart;
end += lineSuffix[0].length;
}
const patches = [{start, end}];
for (const other of branch.nodes) {
if (other === node) continue;
if ((other.next ?? []).length !== (other.nextTokens ?? []).length) throw new Error("关联记录不完整,请重新读取");
for (const token of other.nextTokens ?? []) {
if (token.value !== node.id) continue;
const from = rawOffset(token.start), to = rawOffset(token.end);
if (Number(text.slice(from, to)) !== node.id || to <= from) throw new Error("关联记录已变化,请重新读取");
patches.push({start: from, end: to});
}
}
patches.sort((a, b) => b.start - a.start);
let result = text, boundary = text.length;
for (const patch of patches) {
if (patch.end > boundary) throw new Error("技能记录重叠,请先修正文本");
result = result.slice(0, patch.start) + result.slice(patch.end);
boundary = patch.start;
}
return result;
}
function skillTreeRawOffset(text: string, offset: number): number {
if (!Number.isInteger(offset) || offset < 0) throw new Error("技能记录不完整,请先修正文本");
let raw = 0, unit = 0;
for (; unit < offset && raw < text.length; unit++, raw++) {
if (text[raw] === "\r" && text[raw + 1] === "\n") raw++;
}
if (unit !== offset) throw new Error("技能位置已变化,请重新读取");
return raw;
}
/** Edit a single ordered successor. Index -1 appends; a null target removes it. */
export function editSkillTreeLink(text: string, branch: SkillTreeBranch, node: SkillTreeNode, index: number, target: number | null): string {
if (!branch.nodes?.includes(node)) throw new Error("技能已变化,请重新选择");
const next = node.next ?? [], tokens = node.nextTokens ?? [];
if (next.length !== tokens.length) throw new Error("关联记录不完整,请重新读取");
if (!Number.isInteger(index) || index < -1 || index >= next.length || (index === -1 && target === null)) throw new Error("关联位置已变化,请重新读取");
const start = skillTreeRawOffset(text, node.sourceStart), close = skillTreeRawOffset(text, node.sourceClose);
if (close <= start || !/^\[\s*skill info\s*\]/i.test(text.slice(start)) || !/^\[\s*\/\s*skill info\s*\]/i.test(text.slice(close))) throw new Error("技能记录不完整,请先修正文本");
const ranges = tokens.map((token, i) => {
const from = skillTreeRawOffset(text, token.start), to = skillTreeRawOffset(text, token.end);
if (from <= start || to > close || to <= from || token.value !== next[i] || Number(text.slice(from, to)) !== next[i]) throw new Error("关联记录已变化,请重新读取");
return {from, to};
});
if (target !== null) {
if (!Number.isInteger(target) || target < 0 || target > 2147483647 || !branch.nodes.some(n => n.id === target)) throw new Error("请选择当前分支的目标技能");
if (target === node.id) throw new Error("不能关联技能自身");
if (index >= 0 && next[index] === target) return text;
if (next.some((id, i) => id === target && i !== index)) throw new Error("已存在该目标的关联");
}
if (index >= 0) {
const range = ranges[index]!;
return text.slice(0, range.from) + (target === null ? "" : String(target)) + text.slice(range.to);
}
const newline = text.includes("\r\n") ? "\r\n" : text.includes("\r") ? "\r" : "\n";
const lineStart = Math.max(text.lastIndexOf("\n", close - 1), text.lastIndexOf("\r", close - 1)) + 1;
const prefix = text.slice(lineStart, close);
const ownLine = /^[ \t]*$/.test(prefix), indent = ownLine ? prefix : "";
const insertion = ownLine ? lineStart : close;
// A repeated section appends without rewriting earlier sections or their comments.
const record = `${indent}\t[next skill]${newline}${indent}\t\t${target}${newline}`;
return text.slice(0, insertion) + (ownLine ? "" : newline) + record + text.slice(insertion);
}
+174
View File
@@ -0,0 +1,174 @@
import { computed, reactive, ref, shallowRef } from "vue";
import type { ShopDocument, WorldDropDocument, WorldDropLevel } from "../../bindings/pvfine/services/models";
import type { GUIFile } from "./types";
import { areLevelsEqual, toEditableLevels, toWorldDropLevels, type EditableWorldDropLevel } from "./worldDropForm";
export type GUIGuard = () => boolean | Promise<boolean>;
/** Owned by a pane, so displaying the same file in two panes is independent. */
export function createGUIModes() {
const entries = reactive(new Map<number, { mode: "text" | "gui"; opened: boolean }>());
const guards = new Map<number, GUIGuard>();
function registerGuard(index: number, guard: GUIGuard) {
guards.set(index, guard);
return () => {
if (guards.get(index) === guard) {
guards.delete(index);
}
};
}
function set(index: number, mode: "text" | "gui") {
const previous = entries.get(index);
entries.set(index, { mode, opened: mode === "gui" || !!previous?.opened });
}
async function requestMode(index: number, mode: "text" | "gui"): Promise<boolean> {
const current = entries.get(index)?.mode ?? "text";
if (current === "gui" && mode === "text") {
const guard = guards.get(index);
if (guard) {
const allowed = await guard();
if (!allowed) return false;
}
}
set(index, mode);
return true;
}
function prune(indexes: number[]) {
const remaining = new Set(indexes);
for (const key of entries.keys()) if (!remaining.has(key)) entries.delete(key);
for (const key of guards.keys()) if (!remaining.has(key)) guards.delete(key);
}
return {
entries,
set,
requestMode,
registerGuard,
prune,
reset: () => {
entries.clear();
guards.clear();
},
};
}
/** One request generation spans identity changes, retries and unmounts. */
export function createShopSession(read: (index: number, text: string) => Promise<ShopDocument | null>) {
const document = shallowRef<ShopDocument | null>(null);
const loading = ref(false);
const error = ref("");
const tabIndex = ref(0);
const categoryID = ref("");
let generation = 0;
let identity = "";
function invalidate(clear = false) {
generation++;
loading.value = false;
if (clear) {
identity = "";
document.value = null;
error.value = "";
tabIndex.value = 0;
categoryID.value = "";
}
}
async function load(file: GUIFile, archiveIdentity: string) {
const nextIdentity = `${archiveIdentity}\u0000${file.index}\u0000${file.path}`;
if (identity !== nextIdentity) {
invalidate(true);
identity = nextIdentity;
}
const request = ++generation;
loading.value = true;
error.value = "";
try {
const next = await read(file.index, file.text);
if (request !== generation) return;
if (!next) throw new Error("未收到商店数据");
document.value = next;
if (!next.tabs?.[tabIndex.value]) tabIndex.value = 0;
if (!next.categories?.some((category) => category.id === categoryID.value)) {
categoryID.value = next.categories?.[0]?.id ?? "";
}
} catch (cause) {
if (request === generation) error.value = cause instanceof Error ? cause.message : String(cause);
} finally {
if (request === generation) loading.value = false;
}
}
return { document, loading, error, tabIndex, categoryID, load, invalidate };
}
/** World Drop session manages document loading, selection and generation safety. */
export function createWorldDropSession(
read: (index: number, text: string) => Promise<WorldDropDocument | null>,
) {
const document = shallowRef<WorldDropDocument | null>(null);
const loading = ref(false);
const error = ref("");
const selectedLevel = ref<number | null>(null);
let generation = 0;
let identity = "";
function invalidate(clear = false) {
generation++;
loading.value = false;
if (clear) {
identity = "";
document.value = null;
error.value = "";
selectedLevel.value = null;
}
}
async function load(file: GUIFile, archiveIdentity: string): Promise<boolean> {
const nextIdentity = `${archiveIdentity}\u0000${file.index}\u0000${file.path}`;
if (identity !== nextIdentity) {
invalidate(true);
identity = nextIdentity;
}
const request = ++generation;
loading.value = true;
error.value = "";
try {
const next = await read(file.index, file.text);
if (request !== generation) return false;
if (!next) throw new Error("未收到全局掉率数据");
document.value = next;
const levels = next.levels ?? [];
if (selectedLevel.value === null || !levels.some((l) => l.level === selectedLevel.value)) {
selectedLevel.value = levels[0]?.level ?? null;
}
return true;
} catch (cause) {
if (request === generation) error.value = cause instanceof Error ? cause.message : String(cause);
return false;
} finally {
if (request === generation) loading.value = false;
}
}
return { document, loading, error, selectedLevel, load, invalidate };
}
/** Keep the editor baseline stable while a newer document is being read. */
export function createWorldDropDraftState() {
const editableLevels = ref<EditableWorldDropLevel[]>([]);
const baselineLevels = shallowRef<WorldDropLevel[] | null>(null);
const isDirty = computed(() =>
baselineLevels.value !== null && !areLevelsEqual(toWorldDropLevels(editableLevels.value), baselineLevels.value),
);
function sync(document: WorldDropDocument | null): void {
baselineLevels.value = document?.levels ?? null;
editableLevels.value = toEditableLevels(document?.levels ?? null);
}
return { editableLevels, isDirty, sync };
}
+21
View File
@@ -0,0 +1,21 @@
import type { Component } from "vue";
export interface GUIFile {
index: number;
path: string;
text: string;
editable: boolean;
}
export interface GUIProvider {
id: string;
label: string;
readOnly: boolean;
launcher: {
fileTypes: string;
paths: string[];
queries: string[];
};
matches: (file: GUIFile) => boolean;
component: Component;
}
+88
View File
@@ -0,0 +1,88 @@
import type { SafeUpgradeRow, UpgradeRow } from "../../bindings/pvfine/services/models";
import { editSkillNumbers } from "./skill";
export const upgradeGroupLabels = { T1: "武器", T2: "防具" } as const;
/** T1 is weapons; T2 is armor. Materials are shared by both groups. */
export function upgradeRows(rows: UpgradeRow[], group: "T1" | "T2"): UpgradeRow[] {
if (group === "T1") return rows;
return rows.map(row => {
if (row.fieldsT2?.length !== 3 || row.fields?.length !== 5) throw new Error("缺少防具数据");
return { ...row, fields: [...row.fieldsT2, ...row.fields.slice(3)] };
});
}
export function upgradeSuccessRate(row: UpgradeRow): number {
const failure = row.fields?.[0];
if (!failure) throw new Error("缺少失败概率数据");
return Number((100 - failure.value / 1000).toFixed(3));
}
/** Edit only the selected columns, preserving every other table parameter. */
export function editUpgrade(text: string, rows: UpgradeRow[], column: number, value: number): string {
return editUpgradeBatch(text, rows, [{ column, value }]);
}
export interface UpgradeChange { column: number; value: number }
export function upgradeRange(rows: UpgradeRow[], range: [number, number], minimum = 1): UpgradeRow[] {
if (!range.every(Number.isInteger) || range[0] < minimum || range[1] < range[0]) throw new Error("等级范围无效");
return rows.filter(row => row.level >= range[0] && row.level <= range[1]);
}
/** Keep the two hidden columns and the original level outside the editable projection. */
export function safeUpgradeRows(rows: SafeUpgradeRow[]): UpgradeRow[] {
return rows.map(row => {
if (row.fields?.length !== 8) throw new Error("安全强化数据不完整");
return { level: row.fields[0]!.value + 1, fields: [2, 3, 7, 5, 6].map(column => row.fields![column]!), fieldsT2: [], costName: row.costName };
});
}
export function editSafeUpgradeBatch(text: string, rows: UpgradeRow[], changes: UpgradeChange[]): string {
if (!rows.length || !changes.length) throw new Error("请选择等级和修改项");
const patches = changes.flatMap(({ column, value }) => {
if (!Number.isInteger(column) || column < 0 || column > 4 || !Number.isFinite(value) || value < 0 || value > (column === 0 ? 100 : 2147483647) || (column !== 0 && !Number.isInteger(value))) throw new Error("安全强化数值超出允许范围");
return rows.map(row => {
const token = row.fields?.[column];
if (!token || token.tokenType !== 0) throw new Error("安全强化数据已失效");
return { token, value: column === 0 ? Math.round(value * 1000) : value };
});
});
return applyUpgradePatches(text, patches);
}
/** Validate the complete batch before publishing one source patch. */
export function editUpgradeBatch(text: string, rows: UpgradeRow[], changes: UpgradeChange[]): string {
if (!changes.length) throw new Error("请至少选择一项修改");
const patches = changes.flatMap(({ column, value }) => {
if (!rows.length || !Number.isFinite(value) || column < 0 || column > 4 || !Number.isInteger(column)) throw new Error("请输入有效数值");
const maximum = [100, 3, rows.every(row => row.fieldsT2?.length === 3) ? 2147483647 : 31, 2147483647, 2147483647][column]!;
if (value < (column === 1 ? 1 : 0) || value > maximum || (column !== 0 && !Number.isInteger(value))) throw new Error("数值超出允许范围");
const operand = column === 0 ? Math.round((100 - value) * 1000) : value;
// Switching away from downgrade also clears its parameter, as in pvftools.
return rows.flatMap((row) => {
const field = row.fields?.[column];
if (!field) throw new Error("数据已失效,请重新加载");
const edits = [{ token: field, value: operand }];
if (column === 1 && value !== 2) {
const parameter = row.fields?.[2];
if (!parameter) throw new Error("数据已失效,请重新加载");
edits.push({ token: parameter, value: 0 });
}
return edits;
});
});
return applyUpgradePatches(text, patches);
}
function applyUpgradePatches(text: string, patches: { token: NonNullable<UpgradeRow["fields"]>[number]; value: number }[]): string {
patches = patches.sort((a, b) => b.token.start - a.token.start);
let result = text;
let previous = Infinity;
for (const patch of patches) {
if (patch.token.end > previous) throw new Error("数据位置重叠");
result = editSkillNumbers(result, [patch.token], "set", patch.value);
previous = patch.token.start;
}
return result;
}
+66
View File
@@ -0,0 +1,66 @@
import type { SkillNumber, VendingGroup, VendingReward } from "../../bindings/pvfine/services/models";
import { editSkillNumbers } from "./skill";
export function vendingInteger(value: number | null): number {
if (value === null || !Number.isInteger(value) || value < 0 || value > 2147483647) throw new Error("请输入 0~2147483647 的整数");
return value;
}
export function vendingProbability(weight: number, total: number): string {
return total > 0 ? `${Number((weight / total * 100).toPrecision(6))}%` : "—";
}
export function vendingTotal(rewards: VendingReward[]): number {
return rewards.reduce((sum, row) => sum + (row.fields?.[1]?.value ?? 0), 0);
}
function offsets(text: string): number[] {
const result = [0];
for (let i = 0; i < text.length; i++) {
if (text[i] === "\r" && text[i + 1] === "\n") i++;
result.push(i + 1);
}
return result;
}
function splice(text: string, start: number, end: number, replacement: string): string {
const map = offsets(text);
if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end < start || end >= map.length) throw new Error("数据位置已失效,请重新加载");
return text.slice(0, map[start]) + replacement + text.slice(map[end]);
}
export function editVendingNumbers(text: string, fields: SkillNumber[], values: number[]): string {
if (fields.length !== values.length || !fields.length) throw new Error("字段数量不匹配");
values.forEach(vendingInteger);
return fields.map((field, i) => ({ field, value: values[i]! })).sort((a,b) => b.field.start-a.field.start)
.reduce((source, { field, value }) => editSkillNumbers(source, [field], "set", value), text);
}
function rewardText(values: number[]): string {
if (values.length !== 4) throw new Error("奖品需要四个字段");
values.forEach(vendingInteger);
if (values[3] !== 0 && values[3] !== 1) throw new Error("是否公告必须为 0 或 1");
return values.join("\t");
}
export function addVendingReward(text: string, group: VendingGroup, values: number[]): string {
const map = offsets(text), raw = map[group.outputClose];
if (raw === undefined || !/^\[\/output\]/i.test(text.slice(raw))) throw new Error("奖品列表位置已失效");
const eol = text.includes("\r\n") ? "\r\n" : text.includes("\r") ? "\r" : "\n";
// Inserting at the tag keeps all existing rows and their comments untouched.
return splice(text, group.outputClose, group.outputClose, `${eol}\t\t${rewardText(values)}${eol}\t`);
}
export function removeVendingReward(text: string, reward: VendingReward): string {
const fields = reward.fields ?? [];
if (fields.length !== 4) throw new Error("奖品位置已失效");
editVendingNumbers(text, fields, fields.map(f => f.value)); // Validate coordinates before deletion.
return splice(text, fields[0]!.start, fields[3]!.end, "");
}
export function addVendingGroup(text: string, number: number, material: number, quantity: number): string {
[number, material, quantity].forEach(vendingInteger);
const eol = text.includes("\r\n") ? "\r\n" : text.includes("\r") ? "\r" : "\n";
return text + (text && !/[\r\n]$/.test(text) ? eol : "") + ["[item group]", "\t[group num]", `\t\t${number}`, "\t[material]", `\t\t${material}\t${quantity}`, "\t[output]", "\t[/output]", "[/item group]", ""].join(eol);
}
export function removeVendingGroup(text: string, group: VendingGroup): string {
const map = offsets(text);
if (!/^\[item group\]/i.test(text.slice(map[group.start])) || !/\[\/item group\]$/i.test(text.slice(map[group.start], map[group.end]))) throw new Error("分组位置已失效");
return splice(text, group.start, group.end, "");
}
export function filterVendingMachines<T extends { name: string; id: string; path: string }>(entries: T[], query: string): T[] {
const pattern = query.trim().split(/([*?])/).map(part => part === "*" ? ".*" : part === "?" ? "." : part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("");
const re = new RegExp(pattern, "i");
return entries.filter(entry => re.test(`${entry.name} ${entry.id} ${entry.path}`));
}
+348
View File
@@ -0,0 +1,348 @@
import type { WorldDropItem, WorldDropLevel } from "../../bindings/pvfine/services/models";
export const MAX_INT32 = 2147483647;
export const MIN_INT32 = -2147483648;
export interface EditableWorldDropItem {
id: number;
weight: number;
name: string;
key: number;
}
export interface EditableWorldDropLevel {
level: number;
items: EditableWorldDropItem[];
key: number;
}
let keySequence = 0;
export function nextFormKey(): number {
return ++keySequence;
}
export function isPositiveInt32(value: unknown): boolean {
return typeof value === "number" && Number.isInteger(value) && value > 0 && value <= MAX_INT32;
}
export function isNonNegativeInt32(value: unknown): boolean {
return typeof value === "number" && Number.isInteger(value) && value >= 0 && value <= MAX_INT32;
}
export function isStoredItemId(value: unknown): boolean {
return typeof value === "number" && Number.isInteger(value) && value >= MIN_INT32 && value <= MAX_INT32 && value !== -1;
}
export function validateLevelNumber(
level: number,
existingLevels: number[],
currentLevel?: number,
): string {
if (!isPositiveInt32(level)) {
return "等级必须是正整数 (1 ~ 2147483647)";
}
const duplicates = existingLevels.filter((l) => l !== currentLevel);
if (duplicates.includes(level)) {
return `等级 ${level} 已存在,不可重复`;
}
return "";
}
export function validateItem(id: number, weight: number, allowExistingId = false): string {
if (!(allowExistingId ? isStoredItemId(id) : isPositiveInt32(id))) {
return "道具 ID 必须是正整数 (1 ~ 2147483647)";
}
if (!isNonNegativeInt32(weight)) {
return "道具权重必须是非负整数 (0 ~ 2147483647)";
}
return "";
}
export function validateAllLevels(levels: WorldDropLevel[]): string {
const seen = new Set<number>();
for (const level of levels) {
if (!isPositiveInt32(level.level)) {
return "等级必须是正整数 (1 ~ 2147483647)";
}
if (seen.has(level.level)) {
return `等级 ${level.level} 重复`;
}
seen.add(level.level);
for (const item of level.items ?? []) {
const itemErr = validateItem(item.id, item.weight, true);
if (itemErr) {
return `等级 ${level.level} 中的道具错误:${itemErr}`;
}
}
}
return "";
}
export function calcLevelTotalWeight(items: { weight: number }[] | null | undefined): number {
if (!items || items.length === 0) return 0;
return items.reduce((sum, item) => sum + (Number.isFinite(item.weight) && item.weight > 0 ? item.weight : 0), 0);
}
/**
* 计算权重占比。总权重为 0 时返回中立的 "0.00%" 而不是 NaN。
*/
export function calcItemPercentage(weight: number, totalWeight: number): string {
if (!Number.isFinite(weight) || weight <= 0 || !Number.isFinite(totalWeight) || totalWeight <= 0) {
return "0.00%";
}
const ratio = (weight / totalWeight) * 100;
return `${ratio.toFixed(2)}%`;
}
export function toEditableLevels(levels: WorldDropLevel[] | null): EditableWorldDropLevel[] {
if (!levels) return [];
return levels.map((lvl) => ({
level: lvl.level,
key: nextFormKey(),
items: (lvl.items ?? []).map((item) => ({
id: item.id,
weight: item.weight,
name: item.name || `未知物品 #${item.id}`,
key: nextFormKey(),
})),
}));
}
export function toWorldDropLevels(editable: EditableWorldDropLevel[]): WorldDropLevel[] {
return editable.map((lvl) => ({
level: lvl.level,
items: lvl.items.map((item) => ({
id: item.id,
weight: item.weight,
name: item.name || `未知物品 #${item.id}`,
})),
}));
}
export function areLevelsEqual(a: WorldDropLevel[] | null, b: WorldDropLevel[] | null): boolean {
const listA = a ?? [];
const listB = b ?? [];
if (listA.length !== listB.length) return false;
for (let i = 0; i < listA.length; i++) {
const la = listA[i];
const lb = listB[i];
if (la.level !== lb.level) return false;
const itemsA = la.items ?? [];
const itemsB = lb.items ?? [];
if (itemsA.length !== itemsB.length) return false;
for (let j = 0; j < itemsA.length; j++) {
if (itemsA[j].id !== itemsB[j].id || itemsA[j].weight !== itemsB[j].weight) {
return false;
}
}
}
return true;
}
export function moveItem<T>(list: T[], fromIndex: number, toIndex: number): boolean {
if (fromIndex < 0 || fromIndex >= list.length || toIndex < 0 || toIndex >= list.length) {
return false;
}
if (fromIndex === toIndex) return false;
const [item] = list.splice(fromIndex, 1);
list.splice(toIndex, 0, item);
return true;
}
/**
* 校验等级区间合法性:起始与结束等级必须存在于已有等级列表中,且 startLevel <= endLevel。
*/
export function validateLevelInterval(
startLevel: number | null,
endLevel: number | null,
existingLevels: number[],
): string {
if (startLevel === null || endLevel === null) {
return "请选择起始与结束等级";
}
if (!existingLevels.includes(startLevel)) {
return `起始等级 Lv. ${startLevel} 不存在`;
}
if (!existingLevels.includes(endLevel)) {
return `结束等级 Lv. ${endLevel} 不存在`;
}
if (startLevel > endLevel) {
return "起始等级不能大于结束等级";
}
return "";
}
/**
* 获取闭区间 [startLevel, endLevel] 内已配置的已有等级记录。
* 不创建不存在的中间等级,保留原有顺序。
*/
export function getLevelsInInterval(
levels: EditableWorldDropLevel[],
startLevel: number | null,
endLevel: number | null,
): EditableWorldDropLevel[] {
if (startLevel === null || endLevel === null || startLevel > endLevel) {
return [];
}
return levels.filter((lvl) => lvl.level >= startLevel && lvl.level <= endLevel);
}
/**
* 批量添加道具至指定等级区间内已存在的各个等级。
* 若等级内已存在相同道具 ID,则直接追加新行(不跳过也不覆盖旧行)。
*/
export function batchAddItems(
levels: EditableWorldDropLevel[],
startLevel: number,
endLevel: number,
item: { id: number; weight: number; name: string },
): { affectedLevels: number[]; addedCount: number } {
const targets = getLevelsInInterval(levels, startLevel, endLevel);
for (const lvl of targets) {
lvl.items.push({
id: item.id,
weight: item.weight,
name: item.name || `未知物品 #${item.id}`,
key: nextFormKey(),
});
}
return {
affectedLevels: targets.map((lvl) => lvl.level),
addedCount: targets.length,
};
}
/**
* 统计指定等级区间内匹配指定道具 ID 的等级与行数。
*/
export function countMatchingItems(
levels: EditableWorldDropLevel[],
startLevel: number | null,
endLevel: number | null,
itemId: number,
): { matchingLevels: number[]; matchingRows: number } {
const targets = getLevelsInInterval(levels, startLevel, endLevel);
let matchingRows = 0;
const matchingLevels: number[] = [];
for (const lvl of targets) {
const matches = lvl.items.filter((item) => item.id === itemId);
if (matches.length > 0) {
matchingRows += matches.length;
matchingLevels.push(lvl.level);
}
}
return {
matchingLevels,
matchingRows,
};
}
/**
* 批量删除指定等级区间内的指定道具(删除每个匹配等级中的全部匹配项,保留空等级)。
*/
export function batchDeleteItems(
levels: EditableWorldDropLevel[],
startLevel: number,
endLevel: number,
itemId: number,
): { affectedLevels: number[]; removedCount: number } {
const targets = getLevelsInInterval(levels, startLevel, endLevel);
let removedCount = 0;
const affectedLevels: number[] = [];
for (const lvl of targets) {
const originalCount = lvl.items.length;
lvl.items = lvl.items.filter((item) => item.id !== itemId);
const deleted = originalCount - lvl.items.length;
if (deleted > 0) {
removedCount += deleted;
affectedLevels.push(lvl.level);
}
}
return {
affectedLevels,
removedCount,
};
}
export interface WorldDropFilterCriteria {
exactItemId?: number | null;
query?: string;
}
export function isFilterCriteriaActive(criteria: string | WorldDropFilterCriteria | null | undefined): boolean {
if (!criteria) return false;
if (typeof criteria === "object") {
if (criteria.exactItemId !== undefined && criteria.exactItemId !== null) return true;
return !!criteria.query && criteria.query.trim().length > 0;
}
return typeof criteria === "string" && criteria.trim().length > 0;
}
/**
* 道具筛选(纯视图逻辑,不影响底层保存快照):
* 1. 若指定 exactItemId(来自从物品库精准选取),则严格全等匹配 item.id,同名异 ID 或数字子串冲突均不出现;
* 2. 若仅提供 query(自由文本),支持根据道具 ID(完全或前缀匹配)或名称(不区分大小写包含匹配)筛选。
*/
export function filterItems(
items: EditableWorldDropItem[],
criteria: string | WorldDropFilterCriteria,
): EditableWorldDropItem[] {
if (typeof criteria === "object" && criteria !== null) {
if (criteria.exactItemId !== undefined && criteria.exactItemId !== null) {
const targetId = criteria.exactItemId;
return items.filter((item) => item.id === targetId);
}
const q = (criteria.query ?? "").trim().toLowerCase();
if (!q) return items;
return items.filter((item) => {
if (String(item.id) === q || String(item.id).includes(q)) return true;
if (item.name && item.name.toLowerCase().includes(q)) return true;
return false;
});
}
const q = (criteria ?? "").trim().toLowerCase();
if (!q) return items;
return items.filter((item) => {
if (String(item.id) === q || String(item.id).includes(q)) return true;
if (item.name && item.name.toLowerCase().includes(q)) return true;
return false;
});
}
/**
* 统计某等级下匹配筛选条件的道具数量。
*/
export function countLevelMatches(
level: EditableWorldDropLevel | null | undefined,
criteria: string | WorldDropFilterCriteria,
): number {
if (!level) return 0;
if (typeof criteria === "object" && criteria !== null) {
if (criteria.exactItemId !== undefined && criteria.exactItemId !== null) {
return filterItems(level.items, criteria).length;
}
if (!criteria.query || !criteria.query.trim()) {
return level.items.length;
}
return filterItems(level.items, criteria).length;
}
const q = (criteria ?? "").trim();
if (!q) return level.items.length;
return filterItems(level.items, q).length;
}
/**
* 在所有等级中找到第一个有匹配项的等级数值;若均无匹配则返回 null。
*/
export function findFirstLevelWithMatch(
levels: EditableWorldDropLevel[],
criteria: string | WorldDropFilterCriteria,
): number | null {
for (const lvl of levels) {
if (countLevelMatches(lvl, criteria) > 0) {
return lvl.level;
}
}
return null;
}