368 lines
17 KiB
JavaScript
368 lines
17 KiB
JavaScript
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,
|
||
};
|
||
}
|