Kaynağa Gözat

plan evaluator

raylu 4 gün önce
ebeveyn
işleme
d1f4225174
6 değiştirilmiş dosya ile 282 ekleme ve 1 silme
  1. 1 0
      .gitignore
  2. 1 1
      package.json
  3. 20 0
      ts/counter.ts
  4. 215 0
      ts/plan.ts
  5. 32 0
      www/plan.html
  6. 13 0
      www/style.css

+ 1 - 0
.gitignore

@@ -8,6 +8,7 @@ www/corps.js*
 www/gov.js*
 www/ledger.js*
 www/mat.js*
+www/plan.js*
 www/production.js*
 www/roi.js*
 www/roi_*.json

+ 1 - 1
package.json

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

+ 20 - 0
ts/counter.ts

@@ -0,0 +1,20 @@
+export class Counter {
+	private readonly count = new Map<string, number>();
+
+	add(key: string, amount: number): void {
+		this.count.set(key, this.get(key) + amount);
+	}
+
+	get(key: string): number {
+		return this.count.get(key) ?? 0;
+	}
+
+	update(other: Counter): void {
+		for (const [key, amount] of other.entries())
+			this.add(key, amount);
+	}
+
+	entries(): IterableIterator<[string, number]> {
+		return this.count.entries();
+	}
+}

+ 215 - 0
ts/plan.ts

