|
@@ -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 += ' ';
|
|
|
|
|
+ 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
|
|
|
|
|
+ }>
|
|
|
|
|
+}
|