export const ENTITY_TYPES = ["recipes", "items", "machines"]; export function cloneBook(value) { return JSON.parse(JSON.stringify(value)); } export function makeIndex(book) { const index = { stages: new Map(), biomes: new Map(), machines: new Map(), items: new Map(), recipes: new Map(), producers: new Map(), consumers: new Map(), }; for (const stage of book.stages || []) index.stages.set(stage.id, stage); for (const biome of book.biomes || []) index.biomes.set(biome.id, biome); for (const machine of book.machines || []) index.machines.set(machine.id, machine); for (const item of book.items || []) index.items.set(item.id, item); for (const recipe of book.recipes || []) { index.recipes.set(recipe.id, recipe); for (const input of recipe.inputs || []) { if (!index.consumers.has(input.itemId)) index.consumers.set(input.itemId, []); index.consumers.get(input.itemId).push(recipe); } for (const output of recipe.outputs || []) { if (!index.producers.has(output.itemId)) index.producers.set(output.itemId, []); index.producers.get(output.itemId).push(recipe); } } return index; } function issue(severity, code, message, entityType = "book", entityId = "") { return { severity, code, message, entityType, entityId }; } function duplicateIssues(collection, entityType) { const seen = new Set(); const issues = []; for (const entity of collection || []) { if (!entity.id) { issues.push(issue("error", "missing_id", `${entityType} 中存在空 ID。`, entityType)); } else if (seen.has(entity.id)) { issues.push(issue("error", "duplicate_id", `ID “${entity.id}” 重复。`, entityType, entity.id)); } seen.add(entity.id); } return issues; } function dependencyCycles(book) { const adjacency = new Map(); const recipesByPair = new Map(); for (const item of book.items || []) adjacency.set(item.id, new Set()); for (const recipe of (book.recipes || []).filter((entry) => entry.enabled !== false)) { for (const input of recipe.inputs || []) { for (const output of recipe.outputs || []) { if (!adjacency.has(input.itemId)) adjacency.set(input.itemId, new Set()); adjacency.get(input.itemId).add(output.itemId); recipesByPair.set(`${input.itemId}>${output.itemId}`, recipe); } } } let nextIndex = 0; const stack = []; const indices = new Map(); const lowLinks = new Map(); const onStack = new Set(); const components = []; function visit(id) { indices.set(id, nextIndex); lowLinks.set(id, nextIndex); nextIndex += 1; stack.push(id); onStack.add(id); for (const neighbor of adjacency.get(id) || []) { if (!indices.has(neighbor)) { visit(neighbor); lowLinks.set(id, Math.min(lowLinks.get(id), lowLinks.get(neighbor))); } else if (onStack.has(neighbor)) { lowLinks.set(id, Math.min(lowLinks.get(id), indices.get(neighbor))); } } if (lowLinks.get(id) === indices.get(id)) { const component = []; let member; do { member = stack.pop(); onStack.delete(member); component.push(member); } while (member !== id); const selfLoop = component.length === 1 && (adjacency.get(component[0]) || new Set()).has(component[0]); if (component.length > 1 || selfLoop) components.push(component); } } for (const id of adjacency.keys()) if (!indices.has(id)) visit(id); return components.map((items) => { const recipes = new Set(); for (const from of items) { for (const to of adjacency.get(from) || []) { if (items.includes(to)) recipes.add(recipesByPair.get(`${from}>${to}`)); } } return { items, recipes: [...recipes].filter(Boolean) }; }); } export function validateBook(book, context = {}) { const issues = []; if (!book || typeof book !== "object") return [issue("error", "invalid_book", "数据不是有效对象。")]; if (book.schemaVersion !== 1) issues.push(issue("error", "schema_version", "schemaVersion 必须为 1。")); for (const [type, collection] of [["stages", book.stages], ["biomes", book.biomes], ["machines", book.machines], ["items", book.items], ["recipes", book.recipes]]) { if (!Array.isArray(collection)) issues.push(issue("error", "missing_collection", `缺少 ${type} 数组。`)); else issues.push(...duplicateIssues(collection, type)); } if (issues.some((entry) => entry.code === "missing_collection")) return issues; const index = makeIndex(book); const idPattern = /^[a-z][a-z0-9_]*$/; for (const type of ["machines", "items", "recipes"]) { for (const entity of book[type]) { if (entity.id && !idPattern.test(entity.id)) { issues.push(issue("error", "invalid_id", `ID “${entity.id}” 只能使用小写字母、数字和下划线。`, type, entity.id)); } if (!String(entity.name || "").trim()) issues.push(issue("error", "missing_name", "名称不能为空。", type, entity.id)); } } for (const item of book.items) { if (!index.stages.has(item.stage)) issues.push(issue("error", "missing_stage", `物品引用未知阶段 “${item.stage}”。`, "items", item.id)); if (!index.biomes.has(item.biome)) issues.push(issue("error", "missing_biome", `物品引用未知生态区 “${item.biome}”。`, "items", item.id)); if (!(Number(item.mass) > 0)) issues.push(issue("error", "invalid_mass", "物品质量必须大于 0。", "items", item.id)); if (!(Number(item.stackSize) >= 1)) issues.push(issue("error", "invalid_stack", "堆叠数量必须至少为 1。", "items", item.id)); } for (const machine of book.machines) { if (!index.stages.has(machine.stage)) issues.push(issue("error", "missing_stage", `机器引用未知阶段 “${machine.stage}”。`, "machines", machine.id)); if (!index.biomes.has(machine.biome)) issues.push(issue("error", "missing_biome", `机器引用未知生态区 “${machine.biome}”。`, "machines", machine.id)); if (!(Number(machine.baseSpeed) > 0)) issues.push(issue("error", "invalid_speed", "机器速度必须大于 0。", "machines", machine.id)); } const technologyIds = context.technologyIds || null; for (const recipe of book.recipes) { if (!index.stages.has(recipe.stage)) issues.push(issue("error", "missing_stage", `配方引用未知阶段 “${recipe.stage}”。`, "recipes", recipe.id)); if (!index.biomes.has(recipe.biome)) issues.push(issue("error", "missing_biome", `配方引用未知生态区 “${recipe.biome}”。`, "recipes", recipe.id)); if (!index.machines.has(recipe.machineId)) issues.push(issue("error", "missing_machine", `配方引用未知机器 “${recipe.machineId}”。`, "recipes", recipe.id)); if (!(Number(recipe.duration) > 0)) issues.push(issue("error", "invalid_duration", "制作时间必须大于 0。", "recipes", recipe.id)); if (!Array.isArray(recipe.outputs) || recipe.outputs.length === 0) issues.push(issue("error", "no_outputs", "配方至少需要一个产出。", "recipes", recipe.id)); if (technologyIds && recipe.unlockedBy && !technologyIds.has(recipe.unlockedBy) && recipe.unlockedBy !== "X00") { issues.push(issue("warning", "unknown_technology", `解锁科技 “${recipe.unlockedBy}” 不在科技表中。`, "recipes", recipe.id)); } for (const [side, entries] of [["输入", recipe.inputs || []], ["输出", recipe.outputs || []]]) { entries.forEach((entry, entryIndex) => { if (!index.items.has(entry.itemId)) issues.push(issue("error", "missing_item", `${side} #${entryIndex + 1} 引用未知物品 “${entry.itemId}”。`, "recipes", recipe.id)); if (side === "输入" && !(Number(entry.amount) > 0)) issues.push(issue("error", "invalid_amount", `${side} #${entryIndex + 1} 数量必须大于 0。`, "recipes", recipe.id)); if (side === "输出" && !(Number(entry.amount) >= 0)) issues.push(issue("error", "invalid_amount", `${side} #${entryIndex + 1} 数量不能小于 0。`, "recipes", recipe.id)); if (side === "输出" && entry.min != null && entry.max != null) { if (Number(entry.min) > Number(entry.max)) issues.push(issue("error", "invalid_range", `${side} #${entryIndex + 1} 最小值大于最大值。`, "recipes", recipe.id)); if (Number(entry.amount) < Number(entry.min) || Number(entry.amount) > Number(entry.max)) issues.push(issue("warning", "amount_outside_range", `${side} #${entryIndex + 1} 期望数量不在最小/最大范围内。`, "recipes", recipe.id)); } }); } if (recipe.kind === "result_bag") { const weighted = (recipe.outputs || []).filter((output) => output.weight != null); if (weighted.length) { const total = weighted.reduce((sum, output) => sum + Number(output.weight || 0), 0); if (Math.abs(total - 100) > 0.001) issues.push(issue("error", "bag_weight", `结果袋权重合计为 ${total},必须等于 100。`, "recipes", recipe.id)); } const chanceOutputs = (recipe.outputs || []).filter((output) => output.chance != null); if (chanceOutputs.length === (recipe.outputs || []).length && chanceOutputs.length) { const total = chanceOutputs.reduce((sum, output) => sum + Number(output.chance || 0), 0); if (Math.abs(total - 1) > 0.001) issues.push(issue("error", "chance_total", `概率产出合计为 ${total},必须等于 1。`, "recipes", recipe.id)); } } } const stageOrder = new Map((book.stages || []).map((stage) => [stage.id, Number(stage.order)])); for (const recipe of book.recipes) { const recipeOrder = stageOrder.get(recipe.stage); for (const input of recipe.inputs || []) { const item = index.items.get(input.itemId); if (item && recipeOrder != null && stageOrder.get(item.stage) > recipeOrder) { issues.push(issue("warning", "future_input", `输入“${item.name}”来自更晚阶段。`, "recipes", recipe.id)); } } } for (const item of book.items) { const producers = index.producers.get(item.id) || []; if (!item.isRaw && producers.length === 0 && !(item.tags || []).includes("system_generated")) { issues.push(issue("warning", "no_producer", `非原料物品“${item.name}”没有生产配方。`, "items", item.id)); } const consumers = index.consumers.get(item.id) || []; if (consumers.length === 0 && !(item.tags || []).some((tag) => ["victory", "relic", "record"].includes(tag))) { issues.push(issue("info", "no_consumer", `物品“${item.name}”目前没有下游用途。`, "items", item.id)); } } for (const cycle of dependencyCycles(book)) { const intentional = cycle.recipes.some((recipe) => (recipe.tags || []).includes("cycle")); const names = cycle.items.map((id) => index.items.get(id)?.name || id).join(" → "); issues.push(issue(intentional ? "info" : "warning", intentional ? "intentional_cycle" : "dependency_cycle", `${intentional ? "已标记循环" : "未标记依赖循环"}:${names}`, "recipes", cycle.recipes[0]?.id || "")); } const critical = traceIngredientClosure(book, "first_warp_record"); if (!index.items.has("first_warp_record")) { issues.push(issue("error", "missing_victory_item", "缺少首次折跃记录物品。")); } else if (!critical.producers.length) { issues.push(issue("error", "missing_victory_recipe", "首次折跃记录没有生产配方。", "items", "first_warp_record")); } for (const missing of critical.missing) { issues.push(issue("error", "critical_missing_producer", `首次折跃上游“${missing.name}”没有生产配方或原料来源。`, "items", missing.id)); } const rank = { error: 0, warning: 1, info: 2 }; return issues.sort((a, b) => rank[a.severity] - rank[b.severity] || a.message.localeCompare(b.message, "zh-CN")); } export function traceIngredientClosure(book, targetItemId) { const index = makeIndex(book); const visitedItems = new Set(); const visitedRecipes = new Set(); const missing = []; function visitItem(itemId) { if (visitedItems.has(itemId)) return; visitedItems.add(itemId); const item = index.items.get(itemId); if (!item) { missing.push({ id: itemId, name: itemId }); return; } if (item.isRaw || (item.tags || []).includes("system_generated")) return; const producers = (index.producers.get(itemId) || []).filter((recipe) => recipe.enabled !== false); if (!producers.length) { missing.push(item); return; } const producer = producers[0]; if (visitedRecipes.has(producer.id)) return; visitedRecipes.add(producer.id); for (const input of producer.inputs || []) visitItem(input.itemId); } visitItem(targetItemId); return { items: [...visitedItems], recipes: [...visitedRecipes], producers: index.producers.get(targetItemId) || [], missing, }; } function entityNode(book, type, entity) { return { key: `${type}:${entity.id}`, type, id: entity.id, label: entity.name, stage: entity.stage, biome: entity.biome, subtitle: type === "recipes" ? `${entity.duration}s · ${entity.kind}` : type === "machines" ? `×${entity.baseSpeed}` : entity.category, }; } export function buildGraph(book, options = {}) { const index = makeIndex(book); const mode = options.mode || "focus"; const depth = Math.max(1, Math.min(5, Number(options.depth) || 2)); const nodes = new Map(); const edges = new Map(); function addEntity(type, entity) { if (!entity) return; nodes.set(`${type}:${entity.id}`, entityNode(book, type, entity)); } function addRecipe(recipe) { if (!recipe || recipe.enabled === false) return; addEntity("recipes", recipe); for (const input of recipe.inputs || []) { const item = index.items.get(input.itemId); addEntity("items", item); if (item) edges.set(`items:${item.id}>recipes:${recipe.id}`, { source: `items:${item.id}`, target: `recipes:${recipe.id}`, kind: input.mode || "input", amount: input.amount }); } for (const output of recipe.outputs || []) { const item = index.items.get(output.itemId); addEntity("items", item); if (item) edges.set(`recipes:${recipe.id}>items:${item.id}`, { source: `recipes:${recipe.id}`, target: `items:${item.id}`, kind: "output", amount: output.amount }); } } if (mode === "full") { for (const recipe of book.recipes || []) { if (options.stage && options.stage !== "all" && recipe.stage !== options.stage) continue; if (options.biome && options.biome !== "all" && recipe.biome !== options.biome) continue; addRecipe(recipe); } } else { const selection = options.selection || { type: "items", id: "first_warp_record" }; const queue = [{ type: selection.type, id: selection.id, distance: selection.type === "recipes" ? 1 : 0, direction: "both" }]; const seen = new Set(); while (queue.length) { const current = queue.shift(); const key = `${current.type}:${current.id}:${current.direction}`; if (seen.has(key) || current.distance > depth) continue; seen.add(key); if (current.type === "recipes") { const recipe = index.recipes.get(current.id); addRecipe(recipe); if (current.direction === "both" || current.direction === "upstream") { for (const entry of recipe?.inputs || []) queue.push({ type: "items", id: entry.itemId, distance: current.distance, direction: "upstream" }); } if (current.direction === "both" || current.direction === "downstream") { for (const entry of recipe?.outputs || []) queue.push({ type: "items", id: entry.itemId, distance: current.distance, direction: "downstream" }); } } else if (current.type === "items") { const item = index.items.get(current.id); addEntity("items", item); if (current.distance >= depth) continue; const recipes = current.direction === "upstream" ? index.producers.get(current.id) || [] : current.direction === "downstream" ? index.consumers.get(current.id) || [] : [...(index.producers.get(current.id) || []), ...(index.consumers.get(current.id) || [])]; for (const recipe of recipes) { addRecipe(recipe); queue.push({ type: "recipes", id: recipe.id, distance: current.distance + 1, direction: current.direction }); } } else if (current.type === "machines") { const machine = index.machines.get(current.id); addEntity("machines", machine); if (current.distance >= depth) continue; for (const recipe of (book.recipes || []).filter((entry) => entry.machineId === current.id)) { addRecipe(recipe); edges.set(`machines:${machine.id}>recipes:${recipe.id}`, { source: `machines:${machine.id}`, target: `recipes:${recipe.id}`, kind: "machine" }); queue.push({ type: "recipes", id: recipe.id, distance: current.distance + 1, direction: "both" }); } } } } return { nodes: [...nodes.values()], edges: [...edges.values()] }; } export function primaryRatePerMinute(recipe, machine) { const output = recipe?.outputs?.[0]; if (!output || !(recipe.duration > 0)) return 0; return Number(output.amount || 0) * 60 * Number(machine?.baseSpeed || 1) / Number(recipe.duration); } export function summarizeBook(book, issues = []) { return { items: book.items?.length || 0, recipes: book.recipes?.length || 0, machines: book.machines?.length || 0, errors: issues.filter((entry) => entry.severity === "error").length, warnings: issues.filter((entry) => entry.severity === "warning").length, }; }