@@ -0,0 +1,215 @@
+import {cachedFetchJSON} from './cache';
+import {Counter} from './counter';
+
+const renderTarget = document.querySelector('#plan')!;
+const shareURL = document.querySelector('#share_url') as HTMLInputElement;
+const cxSelect = document.querySelector('#cx') as HTMLSelectElement;
+
+function serializeToHash(shared: string, cx: string): void {
+	const params = new URLSearchParams({shared, cx});
+	document.location.hash = params.toString();
+}
+
+function deserializeFromHash(): {shareUUID: string, cx: string} | null {
+	const params = new URLSearchParams(document.location.hash.substring(1));
+	const shareUUID = params.get('shared');
+	const cx = params.get('cx');
+	if (shareUUID === null || cx === null)
+		return null;
+	shareURL.value = 'https://prunplanner.org/shared/' + shareUUID;
+	cxSelect.value = cx;
+	return {shareUUID, cx};
+}
+
+document.querySelector('form')!.addEventListener('submit', async (event) => {
+	event.preventDefault();
+	let shareUUID = shareURL.value;
+	if (shareUUID) {
+		if (shareUUID.startsWith('https://prunplanner.org/shared/'))
+			shareUUID = shareUUID.substring('https://prunplanner.org/shared/'.length);
+		const cx = cxSelect.value;
+		await render(shareUUID, cx);
+		serializeToHash(shareUUID, cx);
+	}
+});
+
+{
+	const hash = deserializeFromHash();
+	if (hash !== null)
+		void render(hash.shareUUID, hash.cx);
+}
+
+async function render(shareUUID: string, cx: string) {
+	const loader = document.querySelector('#loader') as HTMLElement;
+	loader.style.display = 'block';
+	renderTarget.innerHTML = '';
+	try {
+		await _render(shareUUID, cx);
+	} catch (e) {
+		console.error(e);
+		renderTarget.textContent = (e as Error).message;
+	}
+	loader.style.display = 'none';
+}
+
+async function _render(shareUUID: string, cx: string) {
+	const [plan, buildings, recipeList, exchanges]: [Plan, Building[], Recipe[], Exchange[]] = await Promise.all([
+		cachedFetchJSON('https://api.prunplanner.org/planning/shared/' + shareUUID),
+		cachedFetchJSON('https://api.prunplanner.org/data/buildings/'),
+		cachedFetchJSON('https://api.prunplanner.org/data/recipes/'),
+		cachedFetchJSON('https://api.prunplanner.org/data/exchanges/'),
+	]);
+	const planet: Planet = await cachedFetchJSON(`https://api.prunplanner.org/data/planet/${plan.plan_details.planet_natural_id}/`);
+	const planExperts = new Map<Expertise, number>(plan.plan_details.plan_data.experts.map(
+			e => [e.type.toUpperCase() as Expertise, e.amount]));
+	const buildingExpertise = new Map<string, Expertise>(buildings.map(b => [b.building_ticker, b.expertise]));
+	const recipes = new Map<string, Recipe>(recipeList.map(r => [r.recipe_id, r]));
+	const dailyTraded = new Map<string, number>(exchanges.filter((ex) => ex.exchange_code === cx)
+			.map((ex) => [ex.ticker, ex.avg_traded_7d]));
+
+	const planInput = new Counter();
+	const planOutput = new Counter();
+	for (const building of plan.plan_details.plan_data.buildings) {
+		const expertise = buildingExpertise.get(building.name)!;
+		const experts = planExperts.get(expertise)!;
+		const {input, output} = calcBuilding(recipes, building, plan.plan_details.plan_cogc === expertise, experts, planet);
+		planInput.update(input);
+		planOutput.update(output);
+	}
+
+	const net = new Counter();
+	net.update(planOutput);
+	for (const [key, amount] of planInput.entries())
+		net.add(key, -amount);
+	renderTarget.innerHTML = `
+		<table>
+			<tr>
+				<th></th>
+				<th>in</th>
+				<th>out</th>
+				<th>net</th>
+				<th>daily traded</th>
+			</tr>
+			${[...net.entries()].map(([mat, netAmount]) => {
+				const traded = dailyTraded.get(mat)!;
+				let tradedDisplay = wholeFmt.format(traded) + ' ';
+				if (netAmount > 0)
+					tradedDisplay += '&nbsp;';
+				tradedDisplay += pctFmt.format(netAmount / traded);
+				const colorPct = Math.min(Math.abs(netAmount) / traded * 500, 100);
+				const color = `color-mix(in xyz, #f80 ${colorPct}%, #0aa)`;
+				return `<tr>
+					<td>${mat}</td>
+					<td>${formatNumber(planInput.get(mat))}</td>
+					<td>${formatNumber(planOutput.get(mat))}</td>
+					<td>${formatNumber(netAmount)}</td>
+					<td style="color: ${color}">${tradedDisplay}</td>
+				</tr>`;
+			}).join('')}
+		</table>
+	`;
+}
+
+function calcBuilding(recipes: Map<string, Recipe>, building: PlanBuilding, cogc: boolean, experts: number,
+		planet: Planet): {input: Counter, output: Counter} {
+	let efficiency = expertBonus[experts];
+	if (cogc)
+		efficiency *= 1.25;
+	const input = new Counter();
+	const output = new Counter();
+	if (['COL', 'EXT', 'RIG'].includes(building.name)) {
+		const total = building.active_recipes.reduce((sum, recipe) => sum + recipe.amount, 0);
+		for (const recipe of building.active_recipes) {
+			const [buildingName, recipeName] = recipe.recipeid.split('#');
+			if (buildingName != building.name)
+				throw new Error(`recipe ${recipe.recipeid} doesn't match building ${building.name}`);
+			const resource = planet.resources.find((r) => r.material_ticker === recipeName);
+			if (resource === undefined)
+				throw new Error(`resource ${recipeName} not found on planet ${planet.planet_natural_id}`);
+			const dailyOutput = resource.daily_extraction * efficiency * building.amount * recipe.amount / total;
+			output.add(recipeName, dailyOutput);
+		}
+	} else {
+		const totalMs = building.active_recipes.reduce((sum, recipe) => {
+			const recipeData = recipes.get(recipe.recipeid);
+			if (recipeData === undefined)
+				throw new Error(`recipe ${recipe.recipeid} not found`);
+			return sum + recipeData.time_ms * recipe.amount;
+		}, 0);
+		for (const recipe of building.active_recipes) {
+			const recipeData = recipes.get(recipe.recipeid)!;
+			let runsPerDay = 24 * 60 * 60 * 1000 / totalMs * efficiency * building.amount * recipe.amount;
+			if (['FRM', 'ORC'].includes(building.name))
+				runsPerDay *= 1 + planet.fertility * (10 / 33);
+			for (const inputMaterial of recipeData.inputs)
+				input.add(inputMaterial.material_ticker, inputMaterial.material_amount * runsPerDay);
+			for (const outputMaterial of recipeData.outputs)
+				output.add(outputMaterial.material_ticker, outputMaterial.material_amount * runsPerDay);
+		}
+	}
+	return {input, output};
+}
+
+const numberFmt = new Intl.NumberFormat(undefined, {maximumFractionDigits: 2});
+const wholeFmt = new Intl.NumberFormat(undefined, {maximumFractionDigits: 0});
+const pctFmt = new Intl.NumberFormat(undefined, {style: 'percent', maximumFractionDigits: 2});
+function formatNumber(num: number): string {
+	if (num === 0)
+		return '';
+	return numberFmt.format(num);
+}
+
+const expertBonus: Record<number, number> = {
+	0: 1,
+	1: 1.0306,
+	2: 1.0696,
+	3: 1.1248,
+	4: 1.1974,
+	5: 1.284,
+} as const;
+
+interface Plan {
+	plan_details: {
+		planet_natural_id: string
+		plan_cogc: Expertise
+		plan_data: {
+			buildings: Array<PlanBuilding>
+			experts: Array<{type: string; amount: number}>
+		}
+	}
+}
+
+interface PlanBuilding {
+	name: string
+	active_recipes: Array<{amount: number; recipeid: string}>
+	amount: number
+}
+
+interface Building {
+	building_ticker: string
+	expertise: Expertise
+}
+type Expertise = 'AGRICULTURE' | 'CHEMISTRY' | 'CONSTRUCTION' | 'ELECTRONICS' | 'FOOD_INDUSTRIES' | 'FUEL_REFINING' |
+	'MANUFACTURING' | 'METALLURGY' | 'RESOURCE_EXTRACTION';
+
+interface Recipe {
+	recipe_id: string
+	inputs: Array<{material_ticker: string; material_amount: number}>
+	outputs: Array<{material_ticker: string; material_amount: number}>
+	time_ms: number
+}
+
+interface Exchange {
+	ticker: string
+	exchange_code: string
+	avg_traded_7d: number
+}
+
+interface Planet {
+	planet_natural_id: string
+	fertility: number
+	resources: Array<{
+		material_ticker: string
+		daily_extraction: number
+	}>
+}

