| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354 |
- import {cachedFetchJSON} from './cache';
- const apiKey = document.querySelector('#api-key') as HTMLInputElement;
- const planetSelect = document.querySelector('#planet') as HTMLSelectElement;
- const usersSelect = document.querySelector('#users') as HTMLSelectElement;
- const configureButton = document.querySelector('#configure') as HTMLButtonElement;
- const runButton = document.querySelector('#run') as HTMLButtonElement;
- (async () => {
- const storedApiKey = localStorage.getItem('punoted-api-key');
- if (storedApiKey) {
- apiKey.value = storedApiKey;
- await renderConfig();
- }
- const storedPlanet = localStorage.getItem('coop-planet');
- if (storedPlanet)
- planetSelect.value = storedPlanet;
- const storedUsers = localStorage.getItem('coop-users');
- if (storedUsers) {
- const usernames = new Set(storedUsers.split('\x00'));
- for (const option of usersSelect.options)
- if (!usernames.has(option.value))
- option.selected = false;
- }
- if (storedPlanet && storedUsers)
- await render();
- })();
- configureButton.addEventListener('click', renderConfig);
- runButton.addEventListener('click', render);
- async function renderConfig() {
- const loader = document.querySelector('#loader') as HTMLElement;
- loader.style.display = 'block';
- try {
- await _renderConfig(apiKey.value);
- localStorage.setItem('punoted-api-key', apiKey.value);
- } catch (e) {
- console.error(e);
- (document.querySelector('#coop') as HTMLElement).innerText = e instanceof Error ? e.message : String(e);
- }
- loader.style.display = 'none';
- }
- async function render() {
- const loader = document.querySelector('#loader') as HTMLElement;
- const weight = document.querySelector('#weight') as HTMLInputElement;
- const volume = document.querySelector('#volume') as HTMLInputElement;
- loader.style.display = 'block';
- try {
- await _render(apiKey.value, planetSelect.value, Array.from(usersSelect.selectedOptions).map((o) => o.value),
- {weight: weight.valueAsNumber, volume: volume.valueAsNumber});
- localStorage.setItem('coop-planet', planetSelect.value);
- localStorage.setItem('coop-users', Array.from(usersSelect.selectedOptions).map((o) => o.value).join('\x00'));
- } catch (e) {
- console.error(e);
- (document.querySelector('#coop') as HTMLElement).innerText = e instanceof Error ? e.message : String(e);
- }
- loader.style.display = 'none';
- }
- async function _renderConfig(apiKey: string): Promise<void> {
- if (planetSelect.length === 0)
- await Promise.all([renderPlanets(apiKey), renderUsers(apiKey)]);
- configureButton.disabled = true;
- (document.querySelector('#cargo') as HTMLElement).style.display = 'block';
- runButton.style.display = 'block';
- }
- async function renderPlanets(apiKey: string): Promise<void> {
- const siteUsers: SiteUser[] = await cachedFetchJSON('https://api.punoted.net/v1/sites/',
- {headers: {'X-Data-Token': apiKey}});
- const planetNames = new Set<string>();
- for (const siteUser of siteUsers) {
- for (const site of siteUser.Sites) {
- if (planetNames.has(site.PlanetIdentifier)) continue;
- planetNames.add(site.PlanetIdentifier);
- const option = document.createElement('option');
- option.value = site.PlanetIdentifier;
- option.textContent = site.PlanetName;
- if (site.PlanetName !== site.PlanetIdentifier)
- option.textContent += ` (${site.PlanetIdentifier})`;
- planetSelect.appendChild(option);
- }
- }
- planetSelect.style.display = 'block';
- }
- async function renderUsers(apiKey: string): Promise<void> {
- const companies: Company[] = await cachedFetchJSON('https://api.punoted.net/v1/user/companydata',
- {headers: {'X-Data-Token': apiKey}});
- const usernames = new Set<string>();
- for (const co of companies) {
- if (usernames.has(co.Username)) continue;
- usernames.add(co.Username);
- const option = document.createElement('option');
- option.value = co.Username;
- option.textContent = `${co.Username} | (${co.Company.CompanyCode}) | ${co.Company.CompanyName}`;
- option.selected = true;
- usersSelect.appendChild(option);
- }
- usersSelect.style.display = 'block';
- }
- const format = new Intl.NumberFormat(undefined, {maximumFractionDigits: 2}).format;
- async function _render(apiKey: string, planet: string, usernames: string[], cargo: {weight: number, volume: number}): Promise<void> {
- const renderTarget = document.querySelector('#coop')!;
- renderTarget.innerHTML = '';
- const [userStores, userWorkforces, rawMaterials]: [UserStorages[], UserWorkforces[], Material[]] = await Promise.all([
- cachedFetchJSON(`https://api.punoted.net/v1/storages/?location=${planet}`, {headers: {'X-Data-Token': apiKey}}),
- cachedFetchJSON(`https://api.punoted.net/v1/workforce/?location=${planet}`, {headers: {'X-Data-Token': apiKey}}),
- cachedFetchJSON('https://api.punoted.net/v1/materials/list'),
- ]);
- const allStorages = new Map<string, Map<string, number>>();
- const lastUpdates = new Map<string, number>();
- const combinedStorage = new Map<string, number>();
- for (const userStorage of userStores) {
- const items = new Map<string, number>();
- for (const store of userStorage.Storages) {
- if (store.Type !== 'STORE') continue; // not base storage
- for (const item of store.StorageItems) {
- items.set(item.MaterialTicker, item.MaterialAmount);
- combinedStorage.set(item.MaterialTicker, (combinedStorage.get(item.MaterialTicker) ?? 0) + item.MaterialAmount);
- }
- lastUpdates.set(userStorage.Username, store.LastUpdatedEpochMs);
- break; // we found the user's base storage for this planet
- }
- allStorages.set(userStorage.Username, items);
- }
- const allWfNeeds = new Map<string, Map<string, number>>();
- for (const userWorkforce of userWorkforces) {
- const needs = new Map<string, number>();
- const wf = userWorkforce.Workforce[0];
- for (const workers of wf.Workforces)
- for (const need of workers.WorkforceNeeds)
- if (need.UnitsPerInterval > 0)
- needs.set(need.MaterialTicker, (needs.get(need.MaterialTicker) ?? 0) + need.UnitsPerInterval);
- allWfNeeds.set(userWorkforce.Username, needs);
- }
- const userBurns = await Promise.all(usernames.map((username) => userBurn(apiKey, planet, username,
- allStorages.get(username), lastUpdates.get(username), allWfNeeds.get(username))));
- const combinedBurn = new Map<string, Burn>();
- for (const ub of userBurns) {
- for (const [mat, burn] of ub.burn.entries()) {
- const combined = combinedBurn.get(mat);
- if (combined === undefined)
- combinedBurn.set(mat, {...burn});
- else {
- combined.Production += burn.Production;
- combined.Consumption += burn.Consumption;
- combined.Net += burn.Net;
- }
- }
- }
- renderTarget.innerHTML = `<h2>combined</h2>
- ${burnTable(combinedBurn, combinedStorage)}`;
- const materials = new Map(rawMaterials.map((m) => [m.ticker, m]));
- renderTarget.innerHTML += ship(materials, combinedBurn, combinedStorage, cargo);
- renderTarget.innerHTML += userBurns.map(ub => ub.html).join('');
- }
- async function userBurn(apiKey: string, planet: string, username: string, items?: Map<string, number>,
- lastUpdated?: number, wfNeeds?: Map<string, number>): Promise<{burn: Map<string, Burn>, html: string}> {
- const burns: Record<string, Burn[]> = await cachedFetchJSON(
- `https://api.punoted.net/v1/production/user/burn?location=${planet}&username=${username}`,
- {headers: {'X-Data-Token': apiKey}});
- let burnArray: Burn[] | undefined = [];
- if (burns['detail'])
- console.warn(`error fetching burn for ${username}: ${burns['detail']}`);
- else
- burnArray = Object.values(burns).at(0) ?? [];
- const burn = new Map<string, Burn>(burnArray.map((b) => [b.MaterialTicker, b]));
- if (wfNeeds !== undefined)
- for (const [mat, amount] of wfNeeds.entries()) {
- const productionBurn = burn.get(mat);
- if (productionBurn === undefined)
- burn.set(mat, {MaterialTicker: mat, Production: 0, Consumption: amount, Net: -amount});
- else {
- productionBurn.Consumption += amount;
- productionBurn.Net -= amount;
- }
- }
- const html = `<h2>${username}</h2>
- last updated: ${lastUpdated ? new Date(lastUpdated).toLocaleString() : ''}
- ${burnTable(burn, items)}`;
- return {burn, html};
- }
- function burnTable(burn: Map<string, Burn>, items?: Map<string, number>): string {
- return `<table>
- <tr><th></th><th>in</th><th>out</th><th>net</th><th>inv</th><th>days</th></tr>
- ${Array.from(burn.values().map((b) => {
- const amount = items?.get(b.MaterialTicker);
- let style = '';
- if (b.Net < 0) {
- const percent = amount === undefined ? 0 : Math.min((amount / -b.Net) / 7, 1);
- style = `style="color: color-mix(in xyz, #0aa ${percent * 100}%, #f80)"`;
- }
- return `<tr>
- <td>${b.MaterialTicker}</td>
- <td>${format(b.Consumption)}</td>
- <td>${format(b.Production)}</td>
- <td>${format(b.Net)}</td>
- <td>${amount !== undefined ? format(amount) : 'unknown'}</td>
- <td ${style}>
- ${b.Net < 0 ? (amount !== undefined ? format((amount) / -b.Net) : 'unknown') : '∞'}
- </td>
- </tr>`;
- })).join('')}
- </table>`;
- }
- function ship(materials: Map<string, Material>, burn: Map<string, Burn>, items: Map<string, number>,
- cargo: {weight: number, volume: number}): string {
- let targetDays = Infinity;
- for (const b of burn.values()) {
- if (b.Net >= 0) continue;
- const amount = items.get(b.MaterialTicker);
- if (amount === undefined) continue;
- const days = amount / -b.Net;
- if (days < targetDays)
- targetDays = days;
- }
- targetDays = Math.round((targetDays + 0.05) * 10) / 10; // round to nearest 0.1 days
- let optimal: Map<string, number> | null = null;
- let optimalDays = 0;
- let totalWeightUsed = 0;
- let totalVolumeUsed = 0;
- while (true) {
- const buys = supplyForDays(burn, items, targetDays);
- const [weightUsed, volumeUsed] = shippingUsed(materials, buys);
- if (weightUsed > cargo.weight || volumeUsed > cargo.volume)
- break;
- optimal = buys;
- optimalDays = targetDays;
- totalWeightUsed = weightUsed;
- totalVolumeUsed = volumeUsed;
- targetDays += 0.1;
- }
- if (optimal === null)
- return 'nothing to supply';
- const xitAct = {
- 'actions': [
- {'name': 'BuyItems', 'type': 'CX Buy', 'group': 'A1', 'exchange': 'IC1',
- 'priceLimits': {}, 'buyPartial': false, 'useCXInv': true},
- {'type': 'MTRA', 'name': 'TransferAction', 'group': 'A1',
- 'origin': 'Hortus Station Warehouse', 'dest': 'Configure on Execution'},
- ],
- 'global': {'name': 'supply ' + planetSelect.value},
- 'groups': [{
- 'type': 'Manual', 'name': 'A1', 'materials': Object.fromEntries(optimal.entries())
- }],
- };
- return `<h2>supply for ${format(optimalDays)} days (${format(totalWeightUsed)}t, ${format(totalVolumeUsed)}m³)</h2>
- <textarea>${JSON.stringify(xitAct)}</textarea>`;
- }
- function supplyForDays(burn: Map<string, Burn>, items: Map<string, number>, targetDays: number): Map<string, number> {
- const buy = new Map<string, number>();
- for (const b of burn.values()) {
- const consumption = -b.Net;
- if (consumption <= 0) continue;
- const avail = items.get(b.MaterialTicker) ?? 0;
- const days = avail / consumption;
- let toBuy = 0;
- if (days < targetDays)
- toBuy = Math.ceil((targetDays - days) * consumption);
- if (avail + toBuy < 2)
- toBuy = 2 - avail;
- if (toBuy > 0)
- buy.set(b.MaterialTicker, toBuy);
- }
- return buy;
- }
- function shippingUsed(materials: Map<string, Material>, counts: Map<string, number>): [number, number] {
- let weight = 0, volume = 0;
- for (const [ticker, amount] of counts.entries()) {
- const mat = materials.get(ticker);
- if (mat === undefined) continue;
- weight += amount * mat.weight;
- volume += amount * mat.volume;
- }
- return [weight, volume];
- }
- interface SiteUser {
- Sites: Array<{
- PlanetIdentifier: string
- PlanetName: string
- }>
- }
- interface Company {
- Company: {
- CompanyCode: string
- CompanyName: string
- }
- Username: string
- }
- interface UserStorages {
- Storages: Array<{
- Type: 'FTL_FUEL_STORE' | 'SHIP_STORE' | 'STL_FUEL_STORE' | 'STORE' | 'WAREHOUSE_STORE'
- StorageItems: Array<{
- MaterialTicker: string
- MaterialAmount: number
- }>
- LastUpdatedEpochMs: number
- }>
- Username: string
- }
- interface UserWorkforces {
- Workforce: Array<{
- Type: 'FTL_FUEL_STORE' | 'SHIP_STORE' | 'STL_FUEL_STORE' | 'STORE' | 'WAREHOUSE_STORE'
- Workforces: Array<{
- WorkforceNeeds: Array<{
- MaterialTicker: string
- UnitsPerInterval: number
- }>
- }>
- LastUpdatedEpochMs: number
- }>
- Username: string
- }
- interface Burn {
- MaterialTicker: string
- Production: number
- Consumption: number
- Net: number
- }
- interface Material {
- ticker: string
- weight: number
- volume: number
- }
|