Add two-stage charged-crystal demo and original art set.
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
"""Knock a flat charcoal field out of generated renders so they can be sprites."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
|
||||
|
||||
def sample_background(image: Image.Image) -> tuple[float, float, float]:
|
||||
pixels = image.load()
|
||||
width, height = image.size
|
||||
samples: list[tuple[int, int, int]] = []
|
||||
for x, y in (
|
||||
(2, 2),
|
||||
(width - 3, 2),
|
||||
(2, height - 3),
|
||||
(width - 3, height - 3),
|
||||
(width // 2, 2),
|
||||
(width // 2, height - 3),
|
||||
):
|
||||
pixel = pixels[x, y]
|
||||
samples.append((pixel[0], pixel[1], pixel[2]))
|
||||
count = float(len(samples))
|
||||
return (
|
||||
sum(sample[0] for sample in samples) / count,
|
||||
sum(sample[1] for sample in samples) / count,
|
||||
sum(sample[2] for sample in samples) / count,
|
||||
)
|
||||
|
||||
|
||||
def punch(path: Path) -> None:
|
||||
image = Image.open(path).convert("RGBA")
|
||||
background = sample_background(image)
|
||||
pixels = image.load()
|
||||
width, height = image.size
|
||||
for y in range(height):
|
||||
for x in range(width):
|
||||
red, green, blue, _alpha = pixels[x, y]
|
||||
distance = (
|
||||
(red - background[0]) ** 2
|
||||
+ (green - background[1]) ** 2
|
||||
+ (blue - background[2]) ** 2
|
||||
) ** 0.5
|
||||
if distance < 18:
|
||||
alpha = 0
|
||||
elif distance < 34:
|
||||
alpha = int((distance - 18) * (255.0 / 16.0))
|
||||
else:
|
||||
alpha = 255
|
||||
pixels[x, y] = (red, green, blue, alpha)
|
||||
image.save(path)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
roots = [
|
||||
Path("assets/generated/renders/machines"),
|
||||
Path("assets/generated/renders/logistics"),
|
||||
Path("assets/generated/renders/items"),
|
||||
]
|
||||
count = 0
|
||||
for root in roots:
|
||||
for path in sorted(root.glob("*.png")):
|
||||
punch(path)
|
||||
count += 1
|
||||
print("PUNCHED %d" % count)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,49 @@
|
||||
# 配方树工作台
|
||||
|
||||
这是给策划直接使用的本地数据工具。它编辑的不是一份演示副本,而是项目中的权威配方书:`data/recipe-book.json`。
|
||||
|
||||
## 打开
|
||||
|
||||
在 Windows 资源管理器中双击 `start-editor.cmd`。启动器会在本机打开浏览器页面,保存服务只监听 `127.0.0.1`,不会对局域网或互联网开放。
|
||||
|
||||
如果浏览器没有自动打开,也可以在本目录运行:
|
||||
|
||||
```powershell
|
||||
node server.mjs
|
||||
```
|
||||
|
||||
随后访问 `http://127.0.0.1:8765/tools/recipe-tree-editor/`。
|
||||
|
||||
## 日常使用
|
||||
|
||||
1. 从左侧切换配方、物品或设施,并用阶段、生态区和搜索缩小范围。
|
||||
2. 中央默认显示选中条目的上下游;“全局”模式用于检查整条阶段结构。
|
||||
3. 在右侧修改数值。所有修改会立即反映到图谱和校验结果,并自动保留一份浏览器草稿。
|
||||
4. 先处理顶部的错误,再点“保存数据”。直接保存会写回 `data/recipe-book.json`,并在 `data/backups/` 生成时间戳备份。
|
||||
5. `Ctrl+Z` / `Ctrl+Y` 撤销和重做,`Ctrl+S` 保存,`/` 聚焦搜索。
|
||||
|
||||
## 魔法配方字段
|
||||
|
||||
- 输入方式“消耗”:每批永久扣除。
|
||||
- “催化”:参与仪式但不扣除,例如锚核和边界石。
|
||||
- “流体”:来自魔力或物质管网的批次输入。
|
||||
- “状态”:要求物品携带某种法则状态。
|
||||
- `min` / `max`:随机数量的安全上下界,`amount` 是策划估算用期望值。
|
||||
- `weight`:传送门结果袋权重,同一配方的权重总和必须为 100。
|
||||
- `chance`:独立概率;当所有产出都是概率项时,总和必须为 1。
|
||||
- `entropy`:每批给仪式网络或传送门增加的熵压力。
|
||||
|
||||
## 保存安全
|
||||
|
||||
- 有结构错误时,界面和服务端都会拒绝写入。
|
||||
- 修改 ID 会自动更新配方引用;仍被引用的物品或设施不能直接删除。
|
||||
- 浏览器草稿不是项目文件,顶部会一直显示“未保存”,直到直接保存成功。
|
||||
- 本地服务不可用时,“保存数据”会退化为下载 JSON;此时需要手动替换项目数据文件。
|
||||
|
||||
## 校验
|
||||
|
||||
```powershell
|
||||
node tests.mjs
|
||||
```
|
||||
|
||||
测试覆盖引用完整性、科技 ID、结果袋权重、依赖图构建和首次折跃关键上游。当前 Godot 原型尚未消费这份数据;等内容运行时建立后,应直接读取这份 JSON 或由它生成 Godot Resource,避免维护第二套数值。
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,367 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="dark">
|
||||
<title>魔力工坊 · 配方树工作台</title>
|
||||
<link rel="stylesheet" href="./styles.css?v=20260812-2">
|
||||
</head>
|
||||
<body>
|
||||
<div class="app" id="app" aria-busy="true">
|
||||
<header class="topbar">
|
||||
<div class="brand">
|
||||
<span class="brand-mark" aria-hidden="true">◇</span>
|
||||
<div>
|
||||
<strong>配方树工作台</strong>
|
||||
<span>魔力工坊 / 策划数据</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="top-stats" aria-label="数据统计">
|
||||
<span><b id="statRecipes">—</b> 配方</span>
|
||||
<span><b id="statItems">—</b> 物品</span>
|
||||
<span><b id="statMachines">—</b> 设施</span>
|
||||
</div>
|
||||
|
||||
<div class="toolbar" role="toolbar" aria-label="文件和编辑操作">
|
||||
<span class="save-state loading" id="saveState"><i></i><span>读取中</span></span>
|
||||
<button class="button ghost" id="undoButton" type="button" title="撤销(Ctrl+Z)" disabled>撤销</button>
|
||||
<button class="button ghost" id="redoButton" type="button" title="重做(Ctrl+Y)" disabled>重做</button>
|
||||
<button class="button ghost" id="importButton" type="button">导入</button>
|
||||
<button class="button ghost" id="exportButton" type="button">导出</button>
|
||||
<button class="button issue-button" id="issuesButton" type="button"><span id="issueCount">校验</span></button>
|
||||
<button class="button primary" id="saveButton" type="button" title="保存到 data/recipe-book.json(Ctrl+S)">保存数据</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="workspace">
|
||||
<aside class="catalog panel" aria-label="数据目录">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<span class="eyebrow">数据目录</span>
|
||||
<h1>配方与物料</h1>
|
||||
</div>
|
||||
<button class="icon-button" id="addEntityButton" type="button" aria-label="新增当前类型" title="新增当前类型">+</button>
|
||||
</div>
|
||||
|
||||
<div class="type-tabs" id="typeTabs" role="tablist" aria-label="数据类型">
|
||||
<button type="button" role="tab" data-type="recipes" class="active">配方 <span id="tabRecipes">0</span></button>
|
||||
<button type="button" role="tab" data-type="items">物品 <span id="tabItems">0</span></button>
|
||||
<button type="button" role="tab" data-type="machines">设施 <span id="tabMachines">0</span></button>
|
||||
</div>
|
||||
|
||||
<label class="search-box">
|
||||
<span aria-hidden="true">⌕</span>
|
||||
<input id="catalogSearch" type="search" placeholder="搜索名称、ID 或标签" autocomplete="off">
|
||||
<kbd>/</kbd>
|
||||
</label>
|
||||
|
||||
<div class="catalog-filters">
|
||||
<label>
|
||||
<span>阶段</span>
|
||||
<select id="stageFilter"><option value="all">全部阶段</option></select>
|
||||
</label>
|
||||
<label>
|
||||
<span>生态区</span>
|
||||
<select id="biomeFilter"><option value="all">全部生态区</option></select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="catalog-summary" id="catalogSummary">正在整理数据…</div>
|
||||
<nav class="entity-list" id="entityList" aria-label="实体列表"></nav>
|
||||
</aside>
|
||||
|
||||
<section class="graph-panel panel" aria-label="配方依赖图">
|
||||
<div class="graph-header">
|
||||
<div class="graph-title">
|
||||
<span class="eyebrow">依赖视图</span>
|
||||
<h2 id="graphTitle">配方树</h2>
|
||||
<p id="graphSubtitle">选择左侧条目以查看上下游。</p>
|
||||
</div>
|
||||
<div class="graph-controls">
|
||||
<div class="segmented" id="graphMode" aria-label="图谱范围">
|
||||
<button type="button" data-mode="focus" class="active">聚焦</button>
|
||||
<button type="button" data-mode="full">全局</button>
|
||||
</div>
|
||||
<label class="depth-control" id="depthControl">层级
|
||||
<select id="graphDepth">
|
||||
<option value="1" selected>1</option>
|
||||
<option value="2">2</option>
|
||||
<option value="3">3</option>
|
||||
<option value="4">4</option>
|
||||
<option value="5">5</option>
|
||||
</select>
|
||||
</label>
|
||||
<button class="button ghost compact" id="fitGraphButton" type="button">适应画布</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="graph-stage" id="graphStage">
|
||||
<svg id="recipeGraph" role="img" aria-label="物品与配方依赖关系图">
|
||||
<defs>
|
||||
<marker id="arrowOutput" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto" markerUnits="strokeWidth">
|
||||
<path d="M0,0 L8,4 L0,8 Z" fill="#67d8b2"></path>
|
||||
</marker>
|
||||
<marker id="arrowInput" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto" markerUnits="strokeWidth">
|
||||
<path d="M0,0 L8,4 L0,8 Z" fill="#8795b5"></path>
|
||||
</marker>
|
||||
</defs>
|
||||
<g id="graphViewport">
|
||||
<g id="graphBackdrop"></g>
|
||||
<g id="graphEdges"></g>
|
||||
<g id="graphNodes"></g>
|
||||
</g>
|
||||
</svg>
|
||||
<div class="graph-empty" id="graphEmpty" hidden>
|
||||
<span>◇</span>
|
||||
<strong>没有可显示的节点</strong>
|
||||
<p>调整筛选条件,或选择其他条目。</p>
|
||||
</div>
|
||||
<div class="zoom-tools" aria-label="画布缩放">
|
||||
<button type="button" id="zoomOutButton" aria-label="缩小">−</button>
|
||||
<output id="zoomOutput">100%</output>
|
||||
<button type="button" id="zoomInButton" aria-label="放大">+</button>
|
||||
</div>
|
||||
<div class="graph-legend">
|
||||
<span><i class="legend-item"></i>物品</span>
|
||||
<span><i class="legend-recipe"></i>配方</span>
|
||||
<span><i class="legend-catalyst"></i>催化/状态</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="graph-footer">
|
||||
<div id="routeSummary">—</div>
|
||||
<div class="graph-hint">滚轮缩放 · 拖拽平移 · 单击节点编辑</div>
|
||||
</footer>
|
||||
</section>
|
||||
|
||||
<aside class="inspector panel" aria-label="属性编辑器">
|
||||
<div class="panel-heading inspector-heading">
|
||||
<div>
|
||||
<span class="eyebrow">属性编辑器</span>
|
||||
<h2 id="inspectorTitle">选择一个条目</h2>
|
||||
</div>
|
||||
<span class="entity-kind" id="inspectorKind">—</span>
|
||||
</div>
|
||||
<div id="inspectorContent" class="inspector-content">
|
||||
<div class="empty-inspector">
|
||||
<span>✦</span>
|
||||
<p>从左侧目录或中央配方树选择一个条目。</p>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<dialog id="issuesDialog" class="issues-dialog">
|
||||
<form method="dialog" class="dialog-shell">
|
||||
<header>
|
||||
<div>
|
||||
<span class="eyebrow">实时校验</span>
|
||||
<h2>数据健康检查</h2>
|
||||
<p id="issuesSummary">—</p>
|
||||
</div>
|
||||
<button class="icon-button" value="cancel" aria-label="关闭">×</button>
|
||||
</header>
|
||||
<div class="issue-filter" id="issueFilter">
|
||||
<button type="button" data-severity="all" class="active">全部</button>
|
||||
<button type="button" data-severity="error">错误</button>
|
||||
<button type="button" data-severity="warning">警告</button>
|
||||
<button type="button" data-severity="info">提示</button>
|
||||
</div>
|
||||
<div class="issue-list" id="issueList"></div>
|
||||
<footer>
|
||||
<span>点击问题可定位到对应条目。</span>
|
||||
<button class="button primary" value="cancel">完成</button>
|
||||
</footer>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<dialog id="helpDialog" class="help-dialog">
|
||||
<form method="dialog" class="dialog-shell">
|
||||
<header>
|
||||
<div><span class="eyebrow">字段速查</span><h2>魔法配方怎么填</h2></div>
|
||||
<button class="icon-button" value="cancel" aria-label="关闭">×</button>
|
||||
</header>
|
||||
<div class="help-content">
|
||||
<dl>
|
||||
<dt>消耗</dt><dd>每次制作永久扣除,例如木材、供物。</dd>
|
||||
<dt>催化</dt><dd>参与法则但不扣除,例如生命锚核、边界石。</dd>
|
||||
<dt>流体</dt><dd>按配方批次统计的管网输入。</dd>
|
||||
<dt>状态</dt><dd>物品必须带入某种状态,完成后可由产出表达状态变化。</dd>
|
||||
<dt>权重</dt><dd>用于传送门结果袋;同一配方所有权重应合计 100。</dd>
|
||||
<dt>概率</dt><dd>用于独立概率结果;全部产出都是概率项时应合计 1。</dd>
|
||||
<dt>熵</dt><dd>一次制作给传送门或仪式网络增加的复杂度压力。</dd>
|
||||
</dl>
|
||||
</div>
|
||||
<footer><span>修改会先进入浏览器草稿;“保存数据”才写回项目。</span><button class="button primary" value="cancel">知道了</button></footer>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<input id="importFile" type="file" accept="application/json,.json" hidden>
|
||||
<div class="toast-stack" id="toastStack" aria-live="polite"></div>
|
||||
|
||||
<script type="module" src="./app.mjs?v=20260812-5"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,166 @@
|
||||
import http from "node:http";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { copyFile, mkdir, readFile, rename, stat, unlink, writeFile } from "node:fs/promises";
|
||||
import { validateBook } from "./core.mjs";
|
||||
|
||||
const ROOT = path.resolve(fileURLToPath(new URL("../../", import.meta.url)));
|
||||
const BOOK_PATH = path.join(ROOT, "data", "recipe-book.json");
|
||||
const BACKUP_DIR = path.join(ROOT, "data", "backups");
|
||||
const TECH_PATH = path.join(ROOT, "docs", "tech-tree-nodes-v0.2.csv");
|
||||
const DEFAULT_PORT = 8765;
|
||||
const MAX_BODY_BYTES = 5 * 1024 * 1024;
|
||||
|
||||
const MIME = {
|
||||
".html": "text/html; charset=utf-8",
|
||||
".css": "text/css; charset=utf-8",
|
||||
".js": "text/javascript; charset=utf-8",
|
||||
".mjs": "text/javascript; charset=utf-8",
|
||||
".json": "application/json; charset=utf-8",
|
||||
".csv": "text/csv; charset=utf-8",
|
||||
".svg": "image/svg+xml",
|
||||
".png": "image/png",
|
||||
".ico": "image/x-icon",
|
||||
};
|
||||
|
||||
function json(response, statusCode, payload) {
|
||||
const body = `${JSON.stringify(payload, null, 2)}\n`;
|
||||
response.writeHead(statusCode, {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"Content-Length": Buffer.byteLength(body),
|
||||
"Cache-Control": "no-store",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
});
|
||||
response.end(body);
|
||||
}
|
||||
|
||||
function safeStaticPath(urlPath) {
|
||||
const decoded = decodeURIComponent(urlPath).replace(/^\/+/, "");
|
||||
const relative = decoded || "tools/recipe-tree-editor/index.html";
|
||||
const webPath = relative.replaceAll("\\", "/");
|
||||
const allowed = webPath.startsWith("tools/recipe-tree-editor/")
|
||||
|| webPath === "data/recipe-book.json"
|
||||
|| webPath === "docs/tech-tree-nodes-v0.2.csv";
|
||||
if (!allowed) return null;
|
||||
const target = path.resolve(ROOT, relative);
|
||||
return target === ROOT || target.startsWith(`${ROOT}${path.sep}`) ? target : null;
|
||||
}
|
||||
|
||||
async function readTechnologyIds() {
|
||||
try {
|
||||
const csv = await readFile(TECH_PATH, "utf8");
|
||||
return new Set(csv.split(/\r?\n/).slice(1).map((line) => line.split(",", 1)[0].trim()).filter(Boolean));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function readBody(request) {
|
||||
const chunks = [];
|
||||
let size = 0;
|
||||
for await (const chunk of request) {
|
||||
size += chunk.length;
|
||||
if (size > MAX_BODY_BYTES) throw Object.assign(new Error("数据超过 5 MB 限制。"), { statusCode: 413 });
|
||||
chunks.push(chunk);
|
||||
}
|
||||
return Buffer.concat(chunks).toString("utf8");
|
||||
}
|
||||
|
||||
async function saveBook(request, response) {
|
||||
if (!String(request.headers["content-type"] || "").toLowerCase().includes("application/json")) {
|
||||
return json(response, 415, { ok: false, message: "保存接口只接受 JSON。" });
|
||||
}
|
||||
let book;
|
||||
try {
|
||||
book = JSON.parse(await readBody(request));
|
||||
} catch (error) {
|
||||
return json(response, error.statusCode || 400, { ok: false, message: error.message || "JSON 无法解析。" });
|
||||
}
|
||||
|
||||
const technologyIds = await readTechnologyIds();
|
||||
const issues = validateBook(book, { technologyIds });
|
||||
const errors = issues.filter((entry) => entry.severity === "error");
|
||||
if (errors.length) return json(response, 422, { ok: false, message: `存在 ${errors.length} 个校验错误,未写入文件。`, errors });
|
||||
|
||||
const timestamp = new Date().toISOString().replace(/[.:]/g, "-").replace("T", "_");
|
||||
const backupName = `recipe-book_${timestamp}.json`;
|
||||
const backupPath = path.join(BACKUP_DIR, backupName);
|
||||
const temporaryPath = `${BOOK_PATH}.${process.pid}.tmp`;
|
||||
try {
|
||||
await mkdir(BACKUP_DIR, { recursive: true });
|
||||
await copyFile(BOOK_PATH, backupPath);
|
||||
await writeFile(temporaryPath, `${JSON.stringify(book, null, 2)}\n`, "utf8");
|
||||
await rename(temporaryPath, BOOK_PATH);
|
||||
} catch (error) {
|
||||
await unlink(temporaryPath).catch(() => {});
|
||||
return json(response, 500, { ok: false, message: `写入失败:${error.message}` });
|
||||
}
|
||||
return json(response, 200, {
|
||||
ok: true,
|
||||
savedAt: new Date().toISOString(),
|
||||
backup: `data/backups/${backupName}`,
|
||||
warnings: issues.filter((entry) => entry.severity === "warning").length,
|
||||
});
|
||||
}
|
||||
|
||||
async function serveStatic(request, response, pathname) {
|
||||
const filePath = safeStaticPath(pathname);
|
||||
if (!filePath) return json(response, 403, { ok: false, message: "路径不在项目内。" });
|
||||
try {
|
||||
const fileStat = await stat(filePath);
|
||||
if (!fileStat.isFile()) throw new Error("not a file");
|
||||
const content = await readFile(filePath);
|
||||
response.writeHead(200, {
|
||||
"Content-Type": MIME[path.extname(filePath).toLowerCase()] || "application/octet-stream",
|
||||
"Content-Length": content.length,
|
||||
"Cache-Control": "no-store",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"Content-Security-Policy": "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; img-src 'self' data:; connect-src 'self'",
|
||||
});
|
||||
if (request.method === "HEAD") response.end();
|
||||
else response.end(content);
|
||||
} catch {
|
||||
json(response, 404, { ok: false, message: "文件不存在。" });
|
||||
}
|
||||
}
|
||||
|
||||
const server = http.createServer(async (request, response) => {
|
||||
try {
|
||||
const url = new URL(request.url || "/", "http://127.0.0.1");
|
||||
if (request.method === "GET" && url.pathname === "/api/status") {
|
||||
return json(response, 200, { ok: true, service: "magic-foundry-recipe-editor", root: ROOT });
|
||||
}
|
||||
if (request.method === "GET" && url.pathname === "/api/book") {
|
||||
const book = JSON.parse(await readFile(BOOK_PATH, "utf8"));
|
||||
return json(response, 200, book);
|
||||
}
|
||||
if (request.method === "POST" && url.pathname === "/api/book") return await saveBook(request, response);
|
||||
if (request.method === "GET" && url.pathname === "/") {
|
||||
response.writeHead(302, { Location: "/tools/recipe-tree-editor/" });
|
||||
return response.end();
|
||||
}
|
||||
if ((request.method === "GET" || request.method === "HEAD") && url.pathname === "/tools/recipe-tree-editor/") {
|
||||
return await serveStatic(request, response, "/tools/recipe-tree-editor/index.html");
|
||||
}
|
||||
if (request.method === "GET" || request.method === "HEAD") return await serveStatic(request, response, url.pathname);
|
||||
return json(response, 405, { ok: false, message: "不支持的请求方式。" });
|
||||
} catch (error) {
|
||||
return json(response, 500, { ok: false, message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
const portIndex = process.argv.indexOf("--port");
|
||||
const requestedPort = portIndex >= 0 ? Number(process.argv[portIndex + 1]) : DEFAULT_PORT;
|
||||
const port = Number.isInteger(requestedPort) && requestedPort > 0 && requestedPort < 65536 ? requestedPort : DEFAULT_PORT;
|
||||
|
||||
server.on("error", (error) => {
|
||||
if (error.code === "EADDRINUSE") {
|
||||
console.error(`端口 ${port} 已被占用;如果工作台已经打开,可以直接继续使用。`);
|
||||
} else console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
|
||||
server.listen(port, "127.0.0.1", () => {
|
||||
console.log(`魔力工坊配方树工作台:http://127.0.0.1:${port}/tools/recipe-tree-editor/`);
|
||||
console.log("服务只监听本机;关闭对应 node 进程即可停止。 ");
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
@echo off
|
||||
setlocal
|
||||
where node.exe >nul 2>nul
|
||||
if errorlevel 1 (
|
||||
echo [配方树工作台] 未找到 Node.js。请安装 Node.js 20 或更高版本后重试。
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0start-editor.ps1"
|
||||
|
||||
if errorlevel 1 (
|
||||
echo [配方树工作台] 启动失败。请在当前目录运行 node server.mjs 查看原因。
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
endlocal
|
||||
@@ -0,0 +1,34 @@
|
||||
param([switch]$NoOpen)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$url = "http://127.0.0.1:8765/tools/recipe-tree-editor/"
|
||||
$statusUrl = "http://127.0.0.1:8765/api/status"
|
||||
|
||||
try {
|
||||
Invoke-RestMethod -Uri $statusUrl -TimeoutSec 1 | Out-Null
|
||||
}
|
||||
catch {
|
||||
$serverPath = Join-Path $PSScriptRoot "server.mjs"
|
||||
$quotedServerPath = '"' + $serverPath + '"'
|
||||
Start-Process -FilePath "node.exe" -ArgumentList @($quotedServerPath, "--port", "8765") -WindowStyle Hidden
|
||||
|
||||
$ready = $false
|
||||
foreach ($attempt in 1..25) {
|
||||
Start-Sleep -Milliseconds 100
|
||||
try {
|
||||
Invoke-RestMethod -Uri $statusUrl -TimeoutSec 1 | Out-Null
|
||||
$ready = $true
|
||||
break
|
||||
}
|
||||
catch {
|
||||
# Retry while the local helper starts.
|
||||
}
|
||||
}
|
||||
if (-not $ready) {
|
||||
throw "Recipe editor service did not start on port 8765."
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $NoOpen) {
|
||||
Start-Process $url
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
:root {
|
||||
--bg: #10131b;
|
||||
--panel: #171b25;
|
||||
--panel-raised: #1d2230;
|
||||
--panel-soft: #141822;
|
||||
--line: #2a3040;
|
||||
--line-strong: #3a4256;
|
||||
--text: #e9edf5;
|
||||
--text-soft: #a6b0c5;
|
||||
--text-dim: #707b92;
|
||||
--mint: #67d8b2;
|
||||
--mint-deep: #1c876d;
|
||||
--violet: #9d8cff;
|
||||
--gold: #e7bf68;
|
||||
--red: #ef7180;
|
||||
--orange: #e59a55;
|
||||
--blue: #70a9ff;
|
||||
--shadow: 0 18px 50px rgb(0 0 0 / 24%);
|
||||
--radius: 14px;
|
||||
--font: "Segoe UI", "Microsoft YaHei UI", "Microsoft YaHei", sans-serif;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body { height: 100%; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
background:
|
||||
radial-gradient(circle at 54% 0%, rgb(90 69 144 / 13%), transparent 36%),
|
||||
var(--bg);
|
||||
color: var(--text);
|
||||
font-family: var(--font);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
button, input, select, textarea { font: inherit; }
|
||||
button { color: inherit; }
|
||||
|
||||
button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible {
|
||||
outline: 2px solid var(--mint);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.app { height: 100%; display: grid; grid-template-rows: 68px minmax(0, 1fr); }
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
padding: 0 18px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: rgb(18 22 31 / 94%);
|
||||
backdrop-filter: blur(18px);
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.brand { display: flex; align-items: center; gap: 11px; min-width: 260px; }
|
||||
.brand-mark {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px solid rgb(103 216 178 / 45%);
|
||||
border-radius: 11px;
|
||||
color: var(--mint);
|
||||
background: linear-gradient(145deg, rgb(103 216 178 / 16%), rgb(157 140 255 / 8%));
|
||||
box-shadow: inset 0 0 22px rgb(103 216 178 / 8%);
|
||||
font-size: 20px;
|
||||
}
|
||||
.brand div { display: grid; gap: 2px; }
|
||||
.brand strong { font-size: 15px; letter-spacing: .04em; }
|
||||
.brand span:last-child { color: var(--text-dim); font-size: 11px; }
|
||||
|
||||
.top-stats { display: flex; align-items: center; gap: 16px; color: var(--text-dim); white-space: nowrap; }
|
||||
.top-stats span { padding-left: 15px; border-left: 1px solid var(--line); font-size: 12px; }
|
||||
.top-stats b { color: var(--text); font-size: 14px; margin-right: 3px; }
|
||||
|
||||
.toolbar { margin-left: auto; display: flex; gap: 7px; align-items: center; }
|
||||
.button, .icon-button {
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: 9px;
|
||||
background: var(--panel-raised);
|
||||
cursor: pointer;
|
||||
transition: border-color .15s ease, background .15s ease, transform .15s ease, opacity .15s ease;
|
||||
}
|
||||
.button { min-height: 34px; padding: 0 12px; font-weight: 600; font-size: 12px; }
|
||||
.button:hover:not(:disabled), .icon-button:hover:not(:disabled) { border-color: #566077; background: #242a3a; }
|
||||
.button:active:not(:disabled), .icon-button:active:not(:disabled) { transform: translateY(1px); }
|
||||
.button:disabled { opacity: .35; cursor: default; }
|
||||
.button.ghost { background: transparent; color: var(--text-soft); }
|
||||
.button.primary { border-color: #52c7a1; background: var(--mint); color: #10221d; box-shadow: 0 7px 22px rgb(103 216 178 / 14%); }
|
||||
.button.primary:hover:not(:disabled) { background: #79e5c0; border-color: #79e5c0; }
|
||||
.button.compact { min-height: 30px; padding: 0 10px; }
|
||||
.issue-button.has-errors { border-color: rgb(239 113 128 / 55%); color: #ff9aa6; }
|
||||
.issue-button.has-warnings:not(.has-errors) { border-color: rgb(229 154 85 / 55%); color: #efb078; }
|
||||
.icon-button { width: 34px; height: 34px; font-size: 20px; display: grid; place-items: center; }
|
||||
|
||||
.save-state { display: inline-flex; align-items: center; gap: 7px; color: var(--text-dim); margin-right: 3px; font-size: 12px; white-space: nowrap; }
|
||||
.save-state i { width: 7px; height: 7px; border-radius: 50%; background: var(--text-dim); }
|
||||
.save-state.saved i { background: var(--mint); box-shadow: 0 0 10px rgb(103 216 178 / 65%); }
|
||||
.save-state.dirty { color: var(--gold); }
|
||||
.save-state.dirty i { background: var(--gold); }
|
||||
.save-state.offline { color: var(--orange); }
|
||||
.save-state.offline i { background: var(--orange); }
|
||||
.save-state.loading i { animation: pulse 1s infinite alternate; }
|
||||
@keyframes pulse { to { opacity: .25; } }
|
||||
|
||||
.workspace {
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 292px minmax(500px, 1fr) 390px;
|
||||
gap: 1px;
|
||||
background: var(--line);
|
||||
}
|
||||
|
||||
.panel { min-width: 0; min-height: 0; background: var(--panel); }
|
||||
.catalog, .inspector { display: flex; flex-direction: column; }
|
||||
.panel-heading { min-height: 76px; padding: 17px 16px 13px; display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.panel-heading h1, .panel-heading h2, .graph-title h2 { margin: 3px 0 0; font-size: 17px; line-height: 1.2; }
|
||||
.eyebrow { display: block; color: var(--text-dim); text-transform: uppercase; font-size: 10px; letter-spacing: .16em; font-weight: 700; }
|
||||
|
||||
.type-tabs { margin: 0 13px 12px; padding: 3px; display: grid; grid-template-columns: repeat(3, 1fr); border-radius: 10px; background: var(--panel-soft); border: 1px solid var(--line); }
|
||||
.type-tabs button, .segmented button, .issue-filter button {
|
||||
border: 0;
|
||||
border-radius: 7px;
|
||||
color: var(--text-dim);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
.type-tabs button { padding: 8px 4px; font-size: 12px; font-weight: 600; }
|
||||
.type-tabs button span { margin-left: 3px; font-size: 10px; opacity: .7; }
|
||||
.type-tabs button.active, .segmented button.active, .issue-filter button.active { background: #292f3f; color: var(--text); box-shadow: 0 2px 8px rgb(0 0 0 / 18%); }
|
||||
|
||||
.search-box { margin: 0 13px 10px; height: 38px; display: grid; grid-template-columns: 24px 1fr auto; align-items: center; padding: 0 10px; border-radius: 9px; border: 1px solid var(--line); background: var(--panel-soft); color: var(--text-dim); }
|
||||
.search-box:focus-within { border-color: var(--mint-deep); }
|
||||
.search-box input { min-width: 0; border: 0; outline: 0; color: var(--text); background: transparent; }
|
||||
.search-box input::placeholder { color: #5f687c; }
|
||||
.search-box kbd { border: 1px solid var(--line); border-radius: 4px; padding: 1px 5px; color: var(--text-dim); background: var(--panel-raised); font-size: 10px; }
|
||||
|
||||
.catalog-filters { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; padding: 0 13px 10px; }
|
||||
.catalog-filters label { display: grid; gap: 4px; color: var(--text-dim); font-size: 10px; }
|
||||
select, input[type="text"], input[type="number"], textarea {
|
||||
width: 100%;
|
||||
color: var(--text);
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: 8px;
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
select, input[type="text"], input[type="number"] { height: 34px; padding: 0 9px; }
|
||||
textarea { min-height: 76px; padding: 9px; line-height: 1.5; resize: vertical; }
|
||||
select { padding-right: 24px; }
|
||||
.catalog-filters select { height: 32px; border-color: var(--line); font-size: 11px; }
|
||||
|
||||
.catalog-summary { padding: 1px 15px 9px; color: var(--text-dim); font-size: 11px; }
|
||||
.entity-list { min-height: 0; overflow: auto; padding: 0 8px 18px; scrollbar-color: #3b4355 transparent; }
|
||||
.stage-group-label { position: sticky; top: 0; z-index: 2; padding: 11px 8px 7px; color: var(--text-dim); background: linear-gradient(var(--panel) 72%, transparent); font-size: 10px; font-weight: 700; letter-spacing: .08em; }
|
||||
.entity-row { width: 100%; border: 1px solid transparent; border-radius: 9px; padding: 9px 9px 8px; display: grid; grid-template-columns: 27px 1fr auto; gap: 8px; align-items: center; text-align: left; background: transparent; cursor: pointer; }
|
||||
.entity-row:hover { background: #1d2230; }
|
||||
.entity-row.active { border-color: rgb(103 216 178 / 32%); background: linear-gradient(90deg, rgb(103 216 178 / 11%), rgb(103 216 178 / 3%)); }
|
||||
.entity-icon { width: 27px; height: 27px; display: grid; place-items: center; border-radius: 7px; color: var(--mint); background: rgb(103 216 178 / 9%); font-size: 12px; }
|
||||
.entity-row[data-type="recipes"] .entity-icon { color: var(--violet); background: rgb(157 140 255 / 10%); }
|
||||
.entity-row[data-type="machines"] .entity-icon { color: var(--gold); background: rgb(231 191 104 / 10%); }
|
||||
.entity-copy { min-width: 0; display: grid; gap: 2px; }
|
||||
.entity-copy strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; }
|
||||
.entity-copy small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-dim); font-size: 10px; font-family: Consolas, monospace; }
|
||||
.entity-meta { max-width: 65px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-dim); font-size: 9px; }
|
||||
.list-empty { padding: 36px 15px; text-align: center; color: var(--text-dim); line-height: 1.6; }
|
||||
|
||||
.graph-panel { display: grid; grid-template-rows: auto minmax(0, 1fr) 42px; background: #121620; }
|
||||
.graph-header { min-height: 76px; padding: 15px 18px 12px; display: flex; align-items: center; justify-content: space-between; gap: 16px; border-bottom: 1px solid var(--line); background: var(--panel); }
|
||||
.graph-title { min-width: 0; }
|
||||
.graph-title h2 { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.graph-title p { margin: 4px 0 0; color: var(--text-dim); font-size: 11px; }
|
||||
.graph-controls { display: flex; align-items: center; gap: 8px; }
|
||||
.segmented { display: flex; padding: 3px; border: 1px solid var(--line); border-radius: 9px; background: var(--panel-soft); }
|
||||
.segmented button { min-width: 48px; padding: 6px 8px; font-size: 11px; }
|
||||
.depth-control { display: flex; align-items: center; gap: 6px; color: var(--text-dim); font-size: 11px; }
|
||||
.depth-control select { width: 50px; height: 30px; border-color: var(--line); }
|
||||
.depth-control.is-disabled { opacity: .35; pointer-events: none; }
|
||||
|
||||
.graph-stage {
|
||||
position: relative;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
background-color: #11151e;
|
||||
background-image:
|
||||
linear-gradient(rgb(255 255 255 / 2.3%) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgb(255 255 255 / 2.3%) 1px, transparent 1px),
|
||||
radial-gradient(circle at 50% 45%, rgb(103 216 178 / 4%), transparent 42%);
|
||||
background-size: 28px 28px, 28px 28px, 100% 100%;
|
||||
}
|
||||
#recipeGraph { width: 100%; height: 100%; display: block; cursor: grab; user-select: none; }
|
||||
#recipeGraph.dragging { cursor: grabbing; }
|
||||
.stage-band rect { fill: rgb(255 255 255 / 1.6%); stroke: rgb(255 255 255 / 4%); }
|
||||
.stage-band text { fill: #606b81; font-size: 11px; font-weight: 700; letter-spacing: .08em; }
|
||||
.graph-edge { fill: none; stroke: #68748e; stroke-width: 1.4; opacity: .7; }
|
||||
.graph-edge.output { stroke: #50cda7; marker-end: url(#arrowOutput); }
|
||||
.graph-edge.catalyst, .graph-edge.state { stroke: var(--gold); stroke-dasharray: 5 4; }
|
||||
.graph-edge.fluid { stroke: var(--blue); }
|
||||
.graph-edge.machine { stroke: var(--gold); stroke-dasharray: 3 4; }
|
||||
.graph-edge.consumed { marker-end: url(#arrowInput); }
|
||||
.edge-label { fill: #77839a; font-size: 9px; paint-order: stroke; stroke: #11151e; stroke-width: 4px; }
|
||||
.graph-node { cursor: pointer; }
|
||||
.graph-node rect { stroke-width: 1; filter: drop-shadow(0 6px 8px rgb(0 0 0 / 18%)); }
|
||||
.graph-node.item rect { fill: #1a2530; stroke: #355366; }
|
||||
.graph-node.recipe rect { fill: #242035; stroke: #594f81; }
|
||||
.graph-node.machine rect { fill: #2b261c; stroke: #665535; }
|
||||
.graph-node.selected rect { stroke: var(--mint); stroke-width: 2.2; filter: drop-shadow(0 0 8px rgb(103 216 178 / 30%)); }
|
||||
.graph-node text { pointer-events: none; }
|
||||
.node-label { fill: #edf1f8; font-size: 12px; font-weight: 650; }
|
||||
.node-subtitle { fill: #8490a7; font-size: 9px; }
|
||||
.node-glyph { fill: var(--mint); font-size: 11px; }
|
||||
.graph-node.recipe .node-glyph { fill: var(--violet); }
|
||||
.graph-node.machine .node-glyph { fill: var(--gold); }
|
||||
.graph-empty { position: absolute; inset: 0; display: grid; place-content: center; text-align: center; color: var(--text-dim); pointer-events: none; }
|
||||
.graph-empty[hidden] { display: none; }
|
||||
.graph-empty span { color: var(--mint); font-size: 28px; }
|
||||
.graph-empty strong { margin-top: 7px; color: var(--text-soft); }
|
||||
.graph-empty p { margin: 5px 0 0; font-size: 11px; }
|
||||
.zoom-tools { position: absolute; right: 14px; bottom: 14px; display: flex; align-items: center; border: 1px solid var(--line); border-radius: 9px; overflow: hidden; background: rgb(23 27 37 / 92%); box-shadow: var(--shadow); }
|
||||
.zoom-tools button { width: 32px; height: 30px; border: 0; background: transparent; cursor: pointer; font-size: 17px; }
|
||||
.zoom-tools button:hover { background: #272d3c; }
|
||||
.zoom-tools output { min-width: 48px; padding: 0 5px; color: var(--text-dim); text-align: center; font-size: 10px; }
|
||||
.graph-legend { position: absolute; left: 14px; bottom: 14px; display: flex; gap: 12px; padding: 7px 9px; border: 1px solid var(--line); border-radius: 8px; color: var(--text-dim); background: rgb(23 27 37 / 90%); font-size: 9px; }
|
||||
.graph-legend span { display: flex; align-items: center; gap: 5px; }
|
||||
.graph-legend i { width: 13px; height: 7px; border: 1px solid #355366; border-radius: 2px; background: #1a2530; }
|
||||
.graph-legend .legend-recipe { border-color: #594f81; background: #242035; }
|
||||
.graph-legend .legend-catalyst { height: 0; border: 0; border-top: 1px dashed var(--gold); background: none; }
|
||||
.graph-footer { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 0 16px; border-top: 1px solid var(--line); color: var(--text-dim); background: var(--panel); font-size: 10px; }
|
||||
#routeSummary { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.graph-hint { white-space: nowrap; }
|
||||
|
||||
.inspector-heading { border-bottom: 1px solid var(--line); }
|
||||
.inspector-heading h2 { max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.entity-kind { flex: none; padding: 5px 7px; border: 1px solid var(--line); border-radius: 6px; color: var(--text-dim); font-size: 9px; text-transform: uppercase; letter-spacing: .08em; }
|
||||
.inspector-content { min-height: 0; overflow: auto; padding: 14px 14px 80px; scrollbar-color: #3b4355 transparent; }
|
||||
.empty-inspector { display: grid; place-items: center; gap: 9px; padding: 80px 24px; text-align: center; color: var(--text-dim); line-height: 1.55; }
|
||||
.empty-inspector span { color: var(--violet); font-size: 25px; }
|
||||
.inspector-section { margin-bottom: 12px; padding: 13px; border: 1px solid var(--line); border-radius: 11px; background: var(--panel-soft); }
|
||||
.section-title { margin: 0 0 11px; display: flex; align-items: center; justify-content: space-between; color: var(--text-soft); font-size: 11px; text-transform: uppercase; letter-spacing: .07em; }
|
||||
.section-title button { border: 0; color: var(--mint); background: transparent; cursor: pointer; font-size: 11px; }
|
||||
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
||||
.form-field { min-width: 0; display: grid; align-content: start; gap: 5px; }
|
||||
.form-field.full { grid-column: 1 / -1; }
|
||||
.form-field > span { color: var(--text-dim); font-size: 10px; }
|
||||
.field-help { color: #657088; font-size: 9px; line-height: 1.4; }
|
||||
.id-field { font-family: Consolas, monospace; font-size: 11px; }
|
||||
.checkbox-field { display: flex; align-items: center; gap: 7px; min-height: 34px; padding-top: 15px; color: var(--text-soft); font-size: 11px; }
|
||||
.checkbox-field input { accent-color: var(--mint); }
|
||||
.io-row { margin-bottom: 8px; padding: 9px; border: 1px solid var(--line); border-radius: 9px; background: #181d28; }
|
||||
.io-row:last-child { margin-bottom: 0; }
|
||||
.io-main { display: grid; grid-template-columns: minmax(0, 1fr) 74px 82px 28px; gap: 6px; align-items: center; }
|
||||
.io-row.output .io-main { grid-template-columns: minmax(0, 1fr) 74px 28px; }
|
||||
.io-extra { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; margin-top: 7px; }
|
||||
.io-extra label { display: grid; gap: 3px; color: var(--text-dim); font-size: 8px; }
|
||||
.io-row select, .io-row input { height: 30px; font-size: 10px; }
|
||||
.row-delete { width: 28px; height: 28px; border: 0; border-radius: 6px; color: var(--red); background: rgb(239 113 128 / 8%); cursor: pointer; }
|
||||
.row-delete:hover { background: rgb(239 113 128 / 16%); }
|
||||
.inline-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 7px; }
|
||||
.inline-stats div { padding: 9px; border-radius: 8px; background: #181d28; }
|
||||
.inline-stats strong, .inline-stats span { display: block; }
|
||||
.inline-stats strong { color: var(--text); font-size: 13px; }
|
||||
.inline-stats span { margin-top: 3px; color: var(--text-dim); font-size: 8px; }
|
||||
.dependency-links { display: flex; flex-wrap: wrap; gap: 5px; }
|
||||
.dependency-links button { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 5px 7px; border: 1px solid var(--line); border-radius: 6px; color: var(--text-soft); background: #1b202c; cursor: pointer; font-size: 9px; }
|
||||
.dependency-links button:hover { border-color: var(--mint-deep); color: var(--mint); }
|
||||
.inspector-actions { display: flex; gap: 7px; }
|
||||
.inspector-actions .danger { margin-left: auto; border-color: rgb(239 113 128 / 35%); color: #f48c99; }
|
||||
.help-link { width: 100%; margin-top: 2px; color: var(--text-dim); border: 0; background: transparent; cursor: pointer; font-size: 10px; }
|
||||
.help-link:hover { color: var(--mint); }
|
||||
|
||||
dialog { padding: 0; color: var(--text); border: 1px solid var(--line-strong); border-radius: 15px; background: var(--panel); box-shadow: 0 30px 100px rgb(0 0 0 / 55%); }
|
||||
dialog::backdrop { background: rgb(6 8 12 / 72%); backdrop-filter: blur(4px); }
|
||||
.issues-dialog { width: min(720px, calc(100vw - 32px)); max-height: min(760px, calc(100vh - 32px)); }
|
||||
.help-dialog { width: min(620px, calc(100vw - 32px)); }
|
||||
.dialog-shell { display: grid; grid-template-rows: auto auto minmax(0, 1fr) auto; max-height: inherit; }
|
||||
.dialog-shell > header { padding: 19px 20px 15px; display: flex; justify-content: space-between; gap: 20px; border-bottom: 1px solid var(--line); }
|
||||
.dialog-shell > header h2 { margin: 3px 0 0; font-size: 18px; }
|
||||
.dialog-shell > header p { margin: 6px 0 0; color: var(--text-dim); font-size: 11px; }
|
||||
.dialog-shell > footer { min-height: 58px; padding: 10px 18px; display: flex; align-items: center; justify-content: space-between; gap: 15px; border-top: 1px solid var(--line); color: var(--text-dim); font-size: 10px; }
|
||||
.issue-filter { padding: 10px 18px; display: flex; gap: 7px; border-bottom: 1px solid var(--line); }
|
||||
.issue-filter button { padding: 6px 11px; font-size: 10px; }
|
||||
.issue-list { min-height: 170px; overflow: auto; padding: 9px 11px 15px; }
|
||||
.issue-row { width: 100%; margin-bottom: 5px; padding: 10px; display: grid; grid-template-columns: 9px 1fr auto; gap: 10px; align-items: start; text-align: left; border: 1px solid transparent; border-radius: 9px; color: var(--text-soft); background: transparent; cursor: pointer; }
|
||||
.issue-row:hover { border-color: var(--line); background: var(--panel-raised); }
|
||||
.issue-dot { width: 7px; height: 7px; margin-top: 4px; border-radius: 50%; background: var(--blue); }
|
||||
.issue-row.error .issue-dot { background: var(--red); }
|
||||
.issue-row.warning .issue-dot { background: var(--orange); }
|
||||
.issue-copy { display: grid; gap: 3px; }
|
||||
.issue-copy strong { color: var(--text); font-size: 11px; font-weight: 600; }
|
||||
.issue-copy small, .issue-code { color: var(--text-dim); font-size: 9px; font-family: Consolas, monospace; }
|
||||
.no-issues { padding: 45px 20px; text-align: center; color: var(--mint); }
|
||||
.help-content { padding: 17px 22px 23px; overflow: auto; }
|
||||
.help-content dl { display: grid; grid-template-columns: 70px 1fr; gap: 12px 16px; margin: 0; }
|
||||
.help-content dt { color: var(--mint); font-weight: 700; }
|
||||
.help-content dd { margin: 0; color: var(--text-soft); line-height: 1.55; }
|
||||
|
||||
.toast-stack { position: fixed; right: 18px; bottom: 18px; z-index: 100; display: grid; gap: 8px; pointer-events: none; }
|
||||
.toast { width: min(360px, calc(100vw - 36px)); padding: 11px 13px; border: 1px solid var(--line-strong); border-left: 3px solid var(--mint); border-radius: 9px; color: var(--text-soft); background: rgb(29 34 48 / 96%); box-shadow: var(--shadow); animation: toast-in .18s ease-out; }
|
||||
.toast.error { border-left-color: var(--red); }
|
||||
.toast.warning { border-left-color: var(--orange); }
|
||||
@keyframes toast-in { from { opacity: 0; transform: translateY(6px); } }
|
||||
|
||||
@media (max-width: 1220px) {
|
||||
.workspace { grid-template-columns: 255px minmax(430px, 1fr) 340px; }
|
||||
.top-stats { display: none; }
|
||||
.brand { min-width: 220px; }
|
||||
.toolbar .button.ghost:nth-of-type(3), .toolbar .button.ghost:nth-of-type(4) { display: none; }
|
||||
}
|
||||
|
||||
@media (max-width: 930px) {
|
||||
body { overflow: auto; }
|
||||
.app { min-height: 100%; height: auto; grid-template-rows: auto auto; }
|
||||
.topbar { min-height: 66px; flex-wrap: wrap; padding-block: 10px; }
|
||||
.brand { flex: 1; }
|
||||
.save-state { display: none; }
|
||||
.workspace { min-height: calc(100vh - 66px); grid-template-columns: 245px minmax(0, 1fr); grid-template-rows: minmax(520px, 64vh) minmax(520px, 64vh); }
|
||||
.catalog { grid-row: 1 / span 2; }
|
||||
.inspector { grid-column: 2; min-height: 520px; border-top: 1px solid var(--line); }
|
||||
.graph-controls { flex-wrap: wrap; justify-content: flex-end; }
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.topbar { position: sticky; top: 0; }
|
||||
.brand span:last-child { display: none; }
|
||||
.brand { min-width: 0; }
|
||||
.toolbar .button.ghost { display: none; }
|
||||
.workspace { display: block; }
|
||||
.catalog, .graph-panel, .inspector { min-height: 620px; overflow: hidden; }
|
||||
.catalog { height: 70vh; }
|
||||
.graph-panel { height: 78vh; }
|
||||
.inspector { height: 78vh; }
|
||||
.graph-header { align-items: flex-start; }
|
||||
.graph-title p, .graph-hint { display: none; }
|
||||
.graph-controls .compact { display: none; }
|
||||
.inspector-heading h2 { max-width: 220px; }
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { buildGraph, cloneBook, makeIndex, traceIngredientClosure, validateBook } from "./core.mjs";
|
||||
|
||||
const book = JSON.parse(await readFile(new URL("../../data/recipe-book.json", import.meta.url), "utf8"));
|
||||
const schema = JSON.parse(await readFile(new URL("../../data/recipe-book.schema.json", import.meta.url), "utf8"));
|
||||
const techCsv = await readFile(new URL("../../docs/tech-tree-nodes-v0.2.csv", import.meta.url), "utf8");
|
||||
const technologyIds = new Set(techCsv.split(/\r?\n/).slice(1).map((line) => line.split(",", 1)[0].trim()).filter(Boolean));
|
||||
|
||||
assert.equal(book.schemaVersion, 1, "配方书版本必须为 1");
|
||||
assert.equal(schema.properties.schemaVersion.const, 1, "Schema 与配方书版本必须一致");
|
||||
for (const required of schema.required) assert.ok(Object.hasOwn(book, required), `缺少顶层字段 ${required}`);
|
||||
|
||||
const issues = validateBook(book, { technologyIds });
|
||||
const errors = issues.filter((entry) => entry.severity === "error");
|
||||
const warnings = issues.filter((entry) => entry.severity === "warning");
|
||||
assert.deepEqual(errors, [], `存在校验错误:${errors.map((entry) => entry.message).join(";")}`);
|
||||
assert.deepEqual(warnings, [], `存在非预期警告:${warnings.map((entry) => entry.message).join(";")}`);
|
||||
|
||||
const index = makeIndex(book);
|
||||
assert.ok(index.recipes.has("perform_first_warp"), "缺少首次折跃配方");
|
||||
assert.ok(index.items.has("first_warp_record"), "缺少首次折跃记录");
|
||||
assert.ok(index.recipes.has("gather_guiding_dew"), "生机循环缺少显式启动配方");
|
||||
|
||||
const firstWarp = traceIngredientClosure(book, "first_warp_record");
|
||||
assert.deepEqual(firstWarp.missing, [], "首次折跃上游必须全部可追溯");
|
||||
for (const requiredRecipe of ["perform_first_warp", "assemble_walker_construct", "harvest_life_proof", "recall_death_proof", "prove_element_cycle", "separate_dimension_proof"]) {
|
||||
assert.ok(firstWarp.recipes.includes(requiredRecipe), `首次折跃追溯缺少 ${requiredRecipe}`);
|
||||
}
|
||||
|
||||
for (const recipe of book.recipes.filter((entry) => entry.kind === "result_bag")) {
|
||||
const weighted = recipe.outputs.filter((entry) => entry.weight != null);
|
||||
if (weighted.length) assert.equal(weighted.reduce((sum, entry) => sum + entry.weight, 0), 100, `${recipe.id} 权重不等于 100`);
|
||||
const chance = recipe.outputs.filter((entry) => entry.chance != null);
|
||||
if (chance.length === recipe.outputs.length) assert.ok(Math.abs(chance.reduce((sum, entry) => sum + entry.chance, 0) - 1) < 1e-9, `${recipe.id} 概率不等于 1`);
|
||||
}
|
||||
|
||||
const focused = buildGraph(book, { mode: "focus", depth: 2, selection: { type: "recipes", id: "perform_first_warp" } });
|
||||
assert.ok(focused.nodes.some((node) => node.key === "recipes:perform_first_warp"), "聚焦图缺少选中配方");
|
||||
assert.ok(focused.edges.length >= 10, "首次折跃聚焦图边数异常");
|
||||
|
||||
const broken = cloneBook(book);
|
||||
broken.recipes[0].inputs.push({ itemId: "missing_test_item", amount: 1, mode: "consumed" });
|
||||
assert.ok(validateBook(broken, { technologyIds }).some((entry) => entry.code === "missing_item" && entry.severity === "error"), "缺失引用必须被识别为错误");
|
||||
|
||||
console.log(`配方树测试通过:${book.items.length} 物品 / ${book.recipes.length} 配方 / ${book.machines.length} 设施 / ${firstWarp.recipes.length} 道首次折跃上游配方。`);
|
||||
Reference in New Issue
Block a user