|
@@ -0,0 +1,354 @@
|
|
|
|
|
+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
|
|
|
|
|
+}
|