| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332 |
- 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, materialList, graph]:
- [Plan, Building[], Recipe[], Exchange[], Material[], Graph] = 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/'),
- cachedFetchJSON('https://api.prunplanner.org/data/materials/'),
- cachedFetchJSON('/graph_data.json'),
- ]);
- 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.sum_traded_7d / 7]));
- const materials = new Map<string, Material>(materialList.map(m => [m.ticker, m]));
- const oneWayHours = travel(graph.edges, planet.system_id, cx);
- 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>
- <th>% of traded</th>
- </tr>
- ${[...net.entries()].map(([mat, netAmount]) => {
- const traded = dailyTraded.get(mat)!;
- 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>${wholeFmt.format(traded)}</td>
- <td style="color: ${color}">${pctFmt.format(netAmount / traded)}</td>
- </tr>`;
- }).join('')}
- </table>`;
- if (oneWayHours !== null) {
- const {shippingBottleneckType, shippingBottleneckAmount} = shippingBottleneck(materials, net);
- const roundTripHours = oneWayHours * 2;
- const scbThroughput = 24 / roundTripHours * 500;
- renderTarget.innerHTML += `
- <p>
- shipping bottleneck: ${formatNumber(shippingBottleneckAmount)} (${shippingBottleneckType})
- </p>
- <p>
- one-way to/from ${cx}: ${formatNumber(oneWayHours)} hours
- <br>round-trip to ${cx}: ${formatNumber(roundTripHours)} hours
- <br>non-stop SCB shipping throughput per day: 24 ÷ ${formatNumber(roundTripHours)} × 500 = ${formatNumber(scbThroughput)}
- <br>non-stop SCBs needed: ${formatNumber(shippingBottleneckAmount)} ÷ ${formatNumber(scbThroughput)}
- = ${formatNumber(shippingBottleneckAmount / scbThroughput)}
- </p>
- `;
- }
- }
- function travel(edges: Edge[], system: string, cx: string): number | null {
- const {parsecs, jumps} = ftlDistance(edges, system, cx);
- if (jumps === 0)
- return null;
- // SCB JMPs at 4pc/hr. RCT CHRGs in 7m30s. 30m DEP, 1.5hr APP+(TO/LAND)
- return Math.round(parsecs / 4 + (jumps - 1) * 0.125 + 2);
- }
- /** dijkstra to find the shortest FTL route from system to CX */
- function ftlDistance(edges: Edge[], system: string, cx: string): {parsecs: number; jumps: number} {
- const cxSystems: Record<string, string> = {
- 'AI1': '8ecf9670ba070d78cfb5537e8d9f1b6c',
- 'CI1': '92029ff27c1abe932bd2c61ee4c492c7',
- 'CI2': 'a4ba8b12739da65efc2b518703652ee1',
- 'IC1': 'f2f57766ebaca9d69efae41ccf4d8853',
- 'NC1': '49b6615d39ccba05752b3be77b2ebf36',
- 'NC2': 'afda9bea7f948f4a066a8882cdfa9055',
- };
- const destination = cxSystems[cx]!;
- if (destination === system)
- return {parsecs: 0, jumps: 0};
- const adjacency = new Map<string, Array<{to: string; parsecs: number}>>();
- for (const edge of edges) {
- const startNeighbors = adjacency.get(edge.start) ?? [];
- startNeighbors.push({to: edge.end, parsecs: edge.distance});
- adjacency.set(edge.start, startNeighbors);
- const endNeighbors = adjacency.get(edge.end) ?? [];
- endNeighbors.push({to: edge.start, parsecs: edge.distance});
- adjacency.set(edge.end, endNeighbors);
- }
- const best = new Map<string, number>([[system, 0]]);
- const frontier: Array<{node: string; parsecs: number; jumps: number}> = [{node: system, parsecs: 0, jumps: 0}];
- while (frontier.length > 0) {
- frontier.sort((a, b) => a.parsecs - b.parsecs);
- const current = frontier.shift()!;
- if (current.node === destination)
- return {parsecs: current.parsecs, jumps: current.jumps};
- if (current.parsecs !== best.get(current.node))
- continue;
- for (const neighbor of adjacency.get(current.node) ?? []) {
- const totalDistance = current.parsecs + neighbor.parsecs;
- if (totalDistance >= (best.get(neighbor.to) ?? Number.POSITIVE_INFINITY))
- continue;
- best.set(neighbor.to, totalDistance);
- frontier.push({node: neighbor.to, parsecs: totalDistance, jumps: current.jumps + 1});
- }
- }
- throw new Error(`no route from ${system} to ${cx}`);
- }
- 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};
- }
- function shippingBottleneck(materials: Map<string, Material>, net: Counter):
- {shippingBottleneckType: string, shippingBottleneckAmount: number} {
- let importWeight = 0, importVol = 0;
- let exportWeight = 0, exportVol = 0;
- for (const [mat, amount] of net.entries()) {
- const material = materials.get(mat)!;
- if (amount < 0) {
- importWeight -= amount * material.weight;
- importVol -= amount * material.volume;
- } else if (amount > 0) {
- exportWeight += amount * material.weight;
- exportVol += amount * material.volume;
- }
- }
- const bottlenecks = [
- {shippingBottleneckType: 'import weight', shippingBottleneckAmount: importWeight},
- {shippingBottleneckType: 'import volume', shippingBottleneckAmount: importVol},
- {shippingBottleneckType: 'export weight', shippingBottleneckAmount: exportWeight},
- {shippingBottleneckType: 'export volume', shippingBottleneckAmount: exportVol},
- ];
- bottlenecks.sort((a, b) => b.shippingBottleneckAmount - a.shippingBottleneckAmount);
- return bottlenecks[0];
- }
- 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
- sum_traded_7d: number // avg_traded_7d is nonsense
- }
- interface Material {
- ticker: string
- weight: number
- volume: number
- }
- interface Graph {
- edges: Array<Edge>
- }
- interface Edge {
- start: string
- end: string
- distance: number
- }
- interface Planet {
- planet_natural_id: string
- system_id: string
- fertility: number
- resources: Array<{
- material_ticker: string
- daily_extraction: number
- }>
- }
|