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(plan.plan_details.plan_data.experts.map( e => [e.type.toUpperCase() as Expertise, e.amount])); const buildingExpertise = new Map(buildings.map(b => [b.building_ticker, b.expertise])); const recipes = new Map(recipeList.map(r => [r.recipe_id, r])); const dailyTraded = new Map(exchanges.filter((ex) => ex.exchange_code === cx) .map((ex) => [ex.ticker, ex.sum_traded_7d / 7])); const materials = new Map(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 = ` ${[...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 ``; }).join('')}
in out net daily traded % of traded
${mat} ${formatNumber(planInput.get(mat))} ${formatNumber(planOutput.get(mat))} ${formatNumber(netAmount)} ${wholeFmt.format(traded)} ${pctFmt.format(netAmount / traded)}
`; if (oneWayHours !== null) { const {shippingBottleneckType, shippingBottleneckAmount} = shippingBottleneck(materials, net); const roundTripHours = oneWayHours * 2; const scbThroughput = 24 / roundTripHours * 500; renderTarget.innerHTML += `

shipping bottleneck: ${formatNumber(shippingBottleneckAmount)} (${shippingBottleneckType})

one-way to/from ${cx}: ${formatNumber(oneWayHours)} hours
round-trip to ${cx}: ${formatNumber(roundTripHours)} hours
non-stop SCB shipping throughput per day: 24 ÷ ${formatNumber(roundTripHours)} × 500 = ${formatNumber(scbThroughput)}
non-stop SCBs needed: ${formatNumber(shippingBottleneckAmount)} ÷ ${formatNumber(scbThroughput)} = ${formatNumber(shippingBottleneckAmount / scbThroughput)}

`; } } 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 = { '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>(); 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([[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, 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, 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 = { 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 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 } 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 }> }