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 { 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 { const siteUsers: SiteUser[] = await cachedFetchJSON('https://api.punoted.net/v1/sites/', {headers: {'X-Data-Token': apiKey}}); const planetNames = new Set(); 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 { const companies: Company[] = await cachedFetchJSON('https://api.punoted.net/v1/user/companydata', {headers: {'X-Data-Token': apiKey}}); const usernames = new Set(); 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 { const renderTarget = document.querySelector('#coop')!; renderTarget.innerHTML = ''; const [userStores, rawMaterials]: [UserStorages[], Material[]] = await Promise.all([ cachedFetchJSON(`https://api.punoted.net/v1/storages/?location=${planet}`, {headers: {'X-Data-Token': apiKey}}), cachedFetchJSON('https://api.punoted.net/v1/materials/list'), ]); const allStorages = new Map>(); const lastUpdates = new Map(); const combinedStorage = new Map(); for (const userStorage of userStores) { const items = new Map(); 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 userBurns = await Promise.all(usernames.map((username) => userBurn(apiKey, planet, username, allStorages.get(username), lastUpdates.get(username)))); const combinedBurn = new Map(); 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 = `

combined

${burnTable(combinedBurn, combinedStorage)}`; const materials = new Map(rawMaterials.map((m) => [m.ticker, m])); const combinedShipping = ship(materials, combinedBurn, combinedStorage, cargo); renderTarget.innerHTML += combinedShipping.html; for (const ub of userBurns) { renderTarget.innerHTML += ub.html; const items = allStorages.get(ub.username); if (combinedShipping.supply !== null && items !== undefined) { let pasteLines: string[] = []; for (const mat of combinedShipping.supply.keys()) { const burn = ub.burn.get(mat); if (burn === undefined || burn.Net >= 0) continue; const need = -burn.Net * combinedShipping.days - (items.get(mat) ?? 0); if (need > 0) // unfortunately, rounding means if 4 people each need 0.25, we'll buy 1 and contract each person 0 pasteLines.push(`${mat}\t${Math.round(need)}\t0.01`); } if (pasteLines.length > 0) renderTarget.innerHTML += ``; } } } async function userBurn(apiKey: string, planet: string, username: string, items?: Map, lastUpdated?: number): Promise<{username: string, burn: Map, html: string}> { const burns: Record = 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(burnArray.map((b) => [b.MaterialTicker, b])); const html = `

${username}

last updated: ${lastUpdated ? new Date(lastUpdated).toLocaleString() : ''} ${burnTable(burn, items)}`; return {username, burn, html}; } function burnTable(burn: Map, items?: Map): string { return ` ${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 ``; })).join('')}
inoutnetinvdays
${b.MaterialTicker} ${format(b.Consumption)} ${format(b.Production)} ${format(b.Net)} ${amount !== undefined ? format(amount) : 'unknown'} ${b.Net < 0 ? (amount !== undefined ? format((amount) / -b.Net) : 'unknown') : '∞'}
`; } function ship(materials: Map, burn: Map, items: Map, cargo: {weight: number, volume: number}): {supply: Map | null, days: number, html: 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 | 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 {supply: optimal, days: NaN, html: '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()) }], }; const html = `

supply for ${format(optimalDays)} days (${format(totalWeightUsed)}t, ${format(totalVolumeUsed)}m³)

`; return {supply: optimal, days: optimalDays, html}; } function supplyForDays(burn: Map, items: Map, targetDays: number): Map { const buy = new Map(); 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, counts: Map): [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 Burn { MaterialTicker: string Production: number Consumption: number Net: number } interface Material { ticker: string weight: number volume: number }