coop.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338
  1. import {cachedFetchJSON} from './cache';
  2. const apiKey = document.querySelector('#api-key') as HTMLInputElement;
  3. const planetSelect = document.querySelector('#planet') as HTMLSelectElement;
  4. const usersSelect = document.querySelector('#users') as HTMLSelectElement;
  5. const configureButton = document.querySelector('#configure') as HTMLButtonElement;
  6. const runButton = document.querySelector('#run') as HTMLButtonElement;
  7. (async () => {
  8. const storedApiKey = localStorage.getItem('punoted-api-key');
  9. if (storedApiKey) {
  10. apiKey.value = storedApiKey;
  11. await renderConfig();
  12. }
  13. const storedPlanet = localStorage.getItem('coop-planet');
  14. if (storedPlanet)
  15. planetSelect.value = storedPlanet;
  16. const storedUsers = localStorage.getItem('coop-users');
  17. if (storedUsers) {
  18. const usernames = new Set(storedUsers.split('\x00'));
  19. for (const option of usersSelect.options)
  20. if (!usernames.has(option.value))
  21. option.selected = false;
  22. }
  23. if (storedPlanet && storedUsers)
  24. await render();
  25. })();
  26. configureButton.addEventListener('click', renderConfig);
  27. runButton.addEventListener('click', render);
  28. async function renderConfig() {
  29. const loader = document.querySelector('#loader') as HTMLElement;
  30. loader.style.display = 'block';
  31. try {
  32. await _renderConfig(apiKey.value);
  33. localStorage.setItem('punoted-api-key', apiKey.value);
  34. } catch (e) {
  35. console.error(e);
  36. (document.querySelector('#coop') as HTMLElement).innerText = e instanceof Error ? e.message : String(e);
  37. }
  38. loader.style.display = 'none';
  39. }
  40. async function render() {
  41. const loader = document.querySelector('#loader') as HTMLElement;
  42. const weight = document.querySelector('#weight') as HTMLInputElement;
  43. const volume = document.querySelector('#volume') as HTMLInputElement;
  44. loader.style.display = 'block';
  45. try {
  46. await _render(apiKey.value, planetSelect.value, Array.from(usersSelect.selectedOptions).map((o) => o.value),
  47. {weight: weight.valueAsNumber, volume: volume.valueAsNumber});
  48. localStorage.setItem('coop-planet', planetSelect.value);
  49. localStorage.setItem('coop-users', Array.from(usersSelect.selectedOptions).map((o) => o.value).join('\x00'));
  50. } catch (e) {
  51. console.error(e);
  52. (document.querySelector('#coop') as HTMLElement).innerText = e instanceof Error ? e.message : String(e);
  53. }
  54. loader.style.display = 'none';
  55. }
  56. async function _renderConfig(apiKey: string): Promise<void> {
  57. if (planetSelect.length === 0)
  58. await Promise.all([renderPlanets(apiKey), renderUsers(apiKey)]);
  59. configureButton.disabled = true;
  60. (document.querySelector('#cargo') as HTMLElement).style.display = 'block';
  61. runButton.style.display = 'block';
  62. }
  63. async function renderPlanets(apiKey: string): Promise<void> {
  64. const siteUsers: SiteUser[] = await cachedFetchJSON('https://api.punoted.net/v1/sites/',
  65. {headers: {'X-Data-Token': apiKey}});
  66. const planetNames = new Set<string>();
  67. for (const siteUser of siteUsers) {
  68. for (const site of siteUser.Sites) {
  69. if (planetNames.has(site.PlanetIdentifier)) continue;
  70. planetNames.add(site.PlanetIdentifier);
  71. const option = document.createElement('option');
  72. option.value = site.PlanetIdentifier;
  73. option.textContent = site.PlanetName;
  74. if (site.PlanetName !== site.PlanetIdentifier)
  75. option.textContent += ` (${site.PlanetIdentifier})`;
  76. planetSelect.appendChild(option);
  77. }
  78. }
  79. planetSelect.style.display = 'block';
  80. }
  81. async function renderUsers(apiKey: string): Promise<void> {
  82. const companies: Company[] = await cachedFetchJSON('https://api.punoted.net/v1/user/companydata',
  83. {headers: {'X-Data-Token': apiKey}});
  84. const usernames = new Set<string>();
  85. for (const co of companies) {
  86. if (usernames.has(co.Username)) continue;
  87. usernames.add(co.Username);
  88. const option = document.createElement('option');
  89. option.value = co.Username;
  90. option.textContent = `${co.Username} | (${co.Company.CompanyCode}) | ${co.Company.CompanyName}`;
  91. option.selected = true;
  92. usersSelect.appendChild(option);
  93. }
  94. usersSelect.style.display = 'block';
  95. }
  96. const format = new Intl.NumberFormat(undefined, {maximumFractionDigits: 2}).format;
  97. async function _render(apiKey: string, planet: string, usernames: string[], cargo: {weight: number, volume: number}): Promise<void> {
  98. const renderTarget = document.querySelector('#coop')!;
  99. renderTarget.innerHTML = '';
  100. const [userStores, rawMaterials]: [UserStorages[], Material[]] = await Promise.all([
  101. cachedFetchJSON(`https://api.punoted.net/v1/storages/?location=${planet}`, {headers: {'X-Data-Token': apiKey}}),
  102. cachedFetchJSON('https://api.punoted.net/v1/materials/list'),
  103. ]);
  104. const allStorages = new Map<string, Map<string, number>>();
  105. const lastUpdates = new Map<string, number>();
  106. const combinedStorage = new Map<string, number>();
  107. for (const userStorage of userStores) {
  108. const items = new Map<string, number>();
  109. for (const store of userStorage.Storages) {
  110. if (store.Type !== 'STORE') continue; // not base storage
  111. for (const item of store.StorageItems) {
  112. items.set(item.MaterialTicker, item.MaterialAmount);
  113. combinedStorage.set(item.MaterialTicker, (combinedStorage.get(item.MaterialTicker) ?? 0) + item.MaterialAmount);
  114. }
  115. lastUpdates.set(userStorage.Username, store.LastUpdatedEpochMs);
  116. break; // we found the user's base storage for this planet
  117. }
  118. allStorages.set(userStorage.Username, items);
  119. }
  120. const userBurns = await Promise.all(usernames.map((username) => userBurn(apiKey, planet, username,
  121. allStorages.get(username), lastUpdates.get(username))));
  122. const combinedBurn = new Map<string, Burn>();
  123. for (const ub of userBurns) {
  124. for (const [mat, burn] of ub.burn.entries()) {
  125. const combined = combinedBurn.get(mat);
  126. if (combined === undefined)
  127. combinedBurn.set(mat, {...burn});
  128. else {
  129. combined.Production += burn.Production;
  130. combined.Consumption += burn.Consumption;
  131. combined.Net += burn.Net;
  132. }
  133. }
  134. }
  135. renderTarget.innerHTML = `<h2>combined</h2>
  136. ${burnTable(combinedBurn, combinedStorage)}`;
  137. const materials = new Map(rawMaterials.map((m) => [m.ticker, m]));
  138. const combinedShipping = ship(materials, combinedBurn, combinedStorage, cargo);
  139. renderTarget.innerHTML += combinedShipping.html;
  140. for (const ub of userBurns) {
  141. renderTarget.innerHTML += ub.html;
  142. const items = allStorages.get(ub.username);
  143. if (combinedShipping.supply !== null && items !== undefined) {
  144. let pasteLines: string[] = [];
  145. for (const mat of combinedShipping.supply.keys()) {
  146. const burn = ub.burn.get(mat);
  147. if (burn === undefined || burn.Net >= 0)
  148. continue;
  149. const need = -burn.Net * combinedShipping.days - (items.get(mat) ?? 0);
  150. if (need > 0)
  151. // unfortunately, rounding means if 4 people each need 0.25, we'll buy 1 and contract each person 0
  152. pasteLines.push(`${mat}\t${Math.round(need)}\t0.01`);
  153. }
  154. if (pasteLines.length > 0)
  155. renderTarget.innerHTML += `<textarea readonly>${pasteLines.join('\n')}</textarea>`;
  156. }
  157. }
  158. }
  159. async function userBurn(apiKey: string, planet: string, username: string, items?: Map<string, number>,
  160. lastUpdated?: number): Promise<{username: string, burn: Map<string, Burn>, html: string}> {
  161. const burns: Record<string, Burn[]> = await cachedFetchJSON(
  162. `https://api.punoted.net/v1/production/user/burn?location=${planet}&username=${username}`,
  163. {headers: {'X-Data-Token': apiKey}});
  164. let burnArray: Burn[] | undefined = [];
  165. if (burns['detail'])
  166. console.warn(`error fetching burn for ${username}: ${burns['detail']}`);
  167. else
  168. burnArray = Object.values(burns).at(0) ?? [];
  169. const burn = new Map<string, Burn>(burnArray.map((b) => [b.MaterialTicker, b]));
  170. const html = `<h2>${username}</h2>
  171. last updated: ${lastUpdated ? new Date(lastUpdated).toLocaleString() : ''}
  172. ${burnTable(burn, items)}`;
  173. return {username, burn, html};
  174. }
  175. function burnTable(burn: Map<string, Burn>, items?: Map<string, number>): string {
  176. return `<table>
  177. <tr><th></th><th>in</th><th>out</th><th>net</th><th>inv</th><th>days</th></tr>
  178. ${Array.from(burn.values().map((b) => {
  179. const amount = items?.get(b.MaterialTicker);
  180. let style = '';
  181. if (b.Net < 0) {
  182. const percent = amount === undefined ? 0 : Math.min((amount / -b.Net) / 7, 1);
  183. style = `style="color: color-mix(in xyz, #0aa ${percent * 100}%, #f80)"`;
  184. }
  185. return `<tr>
  186. <td>${b.MaterialTicker}</td>
  187. <td>${format(b.Consumption)}</td>
  188. <td>${format(b.Production)}</td>
  189. <td>${format(b.Net)}</td>
  190. <td>${amount !== undefined ? format(amount) : 'unknown'}</td>
  191. <td ${style}>
  192. ${b.Net < 0 ? (amount !== undefined ? format((amount) / -b.Net) : 'unknown') : '∞'}
  193. </td>
  194. </tr>`;
  195. })).join('')}
  196. </table>`;
  197. }
  198. function ship(materials: Map<string, Material>, burn: Map<string, Burn>, items: Map<string, number>,
  199. cargo: {weight: number, volume: number}): {supply: Map<string, number> | null, days: number, html: string} {
  200. let targetDays = Infinity;
  201. for (const b of burn.values()) {
  202. if (b.Net >= 0) continue;
  203. const amount = items.get(b.MaterialTicker);
  204. if (amount === undefined) continue;
  205. const days = amount / -b.Net;
  206. if (days < targetDays)
  207. targetDays = days;
  208. }
  209. targetDays = Math.round((targetDays + 0.05) * 10) / 10; // round to nearest 0.1 days
  210. let optimal: Map<string, number> | null = null;
  211. let optimalDays = 0;
  212. let totalWeightUsed = 0;
  213. let totalVolumeUsed = 0;
  214. while (true) {
  215. const buys = supplyForDays(burn, items, targetDays);
  216. const [weightUsed, volumeUsed] = shippingUsed(materials, buys);
  217. if (weightUsed > cargo.weight || volumeUsed > cargo.volume)
  218. break;
  219. optimal = buys;
  220. optimalDays = targetDays;
  221. totalWeightUsed = weightUsed;
  222. totalVolumeUsed = volumeUsed;
  223. targetDays += 0.1;
  224. }
  225. if (optimal === null)
  226. return {supply: optimal, days: NaN, html: 'nothing to supply'};
  227. const xitAct = {
  228. 'actions': [
  229. {'name': 'BuyItems', 'type': 'CX Buy', 'group': 'A1', 'exchange': 'IC1',
  230. 'priceLimits': {}, 'buyPartial': false, 'useCXInv': true},
  231. {'type': 'MTRA', 'name': 'TransferAction', 'group': 'A1',
  232. 'origin': 'Hortus Station Warehouse', 'dest': 'Configure on Execution'},
  233. ],
  234. 'global': {'name': 'supply ' + planetSelect.value},
  235. 'groups': [{
  236. 'type': 'Manual', 'name': 'A1', 'materials': Object.fromEntries(optimal.entries())
  237. }],
  238. };
  239. const html = `<h2>supply for ${format(optimalDays)} days (${format(totalWeightUsed)}t, ${format(totalVolumeUsed)}m³)</h2>
  240. <textarea readonly>${JSON.stringify(xitAct)}</textarea>`;
  241. return {supply: optimal, days: optimalDays, html};
  242. }
  243. function supplyForDays(burn: Map<string, Burn>, items: Map<string, number>, targetDays: number): Map<string, number> {
  244. const buy = new Map<string, number>();
  245. for (const b of burn.values()) {
  246. const consumption = -b.Net;
  247. if (consumption <= 0) continue;
  248. const avail = items.get(b.MaterialTicker) ?? 0;
  249. const days = avail / consumption;
  250. let toBuy = 0;
  251. if (days < targetDays)
  252. toBuy = Math.ceil((targetDays - days) * consumption);
  253. if (avail + toBuy < 2)
  254. toBuy = 2 - avail;
  255. if (toBuy > 0)
  256. buy.set(b.MaterialTicker, toBuy);
  257. }
  258. return buy;
  259. }
  260. function shippingUsed(materials: Map<string, Material>, counts: Map<string, number>): [number, number] {
  261. let weight = 0, volume = 0;
  262. for (const [ticker, amount] of counts.entries()) {
  263. const mat = materials.get(ticker);
  264. if (mat === undefined) continue;
  265. weight += amount * mat.weight;
  266. volume += amount * mat.volume;
  267. }
  268. return [weight, volume];
  269. }
  270. interface SiteUser {
  271. Sites: Array<{
  272. PlanetIdentifier: string
  273. PlanetName: string
  274. }>
  275. }
  276. interface Company {
  277. Company: {
  278. CompanyCode: string
  279. CompanyName: string
  280. }
  281. Username: string
  282. }
  283. interface UserStorages {
  284. Storages: Array<{
  285. Type: 'FTL_FUEL_STORE' | 'SHIP_STORE' | 'STL_FUEL_STORE' | 'STORE' | 'WAREHOUSE_STORE'
  286. StorageItems: Array<{
  287. MaterialTicker: string
  288. MaterialAmount: number
  289. }>
  290. LastUpdatedEpochMs: number
  291. }>
  292. Username: string
  293. }
  294. interface Burn {
  295. MaterialTicker: string
  296. Production: number
  297. Consumption: number
  298. Net: number
  299. }
  300. interface Material {
  301. ticker: string
  302. weight: number
  303. volume: number
  304. }