coop.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374
  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. const combinedShipping = ship(materials, combinedBurn, combinedStorage, cargo);
  150. renderTarget.innerHTML += combinedShipping.html;
  151. for (const ub of userBurns) {
  152. renderTarget.innerHTML += ub.html;
  153. const items = allStorages.get(ub.username);
  154. if (combinedShipping.supply !== null && items !== undefined) {
  155. let pasteLines: string[] = [];
  156. for (const mat of combinedShipping.supply.keys()) {
  157. const burn = ub.burn.get(mat);
  158. if (burn === undefined || burn.Net >= 0)
  159. continue;
  160. const need = -burn.Net * combinedShipping.days - (items.get(mat) ?? 0);
  161. if (need > 0)
  162. // unfortunately, rounding means if 4 people each need 0.25, we'll buy 1 and contract each person 0
  163. pasteLines.push(`${mat}\t${Math.round(need)}\t0.01`);
  164. }
  165. if (pasteLines.length > 0)
  166. renderTarget.innerHTML += `<textarea readonly>${pasteLines.join('\n')}</textarea>`;
  167. }
  168. }
  169. }
  170. async function userBurn(apiKey: string, planet: string, username: string, items?: Map<string, number>, lastUpdated?: number,
  171. wfNeeds?: Map<string, number>): Promise<{username: string, burn: Map<string, Burn>, html: string}> {
  172. const burns: Record<string, Burn[]> = await cachedFetchJSON(
  173. `https://api.punoted.net/v1/production/user/burn?location=${planet}&username=${username}`,
  174. {headers: {'X-Data-Token': apiKey}});
  175. let burnArray: Burn[] | undefined = [];
  176. if (burns['detail'])
  177. console.warn(`error fetching burn for ${username}: ${burns['detail']}`);
  178. else
  179. burnArray = Object.values(burns).at(0) ?? [];
  180. const burn = new Map<string, Burn>(burnArray.map((b) => [b.MaterialTicker, b]));
  181. if (wfNeeds !== undefined)
  182. for (const [mat, amount] of wfNeeds.entries()) {
  183. const productionBurn = burn.get(mat);
  184. if (productionBurn === undefined)
  185. burn.set(mat, {MaterialTicker: mat, Production: 0, Consumption: amount, Net: -amount});
  186. else {
  187. productionBurn.Consumption += amount;
  188. productionBurn.Net -= amount;
  189. }
  190. }
  191. const html = `<h2>${username}</h2>
  192. last updated: ${lastUpdated ? new Date(lastUpdated).toLocaleString() : ''}
  193. ${burnTable(burn, items)}`;
  194. return {username, burn, html};
  195. }
  196. function burnTable(burn: Map<string, Burn>, items?: Map<string, number>): string {
  197. return `<table>
  198. <tr><th></th><th>in</th><th>out</th><th>net</th><th>inv</th><th>days</th></tr>
  199. ${Array.from(burn.values().map((b) => {
  200. const amount = items?.get(b.MaterialTicker);
  201. let style = '';
  202. if (b.Net < 0) {
  203. const percent = amount === undefined ? 0 : Math.min((amount / -b.Net) / 7, 1);
  204. style = `style="color: color-mix(in xyz, #0aa ${percent * 100}%, #f80)"`;
  205. }
  206. return `<tr>
  207. <td>${b.MaterialTicker}</td>
  208. <td>${format(b.Consumption)}</td>
  209. <td>${format(b.Production)}</td>
  210. <td>${format(b.Net)}</td>
  211. <td>${amount !== undefined ? format(amount) : 'unknown'}</td>
  212. <td ${style}>
  213. ${b.Net < 0 ? (amount !== undefined ? format((amount) / -b.Net) : 'unknown') : '∞'}
  214. </td>
  215. </tr>`;
  216. })).join('')}
  217. </table>`;
  218. }
  219. function ship(materials: Map<string, Material>, burn: Map<string, Burn>, items: Map<string, number>,
  220. cargo: {weight: number, volume: number}): {supply: Map<string, number> | null, days: number, html: string} {
  221. let targetDays = Infinity;
  222. for (const b of burn.values()) {
  223. if (b.Net >= 0) continue;
  224. const amount = items.get(b.MaterialTicker);
  225. if (amount === undefined) continue;
  226. const days = amount / -b.Net;
  227. if (days < targetDays)
  228. targetDays = days;
  229. }
  230. targetDays = Math.round((targetDays + 0.05) * 10) / 10; // round to nearest 0.1 days
  231. let optimal: Map<string, number> | null = null;
  232. let optimalDays = 0;
  233. let totalWeightUsed = 0;
  234. let totalVolumeUsed = 0;
  235. while (true) {
  236. const buys = supplyForDays(burn, items, targetDays);
  237. const [weightUsed, volumeUsed] = shippingUsed(materials, buys);
  238. if (weightUsed > cargo.weight || volumeUsed > cargo.volume)
  239. break;
  240. optimal = buys;
  241. optimalDays = targetDays;
  242. totalWeightUsed = weightUsed;
  243. totalVolumeUsed = volumeUsed;
  244. targetDays += 0.1;
  245. }
  246. if (optimal === null)
  247. return {supply: optimal, days: NaN, html: 'nothing to supply'};
  248. const xitAct = {
  249. 'actions': [
  250. {'name': 'BuyItems', 'type': 'CX Buy', 'group': 'A1', 'exchange': 'IC1',
  251. 'priceLimits': {}, 'buyPartial': false, 'useCXInv': true},
  252. {'type': 'MTRA', 'name': 'TransferAction', 'group': 'A1',
  253. 'origin': 'Hortus Station Warehouse', 'dest': 'Configure on Execution'},
  254. ],
  255. 'global': {'name': 'supply ' + planetSelect.value},
  256. 'groups': [{
  257. 'type': 'Manual', 'name': 'A1', 'materials': Object.fromEntries(optimal.entries())
  258. }],
  259. };
  260. const html = `<h2>supply for ${format(optimalDays)} days (${format(totalWeightUsed)}t, ${format(totalVolumeUsed)}m³)</h2>
  261. <textarea readonly>${JSON.stringify(xitAct)}</textarea>`;
  262. return {supply: optimal, days: optimalDays, html};
  263. }
  264. function supplyForDays(burn: Map<string, Burn>, items: Map<string, number>, targetDays: number): Map<string, number> {
  265. const buy = new Map<string, number>();
  266. for (const b of burn.values()) {
  267. const consumption = -b.Net;
  268. if (consumption <= 0) continue;
  269. const avail = items.get(b.MaterialTicker) ?? 0;
  270. const days = avail / consumption;
  271. let toBuy = 0;
  272. if (days < targetDays)
  273. toBuy = Math.ceil((targetDays - days) * consumption);
  274. if (avail + toBuy < 2)
  275. toBuy = 2 - avail;
  276. if (toBuy > 0)
  277. buy.set(b.MaterialTicker, toBuy);
  278. }
  279. return buy;
  280. }
  281. function shippingUsed(materials: Map<string, Material>, counts: Map<string, number>): [number, number] {
  282. let weight = 0, volume = 0;
  283. for (const [ticker, amount] of counts.entries()) {
  284. const mat = materials.get(ticker);
  285. if (mat === undefined) continue;
  286. weight += amount * mat.weight;
  287. volume += amount * mat.volume;
  288. }
  289. return [weight, volume];
  290. }
  291. interface SiteUser {
  292. Sites: Array<{
  293. PlanetIdentifier: string
  294. PlanetName: string
  295. }>
  296. }
  297. interface Company {
  298. Company: {
  299. CompanyCode: string
  300. CompanyName: string
  301. }
  302. Username: string
  303. }
  304. interface UserStorages {
  305. Storages: Array<{
  306. Type: 'FTL_FUEL_STORE' | 'SHIP_STORE' | 'STL_FUEL_STORE' | 'STORE' | 'WAREHOUSE_STORE'
  307. StorageItems: Array<{
  308. MaterialTicker: string
  309. MaterialAmount: number
  310. }>
  311. LastUpdatedEpochMs: number
  312. }>
  313. Username: string
  314. }
  315. interface UserWorkforces {
  316. Workforce: Array<{
  317. Type: 'FTL_FUEL_STORE' | 'SHIP_STORE' | 'STL_FUEL_STORE' | 'STORE' | 'WAREHOUSE_STORE'
  318. Workforces: Array<{
  319. WorkforceNeeds: Array<{
  320. MaterialTicker: string
  321. UnitsPerInterval: number
  322. }>
  323. }>
  324. LastUpdatedEpochMs: number
  325. }>
  326. Username: string
  327. }
  328. interface Burn {
  329. MaterialTicker: string
  330. Production: number
  331. Consumption: number
  332. Net: number
  333. }
  334. interface Material {
  335. ticker: string
  336. weight: number
  337. volume: number
  338. }