+ 32 - 0
www/plan.html

@@ -0,0 +1,32 @@
+<!DOCTYPE html>
+<html>
+<head>
+	<meta charset="UTF-8">
+	<title>PrUn plan evaluator</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="plan">
+		<form>
+			share URL: <input type="text" id="share_url"><br>
+			CX: <select id="cx">
+				<option value="AI1">AI1/ANT</option>
+				<option value="CI1">CI1/BEN</option>
+				<option value="CI2">CI2/ARC</option>
+				<option value="IC1" selected>IC1/HRT</option>
+				<option value="NC1">NC1/MOR</option>
+				<option value="NC2">NC2/HUB</option>
+			</select><br>
+			<input type="submit" value="analyze">
+		</form>
+		<section id="loader"></section>
+		<div id="plan"></div>
+	</main>
+	<div id="popover" popover="hint"></div>
+	<script src="plan.js"></script>
+</body>
+</html>

+ 13 - 0
www/style.css

@@ -213,6 +213,19 @@ main.ledger {
 	}
 }
 
+main.plan {
+	form {
+		input, select {
+			margin-bottom: 0.5em;
+		}
+	}
+	table {
+		td:nth-child(1) {
+			text-align: inherit;
+		}
+	}
+}
+
 main.production {
 	.analysis-node {
 		padding-left: 40px;