import {
buildGraph,
cloneBook,
makeIndex,
primaryRatePerMinute,
summarizeBook,
traceIngredientClosure,
validateBook,
} from "./core.mjs";
const $ = (selector) => document.querySelector(selector);
const elements = {
app: $("#app"),
statRecipes: $("#statRecipes"),
statItems: $("#statItems"),
statMachines: $("#statMachines"),
tabRecipes: $("#tabRecipes"),
tabItems: $("#tabItems"),
tabMachines: $("#tabMachines"),
saveState: $("#saveState"),
undoButton: $("#undoButton"),
redoButton: $("#redoButton"),
importButton: $("#importButton"),
exportButton: $("#exportButton"),
issuesButton: $("#issuesButton"),
issueCount: $("#issueCount"),
saveButton: $("#saveButton"),
addEntityButton: $("#addEntityButton"),
typeTabs: $("#typeTabs"),
catalogSearch: $("#catalogSearch"),
stageFilter: $("#stageFilter"),
biomeFilter: $("#biomeFilter"),
catalogSummary: $("#catalogSummary"),
entityList: $("#entityList"),
graphTitle: $("#graphTitle"),
graphSubtitle: $("#graphSubtitle"),
graphMode: $("#graphMode"),
depthControl: $("#depthControl"),
graphDepth: $("#graphDepth"),
fitGraphButton: $("#fitGraphButton"),
graphStage: $("#graphStage"),
graph: $("#recipeGraph"),
graphViewport: $("#graphViewport"),
graphBackdrop: $("#graphBackdrop"),
graphEdges: $("#graphEdges"),
graphNodes: $("#graphNodes"),
graphEmpty: $("#graphEmpty"),
zoomOutButton: $("#zoomOutButton"),
zoomInButton: $("#zoomInButton"),
zoomOutput: $("#zoomOutput"),
routeSummary: $("#routeSummary"),
inspectorTitle: $("#inspectorTitle"),
inspectorKind: $("#inspectorKind"),
inspectorContent: $("#inspectorContent"),
issuesDialog: $("#issuesDialog"),
issuesSummary: $("#issuesSummary"),
issueFilter: $("#issueFilter"),
issueList: $("#issueList"),
helpDialog: $("#helpDialog"),
importFile: $("#importFile"),
toastStack: $("#toastStack"),
};
const TYPE_META = {
recipes: { label: "配方", glyph: "✦", singular: "recipe" },
items: { label: "物品", glyph: "◆", singular: "item" },
machines: { label: "设施", glyph: "▣", singular: "machine" },
};
const RECIPE_KINDS = [
["craft", "制造"], ["inscription", "刻印"], ["growth", "生长"],
["decay", "腐朽"], ["state", "状态转化"], ["separation", "分离"],
["ritual", "仪式"], ["result_bag", "结果袋"], ["recovery", "回收"],
];
const INPUT_MODES = [["consumed", "消耗"], ["catalyst", "催化"], ["fluid", "流体"], ["state", "状态"]];
const ITEM_CATEGORIES = ["raw", "material", "component", "fluid", "proof", "state", "anchor", "offering", "construct", "record", "relic", "waste"];
const DRAFT_KEY = "magic-foundry-recipe-book-draft-v1";
const MAX_HISTORY = 100;
const state = {
book: null,
selected: { type: "recipes", id: "perform_first_warp" },
activeType: "recipes",
search: "",
stage: "all",
biome: "all",
graphMode: "focus",
graphDepth: 1,
graphTransform: { x: 0, y: 0, scale: 1 },
graphBounds: null,
graphNeedsFit: true,
issues: [],
issueSeverity: "all",
history: [],
future: [],
dirty: false,
serverOnline: false,
saving: false,
savedBookJson: "",
technologyIds: null,
draftTimer: null,
pendingEdit: null,
};
function escapeHtml(value) {
return String(value ?? "")
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
.replaceAll("'", "'");
}
function short(value, length = 22) {
const text = String(value ?? "");
return text.length > length ? `${text.slice(0, length - 1)}…` : text;
}
function slugTime() {
return new Date().toISOString().replaceAll(":", "-").replace("T", "_").slice(0, 19);
}
function parseLines(value) {
return String(value || "").split(/[\n,,]/).map((entry) => entry.trim()).filter(Boolean);
}
function numberFromInput(input, nullable = false) {
if (nullable && input.value.trim() === "") return null;
const value = Number(input.value);
return Number.isFinite(value) ? value : 0;
}
function selectedEntity() {
return state.book?.[state.selected.type]?.find((entry) => entry.id === state.selected.id) || null;
}
function ensureSelection() {
if (selectedEntity()) return;
const list = state.book?.[state.activeType] || [];
if (list.length) state.selected = { type: state.activeType, id: list[0].id };
}
function optionHtml(entries, selected, getId = (entry) => entry.id, getLabel = (entry) => entry.name) {
return entries.map((entry) => {
const id = getId(entry);
return ``;
}).join("");
}
function toast(message, severity = "success", timeout = 2800) {
const node = document.createElement("div");
node.className = `toast ${severity}`;
node.textContent = message;
elements.toastStack.append(node);
window.setTimeout(() => node.remove(), timeout);
}
function scheduleDraft() {
window.clearTimeout(state.draftTimer);
state.draftTimer = window.setTimeout(() => {
try {
localStorage.setItem(DRAFT_KEY, JSON.stringify({ savedAt: new Date().toISOString(), book: state.book }));
} catch {
toast("浏览器草稿空间不足,请尽快保存或导出。", "warning", 5000);
}
}, 500);
}
function updateDirty() {
state.dirty = JSON.stringify(state.book) !== state.savedBookJson;
if (state.dirty) scheduleDraft();
else localStorage.removeItem(DRAFT_KEY);
}
function mutate(label, operation, { fitGraph = false } = {}) {
state.pendingEdit = null;
state.history.push({ label, book: cloneBook(state.book), selected: { ...state.selected } });
if (state.history.length > MAX_HISTORY) state.history.shift();
state.future.length = 0;
operation(state.book);
state.book.metadata ||= {};
state.book.metadata.updatedAt = new Date().toISOString();
updateDirty();
state.graphNeedsFit ||= fitGraph;
refreshAll();
}
function undo() {
state.pendingEdit = null;
const snapshot = state.history.pop();
if (!snapshot) return;
state.future.push({ label: snapshot.label, book: cloneBook(state.book), selected: { ...state.selected } });
state.book = snapshot.book;
state.selected = snapshot.selected;
updateDirty();
state.graphNeedsFit = true;
refreshAll();
toast(`已撤销:${snapshot.label}`);
}
function redo() {
state.pendingEdit = null;
const snapshot = state.future.pop();
if (!snapshot) return;
state.history.push({ label: snapshot.label, book: cloneBook(state.book), selected: { ...state.selected } });
state.book = snapshot.book;
state.selected = snapshot.selected;
updateDirty();
state.graphNeedsFit = true;
refreshAll();
toast(`已重做:${snapshot.label}`);
}
function validate() {
state.issues = validateBook(state.book, { technologyIds: state.technologyIds });
return state.issues;
}
function renderStatus() {
const summary = summarizeBook(state.book, state.issues);
elements.statRecipes.textContent = summary.recipes;
elements.statItems.textContent = summary.items;
elements.statMachines.textContent = summary.machines;
elements.tabRecipes.textContent = summary.recipes;
elements.tabItems.textContent = summary.items;
elements.tabMachines.textContent = summary.machines;
elements.undoButton.disabled = state.history.length === 0;
elements.undoButton.dataset.history = String(state.history.length);
elements.undoButton.dataset.pendingEdit = state.pendingEdit?.key || "";
elements.redoButton.disabled = state.future.length === 0;
elements.saveButton.disabled = state.saving;
elements.saveButton.textContent = state.saving ? "保存中…" : "保存数据";
const errors = state.issues.filter((entry) => entry.severity === "error").length;
const warnings = state.issues.filter((entry) => entry.severity === "warning").length;
elements.issueCount.textContent = errors ? `${errors} 个错误` : warnings ? `${warnings} 个警告` : "校验通过";
elements.issuesButton.classList.toggle("has-errors", errors > 0);
elements.issuesButton.classList.toggle("has-warnings", warnings > 0);
let className = "save-state ";
let text;
if (state.saving) {
className += "loading";
text = "正在写入";
} else if (state.dirty) {
className += state.serverOnline ? "dirty" : "offline";
text = state.serverOnline ? "有未保存修改" : "草稿尚未写回";
} else if (state.serverOnline) {
className += "saved";
text = "已保存到项目";
} else {
className += "offline";
text = "仅导入导出模式";
}
elements.saveState.className = className;
elements.saveState.querySelector("span").textContent = text;
}
function renderFilters() {
const currentStage = state.stage;
const currentBiome = state.biome;
elements.stageFilter.innerHTML = `${optionHtml(state.book.stages, currentStage)}`;
elements.biomeFilter.innerHTML = `${optionHtml(state.book.biomes, currentBiome)}`;
elements.stageFilter.value = state.book.stages.some((entry) => entry.id === currentStage) ? currentStage : "all";
elements.biomeFilter.value = state.book.biomes.some((entry) => entry.id === currentBiome) ? currentBiome : "all";
}
function renderCatalog() {
const query = state.search.trim().toLocaleLowerCase("zh-CN");
const stageOrder = new Map(state.book.stages.map((entry) => [entry.id, entry.order]));
const list = (state.book[state.activeType] || []).filter((entity) => {
if (state.stage !== "all" && entity.stage !== state.stage) return false;
if (state.biome !== "all" && entity.biome !== state.biome) return false;
if (!query) return true;
const haystack = [entity.name, entity.id, entity.category, entity.kind, entity.description, entity.notes, ...(entity.tags || [])].join(" ").toLocaleLowerCase("zh-CN");
return haystack.includes(query);
}).sort((a, b) => (stageOrder.get(a.stage) ?? 99) - (stageOrder.get(b.stage) ?? 99) || a.name.localeCompare(b.name, "zh-CN"));
elements.typeTabs.querySelectorAll("button").forEach((button) => button.classList.toggle("active", button.dataset.type === state.activeType));
elements.catalogSummary.textContent = `显示 ${list.length} / ${state.book[state.activeType].length} 个${TYPE_META[state.activeType].label}`;
if (!list.length) {
elements.entityList.innerHTML = `
没有符合条件的条目。
可以清除搜索或筛选。
`;
return;
}
let lastStage = null;
const stageNames = new Map(state.book.stages.map((entry) => [entry.id, entry.name]));
elements.entityList.innerHTML = list.map((entity) => {
const stageLabel = entity.stage !== lastStage ? `${escapeHtml(stageNames.get(entity.stage) || entity.stage)}
` : "";
lastStage = entity.stage;
const meta = state.activeType === "recipes" ? `${entity.duration}s · ${entity.kind}` : state.activeType === "items" ? entity.category : `速度 ×${entity.baseSpeed}`;
return `${stageLabel}`;
}).join("");
}
function commonFields(entity) {
return ``;
}
function linkButtons(type, entities) {
if (!entities.length) return `暂无`;
return `${entities.map((entry) => ``).join("")}
`;
}
function renderItemInspector(item) {
const index = makeIndex(state.book);
const producers = index.producers.get(item.id) || [];
const consumers = index.consumers.get(item.id) || [];
const categories = [...new Set([...ITEM_CATEGORIES, ...state.book.items.map((entry) => entry.category).filter(Boolean)])];
return `${commonFields(item)}
依赖关系
${producers.length}生产配方
${consumers.length}消费配方
${item.mass}质量
由这些配方产出${linkButtons("recipes", producers)}
用于这些配方${linkButtons("recipes", consumers)}
${entityActions("items")}`;
}
function renderInputRows(recipe) {
const itemOptions = [...state.book.items].sort((a, b) => a.name.localeCompare(b.name, "zh-CN"));
return (recipe.inputs || []).map((entry, index) => ``).join("") || `没有物品输入;这通常表示世界采集或环境生成。
`;
}
function renderOutputRows(recipe) {
const itemOptions = [...state.book.items].sort((a, b) => a.name.localeCompare(b.name, "zh-CN"));
return (recipe.outputs || []).map((entry, index) => ``).join("") || `至少需要一项产出,校验会阻止空产出配方被保存。
`;
}
function renderRecipeInspector(recipe) {
const machine = state.book.machines.find((entry) => entry.id === recipe.machineId);
const rate = primaryRatePerMinute(recipe, machine);
const consumed = (recipe.inputs || []).filter((entry) => entry.mode !== "catalyst").reduce((sum, entry) => sum + Number(entry.amount || 0), 0);
return `${commonFields(recipe)}
输入 (${(recipe.inputs || []).length})
${renderInputRows(recipe)}
产出 (${(recipe.outputs || []).length})
${renderOutputRows(recipe)}
即时估算
${rate.toFixed(1)}主产物 / 分
${consumed.toFixed(1)}每批消耗量
${Number(recipe.entropy || 0).toFixed(1)}每批熵增
${entityActions("recipes", true)}`;
}
function renderMachineInspector(machine) {
const recipes = state.book.recipes.filter((entry) => entry.machineId === machine.id);
return `${commonFields(machine)}
承载配方 (${recipes.length})
${linkButtons("recipes", recipes)}
${entityActions("machines")}`;
}
function entityActions(type, canDuplicate = false) {
return `
${canDuplicate ? `` : ""}
`;
}
function renderInspector() {
const entity = selectedEntity();
if (!entity) {
elements.inspectorTitle.textContent = "选择一个条目";
elements.inspectorKind.textContent = "—";
elements.inspectorContent.innerHTML = ``;
return;
}
elements.inspectorTitle.textContent = entity.name;
elements.inspectorKind.textContent = TYPE_META[state.selected.type].label;
if (state.selected.type === "items") elements.inspectorContent.innerHTML = renderItemInspector(entity);
else if (state.selected.type === "recipes") elements.inspectorContent.innerHTML = renderRecipeInspector(entity);
else elements.inspectorContent.innerHTML = renderMachineInspector(entity);
}
function focusGraphLayout(graph) {
const nodeMap = new Map(graph.nodes.map((node) => [node.key, node]));
const adjacency = new Map(graph.nodes.map((node) => [node.key, []]));
for (const edge of graph.edges) {
adjacency.get(edge.source)?.push({ key: edge.target, delta: 1 });
adjacency.get(edge.target)?.push({ key: edge.source, delta: -1 });
}
const selectedKey = `${state.selected.type}:${state.selected.id}`;
const ranks = new Map([[selectedKey, 0]]);
const queue = [selectedKey];
while (queue.length) {
const key = queue.shift();
for (const neighbor of adjacency.get(key) || []) {
if (!ranks.has(neighbor.key)) {
ranks.set(neighbor.key, ranks.get(key) + neighbor.delta);
queue.push(neighbor.key);
}
}
}
for (const node of graph.nodes) if (!ranks.has(node.key)) ranks.set(node.key, 0);
const rankValues = [...new Set(ranks.values())].sort((a, b) => a - b);
const columns = new Map(rankValues.map((rank) => [rank, graph.nodes.filter((node) => ranks.get(node.key) === rank)]));
const stageOrder = new Map(state.book.stages.map((entry) => [entry.id, entry.order]));
for (const column of columns.values()) column.sort((a, b) => (stageOrder.get(a.stage) ?? 99) - (stageOrder.get(b.stage) ?? 99) || a.label.localeCompare(b.label, "zh-CN"));
const maxRows = Math.max(1, ...[...columns.values()].map((column) => column.length));
const columnWidth = 236;
const rowHeight = 76;
const positions = new Map();
const bands = [];
rankValues.forEach((rank, columnIndex) => {
const column = columns.get(rank);
const x = columnIndex * columnWidth;
const yOffset = (maxRows - column.length) * rowHeight / 2;
column.forEach((node, rowIndex) => {
const isRecipe = node.type === "recipes";
positions.set(node.key, { x: x + 22, y: 54 + yOffset + rowIndex * rowHeight, width: isRecipe ? 188 : 174, height: isRecipe ? 62 : 54 });
});
bands.push({
x,
width: columnWidth - 10,
height: maxRows * rowHeight + 100,
label: rank < 0 ? `上游 ${Math.abs(rank)}` : rank > 0 ? `下游 ${rank}` : "当前选择",
});
});
return { positions, bands, width: Math.max(rankValues.length * columnWidth, 400), height: maxRows * rowHeight + 100 };
}
function stagedGraphLayout(graph) {
const stages = new Map(state.book.stages.map((entry) => [entry.id, entry]));
const stageIds = [...new Set(graph.nodes.map((node) => node.stage))].sort((a, b) => (stages.get(a)?.order ?? 99) - (stages.get(b)?.order ?? 99));
const positions = new Map();
const bands = [];
const bandWidth = 670;
let maxHeight = 280;
stageIds.forEach((stageId, stageIndex) => {
const stageNodes = graph.nodes.filter((node) => node.stage === stageId);
const recipes = stageNodes.filter((node) => node.type === "recipes");
const machines = stageNodes.filter((node) => node.type === "machines");
const items = stageNodes.filter((node) => node.type === "items");
const itemConnections = new Map(items.map((item) => [item.key, { in: 0, out: 0 }]));
graph.edges.forEach((edge) => {
if (itemConnections.has(edge.source)) itemConnections.get(edge.source).out += 1;
if (itemConnections.has(edge.target)) itemConnections.get(edge.target).in += 1;
});
const leftItems = items.filter((item) => itemConnections.get(item.key)?.out > 0);
const rightItems = items.filter((item) => !leftItems.includes(item));
const x = stageIndex * bandWidth;
leftItems.forEach((node, index) => positions.set(node.key, { x: x + 28, y: 60 + index * 72, width: 174, height: 54 }));
recipes.forEach((node, index) => positions.set(node.key, { x: x + 240, y: 60 + index * 82, width: 188, height: 62 }));
rightItems.forEach((node, index) => positions.set(node.key, { x: x + 466, y: 60 + index * 72, width: 174, height: 54 }));
machines.forEach((node, index) => positions.set(node.key, { x: x + 240, y: 74 + recipes.length * 82 + index * 70, width: 188, height: 54 }));
const rows = Math.max(leftItems.length * 72, recipes.length * 82 + machines.length * 70, rightItems.length * 72, 190);
const height = rows + 110;
maxHeight = Math.max(maxHeight, height);
bands.push({ x, width: bandWidth - 16, height, label: stages.get(stageId)?.name || stageId });
});
return { positions, bands, width: Math.max(stageIds.length * bandWidth, 400), height: maxHeight };
}
function graphLayout(graph) {
return state.graphMode === "focus" ? focusGraphLayout(graph) : stagedGraphLayout(graph);
}
function edgePath(source, target) {
const sourceCenter = source.x + source.width / 2;
const targetCenter = target.x + target.width / 2;
const forward = sourceCenter <= targetCenter;
const sx = forward ? source.x + source.width : source.x;
const tx = forward ? target.x : target.x + target.width;
const sy = source.y + source.height / 2;
const ty = target.y + target.height / 2;
const bend = Math.max(55, Math.abs(tx - sx) * .44);
const c1 = forward ? sx + bend : sx - bend;
const c2 = forward ? tx - bend : tx + bend;
return { d: `M ${sx} ${sy} C ${c1} ${sy}, ${c2} ${ty}, ${tx} ${ty}`, mx: (sx + tx) / 2, my: (sy + ty) / 2 };
}
function renderGraph() {
const entity = selectedEntity();
const graph = buildGraph(state.book, {
mode: state.graphMode,
depth: state.graphDepth,
selection: state.selected,
stage: state.stage,
biome: state.biome,
});
const layout = graphLayout(graph);
state.graphBounds = { x: 0, y: 0, width: layout.width, height: layout.height };
elements.graphEmpty.hidden = graph.nodes.length > 0;
elements.depthControl.classList.toggle("is-disabled", state.graphMode === "full");
elements.graphMode.querySelectorAll("button").forEach((button) => button.classList.toggle("active", button.dataset.mode === state.graphMode));
elements.graphTitle.textContent = entity ? entity.name : "配方树";
elements.graphSubtitle.textContent = state.graphMode === "focus" ? `显示选中条目前后 ${state.graphDepth} 层关系` : `显示当前筛选下的全部关系(${graph.nodes.length} 个节点)`;
elements.graphBackdrop.innerHTML = layout.bands.map((band) => `${escapeHtml(band.label)}`).join("");
elements.graphEdges.innerHTML = graph.edges.map((edge) => {
const source = layout.positions.get(edge.source);
const target = layout.positions.get(edge.target);
if (!source || !target) return "";
const path = edgePath(source, target);
const amount = Number(edge.amount);
return `${Number.isFinite(amount) ? `×${amount}` : ""}`;
}).join("");
elements.graphNodes.innerHTML = graph.nodes.map((node) => {
const position = layout.positions.get(node.key);
if (!position) return "";
const singular = TYPE_META[node.type]?.singular || "item";
const selected = state.selected.type === node.type && state.selected.id === node.id;
return `
${TYPE_META[node.type]?.glyph || "◆"}
${escapeHtml(short(node.label, 20))}
${escapeHtml(short(node.subtitle, 29))}
`;
}).join("");
const critical = traceIngredientClosure(state.book, "first_warp_record");
const selectedSummary = entity ? `${TYPE_META[state.selected.type].label}“${entity.name}”` : "未选择条目";
elements.routeSummary.textContent = `${selectedSummary} · 首次折跃主线 ${critical.items.length} 种物品 / ${critical.recipes.length} 道配方 / ${critical.missing.length} 处断点`;
updateGraphTransform();
if (state.graphNeedsFit) {
state.graphNeedsFit = false;
requestAnimationFrame(fitGraph);
}
}
function updateGraphTransform() {
const { x, y, scale } = state.graphTransform;
elements.graphViewport.setAttribute("transform", `translate(${x} ${y}) scale(${scale})`);
elements.zoomOutput.value = `${Math.round(scale * 100)}%`;
elements.zoomOutput.textContent = `${Math.round(scale * 100)}%`;
}
function fitGraph() {
const bounds = state.graphBounds;
const rect = elements.graph.getBoundingClientRect();
if (!bounds || rect.width < 20 || rect.height < 20) return;
const padding = 44;
const scale = Math.min(1.1, Math.max(.12, Math.min((rect.width - padding * 2) / bounds.width, (rect.height - padding * 2) / bounds.height)));
state.graphTransform = {
scale,
x: (rect.width - bounds.width * scale) / 2 - bounds.x * scale,
y: (rect.height - bounds.height * scale) / 2 - bounds.y * scale,
};
updateGraphTransform();
}
function zoomGraph(factor, clientX, clientY) {
const rect = elements.graph.getBoundingClientRect();
const px = clientX == null ? rect.width / 2 : clientX - rect.left;
const py = clientY == null ? rect.height / 2 : clientY - rect.top;
const previous = state.graphTransform.scale;
const next = Math.min(2.2, Math.max(.12, previous * factor));
const worldX = (px - state.graphTransform.x) / previous;
const worldY = (py - state.graphTransform.y) / previous;
state.graphTransform.x = px - worldX * next;
state.graphTransform.y = py - worldY * next;
state.graphTransform.scale = next;
updateGraphTransform();
}
function renderIssues() {
const errors = state.issues.filter((entry) => entry.severity === "error").length;
const warnings = state.issues.filter((entry) => entry.severity === "warning").length;
const info = state.issues.filter((entry) => entry.severity === "info").length;
elements.issuesSummary.textContent = `${errors} 个错误 · ${warnings} 个警告 · ${info} 条提示`;
elements.issueFilter.querySelectorAll("button").forEach((button) => button.classList.toggle("active", button.dataset.severity === state.issueSeverity));
const issues = state.issues.filter((entry) => state.issueSeverity === "all" || entry.severity === state.issueSeverity);
elements.issueList.innerHTML = issues.length ? issues.map((entry) => ``).join("") : `✓ 当前筛选下没有问题
`;
}
function refreshAll() {
ensureSelection();
validate();
renderStatus();
renderCatalog();
renderInspector();
renderGraph();
renderIssues();
}
function selectEntity(type, id, { switchTab = false, fitGraph = true } = {}) {
if (!state.book?.[type]?.some((entry) => entry.id === id)) return;
state.selected = { type, id };
if (switchTab) state.activeType = type;
state.graphNeedsFit ||= fitGraph;
renderCatalog();
renderInspector();
renderGraph();
}
function uniqueId(prefix, collection) {
let suffix = 1;
let candidate = `${prefix}_${suffix}`;
const ids = new Set(collection.map((entry) => entry.id));
while (ids.has(candidate)) candidate = `${prefix}_${++suffix}`;
return candidate;
}
function addEntity() {
const type = state.activeType;
const stage = state.stage !== "all" ? state.stage : state.book.stages[0]?.id;
const biome = state.biome !== "all" ? state.biome : state.book.biomes[0]?.id;
let entity;
if (type === "items") {
entity = { id: uniqueId("new_item", state.book.items), name: "新物品", category: "material", stage, biome, mass: 1, stackSize: 100, isRaw: false, tags: [], notes: "" };
} else if (type === "machines") {
entity = { id: uniqueId("new_machine", state.book.machines), name: "新设施", stage, biome, baseSpeed: 1, tags: [], description: "" };
} else {
entity = { id: uniqueId("new_recipe", state.book.recipes), name: "新配方", stage, biome, machineId: state.book.machines[0]?.id || "", kind: "craft", duration: 1, unlockedBy: "", inputs: [], outputs: state.book.items[0] ? [{ itemId: state.book.items[0].id, amount: 1 }] : [], manaPerSecond: 0, entropy: 0, conditions: [], tags: [], notes: "", enabled: true };
}
mutate(`新增${TYPE_META[type].label}`, (book) => book[type].push(entity), { fitGraph: true });
selectEntity(type, entity.id, { switchTab: true });
toast(`已新增${TYPE_META[type].label},请填写右侧属性。`);
}
function renameSelected(nextId) {
const entity = selectedEntity();
const type = state.selected.type;
const previousId = entity.id;
const clean = nextId.trim();
if (clean === previousId) return;
if (!/^[a-z][a-z0-9_]*$/.test(clean)) {
toast("ID 只能使用小写字母、数字和下划线,并以字母开头。", "error", 4500);
renderInspector();
return;
}
if (state.book[type].some((entry) => entry.id === clean)) {
toast(`ID “${clean}” 已存在。`, "error");
renderInspector();
return;
}
const affected = type === "items"
? state.book.recipes.reduce((count, recipe) => count + [...(recipe.inputs || []), ...(recipe.outputs || [])].filter((entry) => entry.itemId === previousId).length, 0)
: type === "machines" ? state.book.recipes.filter((recipe) => recipe.machineId === previousId).length : 0;
if (affected && !window.confirm(`这会同步修改 ${affected} 处配方引用。继续将 ${previousId} 改为 ${clean}?`)) {
renderInspector();
return;
}
mutate(`重命名 ${previousId}`, (book) => {
book[type].find((entry) => entry.id === previousId).id = clean;
if (type === "items") {
for (const recipe of book.recipes) for (const entry of [...(recipe.inputs || []), ...(recipe.outputs || [])]) if (entry.itemId === previousId) entry.itemId = clean;
}
if (type === "machines") for (const recipe of book.recipes) if (recipe.machineId === previousId) recipe.machineId = clean;
state.selected.id = clean;
}, { fitGraph: true });
toast(`已更新 ID,并同步 ${affected} 处引用。`);
}
function duplicateRecipe() {
const recipe = selectedEntity();
if (!recipe || state.selected.type !== "recipes") return;
const copy = cloneBook(recipe);
copy.id = uniqueId(`${recipe.id}_copy`, state.book.recipes);
copy.name = `${recipe.name}(副本)`;
mutate(`复制配方 ${recipe.name}`, (book) => book.recipes.push(copy), { fitGraph: true });
selectEntity("recipes", copy.id, { switchTab: true });
}
function deleteSelected() {
const entity = selectedEntity();
const type = state.selected.type;
if (!entity) return;
const index = makeIndex(state.book);
if (type === "items") {
const references = (index.producers.get(entity.id)?.length || 0) + (index.consumers.get(entity.id)?.length || 0);
if (references) return toast(`不能删除:仍有 ${references} 道配方引用这个物品。请先改接配方。`, "error", 5000);
}
if (type === "machines") {
const references = state.book.recipes.filter((recipe) => recipe.machineId === entity.id).length;
if (references) return toast(`不能删除:仍有 ${references} 道配方使用这个设施。请先更换设施。`, "error", 5000);
}
if (!window.confirm(`确认删除${TYPE_META[type].label}“${entity.name}”?可以用撤销恢复。`)) return;
mutate(`删除${TYPE_META[type].label} ${entity.name}`, (book) => {
const position = book[type].findIndex((entry) => entry.id === entity.id);
book[type].splice(position, 1);
const fallback = book[type][Math.min(position, book[type].length - 1)];
state.selected = fallback ? { type, id: fallback.id } : { type: "recipes", id: book.recipes[0]?.id || "" };
}, { fitGraph: true });
}
function changeEntityField(target) {
const entity = selectedEntity();
if (!entity) return;
const field = target.dataset.field;
let value;
if (target.type === "checkbox") value = target.checked;
else if (target.dataset.array) value = parseLines(target.value);
else if (target.type === "number") value = numberFromInput(target);
else value = target.value;
if (JSON.stringify(entity[field]) === JSON.stringify(value)) return;
mutate(`修改 ${entity.name} / ${field}`, () => { selectedEntity()[field] = value; });
}
function changeIoField(target) {
const recipe = selectedEntity();
if (!recipe || state.selected.type !== "recipes") return;
const side = target.dataset.io;
const index = Number(target.dataset.index);
const key = target.dataset.key;
const entry = recipe[side]?.[index];
if (!entry) return;
let value = target.type === "number" ? numberFromInput(target, target.hasAttribute("data-nullable")) : target.value;
mutate(`修改 ${recipe.name} / ${side}`, () => {
const current = selectedEntity()[side][index];
if (value == null) delete current[key];
else current[key] = value;
});
}
function isLiveInput(target) {
return target.matches('input[type="text"], input[type="number"], textarea') && !target.matches('[data-action="rename-id"]');
}
function beginLiveEdit(target) {
if (!isLiveInput(target)) return;
const editKey = [state.selected.type, state.selected.id, target.dataset.field, target.dataset.io, target.dataset.index, target.dataset.key].join(":");
if (state.pendingEdit?.key === editKey) return;
state.pendingEdit = null;
const entity = selectedEntity();
if (!entity) return;
const label = `修改 ${entity.name} / ${target.dataset.field || target.dataset.io || "字段"}`;
state.history.push({ label, book: cloneBook(state.book), selected: { ...state.selected } });
if (state.history.length > MAX_HISTORY) state.history.shift();
state.future.length = 0;
state.pendingEdit = { key: editKey, label };
}
function applyLiveInput(target) {
if (!isLiveInput(target)) return;
const entity = selectedEntity();
if (!entity) return;
if (target.dataset.io) {
const entry = entity[target.dataset.io]?.[Number(target.dataset.index)];
if (!entry) return;
const value = target.type === "number" ? numberFromInput(target, target.hasAttribute("data-nullable")) : target.value;
if ((value == null && entry[target.dataset.key] == null) || JSON.stringify(entry[target.dataset.key]) === JSON.stringify(value)) return;
beginLiveEdit(target);
if (value == null) delete entry[target.dataset.key];
else entry[target.dataset.key] = value;
} else if (target.dataset.field) {
let value;
if (target.dataset.array) value = parseLines(target.value);
else if (target.type === "number") value = numberFromInput(target);
else value = target.value;
if (JSON.stringify(entity[target.dataset.field]) === JSON.stringify(value)) return;
beginLiveEdit(target);
entity[target.dataset.field] = value;
}
state.book.metadata ||= {};
state.book.metadata.updatedAt = new Date().toISOString();
updateDirty();
validate();
renderStatus();
if (["name", "tags"].includes(target.dataset.field)) renderCatalog();
renderGraph();
renderIssues();
}
function finalizeLiveEdit() {
if (!state.pendingEdit) return;
state.pendingEdit = null;
renderStatus();
}
function addIo(side) {
const recipe = selectedEntity();
const firstItem = state.book.items[0];
if (!recipe || state.selected.type !== "recipes" || !firstItem) return;
mutate(`为 ${recipe.name} 添加${side === "inputs" ? "输入" : "产出"}`, () => {
if (side === "inputs") selectedEntity().inputs.push({ itemId: firstItem.id, amount: 1, mode: "consumed" });
else selectedEntity().outputs.push({ itemId: firstItem.id, amount: 1 });
}, { fitGraph: true });
}
function deleteIo(side, index) {
const recipe = selectedEntity();
if (!recipe || state.selected.type !== "recipes") return;
mutate(`删除 ${recipe.name} 的${side === "inputs" ? "输入" : "产出"}`, () => selectedEntity()[side].splice(index, 1), { fitGraph: true });
}
function downloadBook() {
const blob = new Blob([`${JSON.stringify(state.book, null, 2)}\n`], { type: "application/json" });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `recipe-book_${slugTime()}.json`;
anchor.click();
URL.revokeObjectURL(url);
toast("已导出 JSON;导出不会清除“未保存”状态。", "success", 4000);
}
async function saveBook() {
finalizeLiveEdit();
const issues = validate();
const errors = issues.filter((entry) => entry.severity === "error");
if (errors.length) {
renderIssues();
elements.issuesDialog.showModal();
return toast(`有 ${errors.length} 个错误,修复后才能保存。`, "error", 5000);
}
if (!state.serverOnline) {
downloadBook();
return toast("本地保存服务未连接,已改为导出。请用启动器打开工具以直接写回项目。", "warning", 6500);
}
state.saving = true;
renderStatus();
try {
const response = await fetch("/api/book", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(state.book) });
const result = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(result.message || `保存失败(HTTP ${response.status})`);
state.savedBookJson = JSON.stringify(state.book);
state.dirty = false;
state.history.length = 0;
state.future.length = 0;
localStorage.removeItem(DRAFT_KEY);
toast(`已写回项目,并生成备份 ${result.backup || ""}`.trim(), "success", 4500);
} catch (error) {
state.serverOnline = false;
toast(`${error.message}。数据仍保留在浏览器草稿中。`, "error", 6000);
} finally {
state.saving = false;
renderStatus();
}
}
async function importBook(file) {
try {
const imported = JSON.parse(await file.text());
const issues = validateBook(imported, { technologyIds: state.technologyIds });
const errors = issues.filter((entry) => entry.severity === "error");
if (errors.length) throw new Error(`文件有 ${errors.length} 个结构错误:${errors[0].message}`);
state.history.push({ label: "导入数据", book: cloneBook(state.book), selected: { ...state.selected } });
state.future.length = 0;
state.book = imported;
updateDirty();
state.selected = imported.recipes.some((entry) => entry.id === "perform_first_warp") ? { type: "recipes", id: "perform_first_warp" } : { type: "recipes", id: imported.recipes[0]?.id || "" };
state.activeType = state.selected.type;
state.graphNeedsFit = true;
renderFilters();
refreshAll();
toast(`已导入 ${file.name},请确认后点击“保存数据”。`, "success", 4500);
} catch (error) {
toast(`导入失败:${error.message}`, "error", 6500);
} finally {
elements.importFile.value = "";
}
}
async function loadTechnologyIds() {
try {
const response = await fetch("../../docs/tech-tree-nodes-v0.2.csv", { cache: "no-store" });
if (!response.ok) return;
const text = await response.text();
state.technologyIds = new Set(text.split(/\r?\n/).slice(1).map((line) => line.split(",", 1)[0].trim()).filter(Boolean));
} catch {
state.technologyIds = null;
}
}
async function loadInitialBook() {
let book;
try {
const response = await fetch("/api/book", { cache: "no-store" });
if (!response.ok) throw new Error("API unavailable");
book = await response.json();
state.serverOnline = true;
} catch {
try {
const response = await fetch("../../data/recipe-book.json", { cache: "no-store" });
if (!response.ok) throw new Error("file unavailable");
book = await response.json();
} catch {
elements.app.setAttribute("aria-busy", "false");
elements.saveState.className = "save-state offline";
elements.saveState.querySelector("span").textContent = "等待导入数据";
elements.importFile.click();
toast("无法自动读取配方书。请选择 data/recipe-book.json。", "warning", 8000);
return;
}
}
await loadTechnologyIds();
state.book = book;
state.savedBookJson = JSON.stringify(book);
try {
const draft = JSON.parse(localStorage.getItem(DRAFT_KEY) || "null");
const draftTime = Date.parse(draft?.book?.metadata?.updatedAt || draft?.savedAt || 0);
const bookTime = Date.parse(book?.metadata?.updatedAt || 0);
if (draft?.book && draftTime > bookTime && window.confirm("检测到比项目文件更新的浏览器草稿。要恢复尚未保存的修改吗?")) {
state.book = draft.book;
updateDirty();
toast("已恢复浏览器草稿;确认无误后请保存到项目。", "warning", 5500);
}
} catch {
localStorage.removeItem(DRAFT_KEY);
}
renderFilters();
refreshAll();
elements.app.setAttribute("aria-busy", "false");
}
elements.typeTabs.addEventListener("click", (event) => {
const button = event.target.closest("button[data-type]");
if (!button) return;
state.activeType = button.dataset.type;
const current = state.book[state.activeType].find((entry) => entry.id === state.selected.id);
if (!current) {
const first = state.book[state.activeType][0];
if (first) state.selected = { type: state.activeType, id: first.id };
}
state.graphNeedsFit = true;
refreshAll();
});
elements.entityList.addEventListener("click", (event) => {
const row = event.target.closest(".entity-row");
if (row) selectEntity(row.dataset.type, row.dataset.id);
});
elements.catalogSearch.addEventListener("input", (event) => { state.search = event.target.value; renderCatalog(); });
elements.stageFilter.addEventListener("change", (event) => { state.stage = event.target.value; state.graphNeedsFit = true; renderCatalog(); renderGraph(); });
elements.biomeFilter.addEventListener("change", (event) => { state.biome = event.target.value; state.graphNeedsFit = true; renderCatalog(); renderGraph(); });
elements.addEntityButton.addEventListener("click", addEntity);
elements.inspectorContent.addEventListener("change", (event) => {
const target = event.target;
if (target.matches('[data-action="rename-id"]')) renameSelected(target.value);
else if (isLiveInput(target)) finalizeLiveEdit();
else if (target.dataset.io) changeIoField(target);
else if (target.dataset.field) changeEntityField(target);
});
elements.inspectorContent.addEventListener("input", (event) => applyLiveInput(event.target));
elements.inspectorContent.addEventListener("focusout", (event) => { if (isLiveInput(event.target)) finalizeLiveEdit(); });
elements.inspectorContent.addEventListener("click", (event) => {
const button = event.target.closest("button[data-action]");
if (!button) return;
const action = button.dataset.action;
if (action === "navigate") selectEntity(button.dataset.type, button.dataset.id, { switchTab: true });
else if (action === "add-io") addIo(button.dataset.side);
else if (action === "delete-io") deleteIo(button.dataset.side, Number(button.dataset.index));
else if (action === "duplicate") duplicateRecipe();
else if (action === "delete-entity") deleteSelected();
else if (action === "help") elements.helpDialog.showModal();
});
elements.graphMode.addEventListener("click", (event) => {
const button = event.target.closest("button[data-mode]");
if (!button) return;
state.graphMode = button.dataset.mode;
state.graphNeedsFit = true;
renderGraph();
});
elements.graphDepth.addEventListener("change", (event) => { state.graphDepth = Number(event.target.value); state.graphNeedsFit = true; renderGraph(); });
elements.fitGraphButton.addEventListener("click", fitGraph);
elements.zoomInButton.addEventListener("click", () => zoomGraph(1.2));
elements.zoomOutButton.addEventListener("click", () => zoomGraph(1 / 1.2));
let drag = null;
elements.graph.addEventListener("pointerdown", (event) => {
if (event.button !== 0) return;
drag = { pointerId: event.pointerId, startX: event.clientX, startY: event.clientY, originX: state.graphTransform.x, originY: state.graphTransform.y, moved: false, target: event.target.closest(".graph-node") };
elements.graph.setPointerCapture(event.pointerId);
elements.graph.classList.add("dragging");
});
elements.graph.addEventListener("pointermove", (event) => {
if (!drag || drag.pointerId !== event.pointerId) return;
const dx = event.clientX - drag.startX;
const dy = event.clientY - drag.startY;
if (Math.abs(dx) + Math.abs(dy) > 5) drag.moved = true;
if (drag.moved) {
state.graphTransform.x = drag.originX + dx;
state.graphTransform.y = drag.originY + dy;
updateGraphTransform();
}
});
elements.graph.addEventListener("pointerup", (event) => {
if (!drag || drag.pointerId !== event.pointerId) return;
if (!drag.moved && drag.target) selectEntity(drag.target.dataset.type, drag.target.dataset.id, { switchTab: true, fitGraph: false });
drag = null;
elements.graph.classList.remove("dragging");
});
elements.graph.addEventListener("wheel", (event) => { event.preventDefault(); zoomGraph(event.deltaY < 0 ? 1.12 : 1 / 1.12, event.clientX, event.clientY); }, { passive: false });
elements.undoButton.addEventListener("click", () => { finalizeLiveEdit(); undo(); });
elements.redoButton.addEventListener("click", () => { finalizeLiveEdit(); redo(); });
elements.importButton.addEventListener("click", () => elements.importFile.click());
elements.importFile.addEventListener("change", () => { if (elements.importFile.files[0]) importBook(elements.importFile.files[0]); });
elements.exportButton.addEventListener("click", downloadBook);
elements.saveButton.addEventListener("click", saveBook);
elements.issuesButton.addEventListener("click", () => { renderIssues(); elements.issuesDialog.showModal(); });
elements.issueFilter.addEventListener("click", (event) => {
const button = event.target.closest("button[data-severity]");
if (!button) return;
state.issueSeverity = button.dataset.severity;
renderIssues();
});
elements.issueList.addEventListener("click", (event) => {
const row = event.target.closest(".issue-row");
if (!row || !TYPE_META[row.dataset.type] || !row.dataset.id) return;
elements.issuesDialog.close();
selectEntity(row.dataset.type, row.dataset.id, { switchTab: true });
});
window.addEventListener("keydown", (event) => {
const modifier = event.ctrlKey || event.metaKey;
if (modifier && event.key.toLowerCase() === "s") { event.preventDefault(); finalizeLiveEdit(); saveBook(); }
else if (modifier && event.key.toLowerCase() === "z" && !event.shiftKey) { event.preventDefault(); finalizeLiveEdit(); undo(); }
else if (modifier && (event.key.toLowerCase() === "y" || (event.key.toLowerCase() === "z" && event.shiftKey))) { event.preventDefault(); finalizeLiveEdit(); redo(); }
else if (!modifier && event.key === "/" && !["INPUT", "TEXTAREA", "SELECT"].includes(document.activeElement?.tagName)) { event.preventDefault(); elements.catalogSearch.focus(); }
});
window.addEventListener("beforeunload", (event) => { if (state.dirty) { event.preventDefault(); event.returnValue = ""; } });
window.addEventListener("resize", () => { if (state.graphBounds) fitGraph(); });
loadInitialBook();