plan.ts 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215
  1. import {cachedFetchJSON} from './cache';
  2. import {Counter} from './counter';
  3. const renderTarget = document.querySelector('#plan')!;
  4. const shareURL = document.querySelector('#share_url') as HTMLInputElement;
  5. const cxSelect = document.querySelector('#cx') as HTMLSelectElement;
  6. function serializeToHash(shared: string, cx: string): void {
  7. const params = new URLSearchParams({shared, cx});
  8. document.location.hash = params.toString();
  9. }
  10. function deserializeFromHash(): {shareUUID: string, cx: string} | null {
  11. const params = new URLSearchParams(document.location.hash.substring(1));
  12. const shareUUID = params.get('shared');
  13. const cx = params.get('cx');
  14. if (shareUUID === null || cx === null)
  15. return null;
  16. shareURL.value = 'https://prunplanner.org/shared/' + shareUUID;
  17. cxSelect.value = cx;
  18. return {shareUUID, cx};
  19. }
  20. document.querySelector('form')!.addEventListener('submit', async (event) => {
  21. event.preventDefault();
  22. let shareUUID = shareURL.value;
  23. if (shareUUID) {
  24. if (shareUUID.startsWith('https://prunplanner.org/shared/'))
  25. shareUUID = shareUUID.substring('https://prunplanner.org/shared/'.length);
  26. const cx = cxSelect.value;
  27. await render(shareUUID, cx);
  28. serializeToHash(shareUUID, cx);
  29. }
  30. });
  31. {
  32. const hash = deserializeFromHash();
  33. if (hash !== null)
  34. void render(hash.shareUUID, hash.cx);
  35. }
  36. async function render(shareUUID: string, cx: string) {
  37. const loader = document.querySelector('#loader') as HTMLElement;
  38. loader.style.display = 'block';
  39. renderTarget.innerHTML = '';
  40. try {
  41. await _render(shareUUID, cx);
  42. } catch (e) {
  43. console.error(e);
  44. renderTarget.textContent = (e as Error).message;
  45. }
  46. loader.style.display = 'none';
  47. }
  48. async function _render(shareUUID: string, cx: string) {
  49. const [plan, buildings, recipeList, exchanges]: [Plan, Building[], Recipe[], Exchange[]] = await Promise.all([
  50. cachedFetchJSON('https://api.prunplanner.org/planning/shared/' + shareUUID),
  51. cachedFetchJSON('https://api.prunplanner.org/data/buildings/'),
  52. cachedFetchJSON('https://api.prunplanner.org/data/recipes/'),
  53. cachedFetchJSON('https://api.prunplanner.org/data/exchanges/'),
  54. ]);
  55. const planet: Planet = await cachedFetchJSON(`https://api.prunplanner.org/data/planet/${plan.plan_details.planet_natural_id}/`);
  56. const planExperts = new Map<Expertise, number>(plan.plan_details.plan_data.experts.map(
  57. e => [e.type.toUpperCase() as Expertise, e.amount]));
  58. const buildingExpertise = new Map<string, Expertise>(buildings.map(b => [b.building_ticker, b.expertise]));
  59. const recipes = new Map<string, Recipe>(recipeList.map(r => [r.recipe_id, r]));
  60. const dailyTraded = new Map<string, number>(exchanges.filter((ex) => ex.exchange_code === cx)
  61. .map((ex) => [ex.ticker, ex.avg_traded_7d]));
  62. const planInput = new Counter();
  63. const planOutput = new Counter();
  64. for (const building of plan.plan_details.plan_data.buildings) {
  65. const expertise = buildingExpertise.get(building.name)!;
  66. const experts = planExperts.get(expertise)!;
  67. const {input, output} = calcBuilding(recipes, building, plan.plan_details.plan_cogc === expertise, experts, planet);
  68. planInput.update(input);
  69. planOutput.update(output);
  70. }
  71. const net = new Counter();
  72. net.update(planOutput);
  73. for (const [key, amount] of planInput.entries())
  74. net.add(key, -amount);
  75. renderTarget.innerHTML = `
  76. <table>
  77. <tr>
  78. <th></th>
  79. <th>in</th>
  80. <th>out</th>
  81. <th>net</th>
  82. <th>daily traded</th>
  83. </tr>
  84. ${[...net.entries()].map(([mat, netAmount]) => {
  85. const traded = dailyTraded.get(mat)!;
  86. let tradedDisplay = wholeFmt.format(traded) + ' ';
  87. if (netAmount > 0)
  88. tradedDisplay += '&nbsp;';
  89. tradedDisplay += pctFmt.format(netAmount / traded);
  90. const colorPct = Math.min(Math.abs(netAmount) / traded * 500, 100);
  91. const color = `color-mix(in xyz, #f80 ${colorPct}%, #0aa)`;
  92. return `<tr>
  93. <td>${mat}</td>
  94. <td>${formatNumber(planInput.get(mat))}</td>
  95. <td>${formatNumber(planOutput.get(mat))}</td>
  96. <td>${formatNumber(netAmount)}</td>
  97. <td style="color: ${color}">${tradedDisplay}</td>
  98. </tr>`;
  99. }).join('')}
  100. </table>
  101. `;
  102. }
  103. function calcBuilding(recipes: Map<string, Recipe>, building: PlanBuilding, cogc: boolean, experts: number,
  104. planet: Planet): {input: Counter, output: Counter} {
  105. let efficiency = expertBonus[experts];
  106. if (cogc)
  107. efficiency *= 1.25;
  108. const input = new Counter();
  109. const output = new Counter();
  110. if (['COL', 'EXT', 'RIG'].includes(building.name)) {
  111. const total = building.active_recipes.reduce((sum, recipe) => sum + recipe.amount, 0);
  112. for (const recipe of building.active_recipes) {
  113. const [buildingName, recipeName] = recipe.recipeid.split('#');
  114. if (buildingName != building.name)
  115. throw new Error(`recipe ${recipe.recipeid} doesn't match building ${building.name}`);
  116. const resource = planet.resources.find((r) => r.material_ticker === recipeName);
  117. if (resource === undefined)
  118. throw new Error(`resource ${recipeName} not found on planet ${planet.planet_natural_id}`);
  119. const dailyOutput = resource.daily_extraction * efficiency * building.amount * recipe.amount / total;
  120. output.add(recipeName, dailyOutput);
  121. }
  122. } else {
  123. const totalMs = building.active_recipes.reduce((sum, recipe) => {
  124. const recipeData = recipes.get(recipe.recipeid);
  125. if (recipeData === undefined)
  126. throw new Error(`recipe ${recipe.recipeid} not found`);
  127. return sum + recipeData.time_ms * recipe.amount;
  128. }, 0);
  129. for (const recipe of building.active_recipes) {
  130. const recipeData = recipes.get(recipe.recipeid)!;
  131. let runsPerDay = 24 * 60 * 60 * 1000 / totalMs * efficiency * building.amount * recipe.amount;
  132. if (['FRM', 'ORC'].includes(building.name))
  133. runsPerDay *= 1 + planet.fertility * (10 / 33);
  134. for (const inputMaterial of recipeData.inputs)
  135. input.add(inputMaterial.material_ticker, inputMaterial.material_amount * runsPerDay);
  136. for (const outputMaterial of recipeData.outputs)
  137. output.add(outputMaterial.material_ticker, outputMaterial.material_amount * runsPerDay);
  138. }
  139. }
  140. return {input, output};
  141. }
  142. const numberFmt = new Intl.NumberFormat(undefined, {maximumFractionDigits: 2});
  143. const wholeFmt = new Intl.NumberFormat(undefined, {maximumFractionDigits: 0});
  144. const pctFmt = new Intl.NumberFormat(undefined, {style: 'percent', maximumFractionDigits: 2});
  145. function formatNumber(num: number): string {
  146. if (num === 0)
  147. return '';
  148. return numberFmt.format(num);
  149. }
  150. const expertBonus: Record<number, number> = {
  151. 0: 1,
  152. 1: 1.0306,
  153. 2: 1.0696,
  154. 3: 1.1248,
  155. 4: 1.1974,
  156. 5: 1.284,
  157. } as const;
  158. interface Plan {
  159. plan_details: {
  160. planet_natural_id: string
  161. plan_cogc: Expertise
  162. plan_data: {
  163. buildings: Array<PlanBuilding>
  164. experts: Array<{type: string; amount: number}>
  165. }
  166. }
  167. }
  168. interface PlanBuilding {
  169. name: string
  170. active_recipes: Array<{amount: number; recipeid: string}>
  171. amount: number
  172. }
  173. interface Building {
  174. building_ticker: string
  175. expertise: Expertise
  176. }
  177. type Expertise = 'AGRICULTURE' | 'CHEMISTRY' | 'CONSTRUCTION' | 'ELECTRONICS' | 'FOOD_INDUSTRIES' | 'FUEL_REFINING' |
  178. 'MANUFACTURING' | 'METALLURGY' | 'RESOURCE_EXTRACTION';
  179. interface Recipe {
  180. recipe_id: string
  181. inputs: Array<{material_ticker: string; material_amount: number}>
  182. outputs: Array<{material_ticker: string; material_amount: number}>
  183. time_ms: number
  184. }
  185. interface Exchange {
  186. ticker: string
  187. exchange_code: string
  188. avg_traded_7d: number
  189. }
  190. interface Planet {
  191. planet_natural_id: string
  192. fertility: number
  193. resources: Array<{
  194. material_ticker: string
  195. daily_extraction: number
  196. }>
  197. }