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 ``; } 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();