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(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 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)!; 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 ``; }).join('')}
in out net daily traded
${mat} ${formatNumber(planInput.get(mat))} ${formatNumber(planOutput.get(mat))} ${formatNumber(netAmount)} ${tradedDisplay}
`; } 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}; } 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 Planet { planet_natural_id: string fertility: number resources: Array<{ material_ticker: string daily_extraction: number }> }