raylu vor 1 Woche
Ursprung
Commit
7191383fee
4 geänderte Dateien mit 398 neuen und 0 gelöschten Zeilen
  1. 1 0
      .gitignore
  2. 354 0
      ts/coop.ts
  3. 31 0
      www/coop.html
  4. 12 0
      www/style.css

+ 1 - 0
.gitignore

@@ -4,6 +4,7 @@ config.toml
 node_modules/
 uv.lock
 www/buy.js*
+www/coop.js*
 www/corps.js*
 www/gov.js*
 www/junk.js*

+ 354 - 0
ts/coop.ts

@@ -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
+}

+ 31 - 0
www/coop.html

@@ -0,0 +1,31 @@
+<!DOCTYPE html>
+<html>
+<head>
+	<meta charset="UTF-8">
+	<title>PrUn co-op</title>
+	<link rel="stylesheet" type="text/css" href="style.css">
+	<link rel="icon" href="https://www.raylu.net/hammer-man.svg" />
+	<meta name="viewport" content="width=device-width, initial-scale=1">
+	<meta name="theme-color" content="#222">
+</head>
+<body>
+	<a href="/">← back</a>
+	<main class="coop">
+		<form>
+			<label>PUNoted API key: <input type="password" size="30" id="api-key"></label>
+			<br>
+			<input type="button" value="configure" id="configure">
+			<select id="planet"></select>
+			<select id="users" multiple></select>
+			<div id="cargo">
+				cargo <input id="weight" type="number" value="2000">t <input id="volume" type="number" value="2000">m³
+			</div>
+			<input type="button" value="run" id="run">
+		</form>
+		<section id="loader"></section>
+		<div id="coop"></div>
+	</main>
+	<div id="popover" popover="hint"></div>
+	<script src="coop.js"></script>
+</body>
+</html>

+ 12 - 0
www/style.css

@@ -110,6 +110,18 @@ main.buy {
 	}
 }
 
+main.coop {
+	form {
+		select#planet, select#users, input#run, div#cargo {
+			display: none;
+			margin-top: 0.5em;
+		}
+		input#weight, input#volume {
+			width: 9ch;
+		}
+	}
+}
+
 main.corps {
 	table {
 		td:nth-child(1),