Skip to content

Instantly share code, notes, and snippets.

@CyberShadow
Created September 26, 2022 10:09
Show Gist options
  • Save CyberShadow/a77e882874661f56f0a4f4c0f02fd37e to your computer and use it in GitHub Desktop.
Save CyberShadow/a77e882874661f56f0a4f4c0f02fd37e to your computer and use it in GitHub Desktop.
Satisfactory planner
<!doctype html>
<html lang="en">
<head>
<script crossorigin src="https://visjs.github.io/vis-network/standalone/umd/vis-network.min.js"></script>
<style type="text/css">
* {
box-sizing: border-box;
}
#mynetwork, #mynetwork canvas {
width: 100%;
height: 100%;
/* border: 1px solid lightgray; */
}
body {
margin: 0;
padding: 0;
min-height: 100vh;
display: grid;
grid-template: auto 1fr / 1fr;
}
</style>
</head>
<body>
<div id="toolbar">
</div>
<div style="position: relative; width: 100%; height: 100%;">
<div style="position: absolute; top: 0px; right: 0px; bottom: 0px; left: 0px;" id="mynetwork" />
</div>
<script type="text/javascript" src="script.js"></script>
</body>
</html>
(function() {
const conversions = [
// Specials
{
name : "Conveyor - Splitter",
time : 0,
power : 0, // MW
},
{
name : "Conveyor - Merger",
time : 0,
power : 0, // MW
},
{
name : "Storage",
time : 0,
power : 0, // MW
},
// Miner
{
name : "Miner - Iron",
inputs : [],
outputs: [{resource: 'Iron Ore', quantity: 1}],
time : 0.5, // 120/min.
power : 5, // MW
},
{
name : "Miner - Copper",
inputs : [],
outputs: [{resource: 'Copper Ore', quantity: 1}],
time : 0.5, // 120/min.
power : 5, // MW
},
{
name : "Miner - Limestone",
inputs : [],
outputs: [{resource: 'Limestone', quantity: 1}],
time : 0.5, // 120/min.
power : 5, // MW
},
// Smelter
{
name : "Smelter - Iron",
inputs : [{resource: 'Iron Ore', quantity: 1}],
outputs: [{resource: 'Iron Ingot', quantity: 1}],
time : 2, // 30/min.
power : 4, // MW
},
{
name : "Smelter - Copper",
inputs : [{resource: 'Copper Ore', quantity: 1}],
outputs: [{resource: 'Copper Ingot', quantity: 1}],
time : 2, // 30/min.
power : 4, // MW
},
// Constructor
// Standard Parts
{
name : "Constructor - Iron Plate",
inputs : [{resource: 'Iron Ingot', quantity: 3}],
outputs: [{resource: 'Iron Plate', quantity: 2}],
time : 6, // 30/min. -> 20/min.
power : 4, // MW
},
{
name : "Constructor - Iron Rod",
inputs : [{resource: 'Iron Ingot', quantity: 1}],
outputs: [{resource: 'Iron Rod', quantity: 1}],
time : 4, // 15/min. -> 15/min.
power : 4, // MW
},
{
name : "Constructor - Screw",
inputs : [{resource: 'Iron Rod', quantity: 1}],
outputs: [{resource: 'Screw', quantity: 4}],
time : 6, // 10/min. -> 40/min.
power : 4, // MW
},
{
name : "Constructor - Cast Screw",
inputs : [{resource: 'Iron Ingot', quantity: 5}],
outputs: [{resource: 'Screw', quantity: 20}],
time : 24, // 12.5/min. -> 50/min.
power : 4, // MW
},
{
name : "Constructor - Copper Sheet",
inputs : [{resource: 'Copper Ingot', quantity: 2}],
outputs: [{resource: 'Copper Sheet', quantity: 1}],
time : 6, // 20/min. -> 10/min.
power : 4, // MW
},
// Electronics
{
name : "Constructor - Wire",
inputs : [{resource: 'Copper Ingot', quantity: 1}],
outputs: [{resource: 'Wire', quantity: 2}],
time : 4, // 15/min. -> 30/min.
power : 4, // MW
},
{
name : "Constructor - Cable",
inputs : [{resource: 'Wire', quantity: 2}],
outputs: [{resource: 'Cable', quantity: 1}],
time : 2, // 60/min. -> 30/min.
power : 4, // MW
},
// Combounds
{
name : "Constructor - Concrete",
inputs : [{resource: 'Limestone', quantity: 3}],
outputs: [{resource: 'Concrete', quantity: 1}],
time : 4, // 45/min. -> 15/min.
power : 4, // MW
},
// Assembler
// Standard Parts
{
name : "Assembler - Reinforced Iron Plate",
inputs : [{resource: 'Iron Plate', quantity: 6}, {resource: 'Screw', quantity: 12}],
outputs: [{resource: 'Reinforced Iron Plate', quantity: 1}],
time : 12, // 30/min. + 60/min. -> 5/min.
power : 15, // MW
},
{
name : "Assembler - Stitched Iron Plate",
inputs : [{resource: 'Iron Plate', quantity: 10}, {resource: 'Wire', quantity: 20}],
outputs: [{resource: 'Reinforced Iron Plate', quantity: 3}],
time : 32, // 18.75/min. + 37.5/min. -> 5.625/min.
power : 15, // MW
},
{
name : "Assembler - Modular Frame",
inputs : [{resource: 'Reinforced Iron Plate', quantity: 3}, {resource: 'Iron Rod', quantity: 12}],
outputs: [{resource: 'Modular Frame', quantity: 2}],
time : 60, // 3/min. + 12/min. -> 2/min.
power : 15, // MW
},
{
name : "Assembler - Rotor",
inputs : [{resource: 'Iron Rod', quantity: 5}, {resource: 'Screw', quantity: 25}],
outputs: [{resource: 'Rotor', quantity: 2}],
time : 15, // 20/min. + 100/min. -> 4/min.
power : 15, // MW
},
{
name : "Assembler - Smart Plating",
inputs : [{resource: 'Reinforced Iron Plate', quantity: 1}, {resource: 'Rotor', quantity: 1}],
outputs: [{resource: 'Smart Plating', quantity: 1}],
time : 30, // 2/min. + 2/min. -> 2/min.
power : 15, // MW
},
];
///////////////////////////////////////////////////////////////////////////////////////////
function enforce(cond, msg) {
if (!cond)
throw new Error(msg);
return cond;
}
///////////////////////////////////////////////////////////////////////////////////////////
// https://visjs.github.io/vis-network/examples/network/other/saveAndLoad.html
function getNodeData(data) {
var networkNodes = [];
data.forEach(function (elem, index, array) {
networkNodes.push(elem);
});
return new vis.DataSet(networkNodes);
}
function getEdgeData(data) {
var networkEdges = [];
data.forEach(function (node) {
// add the connection
node.connections.forEach(function (connId, cIndex, conns) {
networkEdges.push({ from: node.id, to: connId });
});
});
return new vis.DataSet(networkEdges);
}
function getLoadData() {
if ('plannerGraph' in window.localStorage) {
var inputData = JSON.parse(window.localStorage.plannerGraph);
return {
nodes: getNodeData(inputData),
edges: getEdgeData(inputData),
};
} else {
return {
nodes: new vis.DataSet([]),
edges: new vis.DataSet([]),
};
}
}
function save() {
const positions = network.getPositions();
const nodeIDs = data.nodes.getIds();
const nodes = [];
for (let id of nodeIDs)
nodes.push({
...data.nodes.get(id),
...positions[id],
connections: network.getConnectedNodes(id, 'to'),
});
window.localStorage.plannerGraph = JSON.stringify(nodes);
}
///////////////////////////////////////////////////////////////////////////////////////////
const toolbar = document.getElementById('toolbar');
function describeIngredients(ingredients, time) {
if (!ingredients.length)
return '(nothing)';
return ingredients.map(ing => `${ing.quantity}x (${(ing.quantity/time*60)}/min) ${ing.resource}`).join(' + ');
}
function addNode(conversionName, fields) {
return data.nodes.add({
conversion: conversionName,
label: conversionName.replace(' - ', ':\n'),
shape: 'box',
...(fields || {})
})[0];
}
(function() {
var lastCategory = '';
conversions.forEach(conversion => {
const category = conversion.name.split(' - ')[0];
if (lastCategory && category !== lastCategory)
toolbar.appendChild(document.createTextNode('   '));
lastCategory = category;
let button = document.createElement('button');
button.textContent = conversion.name;
button.addEventListener('click', function() {
addNode(conversion.name);
});
if ('inputs' in conversion)
button.title = `${describeIngredients(conversion.inputs, conversion.time)} -> ${conversion.time} sec -> ${describeIngredients(conversion.outputs, conversion.time)}`;
toolbar.appendChild(button);
});
})();
///////////////////////////////////////////////////////////////////////////////////////////
function recalculate() {
const seenNodes = {}; // 'visiting', 'visited', or 'looped'
function recalculateNode(id) {
var color = '#808080';
var title = '';
try {
if (id in seenNodes) {
if (seenNodes[id] == 'visiting')
seenNodes[id] = 'looped';
return;
}
seenNodes[id] = 'visiting';
const node = data.nodes.get(id);
const fromNodeIds = network.getConnectedNodes(id, 'from');
const toNodeIds = network.getConnectedNodes(id, 'to');
// Ensure sources are calculated
for (let from of fromNodeIds)
recalculateNode(from);
// Check for loops
enforce(seenNodes[id] != 'looped', 'Loop detected');
const edgeIds = network.getConnectedEdges(id);
const edges = edgeIds.map(edgeId => data.edges.get(edgeId));
const fromEdges = edges.filter(edge => edge.to == id);
const toEdges = edges.filter(edge => edge.from == id);
let conversion = conversions.find(c => c.name == node.conversion);
enforce(conversion, 'Unknown conversion: ' + node.conversion);
switch (conversion.name) {
case "Conveyor - Splitter":
enforce(fromEdges.length == 1, "Splitter needs one input");
enforce(toEdges.length > 0, "Splitter needs outputs");
conversion = {
...conversion,
inputs: [{resource: fromEdges[0].spResource, quantity: toEdges.length}],
outputs: Array(toEdges.length).fill({resource: fromEdges[0].spResource, quantity: 1}),
};
break;
case "Conveyor - Merger":
enforce(toEdges.length == 1, "Merger needs one output");
enforce(fromEdges.length > 0, "Merger needs inputs");
for (let fromEdge of fromEdges) {
enforce(fromEdge.spResource == fromEdges[0].spResource, 'Inconsistent input resources');
}
conversion = {
...conversion,
inputs: Array(fromEdges.length).fill({resource: fromEdges[0].spResource, quantity: 1}),
outputs: [{resource: fromEdges[0].spResource, quantity: fromEdges.length}],
};
break;
case "Storage":
enforce(fromEdges.length == 1, "Storage needs one input");
enforce(toEdges.length <= 1, "Storage can't have more than one output");
conversion = {
...conversion,
inputs: [{resource: fromEdges[0].spResource, quantity: 1}],
outputs: Array(toEdges.length).fill({resource: fromEdges[0].spResource, quantity: 1}),
};
break;
}
// Check input arity
enforce(fromNodeIds.length === conversion.inputs.length,
`Input arity mismatch: have ${fromNodeIds.length}, want ${conversion.inputs.length}`);
// Check output arity
enforce(toNodeIds.length === conversion.outputs.length,
`Output arity mismatch: have ${toNodeIds.length}, want ${conversion.outputs.length}`);
// Initial ideal (inputs are saturated) speed value
var inputTime = 0;
var bottleneck = '(nothing)';
// Match up the labels with the inputs
conversion.inputs.sort((a, b) => a.resource.localeCompare(b.resource));
fromEdges.sort((a, b) => a.spResource.localeCompare(b.spResource));
conversion.inputs.forEach((input, index) => {
const edge = fromEdges[index];
enforce(edge.spResource == input.resource,
`Input resource mismatch: have ${edge.spResource}, want ${input.resource}`);
const edgeTime = edge.spTime * input.quantity;
if (edgeTime > inputTime) {
inputTime = edgeTime;
bottleneck = edge.spResource;
}
});
var finalTime = inputTime;
if (conversion.time >= inputTime) {
bottleneck = '(conversion speed)';
finalTime = conversion.time;
}
title = `${finalTime} sec (${60 / finalTime} / min)\nBottlenecked by ${bottleneck}`;
if (conversion.time == 0)
color = '#a0a0a0';
else if (Math.abs(conversion.time - inputTime) < 0.0001)
color = '#00ff00'; // ideal
else if (inputTime > conversion.time)
color = '#00a0a0'; // over-saturated
else
color = '#a0a000'; // under-saturated
conversion.outputs.forEach((output, index) => {
const resTime = finalTime / output.quantity;
data.edges.update({
id: toEdges[index].id,
spResource: output.resource,
spTime: resTime,
title: `${output.resource}\n${resTime} sec\n(${60 / resTime} / min)`,
color: (
resTime < 60/270 ? '#ff0000' :
resTime < 60/120 ? '#00ff00' :
resTime < 60/ 60 ? '#00a0a0' :
resTime > 0 ? '#4040ff' :
'#808080'
),
});
});
seenNodes[id] = 'visited';
} catch (err) {
color = '#ff0000';
title = err.message;
}
data.nodes.update({id, color, title});
}
for (let id of data.nodes.getIds())
recalculateNode(id);
}
///////////////////////////////////////////////////////////////////////////////////////////
// create a network
const container = document.getElementById("mynetwork");
const options = {
interaction: { hover: true },
edges: {
arrows: "to",
},
layout: {
hierarchical: {
enabled: true,
sortMethod: 'directed',
shakeTowards: 'roots',
},
},
};
const data = getLoadData();
const network = new vis.Network(container, data, options);
var lastStabilized = 0;
network.on('stabilized', function() {
const now = (new Date()).getTime();
if (now - lastStabilized < 100)
return;
lastStabilized = now;
recalculate();
save();
});
network.on('oncontext', function (params) {
params.event.preventDefault();
network.disableEditMode();
const edge = this.getEdgeAt(params.pointer.DOM);
if (edge) {
data.edges.remove(edge);
return;
}
const node = this.getNodeAt(params.pointer.DOM);
if (node) {
data.nodes.remove(node);
return;
}
});
network.on('doubleClick', function (params) {
params.event.preventDefault();
const node = this.getNodeAt(params.pointer.DOM);
if (node) {
network.addEdgeMode();
return;
}
const edge = this.getEdgeAt(params.pointer.DOM);
if (edge) {
network.editEdgeMode();
return;
}
});
container.addEventListener('mousedown', function (event) {
if (event.button == 1) {
// const edge = this.getEdgeAt(params.pointer.DOM);
// if (edge) {
// return;
// }
const nodeId = network.getNodeAt({x: event.offsetX, y: event.offsetY});
if (nodeId) {
event.preventDefault();
const node = data.nodes.get(nodeId);
const node2Id = addNode(node.conversion, network.getPosition(nodeId));
const edgeIds = network.getConnectedEdges(nodeId);
const edges = edgeIds.map(edgeId => data.edges.get(edgeId));
const edges2 = edges.map(edge => ({
from: edge.from == nodeId ? node2Id : edge.from,
to: edge.to == nodeId ? node2Id : edge.to,
}));
data.edges.add(edges2);
return;
}
}
});
container.addEventListener('mouseup', function (event) {
save();
});
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment