coop.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354
  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, userWorkforces, rawMaterials]: [UserStorages[], UserWorkforces[], 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/workforce/?location=${planet}`, {headers: {'X-Data-Token': apiKey}}),
  103. cachedFetchJSON('https://api.punoted.net/v1/materials/list'),
  104. ]);
  105. const allStorages = new Map<string, Map<string, number>>();
  106. const lastUpdates = new Map<string, number>();
  107. const combinedStorage = new Map<string, number>();
  108. for (const userStorage of userStores) {
  109. const items = new Map<string, number>();
  110. for (const store of userStorage.Storages) {
  111. if (store.Type !== 'STORE') continue; // not base storage
  112. for (const item of store.StorageItems) {
  113. items.set(item.MaterialTicker, item.MaterialAmount);
  114. combinedStorage.set(item.MaterialTicker, (combinedStorage.get(item.MaterialTicker) ?? 0) + item.MaterialAmount);
  115. }
  116. lastUpdates.set(userStorage.Username, store.LastUpdatedEpochMs);
  117. break; // we found the user's base storage for this planet
  118. }
  119. allStorages.set(userStorage.Username, items);
  120. }
  121. const allWfNeeds = new Map<string, Map<string, number>>();
  122. for (const userWorkforce of userWorkforces) {
  123. const needs = new Map<string, number>();
  124. const wf = userWorkforce.Workforce[0];
  125. for (const workers of wf.Workforces)
  126. for (const need of workers.WorkforceNeeds)
  127. if (need.UnitsPerInterval > 0)
  128. needs.set(need.MaterialTicker, (needs.get(need.MaterialTicker) ?? 0) + need.UnitsPerInterval);
  129. allWfNeeds.set(userWorkforce.Username, needs);
  130. }
  131. const userBurns = await Promise.all(usernames.map((username) => userBurn(apiKey, planet, username,
  132. allStorages.get(username), lastUpdates.get(username), allWfNeeds.get(username))));
  133. const combinedBurn = new Map<string, Burn>();
  134. for (const ub of userBurns) {
  135. for (const [mat, burn] of ub.burn.entries()) {
  136. const combined = combinedBurn.get(mat);
  137. if (combined === undefined)
  138. combinedBurn.set(mat, {...burn});
  139. else {
  140. combined.Production += burn.Production;
  141. combined.Consumption += burn.Consumption;
  142. combined.Net += burn.Net;
  143. }
  144. }
  145. }
  146. renderTarget.innerHTML = `<h2>combined</h2>
  147. ${burnTable(combinedBurn, combinedStorage)}`;
  148. const materials = new Map(rawMaterials.map((m) => [m.ticker, m]));
  149. renderTarget.innerHTML += ship(materials, combinedBurn, combinedStorage, cargo);
  150. renderTarget.innerHTML += userBurns.map(ub => ub.html).join('');
  151. }
  152. async function userBurn(apiKey: string, planet: string, username: string, items?: Map<string, number>,
  153. lastUpdated?: number, wfNeeds?: Map<string, number>): Promise<{burn: Map<string, Burn>, html: string}> {
  154. const burns: Record<string, Burn[]> = await cachedFetchJSON(
  155. `https://api.punoted.net/v1/production/user/burn?location=${planet}&username=${username}`,
  156. {headers: {'X-Data-Token': apiKey}});
  157. let burnArray: Burn[] | undefined = [];
  158. if (burns['detail'])
  159. console.warn(`error fetching burn for ${username}: ${burns['detail']}`);
  160. else
  161. burnArray = Object.values(burns).at(0) ?? [];
  162. const burn = new Map<string, Burn>(burnArray.map((b) => [b.MaterialTicker, b]));
  163. if (wfNeeds !== undefined)
  164. for (const [mat, amount] of wfNeeds.entries()) {
  165. const productionBurn = burn.get(mat);
  166. if (productionBurn === undefined)
  167. burn.set(mat, {MaterialTicker: mat, Production: 0, Consumption: amount, Net: -amount});
  168. else {
  169. productionBurn.Consumption += amount;
  170. productionBurn.Net -= amount;
  171. }
  172. }
  173. const html = `<h2>${username}</h2>
  174. last updated: ${lastUpdated ? new Date(lastUpdated).toLocaleString() : ''}
  175. ${burnTable(burn, items)}`;
  176. return {burn, html};
  177. }
  178. function burnTable(burn: Map<string, Burn>, items?: Map<string, number>): string {
  179. return `<table>
  180. <tr><th></th><th>in</th><th>out</th><th>net</th><th>inv</th><th>days</th></tr>
  181. ${Array.from(burn.values().map((b) => {
  182. const amount = items?.get(b.MaterialTicker);
  183. let style = '';
  184. if (b.Net < 0) {
  185. const percent = amount === undefined ? 0 : Math.min((amount / -b.Net) / 7, 1);
  186. style = `style="color: color-mix(in xyz, #0aa ${percent * 100}%, #f80)"`;
  187. }
  188. return `<tr>
  189. <td>${b.MaterialTicker}</td>
  190. <td>${format(b.Consumption)}</td>
  191. <td>${format(b.Production)}</td>
  192. <td>${format(b.Net)}</td>
  193. <td>${amount !== undefined ? format(amount) : 'unknown'}</td>
  194. <td ${style}>
  195. ${b.Net < 0 ? (amount !== undefined ? format((amount) / -b.Net) : 'unknown') : '∞'}
  196. </td>
  197. </tr>`;
  198. })).join('')}
  199. </table>`;
  200. }
  201. function ship(materials: Map<string, Material>, burn: Map<string, Burn>, items: Map<string, number>,
  202. cargo: {weight: number, volume: number}): string {
  203. let targetDays = Infinity;
  204. for (const b of burn.values()) {
  205. if (b.Net >= 0) continue;
  206. const amount = items.get(b.MaterialTicker);
  207. if (amount === undefined) continue;
  208. const days = amount / -b.Net;
  209. if (days < targetDays)
  210. targetDays = days;
  211. }
  212. targetDays = Math.round((targetDays + 0.05) * 10) / 10; // round to nearest 0.1 days
  213. let optimal: Map<string, number> | null = null;
  214. let optimalDays = 0;
  215. let totalWeightUsed = 0;
  216. let totalVolumeUsed = 0;
  217. while (true) {
  218. const buys = supplyForDays(burn, items, targetDays);
  219. const [weightUsed, volumeUsed] = shippingUsed(materials, buys);
  220. if (weightUsed > cargo.weight || volumeUsed > cargo.volume)
  221. break;
  222. optimal = buys;
  223. optimalDays = targetDays;
  224. totalWeightUsed = weightUsed;
  225. totalVolumeUsed = volumeUsed;
  226. targetDays += 0.1;
  227. }
  228. if (optimal === null)
  229. return 'nothing to supply';
  230. const xitAct = {
  231. 'actions': [
  232. {'name': 'BuyItems', 'type': 'CX Buy', 'group': 'A1', 'exchange': 'IC1',
  233. 'priceLimits': {}, 'buyPartial': false, 'useCXInv': true},
  234. {'type': 'MTRA', 'name': 'TransferAction', 'group': 'A1',
  235. 'origin': 'Hortus Station Warehouse', 'dest': 'Configure on Execution'},
  236. ],
  237. 'global': {'name': 'supply ' + planetSelect.value},
  238. 'groups': [{
  239. 'type': 'Manual', 'name': 'A1', 'materials': Object.fromEntries(optimal.entries())
  240. }],
  241. };
  242. return `<h2>supply for ${format(optimalDays)} days (${format(totalWeightUsed)}t, ${format(totalVolumeUsed)}m³)</h2>
  243. <textarea>${JSON.stringify(xitAct)}</textarea>`;
  244. }
  245. function supplyForDays(burn: Map<string, Burn>, items: Map<string, number>, targetDays: number): Map<string, number> {
  246. const buy = new Map<string, number>();
  247. for (const b of burn.values()) {
  248. const consumption = -b.Net;
  249. if (consumption <= 0) continue;
  250. const avail = items.get(b.MaterialTicker) ?? 0;
  251. const days = avail / consumption;
  252. let toBuy = 0;
  253. if (days < targetDays)
  254. toBuy = Math.ceil((targetDays - days) * consumption);
  255. if (avail + toBuy < 2)
  256. toBuy = 2 - avail;
  257. if (toBuy > 0)
  258. buy.set(b.MaterialTicker, toBuy);
  259. }
  260. return buy;
  261. }
  262. function shippingUsed(materials: Map<string, Material>, counts: Map<string, number>): [number, number] {
  263. let weight = 0, volume = 0;
  264. for (const [ticker, amount] of counts.entries()) {
  265. const mat = materials.get(ticker);
  266. if (mat === undefined) continue;
  267. weight += amount * mat.weight;
  268. volume += amount * mat.volume;
  269. }
  270. return [weight, volume];
  271. }
  272. interface SiteUser {
  273. Sites: Array<{
  274. PlanetIdentifier: string
  275. PlanetName: string
  276. }>
  277. }
  278. interface Company {
  279. Company: {
  280. CompanyCode: string
  281. CompanyName: string
  282. }
  283. Username: string
  284. }
  285. interface UserStorages {
  286. Storages: Array<{
  287. Type: 'FTL_FUEL_STORE' | 'SHIP_STORE' | 'STL_FUEL_STORE' | 'STORE' | 'WAREHOUSE_STORE'
  288. StorageItems: Array<{
  289. MaterialTicker: string
  290. MaterialAmount: number
  291. }>
  292. LastUpdatedEpochMs: number
  293. }>
  294. Username: string
  295. }
  296. interface UserWorkforces {
  297. Workforce: Array<{
  298. Type: 'FTL_FUEL_STORE' | 'SHIP_STORE' | 'STL_FUEL_STORE' | 'STORE' | 'WAREHOUSE_STORE'
  299. Workforces: Array<{
  300. WorkforceNeeds: Array<{
  301. MaterialTicker: string
  302. UnitsPerInterval: number
  303. }>
  304. }>
  305. LastUpdatedEpochMs: number
  306. }>
  307. Username: string
  308. }
  309. interface Burn {
  310. MaterialTicker: string
  311. Production: number
  312. Consumption: number
  313. Net: number
  314. }
  315. interface Material {
  316. ticker: string
  317. weight: number
  318. volume: number
  319. }