Files
magic-factorio/tools/recipe-tree-editor/core.mjs
T

368 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
};
}