plan.ts 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213
  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.sum_traded_7d / 7]));
  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. <th>% of traded</th>
  84. </tr>
  85. ${[...net.entries()].map(([mat, netAmount]) => {
  86. const traded = dailyTraded.get(mat)!;
  87. const colorPct = Math.min(Math.abs(netAmount) / traded * 500, 100);
  88. const color = `color-mix(in xyz, #f80 ${colorPct}%, #0aa)`;
  89. return `<tr>
  90. <td>${mat}</td>
  91. <td>${formatNumber(planInput.get(mat))}</td>
  92. <td>${formatNumber(planOutput.get(mat))}</td>
  93. <td>${formatNumber(netAmount)}</td>
  94. <td>${wholeFmt.format(traded)}</td>
  95. <td style="color: ${color}">${pctFmt.format(netAmount / traded)}</td>
  96. </tr>`;
  97. }).join('')}
  98. </table>
  99. `;
  100. }
  101. function calcBuilding(recipes: Map<string, Recipe>, building: PlanBuilding, cogc: boolean, experts: number,
  102. planet: Planet): {input: Counter, output: Counter} {
  103. let efficiency = expertBonus[experts];
  104. if (cogc)
  105. efficiency *= 1.25;
  106. const input = new Counter();
  107. const output = new Counter();
  108. if (['COL', 'EXT', 'RIG'].includes(building.name)) {
  109. const total = building.active_recipes.reduce((sum, recipe) => sum + recipe.amount, 0);
  110. for (const recipe of building.active_recipes) {
  111. const [buildingName, recipeName] = recipe.recipeid.split('#');
  112. if (buildingName != building.name)
  113. throw new Error(`recipe ${recipe.recipeid} doesn't match building ${building.name}`);
  114. const resource = planet.resources.find((r) => r.material_ticker === recipeName);
  115. if (resource === undefined)
  116. throw new Error(`resource ${recipeName} not found on planet ${planet.planet_natural_id}`);
  117. const dailyOutput = resource.daily_extraction * efficiency * building.amount * recipe.amount / total;
  118. output.add(recipeName, dailyOutput);
  119. }
  120. } else {
  121. const totalMs = building.active_recipes.reduce((sum, recipe) => {
  122. const recipeData = recipes.get(recipe.recipeid);
  123. if (recipeData === undefined)
  124. throw new Error(`recipe ${recipe.recipeid} not found`);
  125. return sum + recipeData.time_ms * recipe.amount;
  126. }, 0);
  127. for (const recipe of building.active_recipes) {
  128. const recipeData = recipes.get(recipe.recipeid)!;
  129. let runsPerDay = 24 * 60 * 60 * 1000 / totalMs * efficiency * building.amount * recipe.amount;
  130. if (['FRM', 'ORC'].includes(building.name))
  131. runsPerDay *= 1 + planet.fertility * (10 / 33);
  132. for (const inputMaterial of recipeData.inputs)
  133. input.add(inputMaterial.material_ticker, inputMaterial.material_amount * runsPerDay);
  134. for (const outputMaterial of recipeData.outputs)
  135. output.add(outputMaterial.material_ticker, outputMaterial.material_amount * runsPerDay);
  136. }
  137. }
  138. return {input, output};
  139. }
  140. const numberFmt = new Intl.NumberFormat(undefined, {maximumFractionDigits: 2});
  141. const wholeFmt = new Intl.NumberFormat(undefined, {maximumFractionDigits: 0});
  142. const pctFmt = new Intl.NumberFormat(undefined, {style: 'percent', maximumFractionDigits: 2});
  143. function formatNumber(num: number): string {
  144. if (num === 0)
  145. return '';
  146. return numberFmt.format(num);
  147. }
  148. const expertBonus: Record<number, number> = {
  149. 0: 1,
  150. 1: 1.0306,
  151. 2: 1.0696,
  152. 3: 1.1248,
  153. 4: 1.1974,
  154. 5: 1.284,
  155. } as const;
  156. interface Plan {
  157. plan_details: {
  158. planet_natural_id: string
  159. plan_cogc: Expertise
  160. plan_data: {
  161. buildings: Array<PlanBuilding>
  162. experts: Array<{type: string; amount: number}>
  163. }
  164. }
  165. }
  166. interface PlanBuilding {
  167. name: string
  168. active_recipes: Array<{amount: number; recipeid: string}>
  169. amount: number
  170. }
  171. interface Building {
  172. building_ticker: string
  173. expertise: Expertise
  174. }
  175. type Expertise = 'AGRICULTURE' | 'CHEMISTRY' | 'CONSTRUCTION' | 'ELECTRONICS' | 'FOOD_INDUSTRIES' | 'FUEL_REFINING' |
  176. 'MANUFACTURING' | 'METALLURGY' | 'RESOURCE_EXTRACTION';
  177. interface Recipe {
  178. recipe_id: string
  179. inputs: Array<{material_ticker: string; material_amount: number}>
  180. outputs: Array<{material_ticker: string; material_amount: number}>
  181. time_ms: number
  182. }
  183. interface Exchange {
  184. ticker: string
  185. exchange_code: string
  186. sum_traded_7d: number // avg_traded_7d is nonsense
  187. }
  188. interface Planet {
  189. planet_natural_id: string
  190. fertility: number
  191. resources: Array<{
  192. material_ticker: string
  193. daily_extraction: number
  194. }>
  195. }