Эх сурвалжийг харах

shipbuilding: web version

raylu 1 долоо хоног өмнө
parent
commit
921fc7b0ab
5 өөрчлөгдсөн 262 нэмэгдсэн , 1 устгасан
  1. 1 0
      .gitignore
  2. 1 1
      package.json
  3. 233 0
      ts/shipbuilding.ts
  4. 17 0
      www/shipbuilding.html
  5. 10 0
      www/style.css

+ 1 - 0
.gitignore

@@ -7,3 +7,4 @@ www/buy.js*
 www/mat.js*
 www/roi.js*
 www/roi_*.json
+www/shipbuilding.js*

+ 1 - 1
package.json

@@ -2,7 +2,7 @@
 	"name": "prun-calc",
 	"version": "0",
 	"scripts": {
-		"build": "bun build ts/buy.ts ts/mat.ts ts/roi.ts --outdir www --target browser --sourcemap=external",
+		"build": "bun build ts/buy.ts ts/mat.ts ts/roi.ts ts/shipbuilding.ts --outdir www --target browser --sourcemap=external",
 		"typecheck": "tsgo --noEmit",
 		"serve": "python3 -m http.server -d www 8000"
 	},

+ 233 - 0
ts/shipbuilding.ts

@@ -0,0 +1,233 @@
+import {cachedFetchJSON} from './cache';
+
+const buy = new Set([
+	// definitely buy
+	'C', 'FLX', 'H', 'H2O', 'HAL', 'HCP', 'HE', 'LST', 'MG', 'N', 'NA', 'NCS', 'NS', 'O', 'PE', 'PG', 'S', 'TCL', 'THF',
+	// maybe buy
+	'AU', 'BRM', 'CU', 'FE', 'LI', 'RG', 'ROM', 'SI', 'TI',
+	// skip
+	'LFE', 'LHP', 'MFE', 'SFE', 'SSC',
+])
+
+const blueprint = {
+	'FFC': 1,
+	'FSE': 1,
+	'LFE': 2,
+	'MFE': 2,
+	'QCR': 1,
+	'SFE': 1,
+	'LCB': 1,
+	'MFL': 1,
+	'MSL': 1,
+	'LHP': 94,
+	'SSC': 128,
+	'BR1': 1,
+	'CQM': 1,
+}
+
+render();
+
+async function render() {
+	const main = document.querySelector('main.shipbuilding');
+	if (!main)
+		throw new Error('missing shipbuilding container');
+	main.innerHTML = '<p>Loading...</p>';
+
+	const [allPrices, recipes, buildings] = await Promise.all([
+		cachedFetchJSON('https://refined-prun.github.io/refined-prices/all.json') as Promise<RawPrice[]>,
+		recipeForMats(),
+		cachedFetchJSON('https://api.prunplanner.org/data/buildings/') as Promise<Building[]>,
+	]);
+	const prices = Object.fromEntries(allPrices.filter((price) => price.ExchangeCode === 'IC1')
+			.map((price) => [price.MaterialTicker, price]));
+
+	const production: Production = {};
+	const analysisNodes: AnalysisNode[] = [];
+	let cost = 0;
+	for (const [mat, amount] of Object.entries(blueprint)) {
+		const { cost: matCost, node } = analyzeMat(mat, amount, production, prices, recipes);
+		cost += matCost;
+		analysisNodes.push(node);
+	}
+
+	const expertiseGroups: Record<string, string[]> = {};
+	for (const building of buildings) {
+		if (!(building.building_ticker in production))
+			continue;
+		if (!expertiseGroups[building.expertise])
+			expertiseGroups[building.expertise] = [];
+		expertiseGroups[building.expertise].push(building.building_ticker);
+	}
+
+	main.innerHTML = '';
+	main.append(
+		renderAnalysis(analysisNodes),
+		element('p', {textContent: `total cost: ${formatWhole(cost)}`}),
+		renderProduction(expertiseGroups, production, prices),
+	);
+}
+
+async function recipeForMats(): Promise<Record<string, Recipe>> {
+	const allRecipes: Recipe[] = await cachedFetchJSON('https://api.prunplanner.org/data/recipes/');
+	const matRecipes: Record<string, Recipe[]> = {}; // all ways to make a mat
+	for (const recipe of allRecipes)
+		for (const output of recipe.outputs) {
+			const recipes = matRecipes[output.material_ticker];
+			if (recipes)
+				recipes.push(recipe);
+			else
+				matRecipes[output.material_ticker] = [recipe];
+		}
+
+	const matRecipe: Record<string, Recipe> = {}; // mats for which there's only one recipe to make
+	for (const [mat, recipes] of Object.entries(matRecipes))
+		if (recipes.length === 1)
+			matRecipe[mat] = recipes[0];
+	return matRecipe;
+}
+
+function analyzeMat(mat: string, amount: number, production: Production,
+		prices: Record<string, RawPrice>, recipes: Record<string, Recipe>): { cost: number, node: AnalysisNode } {
+	const price = prices[mat];
+	if (!price)
+		throw new Error(`missing price for ${mat}`);
+	const traded = price.AverageTraded30D ?? 0;
+	if (buy.has(mat)) {
+		if (price.Ask == null)
+			throw new Error(`missing ask price for ${mat}`);
+		return {
+			cost: price.Ask * amount,
+			node: { text: `${formatAmount(amount)}x${mat} buy: ${formatAmount(price.Ask)}, daily traded ${formatFixed(traded, 1)}`, children: [] },
+		};
+	}
+
+	const recipe = recipes[mat];
+	if (!recipe)
+		return { cost: 0, node: { text: `${formatAmount(amount)}x${mat} make (unknown recipe)`, children: [] } };
+
+	const building = recipe.building_ticker;
+	if (!production[building])
+		production[building] = {};
+	production[building][mat] = (production[building][mat] ?? 0) + amount;
+
+	const liquid = traded > amount * 2 ? 'liquid' : 'not liquid';
+
+	let totalCost = 0;
+	const children: AnalysisNode[] = [];
+	for (const inputMat of recipe.inputs) {
+		const inputAmount = inputMat.material_amount * amount / recipe.outputs[0].material_amount;
+		const { cost, node } = analyzeMat(inputMat.material_ticker, inputAmount, production, prices, recipes);
+		totalCost += cost;
+		children.push(node);
+	}
+	children.push({ text: `cost: ${formatWhole(totalCost)}`, children: [] });
+	return {
+		cost: totalCost,
+		node: { text: `${formatAmount(amount)}x${mat} make (${building}, ${liquid})`, children },
+	};
+}
+
+function renderAnalysis(nodes: AnalysisNode[]): HTMLElement {
+	const section = element('section');
+	for (const node of nodes)
+		section.append(renderAnalysisNode(node));
+	return section;
+}
+
+function renderAnalysisNode(node: AnalysisNode, level = 0): HTMLElement {
+	if (node.children.length === 0) {
+		const div = element('div', {textContent: node.text, className: 'analysis-node'});
+		return div;
+	}
+	const details = element('details', {className: 'analysis-node'});
+	details.open = true;
+	const summary = element('summary', {textContent: node.text});
+	details.append(summary);
+	for (const child of node.children)
+		details.append(renderAnalysisNode(child, level + 1));
+	return details;
+}
+
+function renderProduction(expertiseGroups: Record<string, string[]>, production: Production,
+		prices: Record<string, RawPrice>): HTMLElement {
+	const section = element('section');
+	section.append(element('h2', {textContent: 'production'}));
+
+	for (const [expertise, buildings] of Object.entries(expertiseGroups)) {
+		section.append(element('h3', {textContent: expertise}));
+		for (const building of buildings) {
+			const buildingRow = element('div', {className: 'building-row', textContent: building});
+			const buildingMats = element('div');
+			const mats = Object.entries(production[building]);
+			for (const [index, [mat, amount]] of mats.entries()) {
+				const traded = prices[mat]?.AverageTraded30D ?? 0;
+				const span = element('span', {
+					textContent: `${formatAmount(amount)}x${mat}`,
+				});
+				span.style.color = traded > amount * 2 ? '#6c6' : '#c66';
+				buildingMats.append(span);
+				if (index < mats.length - 1)
+					buildingMats.append(document.createTextNode(' '));
+			}
+			buildingRow.append(buildingMats);
+			section.append(buildingRow);
+		}
+	}
+
+	return section;
+}
+
+function element<K extends keyof HTMLElementTagNameMap>(tagName: K,
+		properties: Partial<HTMLElementTagNameMap[K]> = {}): HTMLElementTagNameMap[K] {
+	const node = document.createElement(tagName);
+	Object.assign(node, properties);
+	return node;
+}
+
+function formatAmount(n: number): string {
+	return n.toLocaleString(undefined, {maximumFractionDigits: 3});
+}
+
+function formatFixed(n: number, digits: number): string {
+	return n.toLocaleString(undefined, {
+		minimumFractionDigits: digits,
+		maximumFractionDigits: digits,
+	});
+}
+
+function formatWhole(n: number): string {
+	return n.toLocaleString(undefined, {maximumFractionDigits: 0});
+}
+
+interface AnalysisNode {
+	text: string
+	children: AnalysisNode[]
+}
+
+interface Recipe {
+	recipe_name: string
+	building_ticker: string
+	inputs: RecipeMat[]
+	outputs: RecipeMat[]
+	time_ms: number
+}
+
+interface RecipeMat {
+	material_ticker: string
+	material_amount: number
+}
+
+interface RawPrice {
+	MaterialTicker: string
+	ExchangeCode: string
+	Ask: number | null
+	AverageTraded30D: number | null
+	Supply: number
+}
+
+interface Building {
+	building_ticker: string
+	expertise: string
+}
+
+type Production = Record<string, Record<string, number>>;

+ 17 - 0
www/shipbuilding.html

@@ -0,0 +1,17 @@
+<!DOCTYPE html>
+<html>
+<head>
+	<meta charset="UTF-8">
+	<title>PrUn shipbuilding</title>
+	<link rel="stylesheet" type="text/css" href="style.css">
+	<link rel="icon" href="https://www.raylu.net/hammer-man.svg" />
+	<meta name="viewport" content="width=device-width, initial-scale=1">
+	<meta name="theme-color" content="#222">
+</head>
+<body>
+	<a href="/">← back</a>
+	<main class="shipbuilding">
+	</main>
+	<script src="shipbuilding.js"></script>
+</body>
+</html>

+ 10 - 0
www/style.css

@@ -149,6 +149,16 @@ main.mat {
 	}
 }
 
+main.shipbuilding {
+	.analysis-node {
+		padding-left: 40px;
+	}
+	div.building-row > div {
+		display: inline-block;
+		margin-left: 10px;
+	}
+}
+
 #loader {
 	display: none;
 	width: 48px;